Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14What the crate covers:
15- Packed and planar RGB families (`Rgb`, `Rgba`, `Bgra`, `Grey`,
16  `PlanarRgb`, `PlanarRgba`), semi-planar YUV sources (`Nv12`, `Nv16`,
17  `Nv24`), and packed YUV (`Yuyv`, `Vyuy`).
18- Source crop, resize, letterbox, 90° rotation, and flipping in one call.
19- Segmentation-mask rendering, including a fused GPU proto path.
20- SAHI-style input tiling for small-object detection in large frames
21  ([`TilingConfig`], [`ImageProcessor::tile_into`]).
22- Hardware acceleration (OpenGL, NXP G2D) with a CPU fallback that is
23  always available.
24
25The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
26with [`PixelFormat`] metadata describing the pixel layout. The
27[`ImageProcessor`] struct manages the conversion process, selecting
28the appropriate conversion method based on the available hardware.
29
30Geometry is **source-side**: [`Crop`] says which sub-rectangle of the source
31to sample and how to fit it into the destination's shape. Where the output
32lands is the destination itself — pass a
33[`view`](edgefirst_tensor::TensorDyn::view) or
34[`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to render
35into one tile of a batch.
36
37## Examples
38
39```rust
40# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
41# use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
42# use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
43# fn main() -> Result<(), edgefirst_image::Error> {
44let image = edgefirst_bench::testdata::read("zidane.jpg");
45// The codec emits the source's native format (a colour JPEG decodes to NV12)
46// and configures the destination tensor's dims+format during the decode.
47let info = peek_info(&image).expect("peek");
48// Heap tensors are CPU-touched on both sides (codec decode writes, the
49// engine's upload/CPU-fallback paths read), so declare ReadWrite. Pure
50// hardware pipelines allocate their convert destinations with
51// `CpuAccess::None` instead.
52let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
53                                   Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
54let mut decoder = ImageDecoder::new();
55src.load_image(&mut decoder, &image).expect("decode");
56// Convert the native NV12 frame to packed RGB for downstream processing.
57let mut converter = ImageProcessor::new()?;
58let mut dst =
59    converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
60converter.convert(&src.into(), &mut dst, None)?;
61# Ok(())
62# }
63```
64
65## Environment Variables
66The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
67following environment variables:
68- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
69  only that single backend is initialized and no fallback chain is used. If the
70  forced backend fails to initialize, an error is returned immediately. This is
71  useful for benchmarking individual backends in isolation. When this variable is
72  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
73- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
74  conversion, forcing the use of CPU or other available hardware methods.
75- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
76  conversion, forcing the use of CPU or other available hardware methods.
77- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
78  conversion, forcing the use of hardware acceleration methods. If no hardware
79  acceleration methods are available, an error will be returned when attempting
80  to create an `ImageProcessor`.
81
82Additionally the TensorMemory used by default allocations can be controlled using the
83`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
84uses system memory. This will disable the use of specialized memory regions for tensors
85and hardware acceleration. However, this will increase the performance of the CPU converter.
86*/
87#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
88
89/// Retained constructor: installs the coverage flush-on-abort handler for this
90/// crate's instrumented test binary. See `edgefirst_tensor::covguard`. Only
91/// present under coverage on Linux (`.init_array` is ELF-only; flush is Linux-only).
92#[cfg(all(coverage, target_os = "linux"))]
93#[used]
94#[link_section = ".init_array"]
95static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
96    extern "C" fn ctor() {
97        edgefirst_tensor::covguard::install();
98    }
99    ctor
100};
101
102/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
103/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
104/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
105/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
106/// pitch so the constant is harmless on that path. 64 is the smallest
107/// alignment that satisfies every embedded ARM GPU we ship to.
108///
109/// Applied automatically inside [`ImageProcessor::create_image`] when the
110/// allocation lands on `TensorMemory::DmaBuf`. External callers that allocate
111/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
112/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
113/// stride satisfies this requirement.
114pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
115
116/// Round `width` (in pixels) up so the resulting row stride
117/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
118/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
119///
120/// `bpp` must be the per-pixel byte count for the image's primary plane
121/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
122///
123/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
124/// foreign DMA-BUF — should call this when sizing the destination so that
125/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
126/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
127/// are bumped up to the next valid value.
128///
129/// # Overflow behaviour
130///
131/// All arithmetic is checked. If the alignment computation or the rounded
132/// width would overflow `usize`, the function logs a warning and returns the
133/// original `width` unchanged rather than wrapping or producing a smaller
134/// value. Callers can rely on the returned width being **at least** the
135/// requested width.
136///
137/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
138///
139/// # Examples
140///
141/// ```
142/// use edgefirst_image::align_width_for_gpu_pitch;
143///
144/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
145/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
146/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
147/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
148///
149/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
150/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
151/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
152/// ```
153pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
154    if bpp == 0 || width == 0 {
155        return width;
156    }
157
158    // The minimum aligned stride must be a common multiple of both the
159    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
160    // guarantees the rounded stride is an integer multiple of `bpp`, so
161    // converting back to a pixel count is exact.
162    //
163    // Compute the alignment in pixels (`width_alignment`) so we never need
164    // to multiply `width * bpp`, which is the only operation that could
165    // realistically overflow for large caller-supplied widths.
166    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
167    else {
168        log::warn!(
169            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
170             overflows usize, returning unaligned width {width}"
171        );
172        return width;
173    };
174    if lcm_alignment == 0 {
175        return width;
176    }
177
178    debug_assert_eq!(lcm_alignment % bpp, 0);
179    let width_alignment = lcm_alignment / bpp;
180    if width_alignment == 0 {
181        return width;
182    }
183
184    let remainder = width % width_alignment;
185    if remainder == 0 {
186        return width;
187    }
188
189    let pad = width_alignment - remainder;
190    match width.checked_add(pad) {
191        Some(aligned) => aligned,
192        None => {
193            log::warn!(
194                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
195                 returning unaligned (caller should use a smaller width or pre-aligned size)"
196            );
197            width
198        }
199    }
200}
201
202/// Round `min_pitch_bytes` up to the next multiple of
203/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
204/// value would overflow `usize`. Returns `Some(0)` for input 0.
205///
206/// Used internally by [`ImageProcessor::create_image`] to compute the
207/// padded row stride for DMA-backed image allocations. External callers
208/// that need pixel-counted alignment (instead of raw byte pitch) should
209/// use [`align_width_for_gpu_pitch`] instead.
210#[cfg(target_os = "linux")]
211pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
212    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
213    if min_pitch_bytes == 0 {
214        return Some(0);
215    }
216    let remainder = min_pitch_bytes % alignment;
217    if remainder == 0 {
218        return Some(min_pitch_bytes);
219    }
220    min_pitch_bytes.checked_add(alignment - remainder)
221}
222
223/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
224/// would wrap.
225fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
226    if a == 0 || b == 0 {
227        return Some(0);
228    }
229    let g = num_integer_gcd(a, b);
230    // a / g is exact (g divides a by definition) and at most a, so this
231    // division never panics. Only the subsequent multiply can overflow.
232    (a / g).checked_mul(b)
233}
234
235fn num_integer_gcd(a: usize, b: usize) -> usize {
236    if b == 0 {
237        a
238    } else {
239        num_integer_gcd(b, a % b)
240    }
241}
242
243/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
244/// Returns `None` for formats that don't have a single packed BPP (semi-planar
245/// chroma is handled separately, returning the luma-plane bpp).
246///
247/// External callers can use this together with [`align_width_for_gpu_pitch`]
248/// to size their own DMA-BUFs without having to remember per-format BPPs:
249///
250/// ```
251/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
252/// use edgefirst_tensor::PixelFormat;
253///
254/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
255/// let aligned = align_width_for_gpu_pitch(3004, bpp);
256/// assert_eq!(aligned, 3008);
257/// ```
258pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
259    use edgefirst_tensor::PixelLayout;
260    match format.layout() {
261        PixelLayout::Packed => Some(format.channels() * elem),
262        PixelLayout::Planar => Some(elem),
263        // For NV12/NV16 the luma plane is single-channel so the pitch
264        // matches `elem`; the chroma plane uses the same pitch in bytes
265        // (UV is half-width but two interleaved channels = same pitch).
266        PixelLayout::SemiPlanar => Some(elem),
267        // `PixelLayout` is non-exhaustive — fall through unaligned for
268        // any future variant we don't yet recognise.
269        _ => None,
270    }
271}
272
273/// Return the GPU-aligned pitch in bytes when a DMA-backed image of
274/// `width × fmt` would need row-stride padding, or `None` when the
275/// natural pitch already satisfies `GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`
276/// or the caller has explicitly requested non-DMA memory.
277///
278/// Mali G310 (i.MX 95) rejects `eglCreateImage` from DMA-BUFs whose
279/// `PLANE0_PITCH_EXT` is not a multiple of 64 bytes, surfacing as
280/// `EGL_BAD_ALLOC`. The `load_image_test_helper` test-only helper
281/// in this crate uses this to decide whether to allocate a tensor
282/// with padded row stride before invoking the decode path; production
283/// callers do the equivalent peek → allocate → decode dance themselves
284/// (see crate-level docs).
285#[cfg(all(target_os = "linux", test))]
286pub(crate) fn padded_dma_pitch_for(
287    fmt: PixelFormat,
288    width: usize,
289    memory: &Option<TensorMemory>,
290) -> Option<usize> {
291    // Only pad when the caller explicitly requested DMA, or when they
292    // left memory selection to the allocator AND DMA is actually
293    // available. `Tensor::image_with_stride(..., None)` always routes
294    // through DMA allocation, so treating `None` as "DMA wanted"
295    // unconditionally would convert a normally-working image load into
296    // a hard failure on systems where DMA is unavailable (sandboxed
297    // CI, missing `/dev/dma_heap`, permission-denied containers) —
298    // whereas `Tensor::image(..., None)` would have fallen back to
299    // SHM/Mem there.
300    match memory {
301        Some(TensorMemory::DmaBuf) => {}
302        None if edgefirst_tensor::is_dma_available() => {}
303        _ => return None,
304    }
305    // Padding only applies to packed layouts — `Tensor::image_with_stride`
306    // rejects semi-planar / planar formats, and those take their own
307    // per-plane pitches on import anyway.
308    if fmt.layout() != PixelLayout::Packed {
309        return None;
310    }
311    let bpp = primary_plane_bpp(fmt, 1)?;
312    let natural = width.checked_mul(bpp)?;
313    let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
314    if aligned > natural {
315        Some(aligned)
316    } else {
317        None
318    }
319}
320
321pub use cpu::CPUProcessor;
322#[cfg(feature = "codec")]
323pub use edgefirst_codec as codec;
324
325#[cfg(test)]
326use edgefirst_tensor::ProtoLayout;
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(
330    test,
331    all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
332))]
333use edgefirst_tensor::Tensor;
334use edgefirst_tensor::{
335    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
336};
337use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
338use enum_dispatch::enum_dispatch;
339pub use error::{Error, Result};
340#[cfg(target_os = "linux")]
341pub use g2d::G2DProcessor;
342/// Handle returned by `convert_with_fence`: a sync_file fd on Unix, an
343/// event handle on Windows that is set when the GPU work completes.
344#[cfg(unix)]
345pub type CompletionFence = std::os::fd::OwnedFd;
346/// Handle returned by `convert_with_fence`: a sync_file fd on Unix, an
347/// event handle on Windows that is set when the GPU work completes.
348#[cfg(windows)]
349pub type CompletionFence = std::os::windows::io::OwnedHandle;
350#[cfg(all(
351    any(
352        target_os = "linux",
353        target_os = "macos",
354        target_os = "ios",
355        target_os = "android",
356        target_os = "windows"
357    ),
358    feature = "opengl"
359))]
360pub use opengl_headless::EglDisplayKind;
361#[cfg(all(
362    any(
363        target_os = "linux",
364        target_os = "macos",
365        target_os = "ios",
366        target_os = "android",
367        target_os = "windows"
368    ),
369    feature = "opengl"
370))]
371pub use opengl_headless::GLProcessorThreaded;
372#[cfg(all(
373    any(
374        target_os = "linux",
375        target_os = "macos",
376        target_os = "ios",
377        target_os = "android",
378        target_os = "windows"
379    ),
380    feature = "opengl"
381))]
382pub use opengl_headless::Int8InterpolationMode;
383#[cfg(target_os = "linux")]
384#[cfg(feature = "opengl")]
385pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
386// EGLImage cache counter snapshots (diagnostics): see
387// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
388// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
389#[cfg(all(
390    any(
391        target_os = "linux",
392        target_os = "macos",
393        target_os = "ios",
394        target_os = "android",
395        target_os = "windows"
396    ),
397    feature = "opengl"
398))]
399pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
400use std::{fmt::Display, time::Instant};
401
402mod colorimetry;
403mod cpu;
404mod error;
405mod g2d;
406#[path = "gl/mod.rs"]
407mod opengl_headless;
408mod options;
409pub use options::{ConvertOptions, LetterboxPlacement, Normalization, NormalizationPreset};
410#[cfg(test)]
411mod test_support;
412mod tiling;
413pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
414
415// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
416// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
417
418// The image option vocabularies are declared through
419// `edgefirst_tensor::ef_vocabulary!`: the discriminant written here is the
420// code every surface uses (`code()`, the `EF_ROTATION_*` / `EF_FLIP_*` /
421// `EF_COLOR_MODE_*` / `EF_FIT_*` C enumerators, and the Python enum values).
422// The C and Python enums assert against `code()` at compile time.
423edgefirst_tensor::ef_vocabulary! {
424    /// A quarter-turn rotation applied during [`ImageProcessorTrait::convert`].
425    #[derive(Default)]
426    pub enum Rotation {
427        #[default]
428        None = 0, "none", NONE,
429        Clockwise90 = 1, "clockwise90", CLOCKWISE90,
430        Rotate180 = 2, "rotate180", ROTATE180,
431        CounterClockwise90 = 3, "counter_clockwise90", COUNTER_CLOCKWISE90,
432    }
433    #[doc(hidden)]
434    pub mod rotation_code;
435}
436
437impl Rotation {
438    /// Creates a Rotation enum from an angle in degrees. The angle must be a
439    /// multiple of 90.
440    ///
441    /// # Panics
442    /// Panics if the angle is not a multiple of 90.
443    ///
444    /// # Examples
445    /// ```rust
446    /// # use edgefirst_image::Rotation;
447    /// let rotation = Rotation::from_degrees_clockwise(270);
448    /// assert_eq!(rotation, Rotation::CounterClockwise90);
449    /// ```
450    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
451        match angle.rem_euclid(360) {
452            0 => Rotation::None,
453            90 => Rotation::Clockwise90,
454            180 => Rotation::Rotate180,
455            270 => Rotation::CounterClockwise90,
456            _ => panic!("rotation angle is not a multiple of 90"),
457        }
458    }
459}
460
461edgefirst_tensor::ef_vocabulary! {
462    /// A mirror applied during [`ImageProcessorTrait::convert`].
463    #[derive(Default)]
464    pub enum Flip {
465        #[default]
466        None = 0, "none", NONE,
467        /// Mirror top to bottom.
468        Vertical = 1, "vertical", VERTICAL,
469        /// Mirror left to right.
470        Horizontal = 2, "horizontal", HORIZONTAL,
471    }
472    #[doc(hidden)]
473    pub mod flip_code;
474}
475
476edgefirst_tensor::ef_vocabulary! {
477    /// Controls how the color palette index is chosen for each detected object.
478    #[derive(Default)]
479    pub enum ColorMode {
480        /// Color is chosen by object class label (`det.label`). Default.
481        ///
482        /// Preserves backward compatibility and is correct for semantic
483        /// segmentation where colors carry class meaning.
484        #[default]
485        Class = 0, "class", CLASS,
486        /// Color is chosen by instance order (loop index, zero-based).
487        ///
488        /// Each detected object gets a unique color regardless of class,
489        /// useful for instance segmentation.
490        Instance = 1, "instance", INSTANCE,
491        /// Color is chosen by track ID (future use; currently behaves like
492        /// [`Instance`](Self::Instance)).
493        Track = 2, "track", TRACK,
494    }
495    #[doc(hidden)]
496    pub mod color_mode_code;
497}
498
499impl ColorMode {
500    /// Return the palette index for a detection given its loop index and label.
501    #[inline]
502    pub fn index(self, idx: usize, label: usize) -> usize {
503        match self {
504            ColorMode::Class => label,
505            ColorMode::Instance | ColorMode::Track => idx,
506        }
507    }
508}
509
510/// Controls the resolution and coordinate frame of masks produced by
511/// [`ImageProcessor::materialize_masks`].
512///
513/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
514///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
515///   plane). This is the historical behavior of `materialize_masks` and the
516///   fastest path because no upsample runs inside HAL. Mask values are
517///   continuous sigmoid output quantized to `uint8 [0, 255]`.
518/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
519///   pixel resolution by upsampling the full proto plane once and cropping by
520///   bbox after sigmoid. The upsample uses bilinear interpolation with
521///   edge-clamp sampling — semantically equivalent to Ultralytics'
522///   `process_masks_retina` reference. When a `letterbox` is also passed to
523///   [`materialize_masks`], the inverse letterbox transform is applied during
524///   the upsample so mask pixels land in original-content coordinates
525///   (drop-in for overlay on the original image). Mask values are binary
526///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
527///   with `Proto` output via the same `> 127` test.
528///
529/// [`materialize_masks`]: ImageProcessor::materialize_masks
530#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
531pub enum MaskResolution {
532    /// Per-detection tile at proto-plane resolution (default).
533    #[default]
534    Proto,
535    /// Per-detection tile at `(width, height)` pixel resolution in the
536    /// coordinate frame determined by the `letterbox` parameter of
537    /// [`ImageProcessor::materialize_masks`].
538    Scaled {
539        /// Target pixel width of the output coordinate frame.
540        width: u32,
541        /// Target pixel height of the output coordinate frame.
542        height: u32,
543    },
544}
545
546/// Options for mask overlay rendering.
547///
548/// Controls how segmentation masks are composited onto the destination image:
549/// - `background`: when set, the background image is drawn first and masks
550///   are composited over it (result written to `dst`). When `None`, `dst` is
551///   cleared to `0x00000000` (fully transparent) before masks are drawn.
552///   **`dst` is always fully overwritten — its prior contents are never
553///   preserved.** Callers who used to pre-load an image into `dst` before
554///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
555///   image via `background` instead (behaviour changed in v0.16.4).
556/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
557///   preserves the class color's alpha unchanged; `0.5` makes masks
558///   semi-transparent.
559/// - `color_mode`: controls whether colors are assigned by class label,
560///   instance index, or track ID. Defaults to [`ColorMode::Class`].
561#[derive(Debug, Clone, Copy)]
562pub struct MaskOverlay<'a> {
563    /// Compositing source image. Must have the same dimensions and pixel
564    /// format as `dst`. When `Some`, the output is `background + masks`.
565    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
566    pub background: Option<&'a TensorDyn>,
567    pub opacity: f32,
568    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
569    /// space that contains actual image content (the rest is padding).
570    ///
571    /// When set, bounding boxes and mask coordinates from the decoder (which
572    /// are in model-input normalized space) are mapped back to the original
573    /// image coordinate space before rendering.
574    ///
575    /// Use [`with_convert_options`](Self::with_convert_options) to compute this
576    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
577    pub letterbox: Option<[f32; 4]>,
578    pub color_mode: ColorMode,
579}
580
581impl Default for MaskOverlay<'_> {
582    fn default() -> Self {
583        Self {
584            background: None,
585            opacity: 1.0,
586            letterbox: None,
587            color_mode: ColorMode::Class,
588        }
589    }
590}
591
592impl<'a> MaskOverlay<'a> {
593    pub fn new() -> Self {
594        Self::default()
595    }
596
597    /// Set the compositing source image.
598    ///
599    /// `bg` must have the same dimensions and pixel format as the `dst` passed
600    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
601    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
602    /// The output will be `bg + masks`. Without a background, `dst` is cleared
603    /// to `0x00000000`.
604    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
605        self.background = Some(bg);
606        self
607    }
608
609    pub fn with_opacity(mut self, opacity: f32) -> Self {
610        self.opacity = opacity.clamp(0.0, 1.0);
611        self
612    }
613
614    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
615        self.color_mode = mode;
616        self
617    }
618
619    /// Set the letterbox transform from the [`ConvertOptions`] used when
620    /// preparing the model input, so that bounding boxes and masks are
621    /// correctly mapped back to the original image coordinate space during
622    /// rendering.
623    ///
624    /// Pass the same options that were given to
625    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
626    /// input dimensions (`model_w` × `model_h`).
627    ///
628    /// Has no effect without a letterbox fit.
629    pub fn with_convert_options(
630        mut self,
631        options: &ConvertOptions,
632        src_w: usize,
633        src_h: usize,
634        model_w: usize,
635        model_h: usize,
636    ) -> Self {
637        // The letterbox placement is resolved from the same source/destination
638        // dimensions `convert()` used, so the inverse map matches the render.
639        let crop = options.get_crop();
640        if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h, options.get_placement())
641        {
642            if let Some(r) = resolved.dst_rect {
643                self.letterbox = Some([
644                    r.left as f32 / model_w as f32,
645                    r.top as f32 / model_h as f32,
646                    (r.left + r.width) as f32 / model_w as f32,
647                    (r.top + r.height) as f32 / model_h as f32,
648                ]);
649            }
650        }
651        self
652    }
653}
654
655/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
656///
657/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
658/// bytes are copied explicitly. This is only reached on the letterbox path
659/// where the bounds must be rewritten to match an un-letterboxed detect box.
660fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
661    use edgefirst_tensor::TensorMapTrait as _;
662    let src = s
663        .segmentation
664        .as_typed::<u8>()
665        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
666    let map = src.map_read()?;
667    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
668    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
669    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
670    // and turn every downstream map_read() in the render loop into a
671    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
672    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
673    let dst = edgefirst_tensor::Tensor::<u8>::new(
674        s.segmentation.shape(),
675        Some(edgefirst_tensor::TensorMemory::Mem),
676        None,
677    )
678    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
679    {
680        let mut dmap = dst.map_mut()?;
681        dmap.as_mut_slice().copy_from_slice(map.as_slice());
682    }
683    Ok(Segmentation {
684        xmin: bbox[0],
685        ymin: bbox[1],
686        xmax: bbox[2],
687        ymax: bbox[3],
688        segmentation: dst.into(),
689    })
690}
691
692/// Apply the inverse letterbox transform to a bounding box.
693///
694/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
695/// that contains actual image content (output of
696/// [`MaskOverlay::with_letterbox_crop`]). Converts model-input-normalized
697/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
698/// canonicalises the bbox (ensures xmin <= xmax).
699///
700/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
701/// the inverse-letterbox math sits beside the type it transforms, so this mask
702/// path and `edgefirst_decoder::tiling`'s detection lift share one
703/// implementation.
704#[inline]
705fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
706    DetectBox {
707        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
708        ..bbox
709    }
710}
711
712edgefirst_tensor::ef_vocabulary! {
713    /// The kind of a [`Fit`], without its letterbox pad colour.
714    ///
715    /// [`Fit`] carries data, so it cannot carry a code itself; this is the
716    /// code the C `ef_tiling_config.fit` field and the Python `Fit` enum use.
717    pub enum FitMode {
718        Stretch = 0, "stretch", STRETCH,
719        Letterbox = 1, "letterbox", LETTERBOX,
720    }
721    #[doc(hidden)]
722    pub mod fit_code;
723}
724
725/// How a source is fit into the requested destination shape.
726#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
727pub enum Fit {
728    /// Stretch the source (or source crop) to fill the whole destination.
729    #[default]
730    Stretch,
731    /// Preserve the *source* aspect ratio, centring it in the destination and
732    /// padding the remainder with `pad` (RGBA — e.g. `[114, 114, 114, 255]` for
733    /// YOLO-style preprocessing).
734    Letterbox { pad: [u8; 4] },
735}
736
737impl Fit {
738    /// The kind of this fit, as a shared code-carrying [`FitMode`].
739    pub fn mode(&self) -> FitMode {
740        match self {
741            Fit::Stretch => FitMode::Stretch,
742            Fit::Letterbox { .. } => FitMode::Letterbox,
743        }
744    }
745
746    /// Build a fit from its kind. `pad` is used only by
747    /// [`FitMode::Letterbox`].
748    pub fn from_mode(mode: FitMode, pad: [u8; 4]) -> Self {
749        match mode {
750            FitMode::Stretch => Fit::Stretch,
751            FitMode::Letterbox => Fit::Letterbox { pad },
752        }
753    }
754}
755
756/// Source-side convert geometry: which sub-rectangle of the source to sample
757/// (`source`) and how to fit it into the destination (`fit`). Destination
758/// *placement* is the destination itself — a tensor, or a [`Region`] view /
759/// `batch` tile of one — not a field here.
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
761pub struct Crop {
762    /// Sub-rectangle of the source to sample. `None` samples the whole source.
763    pub source: Option<Region>,
764    /// How the source is fit into the destination shape.
765    pub fit: Fit,
766}
767
768impl Crop {
769    /// A no-op crop: whole source, stretched to fill the whole destination.
770    pub fn new() -> Self {
771        Self::default()
772    }
773
774    /// Alias for [`Crop::new`] — whole source, stretch to fill.
775    pub fn no_crop() -> Self {
776        Self::default()
777    }
778
779    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
780    /// with `pad` (RGBA).
781    pub fn letterbox(pad: [u8; 4]) -> Self {
782        Self {
783            source: None,
784            fit: Fit::Letterbox { pad },
785        }
786    }
787
788    /// Sample only `source` of the input (builder).
789    pub fn with_source(mut self, source: Option<Region>) -> Self {
790        self.source = source;
791        self
792    }
793
794    /// Set the fit mode (builder).
795    pub fn with_fit(mut self, fit: Fit) -> Self {
796        self.fit = fit;
797        self
798    }
799
800    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
801    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
802    /// destination placement rect (`None` = whole destination), and the pad
803    /// colour. **The single place letterbox placement is computed** — every
804    /// backend consumes the resolved rects rather than re-deriving them.
805    pub(crate) fn resolve(
806        &self,
807        src_w: usize,
808        src_h: usize,
809        dst_w: usize,
810        dst_h: usize,
811        placement: LetterboxPlacement,
812    ) -> Result<ResolvedCrop, Error> {
813        let src_rect = self.source.map(region_to_rect);
814        // The letterbox aspect uses the *effective* source content — the source
815        // crop when set, else the full source.
816        let (sw, sh) = match self.source {
817            Some(r) => (r.width, r.height),
818            None => (src_w, src_h),
819        };
820        let resolved = match self.fit {
821            Fit::Stretch => ResolvedCrop {
822                src_rect,
823                dst_rect: None,
824                dst_color: None,
825            },
826            Fit::Letterbox { pad } => ResolvedCrop {
827                src_rect,
828                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h, placement)),
829                dst_color: Some(pad),
830            },
831        };
832        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
833        Ok(resolved)
834    }
835
836    /// Validate against `TensorDyn` source and destination dimensions.
837    pub fn check_crop_dyn(
838        &self,
839        src: &edgefirst_tensor::TensorDyn,
840        dst: &edgefirst_tensor::TensorDyn,
841    ) -> Result<(), Error> {
842        self.resolve(
843            src.width().unwrap_or(0),
844            src.height().unwrap_or(0),
845            dst.width().unwrap_or(0),
846            dst.height().unwrap_or(0),
847            LetterboxPlacement::Center,
848        )
849        .map(|_| ())
850    }
851}
852
853/// Resolved crop geometry consumed by the backends. Produced by
854/// [`Crop::resolve`]; the backends read these fields directly (the same shape
855/// the public `Crop` carried before destination placement moved to the view).
856#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
857pub(crate) struct ResolvedCrop {
858    pub(crate) src_rect: Option<Rect>,
859    pub(crate) dst_rect: Option<Rect>,
860    pub(crate) dst_color: Option<[u8; 4]>,
861}
862
863impl ResolvedCrop {
864    /// A no-op resolved crop (whole source → whole destination, no pad).
865    #[allow(dead_code)] // used by unit tests and the batch render paths
866    pub(crate) fn no_crop() -> Self {
867        Self::default()
868    }
869
870    /// Whether a convert from a `src`-sized source to a `dst`-sized
871    /// destination with this crop changes the size of the sampled region. A
872    /// quarter-turn rotation swaps the source region's axes.
873    #[allow(dead_code)] // unused when no resampling backend is compiled in
874    pub(crate) fn resizes(
875        &self,
876        (src_w, src_h): (usize, usize),
877        (dst_w, dst_h): (usize, usize),
878        rotation: Rotation,
879    ) -> bool {
880        let (sw, sh) = self
881            .src_rect
882            .map_or((src_w, src_h), |r| (r.width, r.height));
883        let (dw, dh) = self
884            .dst_rect
885            .map_or((dst_w, dst_h), |r| (r.width, r.height));
886        let (sw, sh) = match rotation {
887            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (sh, sw),
888            Rotation::None | Rotation::Rotate180 => (sw, sh),
889        };
890        (sw, sh) != (dw, dh)
891    }
892
893    /// Validate the resolved rects against explicit dimensions.
894    pub(crate) fn check_crop_dims(
895        &self,
896        src_w: usize,
897        src_h: usize,
898        dst_w: usize,
899        dst_h: usize,
900    ) -> Result<(), Error> {
901        let src_ok = self
902            .src_rect
903            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
904        let dst_ok = self
905            .dst_rect
906            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
907        match (src_ok, dst_ok) {
908            (true, true) => Ok(()),
909            (true, false) => Err(Error::CropInvalid(format!(
910                "Dest crop invalid: {:?}",
911                self.dst_rect
912            ))),
913            (false, true) => Err(Error::CropInvalid(format!(
914                "Src crop invalid: {:?}",
915                self.src_rect
916            ))),
917            (false, false) => Err(Error::CropInvalid(format!(
918                "Dest and Src crop invalid: {:?} {:?}",
919                self.dst_rect, self.src_rect
920            ))),
921        }
922    }
923}
924
925/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
926fn region_to_rect(r: Region) -> Rect {
927    Rect {
928        left: r.x,
929        top: r.y,
930        width: r.width,
931        height: r.height,
932    }
933}
934
935/// Aspect-preserving placement of an `sw`×`sh` source within a `dw`×`dh`
936/// destination — the canonical letterbox rectangle (one home, replacing the
937/// per-caller `calculate_letterbox` copies).
938fn letterbox_rect(
939    sw: usize,
940    sh: usize,
941    dw: usize,
942    dh: usize,
943    placement: LetterboxPlacement,
944) -> Rect {
945    if sw == 0 || sh == 0 {
946        return Rect::new(0, 0, dw, dh);
947    }
948    let src_aspect = sw as f64 / sh as f64;
949    let dst_aspect = dw as f64 / dh as f64;
950    let (new_w, new_h) = if src_aspect > dst_aspect {
951        (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
952    } else {
953        (((dh as f64 * src_aspect).round() as usize).max(1), dh)
954    };
955    let (left, top) = match placement {
956        LetterboxPlacement::Center => (dw.saturating_sub(new_w) / 2, dh.saturating_sub(new_h) / 2),
957        LetterboxPlacement::TopLeft => (0, 0),
958    };
959    Rect::new(left, top, new_w, new_h)
960}
961
962/// Internal placement rectangle (top-left + size). The **public** pixel
963/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
964/// `Rect` is the crate-private resolved-placement representation used by the
965/// CPU/G2D/GL backends after [`Crop::resolve`].
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
967pub(crate) struct Rect {
968    pub left: usize,
969    pub top: usize,
970    pub width: usize,
971    pub height: usize,
972}
973
974impl Rect {
975    // Creates a new Rect with the specified left, top, width, and height.
976    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
977        Self {
978            left,
979            top,
980            width,
981            height,
982        }
983    }
984}
985
986#[enum_dispatch(ImageProcessor)]
987pub trait ImageProcessorTrait {
988    /// Convert `src` into `dst`'s format and size, applying the crop first,
989    /// then the flip, then the rotation.
990    ///
991    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
992    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
993    /// accelerated paths target and what models expect. The CPU backend also
994    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
995    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
996    /// always land on the CPU.
997    ///
998    /// # Arguments
999    ///
1000    /// * `src` - The source image to convert from.
1001    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
1002    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
1003    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
1004    ///   render into one tile of a batch.
1005    /// * `options` - Rotation, flip, source crop, fit (stretch or letterbox),
1006    ///   normalization and pad ([`ConvertOptions`]); `None` is the default
1007    ///   convert. Destination placement lives on `dst`, not here.
1008    ///
1009    /// # Errors
1010    ///
1011    /// - [`Error::CropInvalid`] if the source crop falls outside `src`.
1012    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
1013    /// - [`Error::InvalidOptions`] if the options cannot apply to `dst`.
1014    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
1015    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
1016    ///   declined.
1017    /// - [`Error::Tensor`] with [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied)
1018    ///   if a CPU pass needs access a tensor's declaration does not cover
1019    ///   (source `Read`; destination `Write`, or `ReadWrite` for a view or a
1020    ///   read-back pass).
1021    /// - [`Error::CpuFallbackDenied`] if GL or G2D declined the convert and
1022    ///   the CPU fallback was refused for access. [`Error::cpu_access_denied`]
1023    ///   finds the refusal in either form.
1024    ///
1025    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
1026    /// their own variants. A backend *declining* a pair is not an error while
1027    /// another remains in the chain; it silently costs the zero-copy path, and
1028    /// [`ImageProcessor::convert_fallback_count`] counts those.
1029    fn convert(
1030        &mut self,
1031        src: &TensorDyn,
1032        dst: &mut TensorDyn,
1033        options: Option<ConvertOptions>,
1034    ) -> Result<()>;
1035
1036    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
1037    ///
1038    /// Supports two segmentation modes based on the mask channel count:
1039    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
1040    ///   `segmentation` and `detect` are zipped.
1041    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
1042    ///   all classes; only the first element is used.
1043    ///
1044    /// # Format requirements
1045    ///
1046    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
1047    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
1048    /// - G2D backend: only produces the base frame (empty detections);
1049    ///   returns `NotImplemented` when any detection or segmentation is
1050    ///   supplied.
1051    ///
1052    /// # Output contract
1053    ///
1054    /// This function always fully writes `dst` — it never relies on the
1055    /// caller having pre-cleared the destination. The four cases are:
1056    ///
1057    /// | detections | background | output                              |
1058    /// |------------|------------|-------------------------------------|
1059    /// | none       | none       | dst cleared to `0x00000000`         |
1060    /// | none       | set        | dst ← background                    |
1061    /// | set        | none       | masks drawn over cleared dst        |
1062    /// | set        | set        | masks drawn over background         |
1063    ///
1064    /// Each backend implements this with its native primitives: G2D uses
1065    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
1066    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
1067    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
1068    /// accelerated paths.
1069    ///
1070    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
1071    ///
1072    /// `overlay` controls compositing: `background` is the compositing source
1073    /// (must match `dst` in size and format); `opacity` scales mask alpha.
1074    ///
1075    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
1076    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
1077    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
1078    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
1079    /// `Bgra` destination with no background is unaffected.
1080    ///
1081    /// # Buffer aliasing
1082    ///
1083    /// `dst` and `overlay.background` must reference **distinct underlying
1084    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
1085    /// dispatching to any backend — the GL path would otherwise read and
1086    /// write the same texture in a single draw, which is undefined behaviour
1087    /// on most drivers. Aliasing is detected via
1088    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
1089    /// catches both shared-allocation clones and separate imports over the
1090    /// same dmabuf fd.
1091    ///
1092    /// # Migration from v0.16.3 and earlier
1093    ///
1094    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1095    /// detections. That invariant no longer holds — `dst` is always fully
1096    /// written. Callers who pre-loaded an image into `dst` before calling this
1097    /// function must now pass that image via `overlay.background` instead.
1098    fn draw_decoded_masks(
1099        &mut self,
1100        dst: &mut TensorDyn,
1101        detect: &[DetectBox],
1102        segmentation: &[Segmentation],
1103        overlay: MaskOverlay<'_>,
1104    ) -> Result<()>;
1105
1106    /// Draw masks from proto data onto image (fused decode+draw).
1107    ///
1108    /// For YOLO segmentation models, this avoids materializing intermediate
1109    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1110    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1111    /// at the output resolution using bilinear sampling.
1112    ///
1113    /// `detect` and `proto_data.mask_coefficients` must have the same length
1114    /// (enforced by zip — excess entries are silently ignored). An empty
1115    /// `detect` slice is valid and produces the base frame — cleared or
1116    /// background-blitted — via the selected backend's native primitive.
1117    ///
1118    /// # Format requirements and output contract
1119    ///
1120    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1121    /// the "always fully writes dst" guarantee across all four
1122    /// detection/background combinations.
1123    ///
1124    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1125    fn draw_proto_masks(
1126        &mut self,
1127        dst: &mut TensorDyn,
1128        detect: &[DetectBox],
1129        proto_data: &ProtoData,
1130        overlay: MaskOverlay<'_>,
1131    ) -> Result<()>;
1132
1133    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1134    /// can be set.
1135    ///
1136    /// On [`ImageProcessor`] with no forced backend this reaches every
1137    /// available backend rather than the first that accepts, because a
1138    /// later draw may land on any of them. A forced backend receives the
1139    /// colors alone.
1140    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1141
1142    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1143    ///
1144    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1145    /// into one batched destination by looping
1146    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1147    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1148    /// convert into sibling views of one buffer shares a single EGLImage import
1149    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1150    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1151    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1152    /// until `flush` returns.
1153    ///
1154    /// The default implementation is eager — it simply calls
1155    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1156    /// fast path remain correct (each call completes synchronously and `flush`
1157    /// is a no-op).
1158    fn convert_deferred(
1159        &mut self,
1160        src: &TensorDyn,
1161        dst: &mut TensorDyn,
1162        options: Option<ConvertOptions>,
1163    ) -> Result<()> {
1164        self.convert(src, dst, options)
1165    }
1166
1167    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1168    /// since the last flush, issuing a single GPU synchronization.
1169    ///
1170    /// After this returns, every deferred destination is finished and safe to
1171    /// read back or `cuda_map`. Backends with no deferred path (the default)
1172    /// return `Ok(())` immediately, since their converts already completed.
1173    fn flush(&mut self) -> Result<()> {
1174        Ok(())
1175    }
1176}
1177
1178/// Configuration for [`ImageProcessor`] construction.
1179///
1180/// Use with [`ImageProcessor::with_config`] to override the default EGL
1181/// display auto-detection and backend selection. The default configuration
1182/// preserves the existing auto-detection behaviour.
1183#[derive(Debug, Clone, Default)]
1184pub struct ImageProcessorConfig {
1185    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1186    ///
1187    /// When `None`, the processor probes displays in priority order: GBM,
1188    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1189    /// which displays are available on the current system.
1190    ///
1191    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1192    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1193    /// respectively; a `Some` value logs a debug note and is otherwise
1194    /// ignored).
1195    #[cfg(all(
1196        any(
1197            target_os = "linux",
1198            target_os = "macos",
1199            target_os = "ios",
1200            target_os = "android",
1201            target_os = "windows"
1202        ),
1203        feature = "opengl"
1204    ))]
1205    pub egl_display: Option<EglDisplayKind>,
1206
1207    /// Preferred compute backend.
1208    ///
1209    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1210    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1211    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1212    /// `EDGEFIRST_DISABLE_*` environment variables.
1213    ///
1214    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1215    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1216    /// - [`ComputeBackend::Cpu`]: init CPU only
1217    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1218    pub backend: ComputeBackend,
1219
1220    /// Colorimetry/performance trade-off for `convert()` (see
1221    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1222    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1223    /// overrides this setting when present.
1224    pub colorimetry: ColorimetryMode,
1225
1226    /// How `convert()` resamples when the source (or source crop) and the
1227    /// destination differ in size (see [`Interpolation`]). Defaults to
1228    /// [`Interpolation::Bilinear`]. The `EDGEFIRST_INTERPOLATION` environment
1229    /// variable (`nearest` | `bilinear`) overrides this setting when present.
1230    pub interpolation: Interpolation,
1231
1232    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1233    ///
1234    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1235    /// 16 otherwise. Precedence is:
1236    ///
1237    /// ```text
1238    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1239    /// ```
1240    ///
1241    /// Config beats the environment deliberately, matching
1242    /// [`backend`](Self::backend): an embedder that has measured its own
1243    /// pool must not be silently overridden by a stray environment variable
1244    /// in the deployment. `Some(0)` is rejected and falls through to the
1245    /// environment, then the default, exactly as an unparseable env value
1246    /// does.
1247    ///
1248    /// # This is a buffer *count*, not a size
1249    ///
1250    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1251    /// cannot be freed while an entry names it (that is what makes the cache
1252    /// correct as well as fast; see the "EGL image cache inside HAL" section
1253    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1254    /// how big the caller's buffers are:
1255    ///
1256    /// ```text
1257    /// worst-case pinned bytes                                (upper bound)
1258    ///     = capacity x 3 caches x frame size x live ImageProcessors
1259    ///
1260    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1261    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1262    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1263    /// ```
1264    ///
1265    /// A ceiling rather than an exact total: the source and NV R8 caches
1266    /// often hold two imports of the *same* buffer, and two entries naming
1267    /// one buffer pin it once. The per-cache figure is the exact one.
1268    ///
1269    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1270    /// destination, and the NV R8 source cache) — not per process and not
1271    /// per library. Two `ImageProcessor`s in one process have six
1272    /// independent caches and six independent capacities. A fourth cache,
1273    /// the chroma-plane views of hardware-filtered NV resizes, has the same
1274    /// capacity but imports only buffers the NV R8 cache also holds, so it
1275    /// adds entries without pinning more memory.
1276    ///
1277    /// Whether the worst case is ever approached is up to the caller. A
1278    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1279    /// sizes — settles at its pool size and never nears the bound. A stream
1280    /// that allocates a fresh buffer per frame holds the last `capacity` of
1281    /// them outright, which is the difference between "16 is free" and "16
1282    /// is 597 MB".
1283    ///
1284    /// Size it from measurement rather than guesswork:
1285    /// [`CacheStats::peak_entries`] reports the working set actually
1286    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1287    /// costing you re-imports (it should be zero in a steady state).
1288    ///
1289    /// Ignored when the OpenGL backend is not in use.
1290    pub egl_cache_capacity: Option<usize>,
1291}
1292
1293/// How `convert()` trades colorimetric exactness against speed on platforms
1294/// where the exact path is expensive.
1295///
1296/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1297/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1298/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1299/// but applies the driver's fixed BT.601-limited matrix regardless of the
1300/// source's tagged colorimetry. Platforms where the exact path is already
1301/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1302/// both modes.
1303///
1304/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1305/// precedence over the config field), or per-source by forcing a path with
1306/// `EDGEFIRST_NV_CONVERT_PATH`.
1307#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1308pub enum ColorimetryMode {
1309    /// Prefer the fastest path whose output is correct-enough video RGB
1310    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1311    /// sampler even when the source is not BT.601-limited.
1312    #[default]
1313    Fast,
1314    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1315    /// when it matches the source's resolved (encoding, range) exactly.
1316    Exact,
1317}
1318
1319/// How `convert()` resamples when it resizes.
1320///
1321/// Every backend honours it: the CPU backend through its resizer, the
1322/// OpenGL backend through its texture filtering and NV shader choice, and
1323/// G2D, which has a fixed hardware filter, by declining resizing converts
1324/// under [`Interpolation::Nearest`] so another backend runs them. Converts
1325/// that do not resize read each source pixel exactly in either mode.
1326///
1327/// Override at runtime with `EDGEFIRST_INTERPOLATION=nearest|bilinear`,
1328/// which takes precedence over [`ImageProcessorConfig::interpolation`] and
1329/// [`ImageProcessor::set_interpolation`].
1330#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1331#[non_exhaustive]
1332pub enum Interpolation {
1333    /// Nearest-neighbour: each output pixel copies one source pixel. The
1334    /// fastest mode, at the cost of aliasing that lowers detection accuracy
1335    /// on resized inputs.
1336    Nearest,
1337    /// Bilinear with half-pixel centres (default): two taps per axis at every
1338    /// scale on both the CPU and GL backends, matching OpenCV `INTER_LINEAR`,
1339    /// the preprocessing most detectors and segmenters are trained with. It
1340    /// does not widen its kernel when downscaling, so it smooths less than
1341    /// PIL's bilinear below 1×.
1342    #[default]
1343    Bilinear,
1344}
1345
1346impl Interpolation {
1347    /// The mode selected by `EDGEFIRST_INTERPOLATION`, if it is set to a
1348    /// recognised value. An unrecognised value is logged and ignored.
1349    pub(crate) fn from_env() -> Option<Self> {
1350        let value = std::env::var("EDGEFIRST_INTERPOLATION").ok()?;
1351        match value.trim().to_ascii_lowercase().as_str() {
1352            "nearest" => Some(Self::Nearest),
1353            "bilinear" => Some(Self::Bilinear),
1354            "" => None,
1355            other => {
1356                log::warn!(
1357                    "EDGEFIRST_INTERPOLATION={other:?} is not recognised \
1358                     (expected nearest or bilinear); ignoring it"
1359                );
1360                None
1361            }
1362        }
1363    }
1364}
1365
1366edgefirst_tensor::ef_vocabulary! {
1367    /// Compute backend selection for [`ImageProcessor`].
1368    ///
1369    /// Use with [`ImageProcessorConfig::backend`] to select which backend the
1370    /// processor should prefer. When a specific backend is selected, the
1371    /// processor initializes that backend plus CPU as a fallback. When `Auto`
1372    /// is used, the existing environment-variable-driven selection applies.
1373    ///
1374    /// The code is the one the C `EF_COMPUTE_BACKEND_*` enumerators use.
1375    #[derive(Default)]
1376    pub enum ComputeBackend {
1377        /// Auto-detect based on available hardware and environment variables.
1378        #[default]
1379        Auto = 0, "auto", AUTO,
1380        /// CPU-only processing (no hardware acceleration).
1381        Cpu = 1, "cpu", CPU,
1382        /// Prefer G2D hardware blitter (+ CPU fallback).
1383        G2d = 2, "g2d", G2D,
1384        /// Prefer OpenGL ES (+ CPU fallback).
1385        OpenGl = 3, "opengl", OPENGL,
1386    }
1387    #[doc(hidden)]
1388    pub mod compute_backend_code;
1389}
1390
1391/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1392/// or [`ImageProcessorConfig::backend`].
1393///
1394/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1395/// selected backend — no fallback chain is used.
1396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1397pub(crate) enum ForcedBackend {
1398    Cpu,
1399    G2d,
1400    OpenGl,
1401}
1402
1403/// The CPU fallback's error, carrying the GPU decline reasons when the CPU
1404/// was refused for access: the decline is what the caller has to fix.
1405/// `declined` pairs each declining backend's name with its error, formatted
1406/// only here so a frame the fallback serves pays for no message.
1407fn with_gpu_declines(e: Error, declined: Vec<(&'static str, Error)>) -> Error {
1408    match e {
1409        Error::Tensor(denied @ edgefirst_tensor::Error::CpuAccessDenied { .. })
1410            if !declined.is_empty() =>
1411        {
1412            let declined: Vec<String> = declined
1413                .iter()
1414                .map(|(backend, e)| format!("{backend}: {e}"))
1415                .collect();
1416            Error::CpuFallbackDenied {
1417                declined: declined.join("; "),
1418                denied,
1419            }
1420        }
1421        other => other,
1422    }
1423}
1424
1425/// The CPU access an [`ImageProcessor`]'s own convert may need on a
1426/// destination, from what each present backend needs when it takes it
1427/// (`Some`) or declines it (`None`).
1428///
1429/// A forced backend is the only one that runs. Otherwise a GL render that
1430/// needs no CPU access ends the chain, while G2D and the CPU fallback may
1431/// each still run, so their needs are all included.
1432fn predicted_cpu_access(
1433    forced: Option<ForcedBackend>,
1434    gl: Option<Option<edgefirst_tensor::CpuAccess>>,
1435    g2d: Option<Option<edgefirst_tensor::CpuAccess>>,
1436    cpu: Option<Option<edgefirst_tensor::CpuAccess>>,
1437) -> edgefirst_tensor::CpuAccess {
1438    use edgefirst_tensor::CpuAccess;
1439    let only = |b: Option<Option<CpuAccess>>| b.flatten().unwrap_or(CpuAccess::None);
1440    match forced {
1441        Some(ForcedBackend::Cpu) => return only(cpu),
1442        Some(ForcedBackend::G2d) => return only(g2d),
1443        Some(ForcedBackend::OpenGl) => return only(gl),
1444        None => {}
1445    }
1446    let gl = gl.flatten();
1447    if gl == Some(CpuAccess::None) {
1448        return CpuAccess::None;
1449    }
1450    [gl, g2d.flatten(), cpu.flatten()]
1451        .into_iter()
1452        .flatten()
1453        .fold(CpuAccess::None, CpuAccess::union)
1454}
1455
1456/// Reports which float-color-buffer extensions the GPU backend detected.
1457/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1458/// are independent.
1459///
1460/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1461/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1462/// both flags are typically `true`; on Vivante GC7000UL both are forced
1463/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1464/// exposes both via PBO; the flags match the GPU report.
1465///
1466/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1467/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1468/// extension probe but is not actionable — ANGLE's
1469/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1470/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1471/// path.
1472///
1473/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1474/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1475/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1476///
1477/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1478/// an error due to float capability — it falls back to CPU when the GPU
1479/// path is unavailable.
1480#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1481pub struct RenderDtypeSupport {
1482    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1483    ///
1484    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1485    /// this flag is informational only — no F32 IOSurface path exists.
1486    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1487    /// for every F32 layout.
1488    pub f32: bool,
1489    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1490    ///
1491    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1492    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1493    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1494    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1495    /// every F16 layout.
1496    pub f16: bool,
1497}
1498
1499/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1500///
1501/// Only F16 and F32 are eligible, and only when the corresponding flag in
1502/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1503/// handled by the existing `dtype.size() == 1` PBO gate.
1504///
1505/// The same question `create_image` asks before trying a zero-copy float
1506/// destination, which is why that gate calls this rather than restating it:
1507/// a float image is only worth allocating on the GPU where the display can
1508/// render into that dtype at all.
1509///
1510/// Compiled on every platform with a GL backend -- the name is historical
1511/// (it began as the float *PBO* gate) and both callers are live everywhere
1512/// the `opengl` feature builds a backend.
1513#[cfg(all(
1514    any(
1515        target_os = "linux",
1516        target_os = "windows",
1517        target_os = "macos",
1518        target_os = "ios",
1519        target_os = "android"
1520    ),
1521    feature = "opengl"
1522))]
1523pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1524    match dtype {
1525        DType::F16 => support.f16,
1526        DType::F32 => support.f32,
1527        _ => false,
1528    }
1529}
1530
1531/// Image converter that uses available hardware acceleration or CPU as a
1532/// fallback.
1533#[derive(Debug)]
1534pub struct ImageProcessor {
1535    /// CPU-based image converter as a fallback. This is only None if the
1536    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1537    pub cpu: Option<CPUProcessor>,
1538
1539    #[cfg(target_os = "linux")]
1540    /// G2D-based image converter for Linux systems. This is only available if
1541    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1542    /// is available.
1543    pub g2d: Option<G2DProcessor>,
1544    #[cfg(target_os = "linux")]
1545    #[cfg(feature = "opengl")]
1546    /// OpenGL-based image converter for Linux systems. This is only available
1547    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1548    /// ES is available.
1549    pub opengl: Option<GLProcessorThreaded>,
1550    #[cfg(any(
1551        target_os = "macos",
1552        target_os = "ios",
1553        target_os = "android",
1554        target_os = "windows"
1555    ))]
1556    #[cfg(feature = "opengl")]
1557    /// OpenGL-based image converter — the same unified
1558    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1559    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1560    /// (available when ANGLE's libEGL.dylib can be loaded — see
1561    /// README.md § macOS GPU Acceleration); Android runs it via the
1562    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1563    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1564    /// can be loaded — see README.md § Windows GPU Acceleration).
1565    pub opengl: Option<GLProcessorThreaded>,
1566
1567    /// When set, only the specified backend is used — no fallback chain.
1568    pub(crate) forced_backend: Option<ForcedBackend>,
1569
1570    /// Converts where the GL backend declined and the auto chain fell
1571    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1572    /// sees these frames), unlike the per-feed counters in
1573    /// `GLProcessorThreaded::convert_stats`. Read via
1574    /// [`convert_fallback_count`](Self::convert_fallback_count).
1575    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1576}
1577
1578unsafe impl Send for ImageProcessor {}
1579unsafe impl Sync for ImageProcessor {}
1580
1581impl ImageProcessor {
1582    /// Creates a new `ImageProcessor` instance, initializing available
1583    /// hardware converters based on the system capabilities and environment
1584    /// variables.
1585    ///
1586    /// # Examples
1587    /// ```rust,no_run
1588    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1589    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1590    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1591    /// # fn main() -> Result<(), edgefirst_image::Error> {
1592    /// let image = std::fs::read("zidane.jpg")?;
1593    /// // The codec emits the source's native format (a colour JPEG decodes to
1594    /// // NV12) and configures the destination tensor during the decode.
1595    /// let info = peek_info(&image).expect("peek");
1596    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1597    ///                                    Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
1598    /// let mut decoder = ImageDecoder::new();
1599    /// src.load_image(&mut decoder, &image).expect("decode");
1600    /// let mut converter = ImageProcessor::new()?;
1601    /// let mut dst =
1602    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1603    /// converter.convert(&src.into(), &mut dst, None)?;
1604    /// # Ok(())
1605    /// # }
1606    /// ```
1607    pub fn new() -> Result<Self> {
1608        Self::with_config(ImageProcessorConfig::default())
1609    }
1610
1611    /// Number of converts where the auto chain's GL backend declined and the
1612    /// frame fell through toward G2D/CPU — each one silently lost any
1613    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1614    /// asserts this stays flat across its steady-state loop; pair with
1615    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1616    /// reach GL were fed. Always 0 under a forced backend (no chain).
1617    pub fn convert_fallback_count(&self) -> u64 {
1618        self.convert_fallbacks
1619            .load(std::sync::atomic::Ordering::Relaxed)
1620    }
1621
1622    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1623    /// requests since process start that resolved to a linear layout
1624    /// (process-wide mirror of
1625    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1626    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1627    /// so pipeline telemetry reads from one place). A pipeline that
1628    /// expects compressed convert destinations asserts this stays flat
1629    /// after warmup.
1630    pub fn compression_fallback_count(&self) -> u64 {
1631        edgefirst_tensor::compression_fallback_count()
1632    }
1633
1634    /// Convert and return a native fence that signals when the GPU work
1635    /// completes, instead of blocking the CPU — the GL→NPU handoff
1636    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1637    /// is a manual-reset event set when the GPU work completes; wait on it
1638    /// with `WaitForSingleObject`.
1639    ///
1640    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1641    /// the fence to the consumer (e.g.
1642    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1643    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1644    /// `Ok(None)`: the convert completed with the normal blocking contract
1645    /// (no native fence on this platform, or a non-GL backend handled the
1646    /// frame) — the destination is already safe. Semantics are otherwise
1647    /// identical to [`convert`](ImageProcessorTrait::convert), including
1648    /// the GL→G2D→CPU fallback chain.
1649    ///
1650    /// On Windows, **for a texture destination**, the event and the
1651    /// destination's own `Tensor::gpu_completion` name the same fence value:
1652    /// one flush and one signal cover the convert, and the value recorded on
1653    /// `dst` is the one this event waits for, so a consumer may use either.
1654    /// A destination that is not a texture records nothing, and the event
1655    /// then carries a value of its own that only it can be waited on for.
1656    pub fn convert_with_fence(
1657        &mut self,
1658        src: &TensorDyn,
1659        dst: &mut TensorDyn,
1660        options: Option<ConvertOptions>,
1661    ) -> Result<Option<CompletionFence>> {
1662        let options = options.unwrap_or_default();
1663        #[cfg(any(
1664            target_os = "linux",
1665            target_os = "macos",
1666            target_os = "ios",
1667            target_os = "android",
1668            target_os = "windows"
1669        ))]
1670        #[cfg(feature = "opengl")]
1671        {
1672            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1673            if self.forced_backend.is_none() || gl_forced {
1674                if let Some(opengl) = self.opengl.as_mut() {
1675                    match opengl.convert_with_fence(src, dst, Some(options)) {
1676                        Ok(fd) => return Ok(fd),
1677                        Err(e) if gl_forced => return Err(e),
1678                        Err(e) => {
1679                            // Not counted here: the blocking chain below
1680                            // re-runs the dispatcher, whose decline arm
1681                            // counts the fallback exactly once.
1682                            log::debug!(
1683                                "convert_with_fence: opengl declined, \
1684                                 falling back to the blocking chain: {e}"
1685                            );
1686                        }
1687                    }
1688                } else if gl_forced {
1689                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1690                }
1691            }
1692        }
1693        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1694        // completion == return, so no fence is needed.
1695        self.convert(src, dst, Some(options))?;
1696        Ok(None)
1697    }
1698
1699    /// Report which float dtypes the GPU can render to.
1700    ///
1701    /// Probes `GL_EXT_color_buffer_half_float` and
1702    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1703    /// and caches the result. Call this once at startup to decide whether
1704    /// to request F16 or F32 destination tensors; [`create_image`] uses
1705    /// the result internally to auto-select float PBO when supported.
1706    ///
1707    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1708    /// OpenGL backend is active or the `opengl` feature is disabled.
1709    ///
1710    /// [`create_image`]: Self::create_image
1711    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1712        #[cfg(all(
1713            any(
1714                target_os = "macos",
1715                target_os = "ios",
1716                target_os = "android",
1717                target_os = "windows"
1718            ),
1719            feature = "opengl"
1720        ))]
1721        if let Some(gl) = self.opengl.as_ref() {
1722            return gl.supported_render_dtypes();
1723        }
1724        #[cfg(all(target_os = "linux", feature = "opengl"))]
1725        if let Some(gl) = self.opengl.as_ref() {
1726            return gl.supported_render_dtypes();
1727        }
1728        RenderDtypeSupport {
1729            f32: false,
1730            f16: false,
1731        }
1732    }
1733
1734    /// Creates a new `ImageProcessor` with the given configuration.
1735    ///
1736    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1737    /// environment variables are ignored and the processor initializes the
1738    /// requested backend plus CPU as a fallback.
1739    ///
1740    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1741    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1742    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1743        let interpolation = config.interpolation;
1744        let mut processor = Self::with_config_backends(config)?;
1745        processor.set_interpolation(interpolation)?;
1746        Ok(processor)
1747    }
1748
1749    /// The backend selection half of [`Self::with_config`].
1750    #[allow(unused_variables)]
1751    fn with_config_backends(config: ImageProcessorConfig) -> Result<Self> {
1752        // ── Config-driven backend selection ──────────────────────────
1753        // When the caller explicitly requests a backend via the config,
1754        // skip all environment variable logic.
1755        match config.backend {
1756            ComputeBackend::Cpu => {
1757                log::info!("ComputeBackend::Cpu — CPU only");
1758                return Ok(Self {
1759                    cpu: Some(CPUProcessor::new()),
1760                    #[cfg(target_os = "linux")]
1761                    g2d: None,
1762                    #[cfg(target_os = "linux")]
1763                    #[cfg(feature = "opengl")]
1764                    opengl: None,
1765                    #[cfg(any(
1766                        target_os = "macos",
1767                        target_os = "ios",
1768                        target_os = "android",
1769                        target_os = "windows"
1770                    ))]
1771                    #[cfg(feature = "opengl")]
1772                    opengl: None,
1773                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1774                    forced_backend: None,
1775                });
1776            }
1777            ComputeBackend::G2d => {
1778                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1779                #[cfg(target_os = "linux")]
1780                {
1781                    let g2d = match G2DProcessor::new() {
1782                        Ok(g) => Some(g),
1783                        Err(e) => {
1784                            log::warn!("G2D requested but failed to initialize: {e:?}");
1785                            None
1786                        }
1787                    };
1788                    return Ok(Self {
1789                        cpu: Some(CPUProcessor::new()),
1790                        g2d,
1791                        #[cfg(feature = "opengl")]
1792                        opengl: None,
1793                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1794                        forced_backend: None,
1795                    });
1796                }
1797                #[cfg(not(target_os = "linux"))]
1798                {
1799                    log::warn!("G2D requested but not available on this platform, using CPU");
1800                    return Ok(Self {
1801                        cpu: Some(CPUProcessor::new()),
1802                        #[cfg(any(
1803                            target_os = "macos",
1804                            target_os = "ios",
1805                            target_os = "android",
1806                            target_os = "windows"
1807                        ))]
1808                        #[cfg(feature = "opengl")]
1809                        opengl: None,
1810                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1811                        forced_backend: None,
1812                    });
1813                }
1814            }
1815            ComputeBackend::OpenGl => {
1816                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1817                #[cfg(target_os = "linux")]
1818                {
1819                    #[cfg(feature = "opengl")]
1820                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1821                        config.egl_display,
1822                        config.egl_cache_capacity,
1823                    ) {
1824                        Ok(gl) => Some(gl),
1825                        Err(e) => {
1826                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1827                            None
1828                        }
1829                    };
1830                    return Ok(Self {
1831                        cpu: Some(CPUProcessor::new()),
1832                        g2d: None,
1833                        #[cfg(feature = "opengl")]
1834                        opengl,
1835                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1836                        forced_backend: None,
1837                    }
1838                    .apply_colorimetry_mode(config.colorimetry));
1839                }
1840                #[cfg(any(target_os = "macos", target_os = "ios"))]
1841                {
1842                    #[cfg(feature = "opengl")]
1843                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1844                        config.egl_display,
1845                        config.egl_cache_capacity,
1846                    ) {
1847                        Ok(gl) => Some(gl),
1848                        Err(e) => {
1849                            log::warn!(
1850                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1851                                 (install ANGLE via `brew install startergo/angle/angle` \
1852                                 and re-sign the dylibs — see README.md § macOS GPU \
1853                                 Acceleration). Falling back to CPU."
1854                            );
1855                            None
1856                        }
1857                    };
1858                    return Ok(Self {
1859                        cpu: Some(CPUProcessor::new()),
1860                        #[cfg(feature = "opengl")]
1861                        opengl,
1862                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1863                        forced_backend: None,
1864                    }
1865                    .apply_colorimetry_mode(config.colorimetry));
1866                }
1867                #[cfg(target_os = "android")]
1868                {
1869                    #[cfg(feature = "opengl")]
1870                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1871                        config.egl_display,
1872                        config.egl_cache_capacity,
1873                    ) {
1874                        Ok(gl) => Some(gl),
1875                        Err(e) => {
1876                            log::warn!(
1877                                "OpenGL requested but native EGL init failed: {e:?}. \
1878                                 Falling back to CPU."
1879                            );
1880                            None
1881                        }
1882                    };
1883                    return Ok(Self {
1884                        cpu: Some(CPUProcessor::new()),
1885                        #[cfg(feature = "opengl")]
1886                        opengl,
1887                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1888                        forced_backend: None,
1889                    }
1890                    .apply_colorimetry_mode(config.colorimetry));
1891                }
1892                #[cfg(target_os = "windows")]
1893                {
1894                    #[cfg(feature = "opengl")]
1895                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1896                        config.egl_display,
1897                        config.egl_cache_capacity,
1898                    ) {
1899                        Ok(gl) => Some(gl),
1900                        Err(e) => {
1901                            log::warn!(
1902                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1903                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1904                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1905                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1906                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1907                            );
1908                            None
1909                        }
1910                    };
1911                    return Ok(Self {
1912                        cpu: Some(CPUProcessor::new()),
1913                        #[cfg(feature = "opengl")]
1914                        opengl,
1915                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1916                        forced_backend: None,
1917                    }
1918                    .apply_colorimetry_mode(config.colorimetry));
1919                }
1920                #[cfg(not(any(
1921                    target_os = "linux",
1922                    target_os = "macos",
1923                    target_os = "ios",
1924                    target_os = "android",
1925                    target_os = "windows"
1926                )))]
1927                {
1928                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1929                    return Ok(Self {
1930                        cpu: Some(CPUProcessor::new()),
1931                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1932                        forced_backend: None,
1933                    });
1934                }
1935            }
1936            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1937        }
1938
1939        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1940        // When set, only the requested backend is initialised and no
1941        // fallback chain is used. Accepted values (case-insensitive):
1942        //   "cpu", "g2d", "opengl"
1943        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1944            let val_lower = val.to_lowercase();
1945            let forced = match val_lower.as_str() {
1946                "cpu" => ForcedBackend::Cpu,
1947                "g2d" => ForcedBackend::G2d,
1948                "opengl" => ForcedBackend::OpenGl,
1949                other => {
1950                    return Err(Error::ForcedBackendUnavailable(format!(
1951                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1952                    )));
1953                }
1954            };
1955
1956            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1957
1958            return match forced {
1959                ForcedBackend::Cpu => Ok(Self {
1960                    cpu: Some(CPUProcessor::new()),
1961                    #[cfg(target_os = "linux")]
1962                    g2d: None,
1963                    #[cfg(target_os = "linux")]
1964                    #[cfg(feature = "opengl")]
1965                    opengl: None,
1966                    #[cfg(any(
1967                        target_os = "macos",
1968                        target_os = "ios",
1969                        target_os = "android",
1970                        target_os = "windows"
1971                    ))]
1972                    #[cfg(feature = "opengl")]
1973                    opengl: None,
1974                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1975                    forced_backend: Some(ForcedBackend::Cpu),
1976                }),
1977                ForcedBackend::G2d => {
1978                    #[cfg(target_os = "linux")]
1979                    {
1980                        let g2d = G2DProcessor::new().map_err(|e| {
1981                            Error::ForcedBackendUnavailable(format!(
1982                                "g2d forced but failed to initialize: {e:?}"
1983                            ))
1984                        })?;
1985                        Ok(Self {
1986                            cpu: None,
1987                            g2d: Some(g2d),
1988                            #[cfg(feature = "opengl")]
1989                            opengl: None,
1990                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1991                            forced_backend: Some(ForcedBackend::G2d),
1992                        })
1993                    }
1994                    #[cfg(not(target_os = "linux"))]
1995                    {
1996                        Err(Error::ForcedBackendUnavailable(
1997                            "g2d backend is only available on Linux".into(),
1998                        ))
1999                    }
2000                }
2001                ForcedBackend::OpenGl => {
2002                    #[cfg(target_os = "linux")]
2003                    #[cfg(feature = "opengl")]
2004                    {
2005                        let opengl = GLProcessorThreaded::with_cache_capacity(
2006                            config.egl_display,
2007                            config.egl_cache_capacity,
2008                        )
2009                        .map_err(|e| {
2010                            Error::ForcedBackendUnavailable(format!(
2011                                "opengl forced but failed to initialize: {e:?}"
2012                            ))
2013                        })?;
2014                        Ok(Self {
2015                            cpu: None,
2016                            g2d: None,
2017                            opengl: Some(opengl),
2018                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2019                            forced_backend: Some(ForcedBackend::OpenGl),
2020                        }
2021                        .apply_colorimetry_mode(config.colorimetry))
2022                    }
2023                    #[cfg(any(target_os = "macos", target_os = "ios"))]
2024                    #[cfg(feature = "opengl")]
2025                    {
2026                        let opengl = GLProcessorThreaded::with_cache_capacity(
2027                            config.egl_display,
2028                            config.egl_cache_capacity,
2029                        )
2030                        .map_err(|e| {
2031                            Error::ForcedBackendUnavailable(format!(
2032                                "opengl forced on macOS but ANGLE init failed: {e:?}"
2033                            ))
2034                        })?;
2035                        Ok(Self {
2036                            cpu: None,
2037                            opengl: Some(opengl),
2038                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2039                            forced_backend: Some(ForcedBackend::OpenGl),
2040                        }
2041                        .apply_colorimetry_mode(config.colorimetry))
2042                    }
2043                    #[cfg(target_os = "android")]
2044                    #[cfg(feature = "opengl")]
2045                    {
2046                        let opengl = GLProcessorThreaded::with_cache_capacity(
2047                            config.egl_display,
2048                            config.egl_cache_capacity,
2049                        )
2050                        .map_err(|e| {
2051                            Error::ForcedBackendUnavailable(format!(
2052                                "opengl forced but native EGL init failed: {e:?}"
2053                            ))
2054                        })?;
2055                        Ok(Self {
2056                            cpu: None,
2057                            opengl: Some(opengl),
2058                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2059                            forced_backend: Some(ForcedBackend::OpenGl),
2060                        }
2061                        .apply_colorimetry_mode(config.colorimetry))
2062                    }
2063                    #[cfg(target_os = "windows")]
2064                    #[cfg(feature = "opengl")]
2065                    {
2066                        let opengl = GLProcessorThreaded::with_cache_capacity(
2067                            config.egl_display,
2068                            config.egl_cache_capacity,
2069                        )
2070                        .map_err(|e| {
2071                            Error::ForcedBackendUnavailable(format!(
2072                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
2073                            ))
2074                        })?;
2075                        Ok(Self {
2076                            cpu: None,
2077                            opengl: Some(opengl),
2078                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2079                            forced_backend: Some(ForcedBackend::OpenGl),
2080                        }
2081                        .apply_colorimetry_mode(config.colorimetry))
2082                    }
2083                    #[cfg(not(all(
2084                        any(
2085                            target_os = "linux",
2086                            target_os = "macos",
2087                            target_os = "ios",
2088                            target_os = "android",
2089                            target_os = "windows"
2090                        ),
2091                        feature = "opengl"
2092                    )))]
2093                    {
2094                        Err(Error::ForcedBackendUnavailable(
2095                            "opengl backend requires Linux, macOS, Android or Windows with the \
2096                             'opengl' feature enabled"
2097                                .into(),
2098                        ))
2099                    }
2100                }
2101            };
2102        }
2103
2104        // ── Existing DISABLE logic (unchanged) ──────────────────────
2105        #[cfg(target_os = "linux")]
2106        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
2107            .map(|x| x != "0" && x.to_lowercase() != "false")
2108            .unwrap_or(false)
2109        {
2110            log::debug!("EDGEFIRST_DISABLE_G2D is set");
2111            None
2112        } else {
2113            match G2DProcessor::new() {
2114                Ok(g2d_converter) => Some(g2d_converter),
2115                Err(err) => {
2116                    log::warn!("Failed to initialize G2D converter: {err:?}");
2117                    None
2118                }
2119            }
2120        };
2121
2122        #[cfg(target_os = "linux")]
2123        #[cfg(feature = "opengl")]
2124        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2125            .map(|x| x != "0" && x.to_lowercase() != "false")
2126            .unwrap_or(false)
2127        {
2128            log::debug!("EDGEFIRST_DISABLE_GL is set");
2129            None
2130        } else {
2131            match GLProcessorThreaded::with_cache_capacity(
2132                config.egl_display,
2133                config.egl_cache_capacity,
2134            ) {
2135                Ok(gl_converter) => Some(gl_converter),
2136                Err(err) => {
2137                    log::warn!("Failed to initialize GL converter: {err:?}");
2138                    None
2139                }
2140            }
2141        };
2142
2143        #[cfg(any(
2144            target_os = "macos",
2145            target_os = "ios",
2146            target_os = "android",
2147            target_os = "windows"
2148        ))]
2149        #[cfg(feature = "opengl")]
2150        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2151            .map(|x| x != "0" && x.to_lowercase() != "false")
2152            .unwrap_or(false)
2153        {
2154            log::debug!("EDGEFIRST_DISABLE_GL is set");
2155            None
2156        } else {
2157            match GLProcessorThreaded::with_cache_capacity(
2158                config.egl_display,
2159                config.egl_cache_capacity,
2160            ) {
2161                Ok(gl_converter) => Some(gl_converter),
2162                Err(err) => {
2163                    log::debug!(
2164                        "GL backend unavailable: {err:?} \
2165                         (CPU fallback will be used)"
2166                    );
2167                    None
2168                }
2169            }
2170        };
2171
2172        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
2173            .map(|x| x != "0" && x.to_lowercase() != "false")
2174            .unwrap_or(false)
2175        {
2176            log::debug!("EDGEFIRST_DISABLE_CPU is set");
2177            None
2178        } else {
2179            Some(CPUProcessor::new())
2180        };
2181        Ok(Self {
2182            cpu,
2183            #[cfg(target_os = "linux")]
2184            g2d,
2185            #[cfg(target_os = "linux")]
2186            #[cfg(feature = "opengl")]
2187            opengl,
2188            #[cfg(any(
2189                target_os = "macos",
2190                target_os = "ios",
2191                target_os = "android",
2192                target_os = "windows"
2193            ))]
2194            #[cfg(feature = "opengl")]
2195            opengl,
2196            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2197            forced_backend: None,
2198        }
2199        .apply_colorimetry_mode(config.colorimetry))
2200    }
2201
2202    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
2203    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
2204    /// plumbing for [`ImageProcessorConfig::colorimetry`].
2205    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
2206        #[cfg(all(
2207            any(
2208                target_os = "linux",
2209                target_os = "macos",
2210                target_os = "ios",
2211                target_os = "android",
2212                target_os = "windows"
2213            ),
2214            feature = "opengl"
2215        ))]
2216        {
2217            let mut me = self;
2218            if let Err(e) = me.set_colorimetry_mode(_mode) {
2219                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2220            }
2221            me
2222        }
2223        #[cfg(not(all(
2224            any(
2225                target_os = "linux",
2226                target_os = "macos",
2227                target_os = "ios",
2228                target_os = "android",
2229                target_os = "windows"
2230            ),
2231            feature = "opengl"
2232        )))]
2233        {
2234            let _ = _mode;
2235            self
2236        }
2237    }
2238
2239    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2240    /// on the OpenGL backend. No-op if OpenGL is not available. The
2241    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2242    /// it is set, this call logs and keeps the env-selected mode.
2243    #[cfg(all(
2244        any(
2245            target_os = "linux",
2246            target_os = "macos",
2247            target_os = "ios",
2248            target_os = "android",
2249            target_os = "windows"
2250        ),
2251        feature = "opengl"
2252    ))]
2253    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2254        if let Some(ref mut gl) = self.opengl {
2255            gl.set_colorimetry_mode(mode)?;
2256        }
2257        Ok(())
2258    }
2259
2260    /// Sets how `convert()` resamples when it resizes (see [`Interpolation`])
2261    /// on every backend this processor holds. The `EDGEFIRST_INTERPOLATION`
2262    /// environment variable takes precedence: when it is set, this call logs
2263    /// and keeps the env-selected mode.
2264    pub fn set_interpolation(&mut self, mode: Interpolation) -> Result<()> {
2265        let mode = match Interpolation::from_env() {
2266            Some(env) => {
2267                if env != mode {
2268                    log::info!(
2269                        "EDGEFIRST_INTERPOLATION pins Interpolation::{env:?}; \
2270                         ignoring Interpolation::{mode:?}"
2271                    );
2272                }
2273                env
2274            }
2275            None => mode,
2276        };
2277        if let Some(cpu) = self.cpu.as_mut() {
2278            cpu.set_interpolation(mode);
2279        }
2280        #[cfg(target_os = "linux")]
2281        if let Some(g2d) = self.g2d.as_mut() {
2282            g2d.set_interpolation(mode);
2283        }
2284        #[cfg(all(
2285            any(
2286                target_os = "linux",
2287                target_os = "macos",
2288                target_os = "ios",
2289                target_os = "android",
2290                target_os = "windows"
2291            ),
2292            feature = "opengl"
2293        ))]
2294        if let Some(gl) = self.opengl.as_mut() {
2295            gl.set_interpolation(mode)?;
2296        }
2297        Ok(())
2298    }
2299
2300    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2301    /// backend. No-op if OpenGL is not available.
2302    #[cfg(all(
2303        any(
2304            target_os = "linux",
2305            target_os = "macos",
2306            target_os = "ios",
2307            target_os = "android",
2308            target_os = "windows"
2309        ),
2310        feature = "opengl"
2311    ))]
2312    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2313        if let Some(ref mut gl) = self.opengl {
2314            gl.set_int8_interpolation_mode(mode)?;
2315        }
2316        Ok(())
2317    }
2318
2319    /// The declaration [`create_image`](Self::create_image) allocates a
2320    /// `memory` destination with: the caller's `access` plus whatever this
2321    /// processor's own convert may need on the CPU (see
2322    /// [`predicted_cpu_access`]). `views` marks a destination written through
2323    /// views, where any CPU write is partial and so needs `ReadWrite`.
2324    fn producer_access(
2325        &self,
2326        access: edgefirst_tensor::CpuAccess,
2327        format: PixelFormat,
2328        dtype: DType,
2329        memory: TensorMemory,
2330        views: bool,
2331    ) -> edgefirst_tensor::CpuAccess {
2332        use edgefirst_tensor::CpuAccess;
2333        #[cfg(all(
2334            any(
2335                target_os = "linux",
2336                target_os = "macos",
2337                target_os = "ios",
2338                target_os = "android",
2339                target_os = "windows"
2340            ),
2341            feature = "opengl"
2342        ))]
2343        let gl = self
2344            .opengl
2345            .as_ref()
2346            .map(|gl| gl.dst_cpu_access(memory, format, dtype));
2347        #[cfg(not(all(
2348            any(
2349                target_os = "linux",
2350                target_os = "macos",
2351                target_os = "ios",
2352                target_os = "android",
2353                target_os = "windows"
2354            ),
2355            feature = "opengl"
2356        )))]
2357        let gl = None;
2358        #[cfg(target_os = "linux")]
2359        let g2d = self
2360            .g2d
2361            .as_ref()
2362            .map(|_| g2d::dst_cpu_access(memory, format, dtype));
2363        #[cfg(not(target_os = "linux"))]
2364        let g2d = None;
2365        let cpu = self
2366            .cpu
2367            .as_ref()
2368            .map(|_| Some(cpu::convert_dst_access(None, format, dtype)));
2369        let needed = predicted_cpu_access(self.forced_backend, gl, g2d, cpu);
2370        let needed = if views && needed.writes() {
2371            CpuAccess::ReadWrite
2372        } else {
2373            needed
2374        };
2375        access.union(needed)
2376    }
2377
2378    /// Allocate an image tensor from a declarative
2379    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2380    /// full-featured variant of [`create_image`](Self::create_image).
2381    ///
2382    /// Without a compression or contiguous request this is exactly
2383    /// `create_image` (the processor's memory negotiation applies). With
2384    /// one, the allocation rides the tensor desc path un-negotiated: the
2385    /// layout decision belongs to the platform allocator, and the request's
2386    /// guards and fallback counting live there (see
2387    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)). A
2388    /// contiguous request is a CMA DMA-BUF at the same 64-byte GPU pitch
2389    /// `create_image` uses, or an error; it never falls back to a PBO or
2390    /// host memory. Its CPU access gets the same widening as `create_image`;
2391    /// a compression request's `CpuAccess::None` does not.
2392    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2393        // Tile compression requires `CpuAccess::None`, so it gets no widening.
2394        if desc.compression().is_some() {
2395            return Ok(TensorDyn::image_desc(desc)?);
2396        }
2397        if !desc.contiguous() {
2398            return self.create_image(
2399                desc.width(),
2400                desc.height(),
2401                desc.format(),
2402                desc.dtype(),
2403                desc.memory(),
2404                desc.access(),
2405            );
2406        }
2407        let access = self.producer_access(
2408            desc.access(),
2409            desc.format(),
2410            desc.dtype(),
2411            TensorMemory::DmaBuf,
2412            false,
2413        );
2414        Ok(TensorDyn::image_desc(&desc.clone().with_access(access))?)
2415    }
2416
2417    /// Create a [`TensorDyn`] image with the best available memory backend.
2418    ///
2419    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2420    ///
2421    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2422    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2423    /// memory backing (including PBO for GPU zero-copy) which direct
2424    /// allocation cannot achieve.
2425    ///
2426    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2427    /// because optimal allocation requires knowledge of the active compute
2428    /// backends (e.g. the GL context handle for PBO allocation). Individual
2429    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2430    /// cross-backend visibility.
2431    ///
2432    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2433    /// [`supported_render_dtypes`] reports the GPU supports that type,
2434    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2435    /// F16 only). If GPU float support is absent the allocation falls through
2436    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2437    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2438    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2439    ///
2440    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2441    /// [`convert`]: ImageProcessorTrait::convert
2442    ///
2443    /// # Arguments
2444    ///
2445    /// * `width` - Image width in pixels
2446    /// * `height` - Image height in pixels
2447    /// * `format` - Pixel format
2448    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2449    /// * `memory` - Optional memory type override; when `None`, the best
2450    ///   available backend is selected automatically.
2451    /// * `access` - Declares the CPU access you plan on (`ReadWrite` is the
2452    ///   natural choice). Use [`CpuAccess::None`](edgefirst_tensor::CpuAccess)
2453    ///   for a destination that only hardware reads (an NPU input).
2454    ///   Hardware access is always implied. What this processor's own
2455    ///   convert may do to the image as a destination on the CPU is added
2456    ///   to the declaration so that convert succeeds: `Write` when it may
2457    ///   fill the whole destination on the CPU (the CPU backend, host
2458    ///   memory, a format or float dtype the GPU path does not render),
2459    ///   `ReadWrite` when a pass reads back what was written or writes only
2460    ///   part of the buffer (an `I8` destination through the CPU or G2D, a
2461    ///   `Bgra` destination through the CPU, a G2D `Rgb` letterbox border).
2462    ///   The returned tensor's [`cpu_access`](edgefirst_tensor::TensorDyn::cpu_access)
2463    ///   may therefore be wider than `access`. Any other CPU map beyond the
2464    ///   declaration fails with
2465    ///   [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied).
2466    ///   The widening covers the destination role of convert only: a
2467    ///   tensor later used as a convert source needs `Read` declared by the
2468    ///   caller, and a CPU mask draw blends into its destination and needs
2469    ///   `ReadWrite`, so declare it when the CPU may draw. A destination
2470    ///   written through views (a batch) needs `ReadWrite` for a CPU convert,
2471    ///   which [`alloc_tile_batch`](Self::alloc_tile_batch) predicts. A
2472    ///   destination left without CPU access (`None` on a GPU-rendered
2473    ///   format) is written only by the GPU, so a convert into it needs the
2474    ///   GPU to accept the source as well; when it declines, the CPU fallback
2475    ///   is refused with [`Error::CpuFallbackDenied`] carrying the GPU's
2476    ///   reason. A PBO destination is always `ReadWrite`: the GL readback
2477    ///   maps it on the CPU itself, and a narrower declaration would buy no
2478    ///   cheaper mapping.
2479    ///
2480    /// # Returns
2481    ///
2482    /// A [`TensorDyn`] backed by the highest-performance memory type
2483    /// available on this system.
2484    ///
2485    /// # Examples
2486    ///
2487    /// ```rust,no_run
2488    /// # use edgefirst_image::ImageProcessor;
2489    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2490    /// # fn main() -> Result<(), edgefirst_image::Error> {
2491    /// let processor = ImageProcessor::new()?;
2492    ///
2493    /// // Model input the NPU reads directly — no CPU mapping planned.
2494    /// let input = processor.create_image(
2495    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2496    /// )?;
2497    /// # let _ = input;
2498    /// # Ok(())
2499    /// # }
2500    /// ```
2501    ///
2502    /// # Pitch alignment for DMA-backed allocations
2503    ///
2504    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2505    /// specifically) require every row pitch to be a multiple of
2506    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2507    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2508    /// silently padded so the row stride satisfies that requirement.
2509    ///
2510    /// **The user-requested `width` is preserved** — `tensor.width()`
2511    /// returns the same value you passed in. The padding is carried by
2512    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2513    /// GL backend reads when importing the buffer as an EGLImage.
2514    /// Callers that compute byte offsets from the tensor must use the
2515    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2516    /// full `stride × height` bytes.
2517    ///
2518    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2519    /// exactly `width × bpp × height` bytes with no padding. PBO and
2520    /// Mem fallbacks never pad — they don't go through EGLImage import.
2521    ///
2522    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2523    /// that external callers (GStreamer plugins, video pipelines) can
2524    /// use to size their own DMA-BUFs for GL compatibility.
2525    ///
2526    /// # Errors
2527    ///
2528    /// Returns an error if all allocation strategies fail.
2529    pub fn create_image(
2530        &self,
2531        width: usize,
2532        height: usize,
2533        format: PixelFormat,
2534        dtype: DType,
2535        memory: Option<TensorMemory>,
2536        access: edgefirst_tensor::CpuAccess,
2537    ) -> Result<TensorDyn> {
2538        self.create_image_with(width, height, format, dtype, memory, access, false)
2539    }
2540
2541    /// A DMA image for [`create_image_with`](Self::create_image_with), its
2542    /// packed rows padded to the GPU pitch alignment when that exceeds the
2543    /// natural pitch.
2544    ///
2545    /// Semi-planar and planar formats keep their natural layout: they are
2546    /// imported from camera capture via `from_fd` far more often than
2547    /// allocated here, and `Tensor::image_with_stride` rejects them.
2548    #[cfg(target_os = "linux")]
2549    fn create_dma_image(
2550        &self,
2551        width: usize,
2552        height: usize,
2553        format: PixelFormat,
2554        dtype: DType,
2555        access: edgefirst_tensor::CpuAccess,
2556        views: bool,
2557    ) -> Result<TensorDyn> {
2558        let dma_access = self.producer_access(access, format, dtype, TensorMemory::DmaBuf, views);
2559        let natural =
2560            primary_plane_bpp(format, dtype.size()).and_then(|bpp| width.checked_mul(bpp));
2561        let padded = natural
2562            .and_then(align_pitch_bytes_to_gpu_alignment)
2563            .filter(|&stride| {
2564                format.layout() == edgefirst_tensor::PixelLayout::Packed
2565                    && natural.is_some_and(|n| stride > n)
2566            });
2567        let memory = Some(edgefirst_tensor::TensorMemory::DmaBuf);
2568        match padded {
2569            Some(stride) => {
2570                log::debug!(
2571                    "create_image: padding row stride for {format:?} {width}x{height} \
2572                     from natural pitch to {stride} bytes for GPU alignment"
2573                );
2574                Ok(TensorDyn::image_with_stride(
2575                    width, height, format, dtype, stride, memory, dma_access,
2576                )?)
2577            }
2578            None => Ok(TensorDyn::image(
2579                width, height, format, dtype, memory, dma_access,
2580            )?),
2581        }
2582    }
2583
2584    /// A u8/i8 PBO image for [`create_image_with`](Self::create_image_with),
2585    /// or `None` when the dtype is wider or there is no PBO to be had. A PBO
2586    /// stays `ReadWrite`: the GL readback maps it itself.
2587    #[cfg(any(target_os = "linux", target_os = "windows"))]
2588    #[cfg(feature = "opengl")]
2589    fn create_byte_pbo_image(
2590        &self,
2591        width: usize,
2592        height: usize,
2593        format: PixelFormat,
2594        dtype: DType,
2595    ) -> Option<TensorDyn> {
2596        if dtype.size() != 1 {
2597            return None;
2598        }
2599        let gl = self.opengl.as_ref()?;
2600        let t = gl
2601            .create_pbo_image(width, height, format)
2602            .map_err(|e| log::debug!("PBO image creation failed, falling back to Mem: {e:?}"))
2603            .ok()?;
2604        if dtype != DType::I8 {
2605            return Some(TensorDyn::from(t));
2606        }
2607        // The int8 shader emulates i8 output via XOR 0x80 on the same u8 buffer.
2608        // Invariant: PBO tensors never have chroma (create_pbo_image → Tensor::wrap sets it None).
2609        debug_assert!(
2610            t.chroma().is_none(),
2611            "PBO i8 transmute requires chroma == None"
2612        );
2613        // SAFETY: Tensor<u8> and Tensor<i8> are layout-identical (same element
2614        // size, no T-dependent drop glue); same rationale as
2615        // gl::processor::tensor_i8_as_u8_mut.
2616        let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2617        Some(TensorDyn::from(t_i8))
2618    }
2619
2620    /// [`create_image`](Self::create_image), for a destination written
2621    /// through views when `views` is set (see `producer_access`).
2622    #[allow(clippy::too_many_arguments)]
2623    pub(crate) fn create_image_with(
2624        &self,
2625        width: usize,
2626        height: usize,
2627        format: PixelFormat,
2628        dtype: DType,
2629        memory: Option<TensorMemory>,
2630        access: edgefirst_tensor::CpuAccess,
2631        views: bool,
2632    ) -> Result<TensorDyn> {
2633        #[cfg(target_os = "linux")]
2634        let try_dma = || self.create_dma_image(width, height, format, dtype, access, views);
2635
2636        // If an explicit memory type is requested, honour it directly.
2637        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2638        // other memory types take the user-requested width verbatim.
2639        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2640        // which selects the IOSurface allocation path (FourCC-formatted)
2641        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2642        match memory {
2643            #[cfg(target_os = "linux")]
2644            Some(TensorMemory::DmaBuf) => {
2645                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2646                if dtype == DType::F32 {
2647                    return Err(Error::NotSupported(
2648                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2649                         use TensorMemory::Pbo for F32"
2650                            .to_string(),
2651                    ));
2652                }
2653                return try_dma();
2654            }
2655            Some(mem) => {
2656                return Ok(TensorDyn::image(
2657                    width,
2658                    height,
2659                    format,
2660                    dtype,
2661                    Some(mem),
2662                    self.producer_access(access, format, dtype, mem, views),
2663                )?);
2664            }
2665            None => {}
2666        }
2667
2668        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2669        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2670        // on Windows) for zero-copy import. Formats without a zero-copy
2671        // mapping now ERROR under explicit Dma (the explicit-Dma
2672        // contract), so auto-select catches that error here and falls
2673        // back to host storage — loudly, via the debug log below.
2674        #[cfg(any(
2675            target_os = "macos",
2676            target_os = "ios",
2677            target_os = "android",
2678            target_os = "windows"
2679        ))]
2680        #[cfg(feature = "opengl")]
2681        if let Some(gl) = self.opengl.as_ref() {
2682            let _ = gl;
2683            // Float textures only where the display can render into them;
2684            // otherwise the float PBO step below applies. The same question
2685            // that step asks, asked through the same helper so the two
2686            // cannot answer differently.
2687            let support = self.supported_render_dtypes();
2688            let dtype_ok =
2689                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2690            if dtype_ok {
2691                match TensorDyn::image(
2692                    width,
2693                    height,
2694                    format,
2695                    dtype,
2696                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2697                    self.producer_access(access, format, dtype, TensorMemory::DmaBuf, views),
2698                ) {
2699                    Ok(img) => return Ok(img),
2700                    Err(e) => {
2701                        // Falling back to a non-zero-copy destination is a real
2702                        // perf cliff — never do it silently (on-device triage
2703                        // starts from this line).
2704                        log::debug!(
2705                            "create_image: zero-copy Dma allocation declined \
2706                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2707                        );
2708                    }
2709                }
2710            }
2711        }
2712
2713        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2714        // as the preferred transfer path (PBO is better than DMA in that case).
2715        #[cfg(target_os = "linux")]
2716        {
2717            #[cfg(feature = "opengl")]
2718            let gl_uses_pbo = self
2719                .opengl
2720                .as_ref()
2721                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2722            #[cfg(not(feature = "opengl"))]
2723            let gl_uses_pbo = false;
2724
2725            if !gl_uses_pbo {
2726                if let Ok(img) = try_dma() {
2727                    return Ok(img);
2728                }
2729            }
2730        }
2731
2732        #[cfg(any(target_os = "linux", target_os = "windows"))]
2733        #[cfg(feature = "opengl")]
2734        if let Some(img) = self.create_byte_pbo_image(width, height, format, dtype) {
2735            return Ok(img);
2736        }
2737
2738        // Try float PBO when the GPU backend reports support for this dtype.
2739        // Falls through to Mem on error (same policy as u8 PBO above).
2740        #[cfg(any(target_os = "linux", target_os = "windows"))]
2741        #[cfg(feature = "opengl")]
2742        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2743            if let Some(gl) = &self.opengl {
2744                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2745                    Ok(t) => return Ok(t),
2746                    Err(e) => {
2747                        log::debug!(
2748                            "Float PBO image creation failed for {dtype:?}, \
2749                             falling back to Mem: {e:?}"
2750                        );
2751                    }
2752                }
2753            }
2754        }
2755
2756        // Fallback to Mem
2757        Ok(TensorDyn::image(
2758            width,
2759            height,
2760            format,
2761            dtype,
2762            Some(edgefirst_tensor::TensorMemory::Mem),
2763            self.producer_access(access, format, dtype, TensorMemory::Mem, views),
2764        )?)
2765    }
2766
2767    /// Import an external DMA-BUF image.
2768    ///
2769    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2770    /// consumes the descriptors and takes ownership of those fds (whether
2771    /// the call succeeds or fails).
2772    ///
2773    /// The caller must ensure the DMA-BUF allocation is large enough for the
2774    /// specified width, height, format, and any stride/offset on the plane
2775    /// descriptors. No buffer-size validation is performed; an undersized
2776    /// buffer may cause GPU faults or EGL import failure.
2777    ///
2778    /// # Arguments
2779    ///
2780    /// * `image` - Plane descriptor for the primary (or only) plane
2781    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2782    ///   (required for multiplane NV12)
2783    /// * `width` - Image width in pixels
2784    /// * `height` - Image height in pixels
2785    /// * `format` - Pixel format of the buffer
2786    /// * `dtype` - Element data type (e.g. `DType::U8`)
2787    ///
2788    /// # Returns
2789    ///
2790    /// A `TensorDyn` configured as an image.
2791    ///
2792    /// # Errors
2793    ///
2794    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2795    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2796    ///   DMA-backed
2797    /// * [`Error::InvalidShape`] if NV12 height is odd
2798    ///
2799    /// # Platform
2800    ///
2801    /// Linux only.
2802    ///
2803    /// # Examples
2804    ///
2805    /// ```rust,ignore
2806    /// use edgefirst_tensor::PlaneDescriptor;
2807    ///
2808    /// // Single-plane RGBA
2809    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2810    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2811    ///
2812    /// // Multi-plane NV12 with stride
2813    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2814    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2815    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2816    ///                             PixelFormat::Nv12, DType::U8, None)?;
2817    /// ```
2818    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2819    // a params struct would obscure more than it clarifies here.
2820    #[allow(clippy::too_many_arguments)]
2821    #[cfg(target_os = "linux")]
2822    pub fn import_image(
2823        &self,
2824        image: edgefirst_tensor::PlaneDescriptor,
2825        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2826        width: usize,
2827        height: usize,
2828        format: PixelFormat,
2829        dtype: DType,
2830        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2831    ) -> Result<TensorDyn> {
2832        use edgefirst_tensor::{Tensor, TensorMemory};
2833
2834        // Capture stride/offset from descriptors before consuming them
2835        let image_stride = image.stride();
2836        let image_offset = image.offset();
2837        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2838        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2839
2840        if let Some(chroma_pd) = chroma {
2841            // ── Multiplane path ──────────────────────────────────────
2842            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2843            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2844            // tensor with the wrong element type.
2845            if dtype != DType::U8 && dtype != DType::I8 {
2846                return Err(Error::NotSupported(format!(
2847                    "multiplane import only supports U8/I8, got {dtype:?}"
2848                )));
2849            }
2850            if format.layout() != PixelLayout::SemiPlanar {
2851                return Err(Error::NotSupported(format!(
2852                    "import_image with chroma requires a semi-planar format, got {format:?}"
2853                )));
2854            }
2855
2856            let chroma_h = match format {
2857                PixelFormat::Nv12 => {
2858                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2859                    height.div_ceil(2)
2860                }
2861                // NV16 multiplane will be supported in a future release;
2862                // the GL backend currently only handles NV12 plane1 attributes.
2863                PixelFormat::Nv16 => {
2864                    return Err(Error::NotSupported(
2865                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2866                    ))
2867                }
2868                _ => {
2869                    return Err(Error::NotSupported(format!(
2870                        "unsupported semi-planar format: {format:?}"
2871                    )))
2872                }
2873            };
2874
2875            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2876            if luma.memory() != TensorMemory::DmaBuf {
2877                return Err(Error::NotSupported(format!(
2878                    "luma fd must be DMA-backed, got {:?}",
2879                    luma.memory()
2880                )));
2881            }
2882
2883            let mut chroma_tensor =
2884                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2885            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2886                return Err(Error::NotSupported(format!(
2887                    "chroma fd must be DMA-backed, got {:?}",
2888                    chroma_tensor.memory()
2889                )));
2890            }
2891
2892            // Geometry on the chroma plane must be set before from_planes
2893            // consumes it: the combined handle answers chroma() from the
2894            // library, and there is no chroma_mut() on the dynamic lens.
2895            if let Some(s) = chroma_stride {
2896                if s < width {
2897                    return Err(Error::InvalidShape(format!(
2898                        "chroma stride {s} < minimum {width} for {format:?}"
2899                    )));
2900                }
2901                chroma_tensor.set_row_stride_unchecked(s);
2902            }
2903            if let Some(o) = chroma_offset {
2904                chroma_tensor.set_plane_offset(o);
2905            }
2906
2907            // from_planes creates the combined tensor with format set,
2908            // preserving luma's row_stride (currently None since luma was raw).
2909            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2910
2911            // Apply stride/offset to the combined tensor (luma plane)
2912            if let Some(s) = image_stride {
2913                tensor.set_row_stride(s)?;
2914            }
2915            if let Some(o) = image_offset {
2916                tensor.set_plane_offset(o);
2917            }
2918
2919            if dtype == DType::I8 {
2920                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2921                // the struct contains only type-erased storage (OwnedFd, shape, name),
2922                // no inline T values. This assertion catches layout drift at compile time.
2923                const {
2924                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2925                    assert!(
2926                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2927                    );
2928                }
2929                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2930                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2931                dyn_tensor.set_colorimetry(colorimetry);
2932                return Ok(dyn_tensor);
2933            }
2934            let mut dyn_tensor = TensorDyn::from(tensor);
2935            dyn_tensor.set_colorimetry(colorimetry);
2936            Ok(dyn_tensor)
2937        } else {
2938            // ── Single-plane path ────────────────────────────────────
2939            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2940            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2941            // hand-rolled match erroneously rejected NV24).
2942            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2943                Error::NotSupported(format!(
2944                    "unsupported pixel format for import_image: {format:?}"
2945                ))
2946            })?;
2947            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2948            if tensor.memory() != TensorMemory::DmaBuf {
2949                return Err(Error::NotSupported(format!(
2950                    "import_image requires DMA-backed fd, got {:?}",
2951                    tensor.memory()
2952                )));
2953            }
2954            let mut tensor = tensor.with_format(format)?;
2955            if let Some(s) = image_stride {
2956                tensor.set_row_stride(s)?;
2957            }
2958            if let Some(o) = image_offset {
2959                tensor.set_plane_offset(o);
2960            }
2961            tensor.set_colorimetry(colorimetry);
2962            Ok(tensor)
2963        }
2964    }
2965
2966    /// Decode model outputs and draw segmentation masks onto `dst`.
2967    ///
2968    /// The processor decodes via the provided
2969    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2970    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2971    ///
2972    /// Returns the detected bounding boxes.
2973    ///
2974    /// **Requires the `decode` feature**, which is off by default because it
2975    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2976    /// method sequences a decode and a draw; both halves are available
2977    /// unconditionally and are equally fused, so without the feature write:
2978    ///
2979    /// ```ignore
2980    /// let mut boxes = Vec::new();
2981    /// match decoder.decode_proto(outputs, &mut boxes)? {
2982    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2983    ///     None => {
2984    ///         let mut masks = Vec::new();
2985    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2986    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2987    ///     }
2988    /// }
2989    /// ```
2990    #[cfg(feature = "decode")]
2991    pub fn draw_masks(
2992        &mut self,
2993        decoder: &edgefirst_decoder::Decoder,
2994        outputs: &[&TensorDyn],
2995        dst: &mut TensorDyn,
2996        overlay: MaskOverlay<'_>,
2997    ) -> Result<Vec<DetectBox>> {
2998        let mut output_boxes = Vec::with_capacity(100);
2999
3000        // Try proto path first (fused rendering without materializing masks)
3001        let proto_result = decoder
3002            .decode_proto(outputs, &mut output_boxes)
3003            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
3004
3005        if let Some(proto_data) = proto_result {
3006            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
3007        } else {
3008            // Detection-only or unsupported model: full decode + render
3009            let mut output_masks = Vec::with_capacity(100);
3010            decoder
3011                .decode(outputs, &mut output_boxes, &mut output_masks)
3012                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
3013            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
3014        }
3015        Ok(output_boxes)
3016    }
3017
3018    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
3019    ///
3020    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
3021    /// maintaining object identities across frames. The tracker runs after
3022    /// NMS but before mask extraction.
3023    ///
3024    /// Returns detected boxes and track info.
3025    #[cfg(feature = "tracker")]
3026    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
3027        &mut self,
3028        decoder: &edgefirst_decoder::Decoder,
3029        tracker: &mut TR,
3030        timestamp: u64,
3031        outputs: &[&TensorDyn],
3032        dst: &mut TensorDyn,
3033        overlay: MaskOverlay<'_>,
3034    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
3035        let mut output_boxes = Vec::with_capacity(100);
3036        let mut output_tracks = Vec::new();
3037
3038        let proto_result = decoder
3039            .decode_proto_tracked(
3040                tracker,
3041                timestamp,
3042                outputs,
3043                &mut output_boxes,
3044                &mut output_tracks,
3045            )
3046            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
3047
3048        if let Some(proto_data) = proto_result {
3049            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
3050        } else {
3051            // Note: decode_proto_tracked returns None for detection-only/ModelPack
3052            // models WITHOUT calling the tracker. The else branch below is the
3053            // first (and only) tracker call for those model types.
3054            let mut output_masks = Vec::with_capacity(100);
3055            decoder
3056                .decode_tracked(
3057                    tracker,
3058                    timestamp,
3059                    outputs,
3060                    &mut output_boxes,
3061                    &mut output_masks,
3062                    &mut output_tracks,
3063                )
3064                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
3065            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
3066        }
3067        Ok((output_boxes, output_tracks))
3068    }
3069
3070    /// Materialize per-instance segmentation masks from raw prototype data.
3071    ///
3072    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
3073    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
3074    /// Mask values are continuous sigmoid confidence outputs quantized to u8
3075    /// (0 = background, 255 = full confidence), NOT binary thresholded.
3076    ///
3077    /// The returned [`Vec<Segmentation>`] can be:
3078    /// - Inspected or exported for analytics, IoU computation, etc.
3079    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
3080    ///   GPU-interpolated rendering.
3081    ///
3082    /// # Performance Note
3083    ///
3084    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
3085    /// the HAL from using its internal fused optimization path. For render-only
3086    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
3087    /// the fastest path automatically (currently 1.6×–27× faster on tested
3088    /// platforms). Use this method when you need access to the intermediate masks.
3089    ///
3090    /// # Errors
3091    ///
3092    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
3093    pub fn materialize_masks(
3094        &mut self,
3095        detect: &[DetectBox],
3096        proto_data: &ProtoData,
3097        letterbox: Option<[f32; 4]>,
3098        resolution: MaskResolution,
3099    ) -> Result<Vec<Segmentation>> {
3100        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
3101        match resolution {
3102            MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
3103            MaskResolution::Scaled { width, height } => {
3104                cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
3105            }
3106        }
3107    }
3108}
3109
3110impl ImageProcessorTrait for ImageProcessor {
3111    /// Converts the source image to the destination image format and size. The
3112    /// image is cropped first, then flipped, then rotated
3113    ///
3114    /// Prefer hardware accelerators when available, falling back to CPU if
3115    /// necessary.
3116    fn convert(
3117        &mut self,
3118        src: &TensorDyn,
3119        dst: &mut TensorDyn,
3120        options: Option<ConvertOptions>,
3121    ) -> Result<()> {
3122        let options = options.unwrap_or_default();
3123        let (rotation, flip) = (options.get_rotation(), options.get_flip());
3124        let start = Instant::now();
3125        let src_fmt = src.format();
3126        let dst_fmt = dst.format();
3127        let _span = tracing::trace_span!(
3128            "image.convert",
3129            ?src_fmt,
3130            ?dst_fmt,
3131            src_memory = ?src.memory(),
3132            dst_memory = ?dst.memory(),
3133            ?rotation,
3134            ?flip,
3135        )
3136        .entered();
3137        log::trace!(
3138            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
3139             rotation={rotation:?}, flip={flip:?}, backend={:?}",
3140            src.dtype(),
3141            src.memory(),
3142            dst.dtype(),
3143            dst.memory(),
3144            self.forced_backend,
3145        );
3146
3147        // ── Forced backend: no fallback chain ────────────────────────
3148        if let Some(forced) = self.forced_backend {
3149            return match forced {
3150                ForcedBackend::Cpu => {
3151                    if let Some(cpu) = self.cpu.as_mut() {
3152                        let r = cpu.convert(src, dst, Some(options));
3153                        log::trace!(
3154                            "convert: forced=cpu result={} ({:?})",
3155                            if r.is_ok() { "ok" } else { "err" },
3156                            start.elapsed()
3157                        );
3158                        return r;
3159                    }
3160                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3161                }
3162                ForcedBackend::G2d => {
3163                    #[cfg(target_os = "linux")]
3164                    if let Some(g2d) = self.g2d.as_mut() {
3165                        let r = g2d.convert(src, dst, Some(options));
3166                        log::trace!(
3167                            "convert: forced=g2d result={} ({:?})",
3168                            if r.is_ok() { "ok" } else { "err" },
3169                            start.elapsed()
3170                        );
3171                        return r;
3172                    }
3173                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3174                }
3175                ForcedBackend::OpenGl => {
3176                    #[cfg(any(
3177                        target_os = "linux",
3178                        target_os = "macos",
3179                        target_os = "ios",
3180                        target_os = "android",
3181                        target_os = "windows"
3182                    ))]
3183                    #[cfg(feature = "opengl")]
3184                    if let Some(opengl) = self.opengl.as_mut() {
3185                        let r = opengl.convert(src, dst, Some(options));
3186                        log::trace!(
3187                            "convert: forced=opengl result={} ({:?})",
3188                            if r.is_ok() { "ok" } else { "err" },
3189                            start.elapsed()
3190                        );
3191                        return r;
3192                    }
3193                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3194                }
3195            };
3196        }
3197
3198        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
3199        #[allow(unused_mut)]
3200        let mut declined: Vec<(&'static str, Error)> = Vec::new();
3201        #[cfg(any(
3202            target_os = "linux",
3203            target_os = "macos",
3204            target_os = "ios",
3205            target_os = "android",
3206            target_os = "windows"
3207        ))]
3208        #[cfg(feature = "opengl")]
3209        if let Some(opengl) = self.opengl.as_mut() {
3210            match opengl.convert(src, dst, Some(options)) {
3211                Ok(_) => {
3212                    log::trace!(
3213                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
3214                        start.elapsed()
3215                    );
3216                    return Ok(());
3217                }
3218                Err(e) => {
3219                    self.convert_fallbacks
3220                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3221                    log::debug!(
3222                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
3223                         falling back toward G2D/CPU: {e}",
3224                        src.memory(),
3225                        dst.memory(),
3226                    );
3227                    declined.push(("opengl", e));
3228                }
3229            }
3230        }
3231
3232        #[cfg(target_os = "linux")]
3233        if let Some(g2d) = self.g2d.as_mut() {
3234            // G2D is matrix-only (no range control, no BT.2020). For any
3235            // conversion with a YUV side, resolve that side's colorimetry and
3236            // skip G2D entirely when it cannot be expressed (full-range YUV or
3237            // BT.2020), letting the chain fall through to GL/CPU which honour
3238            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
3239            // uses the destination. RGB→RGB has no YUV side and is unaffected.
3240            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
3241            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
3242            let g2d_eligible = if src_is_yuv || dst_is_yuv {
3243                let cm = if src_is_yuv {
3244                    crate::colorimetry::effective_colorimetry(src)
3245                } else {
3246                    crate::colorimetry::effective_colorimetry(dst)
3247                };
3248                crate::g2d::g2d_can_handle(&cm, true)
3249            } else {
3250                true
3251            };
3252            if !g2d_eligible {
3253                log::trace!(
3254                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
3255                     (colorimetry not expressible: full-range/BT.2020)"
3256                );
3257            } else {
3258                match g2d.convert(src, dst, Some(options)) {
3259                    Ok(_) => {
3260                        log::trace!(
3261                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
3262                            start.elapsed()
3263                        );
3264                        return Ok(());
3265                    }
3266                    Err(e) => {
3267                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
3268                        declined.push(("g2d", e));
3269                    }
3270                }
3271            }
3272        }
3273
3274        if let Some(cpu) = self.cpu.as_mut() {
3275            match cpu.convert(src, dst, Some(options)) {
3276                Ok(_) => {
3277                    log::trace!(
3278                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
3279                        start.elapsed()
3280                    );
3281                    return Ok(());
3282                }
3283                Err(e) => {
3284                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
3285                    return Err(with_gpu_declines(e, declined));
3286                }
3287            }
3288        }
3289        Err(Error::NoConverter)
3290    }
3291
3292    fn convert_deferred(
3293        &mut self,
3294        src: &TensorDyn,
3295        dst: &mut TensorDyn,
3296        options: Option<ConvertOptions>,
3297    ) -> Result<()> {
3298        let options = options.unwrap_or_default();
3299        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
3300        // no per-tile glFinish). Route to the GL backend's deferred path when GL
3301        // is forced or auto-selectable; on a GL decline fall back to an eager
3302        // convert (the auto chain), which is correct everywhere — it completes
3303        // synchronously and `flush` stays a no-op for non-GL backends.
3304        #[cfg(any(
3305            target_os = "linux",
3306            target_os = "macos",
3307            target_os = "ios",
3308            target_os = "android",
3309            target_os = "windows"
3310        ))]
3311        #[cfg(feature = "opengl")]
3312        {
3313            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
3314            if gl_forced || self.forced_backend.is_none() {
3315                if let Some(opengl) = self.opengl.as_mut() {
3316                    match opengl.convert_deferred(src, dst, Some(options)) {
3317                        Ok(()) => return Ok(()),
3318                        Err(e) => {
3319                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
3320                            // A forced-GL caller gets the GL error, matching
3321                            // `convert`'s no-fallback forced-backend contract.
3322                            if gl_forced {
3323                                return Err(e);
3324                            }
3325                        }
3326                    }
3327                }
3328            }
3329        }
3330        self.convert(src, dst, Some(options))
3331    }
3332
3333    fn flush(&mut self) -> Result<()> {
3334        let _span = tracing::trace_span!("image.flush").entered();
3335        // Only the OpenGL backend defers; flushing it issues the single GPU
3336        // sync. CPU/G2D converts already completed, so there is nothing to flush.
3337        #[cfg(any(
3338            target_os = "linux",
3339            target_os = "macos",
3340            target_os = "ios",
3341            target_os = "android",
3342            target_os = "windows"
3343        ))]
3344        #[cfg(feature = "opengl")]
3345        if let Some(opengl) = self.opengl.as_mut() {
3346            return opengl.flush();
3347        }
3348        Ok(())
3349    }
3350
3351    fn draw_decoded_masks(
3352        &mut self,
3353        dst: &mut TensorDyn,
3354        detect: &[DetectBox],
3355        segmentation: &[Segmentation],
3356        overlay: MaskOverlay<'_>,
3357    ) -> Result<()> {
3358        let _span = tracing::trace_span!(
3359            "image.draw_decoded_masks",
3360            n_detections = detect.len(),
3361            n_segmentations = segmentation.len(),
3362        )
3363        .entered();
3364        let start = Instant::now();
3365
3366        if let Some(bg) = overlay.background {
3367            if bg.aliases(dst) {
3368                return Err(Error::AliasedBuffers(
3369                    "background must not reference the same buffer as dst".to_string(),
3370                ));
3371            }
3372        }
3373
3374        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3375        // a letterbox was applied to prepare the model input.
3376        let lb_boxes: Vec<DetectBox>;
3377        let lb_segs: Vec<Segmentation>;
3378        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
3379            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3380            // Keep segmentation bboxes in sync with the transformed detect boxes
3381            // when we have a 1:1 correspondence (instance segmentation).
3382            if segmentation.len() == lb_boxes.len() {
3383                // Only the four bounds change; the mask bytes are copied
3384                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3385                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3386                // pins the allocation to Mem so the ALLOCATOR cost matches too
3387                // (see there). Rendering from borrowed masks plus a separate
3388                // bounds list would avoid the copy entirely.
3389                lb_segs = segmentation
3390                    .iter()
3391                    .zip(lb_boxes.iter())
3392                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3393                    .collect::<Result<Vec<_>>>()?;
3394                (lb_boxes.as_slice(), lb_segs.as_slice())
3395            } else {
3396                // No 1:1 correspondence, so the segmentation bounds are left
3397                // as they are -- borrow rather than the copy `to_vec()` used
3398                // to make.
3399                (lb_boxes.as_slice(), segmentation)
3400            }
3401        } else {
3402            (detect, segmentation)
3403        };
3404        #[cfg(target_os = "linux")]
3405        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3406
3407        // ── Forced backend: no fallback chain ────────────────────────
3408        if let Some(forced) = self.forced_backend {
3409            return match forced {
3410                ForcedBackend::Cpu => {
3411                    if let Some(cpu) = self.cpu.as_mut() {
3412                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3413                    }
3414                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3415                }
3416                ForcedBackend::G2d => {
3417                    // G2D can only produce empty frames (clear / bg blit).
3418                    // For populated frames it has no rasterizer — fail loudly.
3419                    #[cfg(target_os = "linux")]
3420                    if let Some(g2d) = self.g2d.as_mut() {
3421                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3422                    }
3423                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3424                }
3425                ForcedBackend::OpenGl => {
3426                    // GL handles background natively via GPU blit, and now
3427                    // actively clears when there is no background.
3428                    #[cfg(any(
3429                        target_os = "linux",
3430                        target_os = "macos",
3431                        target_os = "ios",
3432                        target_os = "android",
3433                        target_os = "windows"
3434                    ))]
3435                    #[cfg(feature = "opengl")]
3436                    if let Some(opengl) = self.opengl.as_mut() {
3437                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3438                    }
3439                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3440                }
3441            };
3442        }
3443
3444        // ── Auto dispatch ──────────────────────────────────────────
3445        // Empty frames prefer G2D when available — a single g2d_clear or
3446        // g2d_blit is the cheapest HW path to produce the correct output
3447        // and avoids spinning up the GL pipeline every zero-detection
3448        // frame in a triple-buffered display loop.
3449        #[cfg(target_os = "linux")]
3450        if is_empty_frame {
3451            if let Some(g2d) = self.g2d.as_mut() {
3452                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3453                    Ok(_) => {
3454                        log::trace!(
3455                            "draw_decoded_masks empty frame via g2d in {:?}",
3456                            start.elapsed()
3457                        );
3458                        return Ok(());
3459                    }
3460                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3461                }
3462            }
3463        }
3464
3465        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3466        // Both backends now own their own base-layer handling (bg blit
3467        // or clear), so we hand the overlay through untouched.
3468        #[cfg(any(
3469            target_os = "linux",
3470            target_os = "macos",
3471            target_os = "ios",
3472            target_os = "android",
3473            target_os = "windows"
3474        ))]
3475        #[cfg(feature = "opengl")]
3476        if let Some(opengl) = self.opengl.as_mut() {
3477            log::trace!(
3478                "draw_decoded_masks started with opengl in {:?}",
3479                start.elapsed()
3480            );
3481            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3482                Ok(_) => {
3483                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3484                    return Ok(());
3485                }
3486                Err(e) => {
3487                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3488                }
3489            }
3490        }
3491
3492        log::trace!(
3493            "draw_decoded_masks started with cpu in {:?}",
3494            start.elapsed()
3495        );
3496        if let Some(cpu) = self.cpu.as_mut() {
3497            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3498                Ok(_) => {
3499                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3500                    return Ok(());
3501                }
3502                Err(e) => {
3503                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3504                    return Err(e);
3505                }
3506            }
3507        }
3508        Err(Error::NoConverter)
3509    }
3510
3511    fn draw_proto_masks(
3512        &mut self,
3513        dst: &mut TensorDyn,
3514        detect: &[DetectBox],
3515        proto_data: &ProtoData,
3516        overlay: MaskOverlay<'_>,
3517    ) -> Result<()> {
3518        let start = Instant::now();
3519
3520        if let Some(bg) = overlay.background {
3521            if bg.aliases(dst) {
3522                return Err(Error::AliasedBuffers(
3523                    "background must not reference the same buffer as dst".to_string(),
3524                ));
3525            }
3526        }
3527
3528        // Un-letterbox detect boxes for rendering when a letterbox was applied
3529        // to prepare the model input.  The original `detect` coords are still
3530        // passed to `materialize_segmentations` (which needs model-space coords
3531        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
3532        // it can emit `Segmentation` structs in output-image space.
3533        let lb_boxes: Vec<DetectBox>;
3534        let render_detect = if let Some(lb) = overlay.letterbox {
3535            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3536            lb_boxes.as_slice()
3537        } else {
3538            detect
3539        };
3540        #[cfg(target_os = "linux")]
3541        let is_empty_frame = detect.is_empty();
3542
3543        // ── Forced backend: no fallback chain ────────────────────────
3544        if let Some(forced) = self.forced_backend {
3545            return match forced {
3546                ForcedBackend::Cpu => {
3547                    if let Some(cpu) = self.cpu.as_mut() {
3548                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3549                    }
3550                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3551                }
3552                ForcedBackend::G2d => {
3553                    #[cfg(target_os = "linux")]
3554                    if let Some(g2d) = self.g2d.as_mut() {
3555                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3556                    }
3557                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3558                }
3559                ForcedBackend::OpenGl => {
3560                    #[cfg(any(
3561                        target_os = "linux",
3562                        target_os = "macos",
3563                        target_os = "ios",
3564                        target_os = "android",
3565                        target_os = "windows"
3566                    ))]
3567                    #[cfg(feature = "opengl")]
3568                    if let Some(opengl) = self.opengl.as_mut() {
3569                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3570                    }
3571                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3572                }
3573            };
3574        }
3575
3576        // ── Auto dispatch ──────────────────────────────────────────
3577        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3578        #[cfg(target_os = "linux")]
3579        if is_empty_frame {
3580            if let Some(g2d) = self.g2d.as_mut() {
3581                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3582                    Ok(_) => {
3583                        log::trace!(
3584                            "draw_proto_masks empty frame via g2d in {:?}",
3585                            start.elapsed()
3586                        );
3587                        return Ok(());
3588                    }
3589                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3590                }
3591            }
3592        }
3593
3594        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3595        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3596        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3597        // GL owns its own bg-blit / glClear — we pass the overlay through.
3598        //
3599        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3600        // needs `&mut`. The CPU borrow is scoped to its block so the
3601        // subsequent GL borrow is free to take over `self`.
3602        #[cfg(any(
3603            target_os = "linux",
3604            target_os = "macos",
3605            target_os = "ios",
3606            target_os = "android",
3607            target_os = "windows"
3608        ))]
3609        #[cfg(feature = "opengl")]
3610        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3611            let segmentation = match self.cpu.as_mut() {
3612                Some(cpu) => {
3613                    log::trace!(
3614                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3615                        start.elapsed()
3616                    );
3617                    cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3618                }
3619                None => unreachable!("cpu presence checked above"),
3620            };
3621            if let Some(opengl) = self.opengl.as_mut() {
3622                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3623                    Ok(_) => {
3624                        log::trace!(
3625                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3626                            start.elapsed()
3627                        );
3628                        return Ok(());
3629                    }
3630                    Err(e) => {
3631                        log::trace!(
3632                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3633                        );
3634                    }
3635                }
3636            }
3637        }
3638
3639        let Some(cpu) = self.cpu.as_mut() else {
3640            return Err(Error::Internal(
3641                "draw_proto_masks requires CPU backend for fallback path".into(),
3642            ));
3643        };
3644        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3645        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3646    }
3647
3648    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3649        let start = Instant::now();
3650
3651        // ── Forced backend: no fallback chain ────────────────────────
3652        if let Some(forced) = self.forced_backend {
3653            return match forced {
3654                ForcedBackend::Cpu => {
3655                    if let Some(cpu) = self.cpu.as_mut() {
3656                        return cpu.set_class_colors(colors);
3657                    }
3658                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3659                }
3660                ForcedBackend::G2d => Err(Error::NotSupported(
3661                    "g2d does not support set_class_colors".into(),
3662                )),
3663                ForcedBackend::OpenGl => {
3664                    #[cfg(any(
3665                        target_os = "linux",
3666                        target_os = "macos",
3667                        target_os = "ios",
3668                        target_os = "android",
3669                        target_os = "windows"
3670                    ))]
3671                    #[cfg(feature = "opengl")]
3672                    if let Some(opengl) = self.opengl.as_mut() {
3673                        return opengl.set_class_colors(colors);
3674                    }
3675                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3676                }
3677            };
3678        }
3679
3680        // skip G2D as it doesn't support rendering to image
3681
3682        // Every available backend, not the first that accepts. A palette is
3683        // state a later draw reads, and which backend that draw lands on is
3684        // not known here: GL declines a `view()` destination, an unsupported
3685        // format or an import failure, and the CPU backend takes over. A
3686        // backend that never received the colours would render
3687        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3688        let mut backends = 0usize;
3689        let mut first_err = None;
3690        #[cfg(any(
3691            target_os = "linux",
3692            target_os = "macos",
3693            target_os = "ios",
3694            target_os = "android",
3695            target_os = "windows"
3696        ))]
3697        #[cfg(feature = "opengl")]
3698        if let Some(opengl) = self.opengl.as_mut() {
3699            backends += 1;
3700            match opengl.set_class_colors(colors) {
3701                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3702                Err(e) => {
3703                    log::trace!("colors didn't set with opengl: {e:?}");
3704                    first_err.get_or_insert(e);
3705                }
3706            }
3707        }
3708        if let Some(cpu) = self.cpu.as_mut() {
3709            backends += 1;
3710            match cpu.set_class_colors(colors) {
3711                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3712                Err(e) => {
3713                    log::trace!("colors didn't set with cpu: {e:?}");
3714                    first_err.get_or_insert(e);
3715                }
3716            }
3717        }
3718        // The first error, so a GL failure is not hidden by the CPU backend
3719        // accepting afterwards; the colours the other backends took are kept
3720        // either way, since a partial palette beats a stale one.
3721        match first_err {
3722            Some(e) => Err(e),
3723            None if backends > 0 => Ok(()),
3724            None => Err(Error::NoConverter),
3725        }
3726    }
3727}
3728
3729// ---------------------------------------------------------------------------
3730// Image loading / saving helpers
3731// ---------------------------------------------------------------------------
3732
3733/// Test-only convenience helper that peeks the image header, allocates a
3734/// tensor sized to the image (honoring DMA pitch padding on Linux when
3735/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3736/// the removed public `load_image` API for test sites; production callers
3737/// should use the explicit peek → allocate → decode pattern directly.
3738#[cfg(all(test, feature = "codec"))]
3739pub(crate) fn load_image_test_helper(
3740    image: &[u8],
3741    format: Option<PixelFormat>,
3742    memory: Option<TensorMemory>,
3743) -> Result<TensorDyn> {
3744    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3745
3746    // Peek the source header to get its NATIVE format and dimensions. The
3747    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3748    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3749    let info = peek_info(image)?;
3750    let native_fmt = info.format;
3751    let w = info.width;
3752    let h = info.height;
3753
3754    let mut decoder = ImageDecoder::new();
3755
3756    // Decode into a native-format tensor. The decoder sets the tensor's
3757    // dims+format, so we allocate it sized to the native layout.
3758    #[cfg(target_os = "linux")]
3759    let native_src = {
3760        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3761            let mut dma = Tensor::<u8>::image_with_stride(
3762                w,
3763                h,
3764                native_fmt,
3765                aligned_pitch,
3766                Some(TensorMemory::DmaBuf),
3767                edgefirst_tensor::CpuAccess::ReadWrite,
3768            )?;
3769            dma.load_image(&mut decoder, image)?;
3770            TensorDyn::from(dma)
3771        } else {
3772            let mut img = Tensor::<u8>::image(
3773                w,
3774                h,
3775                native_fmt,
3776                memory,
3777                edgefirst_tensor::CpuAccess::ReadWrite,
3778            )?;
3779            img.load_image(&mut decoder, image)?;
3780            TensorDyn::from(img)
3781        }
3782    };
3783    #[cfg(not(target_os = "linux"))]
3784    let native_src = {
3785        let mut img = Tensor::<u8>::image(
3786            w,
3787            h,
3788            native_fmt,
3789            memory,
3790            edgefirst_tensor::CpuAccess::ReadWrite,
3791        )?;
3792        img.load_image(&mut decoder, image)?;
3793        TensorDyn::from(img)
3794    };
3795
3796    // If the caller requested a different format, convert into it (same
3797    // dims) using a headless CPU-backed processor so the helper works
3798    // without GPU/G2D hardware.
3799    match format {
3800        Some(f) if f != native_fmt => {
3801            let mut dst = TensorDyn::image(
3802                w,
3803                h,
3804                f,
3805                DType::U8,
3806                memory,
3807                edgefirst_tensor::CpuAccess::ReadWrite,
3808            )?;
3809            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3810            // carries extra GL/G2D options so `..Default::default()` is needed,
3811            // but on macOS `backend` is the only field, making the update
3812            // redundant (clippy::needless_update). Allow it for cross-platform
3813            // parity — the alternative (field reassign) trips
3814            // clippy::field_reassign_with_default on Linux instead.
3815            #[allow(clippy::needless_update)]
3816            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3817                backend: ComputeBackend::Cpu,
3818                ..Default::default()
3819            })?;
3820            proc.convert(&native_src, &mut dst, None)?;
3821            Ok(dst)
3822        }
3823        _ => Ok(native_src),
3824    }
3825}
3826
3827/// Save a [`TensorDyn`] image as a JPEG file.
3828///
3829/// Only packed RGB and RGBA formats are supported.
3830pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3831    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3832        "save_jpeg requires u8 tensor".to_string(),
3833    ))?;
3834    let fmt = t.format().ok_or(Error::NotAnImage)?;
3835    if fmt.layout() != PixelLayout::Packed {
3836        return Err(Error::NotImplemented(
3837            "Saving planar images is not supported".to_string(),
3838        ));
3839    }
3840
3841    let colour = match fmt {
3842        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3843        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3844        _ => {
3845            return Err(Error::NotImplemented(
3846                "Unsupported image format for saving".to_string(),
3847            ));
3848        }
3849    };
3850
3851    let w = t.width().ok_or(Error::NotAnImage)?;
3852    let h = t.height().ok_or(Error::NotAnImage)?;
3853    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3854    let tensor_map = t.map_read()?;
3855
3856    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3857
3858    Ok(())
3859}
3860
3861pub(crate) struct FunctionTimer<T: Display> {
3862    name: T,
3863    start: std::time::Instant,
3864}
3865
3866impl<T: Display> FunctionTimer<T> {
3867    pub fn new(name: T) -> Self {
3868        Self {
3869            name,
3870            start: std::time::Instant::now(),
3871        }
3872    }
3873}
3874
3875impl<T: Display> Drop for FunctionTimer<T> {
3876    fn drop(&mut self) {
3877        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3878    }
3879}
3880
3881const DEFAULT_COLORS: [[f32; 4]; 20] = [
3882    [0., 1., 0., 0.7],
3883    [1., 0.5568628, 0., 0.7],
3884    [0.25882353, 0.15294118, 0.13333333, 0.7],
3885    [0.8, 0.7647059, 0.78039216, 0.7],
3886    [0.3137255, 0.3137255, 0.3137255, 0.7],
3887    [0.1411765, 0.3098039, 0.1215686, 0.7],
3888    [1., 0.95686275, 0.5137255, 0.7],
3889    [0.3529412, 0.32156863, 0., 0.7],
3890    [0.4235294, 0.6235294, 0.6509804, 0.7],
3891    [0.5098039, 0.5098039, 0.7294118, 0.7],
3892    [0.00784314, 0.18823529, 0.29411765, 0.7],
3893    [0.0, 0.2706, 1.0, 0.7],
3894    [0.0, 0.0, 0.0, 0.7],
3895    [0.0, 0.5, 0.0, 0.7],
3896    [1.0, 0.0, 0.0, 0.7],
3897    [0.0, 0.0, 1.0, 0.7],
3898    [1.0, 0.5, 0.5, 0.7],
3899    [0.1333, 0.5451, 0.1333, 0.7],
3900    [0.1176, 0.4118, 0.8235, 0.7],
3901    [1., 1., 1., 0.7],
3902];
3903
3904const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3905    let mut result = [[0; M]; N];
3906    let mut i = 0;
3907    while i < N {
3908        let mut j = 0;
3909        while j < M {
3910            result[i][j] = (a[i][j] * 255.0).round() as u8;
3911            j += 1;
3912        }
3913        i += 1;
3914    }
3915    result
3916}
3917
3918const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3919
3920#[cfg(test)]
3921#[cfg_attr(coverage_nightly, coverage(off))]
3922mod alignment_tests {
3923    use super::*;
3924
3925    #[test]
3926    fn align_width_rgba8_common_widths() {
3927        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3928        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3929        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3930        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3931        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3932                                                              // crowd.png case from the imx95 investigation:
3933        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3934        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3935        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3936        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3937    }
3938
3939    #[test]
3940    fn align_width_rgb888_packed() {
3941        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3942        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3943        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3944        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3945        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3946                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3947        for w in [3004usize, 1281, 100, 17] {
3948            let padded = align_width_for_gpu_pitch(w, 3);
3949            assert!(padded >= w);
3950            assert_eq!((padded * 3) % 64, 0);
3951            assert_eq!((padded * 3) % 3, 0);
3952        }
3953    }
3954
3955    #[test]
3956    fn align_width_grey_u8() {
3957        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3958        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3959        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3960        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3961        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3962    }
3963
3964    #[test]
3965    fn align_width_zero_inputs() {
3966        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3967        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3968    }
3969
3970    #[test]
3971    fn align_width_never_returns_smaller_than_input() {
3972        // Spot-check the "returned width >= input width" contract across a
3973        // range of values that would previously have hit `width * bpp`
3974        // overflow paths.
3975        for &bpp in &[1usize, 2, 3, 4, 8] {
3976            for &w in &[
3977                1usize,
3978                17,
3979                64,
3980                65,
3981                100,
3982                1280,
3983                1281,
3984                1920,
3985                3004,
3986                3072,
3987                3840,
3988                usize::MAX / 8,
3989                usize::MAX / 4,
3990                usize::MAX / 2,
3991                usize::MAX - 1,
3992                usize::MAX,
3993            ] {
3994                let aligned = align_width_for_gpu_pitch(w, bpp);
3995                assert!(
3996                    aligned >= w,
3997                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3998                );
3999            }
4000        }
4001    }
4002
4003    #[test]
4004    fn align_width_overflow_returns_unaligned_not_smaller() {
4005        // For width values close to usize::MAX, padding up would wrap. The
4006        // function must return the original width rather than wrapping or
4007        // panicking. A pre-aligned width round-trips unchanged even at the
4008        // extreme.
4009        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
4010        assert_eq!(
4011            align_width_for_gpu_pitch(aligned_extreme, 4),
4012            aligned_extreme
4013        );
4014        // A misaligned extreme value cannot be rounded up — the function
4015        // returns the original.
4016        let misaligned_extreme = usize::MAX - 1;
4017        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
4018        assert!(
4019            result == misaligned_extreme || result >= misaligned_extreme,
4020            "extreme misaligned width must not be rounded down to {result}"
4021        );
4022    }
4023
4024    #[test]
4025    fn checked_lcm_basic_and_overflow() {
4026        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
4027        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
4028        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
4029        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
4030        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
4031        // Coprime values whose product exceeds usize::MAX must return None.
4032        assert_eq!(
4033            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
4034            None,
4035            "coprime extreme values must overflow detect, not panic"
4036        );
4037    }
4038
4039    #[test]
4040    fn primary_plane_bpp_known_formats() {
4041        // Packed formats use channels × elem_size.
4042        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
4043        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
4044        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
4045        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
4046        // Semi-planar (NV12) reports the luma plane's bpp.
4047        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
4048    }
4049}
4050
4051#[cfg(test)]
4052#[cfg_attr(coverage_nightly, coverage(off))]
4053#[allow(deprecated)]
4054mod image_tests {
4055    use super::*;
4056    #[cfg(target_os = "linux")]
4057    use crate::test_support::is_dma_available;
4058    use crate::{CPUProcessor, Rotation};
4059    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
4060    use image::buffer::ConvertBuffer;
4061
4062    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
4063    /// by going through the `TensorDyn` API.
4064    ///
4065    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
4066    /// round-trip so the caller can feed them to `compare_images` etc.
4067    fn convert_img(
4068        proc: &mut dyn ImageProcessorTrait,
4069        src: TensorDyn,
4070        dst: TensorDyn,
4071        rotation: Rotation,
4072        flip: Flip,
4073        crop: Crop,
4074    ) -> (Result<()>, TensorDyn, TensorDyn) {
4075        let src_fourcc = src.format().unwrap();
4076        let dst_fourcc = dst.format().unwrap();
4077        let src_dyn = src;
4078        let mut dst_dyn = dst;
4079        let result = proc.convert(
4080            &src_dyn,
4081            &mut dst_dyn,
4082            Some(
4083                ConvertOptions::new()
4084                    .rotation(rotation)
4085                    .flip(flip)
4086                    .crop(crop),
4087            ),
4088        );
4089        let src_back = {
4090            let mut __t = src_dyn.into_u8().unwrap();
4091            __t.set_format(src_fourcc).unwrap();
4092            TensorDyn::from(__t)
4093        };
4094        let dst_back = {
4095            let mut __t = dst_dyn.into_u8().unwrap();
4096            __t.set_format(dst_fourcc).unwrap();
4097            TensorDyn::from(__t)
4098        };
4099        (result, src_back, dst_back)
4100    }
4101
4102    #[ctor::ctor(unsafe)]
4103    fn init() {
4104        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
4105    }
4106
4107    macro_rules! function {
4108        () => {{
4109            fn f() {}
4110            fn type_name_of<T>(_: T) -> &'static str {
4111                std::any::type_name::<T>()
4112            }
4113            let name = type_name_of(f);
4114
4115            // Find and cut the rest of the path
4116            match &name[..name.len() - 3].rfind(':') {
4117                Some(pos) => &name[pos + 1..name.len() - 3],
4118                None => &name[..name.len() - 3],
4119            }
4120        }};
4121    }
4122
4123    /// Master oracle for the view/batch **destination** batch engine: render `N`
4124    /// tiles into row-bands of ONE tall destination and assert each band equals
4125    /// the same source converted standalone — proving correct band placement and
4126    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
4127    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
4128    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
4129    /// once at `flush()`; other backends fall back to an eager per-band convert
4130    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
4131    ///
4132    /// Identical source/tile size makes the convert an exact copy, so the
4133    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
4134    /// solid colors per tile make any sibling wipe a hard failure.
4135    #[test]
4136    fn batch_view_dst_tiles_match_standalone() {
4137        let mut proc = match ImageProcessor::new() {
4138            Ok(p) => p,
4139            Err(e) => {
4140                crate::test_support::report_skip(&format!(
4141                    "{} — ImageProcessor init failed ({e:?})",
4142                    function!()
4143                ));
4144                return;
4145            }
4146        };
4147        let n = 3usize;
4148        let (w, h) = (32usize, 24usize);
4149        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
4150        let make_src = |c: [u8; 4]| -> TensorDyn {
4151            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
4152            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4153        };
4154        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
4155        // runs (one parent import + per-tile glViewport); skip if unavailable.
4156        let Some(parent) = crate::test_support::dma_allocated(
4157            TensorDyn::image(
4158                w,
4159                n * h,
4160                PixelFormat::Rgba,
4161                DType::U8,
4162                Some(TensorMemory::DmaBuf),
4163                edgefirst_tensor::CpuAccess::ReadWrite,
4164            ),
4165            "tall DMA destination",
4166        ) else {
4167            return;
4168        };
4169
4170        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
4171        for (i, &c) in colors.iter().enumerate().take(n) {
4172            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
4173            proc.convert_deferred(&make_src(c), &mut tile, None)
4174                .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
4175        }
4176        proc.flush().unwrap();
4177
4178        for (i, &c) in colors.iter().enumerate().take(n) {
4179            // Standalone full-buffer convert of the same source = the oracle.
4180            let mut solo = TensorDyn::image(
4181                w,
4182                h,
4183                PixelFormat::Rgba,
4184                DType::U8,
4185                Some(TensorMemory::DmaBuf),
4186                edgefirst_tensor::CpuAccess::ReadWrite,
4187            )
4188            .unwrap();
4189            proc.convert(&make_src(c), &mut solo, None).unwrap();
4190
4191            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
4192            let band_bytes = band
4193                .as_typed::<u8>()
4194                .unwrap()
4195                .map()
4196                .unwrap()
4197                .as_slice()
4198                .to_vec();
4199            let solo_bytes = solo
4200                .as_typed::<u8>()
4201                .unwrap()
4202                .map()
4203                .unwrap()
4204                .as_slice()
4205                .to_vec();
4206            assert_eq!(
4207                band_bytes, solo_bytes,
4208                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
4209            );
4210            assert!(
4211                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
4212                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
4213            );
4214        }
4215    }
4216
4217    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
4218    /// at any origin has distinct content (catches crop-origin / band-placement
4219    /// bugs a solid color cannot).
4220    #[cfg(test)]
4221    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
4222        let mut bytes = vec![0u8; w * h * 4];
4223        for y in 0..h {
4224            for x in 0..w {
4225                let i = (y * w + x) * 4;
4226                bytes[i] = x as u8;
4227                bytes[i + 1] = y as u8;
4228                bytes[i + 2] = (x ^ y) as u8;
4229                bytes[i + 3] = 255;
4230            }
4231        }
4232        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4233    }
4234
4235    /// `tile_into` band == standalone crop-convert of the same source region,
4236    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
4237    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
4238    #[test]
4239    fn tile_into_cpu_distinct_content_parity() {
4240        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4241            backend: ComputeBackend::Cpu,
4242            ..Default::default()
4243        })
4244        .expect("the CPU backend is always available");
4245        let (fw, fh) = (96usize, 64usize);
4246        let src = gradient_frame(fw, fh);
4247        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
4248        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4249        assert_eq!(n, 6);
4250
4251        let mut parent = proc
4252            .alloc_tile_batch(
4253                n,
4254                &cfg,
4255                PixelFormat::Rgba,
4256                DType::U8,
4257                Some(TensorMemory::Mem),
4258                edgefirst_tensor::CpuAccess::ReadWrite,
4259            )
4260            .unwrap();
4261        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4262        assert_eq!(placements.len(), n);
4263
4264        for p in &placements {
4265            let source = Region::new(
4266                p.origin.0 as usize,
4267                p.origin.1 as usize,
4268                p.crop_size.0 as usize,
4269                p.crop_size.1 as usize,
4270            );
4271            let mut solo = TensorDyn::image(
4272                32,
4273                32,
4274                PixelFormat::Rgba,
4275                DType::U8,
4276                Some(TensorMemory::Mem),
4277                edgefirst_tensor::CpuAccess::ReadWrite,
4278            )
4279            .unwrap();
4280            proc.convert(
4281                &src,
4282                &mut solo,
4283                Some(
4284                    crate::ConvertOptions::new().crop(
4285                        Crop::default()
4286                            .with_source(Some(source))
4287                            .with_fit(Fit::Stretch),
4288                    ),
4289                ),
4290            )
4291            .unwrap();
4292            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4293            let band_bytes = band
4294                .as_typed::<u8>()
4295                .unwrap()
4296                .map()
4297                .unwrap()
4298                .as_slice()
4299                .to_vec();
4300            let solo_bytes = solo
4301                .as_typed::<u8>()
4302                .unwrap()
4303                .map()
4304                .unwrap()
4305                .as_slice()
4306                .to_vec();
4307            assert_eq!(
4308                band_bytes, solo_bytes,
4309                "tile {} band differs from standalone crop-convert",
4310                p.index
4311            );
4312        }
4313    }
4314
4315    /// Streaming `tile_one` into a single slot == the corresponding batched
4316    /// `tile_into` band (proves the two paths agree).
4317    #[test]
4318    fn tile_one_matches_tile_into_band() {
4319        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4320            backend: ComputeBackend::Cpu,
4321            ..Default::default()
4322        })
4323        .expect("the CPU backend is always available");
4324        let (fw, fh) = (96usize, 64usize);
4325        let src = gradient_frame(fw, fh);
4326        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4327        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4328
4329        let mut parent = proc
4330            .alloc_tile_batch(
4331                n,
4332                &cfg,
4333                PixelFormat::Rgba,
4334                DType::U8,
4335                Some(TensorMemory::Mem),
4336                edgefirst_tensor::CpuAccess::ReadWrite,
4337            )
4338            .unwrap();
4339        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4340
4341        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4342        for p in &plan {
4343            let mut slot = TensorDyn::image(
4344                32,
4345                32,
4346                PixelFormat::Rgba,
4347                DType::U8,
4348                Some(TensorMemory::Mem),
4349                edgefirst_tensor::CpuAccess::ReadWrite,
4350            )
4351            .unwrap();
4352            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4353            proc.flush().unwrap();
4354            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4355            let slot_bytes = slot
4356                .as_typed::<u8>()
4357                .unwrap()
4358                .map()
4359                .unwrap()
4360                .as_slice()
4361                .to_vec();
4362            let band_bytes = band
4363                .as_typed::<u8>()
4364                .unwrap()
4365                .map()
4366                .unwrap()
4367                .as_slice()
4368                .to_vec();
4369            assert_eq!(
4370                slot_bytes, band_bytes,
4371                "tile {} stream != batch band",
4372                p.index
4373            );
4374        }
4375    }
4376
4377    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4378    /// GPU render).
4379    /// `tile_into` through the auto backend with a DMA parent — exercises the
4380    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4381    /// unavailable). The source is sampled via `Crop::with_source`, never
4382    /// `src.view()`, so all tiles share one source import.
4383    #[test]
4384    fn tile_into_auto_dma_parity() {
4385        let mut proc = match ImageProcessor::new() {
4386            Ok(p) => p,
4387            Err(e) => {
4388                crate::test_support::report_skip(&format!(
4389                    "{} — ImageProcessor init failed ({e:?})",
4390                    function!()
4391                ));
4392                return;
4393            }
4394        };
4395        let (fw, fh) = (96usize, 64usize);
4396        let src = gradient_frame(fw, fh);
4397        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4398        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4399
4400        let Some(mut parent) = crate::test_support::dma_allocated(
4401            proc.alloc_tile_batch(
4402                n,
4403                &cfg,
4404                PixelFormat::Rgba,
4405                DType::U8,
4406                Some(TensorMemory::DmaBuf),
4407                edgefirst_tensor::CpuAccess::ReadWrite,
4408            ),
4409            "tall DMA parent",
4410        ) else {
4411            return;
4412        };
4413        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4414
4415        for p in &placements {
4416            let source = Region::new(
4417                p.origin.0 as usize,
4418                p.origin.1 as usize,
4419                p.crop_size.0 as usize,
4420                p.crop_size.1 as usize,
4421            );
4422            let mut solo = TensorDyn::image(
4423                32,
4424                32,
4425                PixelFormat::Rgba,
4426                DType::U8,
4427                Some(TensorMemory::DmaBuf),
4428                edgefirst_tensor::CpuAccess::ReadWrite,
4429            )
4430            .unwrap();
4431            proc.convert(
4432                &src,
4433                &mut solo,
4434                Some(
4435                    crate::ConvertOptions::new().crop(
4436                        Crop::default()
4437                            .with_source(Some(source))
4438                            .with_fit(Fit::Stretch),
4439                    ),
4440                ),
4441            )
4442            .unwrap();
4443            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4444            // Structural-similarity tolerance (the house GPU-parity bar) rather
4445            // than exact bytes: rendering a tile into a viewport band at a
4446            // non-zero offset vs. a standalone origin render diverges by sampling
4447            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4448            // the same tolerance class as the other cross-backend parity tests.
4449            compare_images(
4450                &band,
4451                &solo,
4452                0.98,
4453                &format!("{}_tile{}", function!(), p.index),
4454            );
4455        }
4456    }
4457
4458    /// `tile_into` rejects a destination too small to hold all tile bands.
4459    #[test]
4460    fn tile_into_undersized_dst_errors() {
4461        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4462            backend: ComputeBackend::Cpu,
4463            ..Default::default()
4464        })
4465        .expect("the CPU backend is always available");
4466        let (fw, fh) = (96usize, 64usize);
4467        let src = gradient_frame(fw, fh);
4468        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4469                                                               // Allocate a parent for only 2 bands, far short of 6.
4470        let mut small = TensorDyn::image(
4471            32,
4472            2 * 32,
4473            PixelFormat::Rgba,
4474            DType::U8,
4475            Some(TensorMemory::Mem),
4476            edgefirst_tensor::CpuAccess::ReadWrite,
4477        )
4478        .unwrap();
4479        let r = proc.tile_into(&src, &mut small, &cfg);
4480        assert!(
4481            matches!(r, Err(Error::InvalidShape(_))),
4482            "expected InvalidShape, got {r:?}"
4483        );
4484    }
4485
4486    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4487    #[test]
4488    fn tiling_alloc_rejects_invalid_config() {
4489        let proc = ImageProcessor::with_config(ImageProcessorConfig {
4490            backend: ComputeBackend::Cpu,
4491            ..Default::default()
4492        })
4493        .expect("the CPU backend is always available");
4494        let bad = TilingConfig::new(0, 640);
4495        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4496        assert!(proc
4497            .alloc_tile_batch(
4498                4,
4499                &bad,
4500                PixelFormat::Rgba,
4501                DType::U8,
4502                Some(TensorMemory::Mem),
4503                edgefirst_tensor::CpuAccess::ReadWrite,
4504            )
4505            .is_err());
4506    }
4507
4508    #[test]
4509    fn plan_tiles_metadata_4k() {
4510        let proc = ImageProcessor::with_config(ImageProcessorConfig {
4511            backend: ComputeBackend::Cpu,
4512            ..Default::default()
4513        })
4514        .expect("the CPU backend is always available");
4515        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4516        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4517        assert_eq!(plan.len(), 32);
4518        assert!(plan.iter().all(|p| p.count == 32));
4519        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4520        assert!(plan.iter().all(|p| p.letterbox.is_none())); // stretch fit
4521        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4522        assert_eq!(plan[0].origin, (0.0, 0.0));
4523    }
4524
4525    #[test]
4526    fn test_invalid_crop() {
4527        let src = TensorDyn::image(
4528            100,
4529            100,
4530            PixelFormat::Rgb,
4531            DType::U8,
4532            None,
4533            edgefirst_tensor::CpuAccess::ReadWrite,
4534        )
4535        .unwrap();
4536        let dst = TensorDyn::image(
4537            100,
4538            100,
4539            PixelFormat::Rgb,
4540            DType::U8,
4541            None,
4542            edgefirst_tensor::CpuAccess::ReadWrite,
4543        )
4544        .unwrap();
4545
4546        // A source crop exceeding the source bounds is rejected.
4547        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4548        assert!(matches!(
4549            crop.check_crop_dyn(&src, &dst),
4550            Err(Error::CropInvalid(_))
4551        ));
4552
4553        // A source crop within bounds is valid.
4554        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4555        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4556
4557        // Letterbox is always valid — placement is computed within the dst.
4558        assert!(Crop::letterbox([0, 0, 0, 255])
4559            .check_crop_dyn(&src, &dst)
4560            .is_ok());
4561    }
4562
4563    #[test]
4564    fn test_invalid_tensor_format() -> Result<(), Error> {
4565        // 4D tensor cannot be set to a 3-channel pixel format
4566        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4567        let result = tensor.set_format(PixelFormat::Rgb);
4568        assert!(result.is_err(), "4D tensor should reject set_format");
4569
4570        // Tensor with wrong channel count for the format
4571        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4572        let result = tensor.set_format(PixelFormat::Rgb);
4573        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4574
4575        Ok(())
4576    }
4577
4578    #[test]
4579    fn test_invalid_image_file() -> Result<(), Error> {
4580        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4581        assert!(
4582            matches!(result, Err(Error::Codec(_))),
4583            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4584        );
4585        Ok(())
4586    }
4587
4588    #[test]
4589    fn test_invalid_jpeg_format() -> Result<(), Error> {
4590        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4591        // YUYV is not a valid decode target; peek_info fails before the magic-
4592        // bytes check, so the precise variant depends on which error fires first.
4593        assert!(
4594            matches!(result, Err(Error::Codec(_))),
4595            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4596        );
4597        Ok(())
4598    }
4599
4600    #[test]
4601    fn test_load_resize_save() {
4602        let file = edgefirst_bench::testdata::read("zidane.jpg");
4603        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4604        assert_eq!(img.width(), Some(1280));
4605        assert_eq!(img.height(), Some(720));
4606
4607        let dst = TensorDyn::image(
4608            640,
4609            360,
4610            PixelFormat::Rgba,
4611            DType::U8,
4612            None,
4613            edgefirst_tensor::CpuAccess::ReadWrite,
4614        )
4615        .unwrap();
4616        let mut converter = CPUProcessor::new();
4617        let (result, _img, dst) = convert_img(
4618            &mut converter,
4619            img,
4620            dst,
4621            Rotation::None,
4622            Flip::None,
4623            Crop::no_crop(),
4624        );
4625        result.unwrap();
4626        assert_eq!(dst.width(), Some(640));
4627        assert_eq!(dst.height(), Some(360));
4628
4629        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4630
4631        let file = std::fs::read("zidane_resized.jpg").unwrap();
4632        // With `format: None` the helper returns the source's native format.
4633        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4634        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4635        assert_eq!(img.width(), Some(640));
4636        assert_eq!(img.height(), Some(360));
4637        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4638    }
4639
4640    #[test]
4641    fn test_from_tensor_planar() -> Result<(), Error> {
4642        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4643        tensor
4644            .map()?
4645            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4646        let planar = {
4647            tensor
4648                .set_format(PixelFormat::PlanarRgb)
4649                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4650            TensorDyn::from(tensor)
4651        };
4652
4653        let rbga = load_bytes_to_tensor(
4654            1280,
4655            720,
4656            PixelFormat::Rgba,
4657            None,
4658            &edgefirst_bench::testdata::read("camera720p.rgba"),
4659        )?;
4660        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4661
4662        Ok(())
4663    }
4664
4665    #[test]
4666    fn test_from_tensor_invalid_format() {
4667        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4668        // Since there's no "TEST" pixel format, this validates graceful handling.
4669        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4670    }
4671
4672    #[test]
4673    #[should_panic(expected = "Failed to save planar RGB image")]
4674    fn test_save_planar() {
4675        let planar_img = load_bytes_to_tensor(
4676            1280,
4677            720,
4678            PixelFormat::PlanarRgb,
4679            None,
4680            &edgefirst_bench::testdata::read("camera720p.8bps"),
4681        )
4682        .unwrap();
4683
4684        let save_path = "/tmp/planar_rgb.jpg";
4685        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4686    }
4687
4688    #[test]
4689    #[should_panic(expected = "Failed to save YUYV image")]
4690    fn test_save_yuyv() {
4691        let planar_img = load_bytes_to_tensor(
4692            1280,
4693            720,
4694            PixelFormat::Yuyv,
4695            None,
4696            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4697        )
4698        .unwrap();
4699
4700        let save_path = "/tmp/yuyv.jpg";
4701        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4702    }
4703
4704    #[test]
4705    fn test_rotation_angle() {
4706        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4707        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4708        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4709        assert_eq!(
4710            Rotation::from_degrees_clockwise(270),
4711            Rotation::CounterClockwise90
4712        );
4713        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4714        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4715        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4716        assert_eq!(
4717            Rotation::from_degrees_clockwise(630),
4718            Rotation::CounterClockwise90
4719        );
4720    }
4721
4722    #[test]
4723    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4724    fn test_rotation_angle_panic() {
4725        Rotation::from_degrees_clockwise(361);
4726    }
4727
4728    #[test]
4729    fn test_disable_env_var() -> Result<(), Error> {
4730        // Acquire the env-var mutex for the entire test body so we never race
4731        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4732        let _lock = acquire_env_lock();
4733
4734        // Snapshot ALL env vars we might touch so the RAII guard restores them
4735        // on exit (even on panic), preventing env-var poisoning of other tests.
4736        let _guard = EnvGuard::snapshot(&[
4737            "EDGEFIRST_FORCE_BACKEND",
4738            "EDGEFIRST_DISABLE_GL",
4739            "EDGEFIRST_DISABLE_G2D",
4740            "EDGEFIRST_DISABLE_CPU",
4741        ]);
4742
4743        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4744        // so clear it for the duration of this test.
4745        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4746
4747        #[cfg(target_os = "linux")]
4748        {
4749            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4750            let converter = ImageProcessor::new()?;
4751            assert!(converter.g2d.is_none());
4752            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4753        }
4754
4755        #[cfg(any(target_os = "linux", target_os = "windows"))]
4756        #[cfg(feature = "opengl")]
4757        {
4758            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4759            let converter = ImageProcessor::new()?;
4760            assert!(converter.opengl.is_none());
4761            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4762        }
4763
4764        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4765        let converter = ImageProcessor::new()?;
4766        assert!(converter.cpu.is_none());
4767        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4768
4769        // Disable everything — convert must return NoConverter.
4770        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4771        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4772        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4773        let mut converter = ImageProcessor::new()?;
4774
4775        let src = TensorDyn::image(
4776            1280,
4777            720,
4778            PixelFormat::Rgba,
4779            DType::U8,
4780            None,
4781            edgefirst_tensor::CpuAccess::ReadWrite,
4782        )?;
4783        let dst = TensorDyn::image(
4784            640,
4785            360,
4786            PixelFormat::Rgba,
4787            DType::U8,
4788            None,
4789            edgefirst_tensor::CpuAccess::ReadWrite,
4790        )?;
4791        let (result, _src, _dst) = convert_img(
4792            &mut converter,
4793            src,
4794            dst,
4795            Rotation::None,
4796            Flip::None,
4797            Crop::no_crop(),
4798        );
4799        assert!(matches!(result, Err(Error::NoConverter)));
4800        // _guard restores all env vars on drop.
4801        Ok(())
4802    }
4803
4804    #[test]
4805    fn test_unsupported_conversion() {
4806        let src = TensorDyn::image(
4807            1280,
4808            720,
4809            PixelFormat::Nv12,
4810            DType::U8,
4811            None,
4812            edgefirst_tensor::CpuAccess::ReadWrite,
4813        )
4814        .unwrap();
4815        let dst = TensorDyn::image(
4816            640,
4817            360,
4818            PixelFormat::Nv12,
4819            DType::U8,
4820            None,
4821            edgefirst_tensor::CpuAccess::ReadWrite,
4822        )
4823        .unwrap();
4824        let mut converter = ImageProcessor::new().unwrap();
4825        let (result, _src, _dst) = convert_img(
4826            &mut converter,
4827            src,
4828            dst,
4829            Rotation::None,
4830            Flip::None,
4831            Crop::no_crop(),
4832        );
4833        log::debug!("result: {:?}", result);
4834        assert!(matches!(
4835            result,
4836            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4837        ));
4838    }
4839
4840    #[test]
4841    fn test_load_grey() {
4842        // A single-component (greyscale) JPEG decodes to its native GREY
4843        // format, which has no even-dimension constraint, so the 1024×681
4844        // `grey.jpg` loads and converts to RGBA successfully.
4845        let grey_img = crate::load_image_test_helper(
4846            &edgefirst_bench::testdata::read("grey.jpg"),
4847            Some(PixelFormat::Rgba),
4848            None,
4849        )
4850        .unwrap();
4851        assert_eq!(grey_img.width(), Some(1024));
4852        assert_eq!(grey_img.height(), Some(681));
4853
4854        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4855        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4856        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4857        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4858        // decode succeeds and converts to RGBA at the true dimensions.
4859        let grey_but_rgb = crate::load_image_test_helper(
4860            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4861            Some(PixelFormat::Rgba),
4862            None,
4863        )
4864        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4865        assert_eq!(grey_but_rgb.width(), Some(1024));
4866        assert_eq!(grey_but_rgb.height(), Some(681));
4867    }
4868
4869    #[test]
4870    fn test_new_nv12() {
4871        let nv12 = TensorDyn::image(
4872            1280,
4873            720,
4874            PixelFormat::Nv12,
4875            DType::U8,
4876            None,
4877            edgefirst_tensor::CpuAccess::ReadWrite,
4878        )
4879        .unwrap();
4880        assert_eq!(nv12.height(), Some(720));
4881        assert_eq!(nv12.width(), Some(1280));
4882        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4883        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4884        assert_eq!(nv12.format().unwrap().channels(), 1);
4885        assert!(nv12.format().is_some_and(
4886            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4887        ))
4888    }
4889
4890    #[test]
4891    #[cfg(any(target_os = "linux", target_os = "windows"))]
4892    fn test_new_image_converter() {
4893        let dst_width = 640;
4894        let dst_height = 360;
4895        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4896        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4897
4898        let mut converter = ImageProcessor::new().unwrap();
4899        let converter_dst = converter
4900            .create_image(
4901                dst_width,
4902                dst_height,
4903                PixelFormat::Rgba,
4904                DType::U8,
4905                None,
4906                edgefirst_tensor::CpuAccess::ReadWrite,
4907            )
4908            .unwrap();
4909        let (result, src, converter_dst) = convert_img(
4910            &mut converter,
4911            src,
4912            converter_dst,
4913            Rotation::None,
4914            Flip::None,
4915            Crop::no_crop(),
4916        );
4917        result.unwrap();
4918
4919        let cpu_dst = TensorDyn::image(
4920            dst_width,
4921            dst_height,
4922            PixelFormat::Rgba,
4923            DType::U8,
4924            None,
4925            edgefirst_tensor::CpuAccess::ReadWrite,
4926        )
4927        .unwrap();
4928        let mut cpu_converter = CPUProcessor::new();
4929        let (result, _src, cpu_dst) = convert_img(
4930            &mut cpu_converter,
4931            src,
4932            cpu_dst,
4933            Rotation::None,
4934            Flip::None,
4935            Crop::no_crop(),
4936        );
4937        result.unwrap();
4938
4939        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4940    }
4941
4942    #[test]
4943    #[cfg(any(target_os = "linux", target_os = "windows"))]
4944    fn test_create_image_dtype_i8() {
4945        let mut converter = ImageProcessor::new().unwrap();
4946
4947        // I8 image should allocate successfully via create_image
4948        let dst = converter
4949            .create_image(
4950                320,
4951                240,
4952                PixelFormat::Rgb,
4953                DType::I8,
4954                None,
4955                edgefirst_tensor::CpuAccess::ReadWrite,
4956            )
4957            .unwrap();
4958        assert_eq!(dst.dtype(), DType::I8);
4959        assert!(dst.width() == Some(320));
4960        assert!(dst.height() == Some(240));
4961        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4962
4963        // U8 for comparison
4964        let dst_u8 = converter
4965            .create_image(
4966                320,
4967                240,
4968                PixelFormat::Rgb,
4969                DType::U8,
4970                None,
4971                edgefirst_tensor::CpuAccess::ReadWrite,
4972            )
4973            .unwrap();
4974        assert_eq!(dst_u8.dtype(), DType::U8);
4975
4976        // Convert into I8 dst should succeed
4977        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4978        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4979        let mut dst_i8 = converter
4980            .create_image(
4981                320,
4982                240,
4983                PixelFormat::Rgb,
4984                DType::I8,
4985                None,
4986                edgefirst_tensor::CpuAccess::ReadWrite,
4987            )
4988            .unwrap();
4989        converter.convert(&src, &mut dst_i8, None).unwrap();
4990    }
4991
4992    #[test]
4993    #[cfg(target_os = "linux")]
4994    fn test_create_image_nv12_dma_non_aligned_width() {
4995        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4996        // may legitimately carry a GPU-pitch-padded row stride — that is the
4997        // intended behaviour, not a bug. Verify the logical geometry is preserved
4998        // for any width and that a reported stride is a valid (>= logical)
4999        // padding, rather than asserting the absence of a stride.
5000        let converter = ImageProcessor::new().unwrap();
5001
5002        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
5003        let Some(img) = crate::test_support::dma_allocated(
5004            converter.create_image(
5005                100,
5006                64,
5007                PixelFormat::Nv12,
5008                DType::U8,
5009                Some(TensorMemory::DmaBuf),
5010                edgefirst_tensor::CpuAccess::ReadWrite,
5011            ),
5012            "create_image NV12 DMA non-aligned width",
5013        ) else {
5014            return;
5015        };
5016        assert_eq!(img.width(), Some(100));
5017        assert_eq!(img.height(), Some(64));
5018        assert_eq!(img.format(), Some(PixelFormat::Nv12));
5019        if let Some(stride) = img.row_stride() {
5020            assert!(
5021                stride >= 100,
5022                "NV12 row_stride {stride} must be >= the logical width (100)",
5023            );
5024        }
5025    }
5026
5027    #[test]
5028    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
5029              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
5030              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
5031    fn test_crop_skip() {
5032        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5033        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5034
5035        let mut converter = ImageProcessor::new().unwrap();
5036        let converter_dst = converter
5037            .create_image(
5038                1280,
5039                720,
5040                PixelFormat::Rgba,
5041                DType::U8,
5042                None,
5043                edgefirst_tensor::CpuAccess::ReadWrite,
5044            )
5045            .unwrap();
5046        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
5047        let (result, src, converter_dst) = convert_img(
5048            &mut converter,
5049            src,
5050            converter_dst,
5051            Rotation::None,
5052            Flip::None,
5053            crop,
5054        );
5055        result.unwrap();
5056
5057        let cpu_dst = TensorDyn::image(
5058            1280,
5059            720,
5060            PixelFormat::Rgba,
5061            DType::U8,
5062            None,
5063            edgefirst_tensor::CpuAccess::ReadWrite,
5064        )
5065        .unwrap();
5066        let mut cpu_converter = CPUProcessor::new();
5067        let (result, _src, cpu_dst) = convert_img(
5068            &mut cpu_converter,
5069            src,
5070            cpu_dst,
5071            Rotation::None,
5072            Flip::None,
5073            crop,
5074        );
5075        result.unwrap();
5076
5077        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
5078    }
5079
5080    #[test]
5081    fn test_invalid_pixel_format() {
5082        // PixelFormat::from_fourcc returns None for unknown formats,
5083        // so TensorDyn::image cannot be called with an invalid format.
5084        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
5085    }
5086
5087    // Helper function to check if G2D library is available (Linux/i.MX8 only)
5088    #[cfg(target_os = "linux")]
5089    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5090
5091    #[cfg(target_os = "linux")]
5092    fn is_g2d_available() -> bool {
5093        crate::test_support::g2d_required_or(
5094            *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok()),
5095        )
5096    }
5097
5098    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
5099    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
5100    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
5101    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
5102    // `gl_backend_available_canary`).
5103    #[cfg(any(
5104        target_os = "linux",
5105        target_os = "macos",
5106        target_os = "ios",
5107        target_os = "android",
5108        target_os = "windows"
5109    ))]
5110    #[cfg(feature = "opengl")]
5111    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5112
5113    #[cfg(any(
5114        target_os = "linux",
5115        target_os = "macos",
5116        target_os = "ios",
5117        target_os = "android",
5118        target_os = "windows"
5119    ))]
5120    #[cfg(feature = "opengl")]
5121    // Helper function to check if OpenGL is available
5122    fn is_opengl_available() -> bool {
5123        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
5124    }
5125
5126    /// CI canary: fails the lane when the GL backend cannot initialize.
5127    ///
5128    /// Every GL test in this suite self-skips when the backend is
5129    /// unavailable — correct for developer machines, but it means a broken
5130    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
5131    /// failure mode documented in the workflow) ships an untested GL
5132    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
5133    /// only by CI jobs that install a working GL stack; local runs without
5134    /// one pass trivially. On macOS it additionally requires
5135    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
5136    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
5137    #[test]
5138    #[cfg(feature = "opengl")]
5139    fn gl_backend_available_canary() {
5140        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
5141        if !require_gl {
5142            crate::test_support::report_skip(&format!(
5143                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
5144                function!()
5145            ));
5146            return;
5147        }
5148        #[cfg(target_os = "macos")]
5149        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
5150            crate::test_support::report_skip(&format!(
5151                "{} — ANGLE dlopen gate closed (coverage pass 1)",
5152                function!()
5153            ));
5154            return;
5155        }
5156        GLProcessorThreaded::new(None).expect(
5157            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
5158             check the ANGLE install/re-sign step and binary entitlements \
5159             (macOS) or the EGL stack (Linux)",
5160        );
5161    }
5162
5163    #[test]
5164    fn test_load_jpeg_with_exif() {
5165        use edgefirst_codec::peek_info;
5166
5167        // The migrated codec NEVER applies EXIF orientation: it decodes to the
5168        // source's native (un-rotated) dimensions and reports the rotation via
5169        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
5170        // (90° clockwise) over a 1280×720 frame.
5171        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
5172        let info = peek_info(&file).unwrap();
5173        assert_eq!(info.rotation_degrees, 90);
5174        assert!(!info.flip_horizontal);
5175
5176        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5177        // Native (un-rotated) dimensions — the decode does not rotate.
5178        assert_eq!(loaded.width(), Some(1280));
5179        assert_eq!(loaded.height(), Some(720));
5180
5181        // Applying the reported rotation downstream reproduces the upright
5182        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
5183        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5184        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5185
5186        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5187        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
5188
5189        let cpu_dst = TensorDyn::image(
5190            dst_width,
5191            dst_height,
5192            PixelFormat::Rgba,
5193            DType::U8,
5194            None,
5195            edgefirst_tensor::CpuAccess::ReadWrite,
5196        )
5197        .unwrap();
5198        let mut cpu_converter = CPUProcessor::new();
5199
5200        // Rotate the native-orientation `loaded` frame and the native `zidane`
5201        // frame by the same reported rotation; the results must agree.
5202        let loaded_rotated = TensorDyn::image(
5203            dst_width,
5204            dst_height,
5205            PixelFormat::Rgba,
5206            DType::U8,
5207            None,
5208            edgefirst_tensor::CpuAccess::ReadWrite,
5209        )
5210        .unwrap();
5211        let (r0, _loaded, loaded_rotated) = convert_img(
5212            &mut cpu_converter,
5213            loaded,
5214            loaded_rotated,
5215            rotation,
5216            Flip::None,
5217            Crop::no_crop(),
5218        );
5219        r0.unwrap();
5220
5221        let (result, _cpu_src, cpu_dst) = convert_img(
5222            &mut cpu_converter,
5223            cpu_src,
5224            cpu_dst,
5225            rotation,
5226            Flip::None,
5227            Crop::no_crop(),
5228        );
5229        result.unwrap();
5230
5231        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
5232    }
5233
5234    #[test]
5235    fn test_load_png_with_exif() {
5236        use edgefirst_codec::peek_info;
5237
5238        // PNGs also report EXIF orientation without applying it.
5239        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
5240        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
5241        let info = peek_info(&file).unwrap();
5242        assert_eq!(info.rotation_degrees, 180);
5243        assert!(!info.flip_horizontal);
5244
5245        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5246        // Native (un-rotated) dimensions — PNG decodes upright as authored.
5247        assert_eq!(loaded.height(), Some(720));
5248        assert_eq!(loaded.width(), Some(1280));
5249
5250        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
5251        // EXIF orientation. Because the codec no longer applies the rotation,
5252        // the decoded pixels match `zidane.jpg` directly (no convert needed).
5253        // Re-applying the reported rotation to both must still agree.
5254        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5255        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5256
5257        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5258        let cpu_dst = TensorDyn::image(
5259            1280,
5260            720,
5261            PixelFormat::Rgba,
5262            DType::U8,
5263            None,
5264            edgefirst_tensor::CpuAccess::ReadWrite,
5265        )
5266        .unwrap();
5267        let mut cpu_converter = CPUProcessor::new();
5268
5269        let (result, _cpu_src, cpu_dst) = convert_img(
5270            &mut cpu_converter,
5271            cpu_src,
5272            cpu_dst,
5273            rotation,
5274            Flip::None,
5275            Crop::no_crop(),
5276        );
5277        result.unwrap();
5278
5279        // Rotate the decoded PNG by the same reported angle so both frames are
5280        // in the same (180°-rotated) orientation before comparing.
5281        let loaded_rotated = TensorDyn::image(
5282            1280,
5283            720,
5284            PixelFormat::Rgba,
5285            DType::U8,
5286            None,
5287            edgefirst_tensor::CpuAccess::ReadWrite,
5288        )
5289        .unwrap();
5290        let (r0, _loaded, loaded_rotated) = convert_img(
5291            &mut cpu_converter,
5292            loaded,
5293            loaded_rotated,
5294            rotation,
5295            Flip::None,
5296            Crop::no_crop(),
5297        );
5298        r0.unwrap();
5299
5300        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5301        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5302        // subsampling) before the RGBA conversion, so the two paths differ by a
5303        // couple of percent versus the old direct-RGB JPEG decode.
5304        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5305    }
5306
5307    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5308    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5309    /// compiled without its JPEG feature). Used to exercise the decoder /
5310    /// pitch-padding paths for arbitrary dimensions without having to bundle
5311    /// a fixture file per test size.
5312    #[cfg(target_os = "linux")]
5313    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5314        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5315        for y in 0..height {
5316            for x in 0..width {
5317                bytes.push(((x + y) & 0xFF) as u8);
5318                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5319                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5320            }
5321        }
5322        let mut out = Vec::new();
5323        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5324        encoder
5325            .encode(
5326                &bytes,
5327                width as u16,
5328                height as u16,
5329                jpeg_encoder::ColorType::Rgb,
5330            )
5331            .expect("jpeg-encoder must succeed on trivial input");
5332        out
5333    }
5334
5335    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5336    /// via the pitch-padded DMA path and letterboxed through the GL
5337    /// backend must produce correct output. Before the Rgba/Bgra
5338    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5339    /// failed the pre-check and forced a CPU texture upload fallback;
5340    /// with the relaxation, EGL import succeeds at the driver level and
5341    /// the GL fast path runs. Output correctness is checked against a
5342    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5343    #[test]
5344    #[cfg(target_os = "linux")]
5345    #[cfg(feature = "opengl")]
5346    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5347        use crate::test_support::is_dma_available;
5348        if !is_dma_available() {
5349            crate::test_support::report_skip(
5350                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5351            );
5352            return;
5353        }
5354        // 375 is the canonical failure width from dataset loaders —
5355        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5356        // so the old pre-check rejected it; new code accepts it.
5357        let jpeg = make_rgb_jpeg(375, 333);
5358        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5359        assert_eq!(src_gl.width(), Some(375));
5360        // Row stride must still be pitch-padded (separate concern from width).
5361        let stride = src_gl.row_stride().unwrap();
5362        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5363
5364        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5365        let mut gl_proc = ImageProcessor::new().unwrap();
5366        let gl_dst = gl_proc
5367            .create_image(
5368                640,
5369                640,
5370                PixelFormat::Rgba,
5371                DType::U8,
5372                None,
5373                edgefirst_tensor::CpuAccess::ReadWrite,
5374            )
5375            .unwrap();
5376        let (r_gl, _src_gl, gl_dst) = convert_img(
5377            &mut gl_proc,
5378            src_gl,
5379            gl_dst,
5380            Rotation::None,
5381            Flip::None,
5382            Crop::no_crop(),
5383        );
5384        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5385
5386        // CPU reference via a fresh load so the two paths start from
5387        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5388        // CPU-only processor regardless of which backends the host has
5389        // available.
5390        let src_cpu =
5391            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5392                .unwrap();
5393        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5394            backend: ComputeBackend::Cpu,
5395            ..Default::default()
5396        })
5397        .unwrap();
5398        let cpu_dst = TensorDyn::image(
5399            640,
5400            640,
5401            PixelFormat::Rgba,
5402            DType::U8,
5403            Some(TensorMemory::Mem),
5404            edgefirst_tensor::CpuAccess::ReadWrite,
5405        )
5406        .unwrap();
5407        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5408            &mut cpu_proc,
5409            src_cpu,
5410            cpu_dst,
5411            Rotation::None,
5412            Flip::None,
5413            Crop::no_crop(),
5414        );
5415        r_cpu.unwrap();
5416
5417        // Structural similarity: the GL path may have gone through EGL
5418        // import OR fallen back to CPU texture upload — either way, the
5419        // output must match the CPU reference closely.
5420        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5421    }
5422
5423    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5424    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5425    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5426    /// to the padded value. The earlier pitch-padding commit fixed this in
5427    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5428    /// `effective_row_stride == 2000`.
5429    #[test]
5430    #[cfg(target_os = "linux")]
5431    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5432        use crate::test_support::is_dma_available;
5433        if !is_dma_available() {
5434            crate::test_support::report_skip(
5435                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5436            );
5437            return;
5438        }
5439        // Widths that force a non-64-aligned natural RGBA pitch. All three
5440        // are divisible by 4 so the EGL width-alignment pre-check passes.
5441        // The pitch-padding fix is what makes these importable at all.
5442        for &w in &[500u32, 612, 428] {
5443            let jpeg = make_rgb_jpeg(w, 333);
5444            let loaded =
5445                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5446            let natural = (w as usize) * 4;
5447            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5448            assert!(
5449                aligned > natural,
5450                "test sanity: width {w} should be unaligned"
5451            );
5452            let stride = loaded
5453                .row_stride()
5454                .expect("padded DMA path must set an explicit row_stride — regression if None");
5455            assert_eq!(
5456                stride, aligned,
5457                "width {w}: expected padded stride {aligned}, got {stride} \
5458                 (regression: pitch-padding branch skipped?)"
5459            );
5460            let eff = loaded.effective_row_stride().unwrap();
5461            assert_eq!(
5462                eff, aligned,
5463                "effective_row_stride must match stored stride"
5464            );
5465            assert_eq!(loaded.width(), Some(w as usize));
5466            assert_eq!(loaded.height(), Some(333));
5467        }
5468    }
5469
5470    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5471    /// must NOT route into the pitch-padded DMA path when the caller left
5472    /// the choice to the allocator (`None`) but DMA is unavailable on the
5473    /// host. The padded path requires `image_with_stride`, which always
5474    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5475    /// would convert a normally-working image load into a hard failure
5476    /// (since `Tensor::image(..., None)` would have fallen back to
5477    /// SHM/Mem).
5478    #[test]
5479    #[cfg(target_os = "linux")]
5480    fn test_padded_dma_pitch_for_respects_memory_choice() {
5481        use crate::test_support::is_dma_available;
5482        use edgefirst_tensor::TensorMemory;
5483
5484        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5485        // every case so any "no padding" answer is unambiguous.
5486        let unaligned_w = 500;
5487
5488        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5489        assert_eq!(
5490            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5491            None,
5492            "Mem must never trigger DMA padding"
5493        );
5494        assert_eq!(
5495            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5496            None,
5497            "Shm must never trigger DMA padding"
5498        );
5499
5500        // Caller explicitly asks for DMA: always pad if width needs it.
5501        // Even if the runtime can't actually allocate DMA, the caller
5502        // owns that decision and the resulting allocation error is
5503        // their problem, not ours.
5504        assert_eq!(
5505            crate::padded_dma_pitch_for(
5506                PixelFormat::Rgba,
5507                unaligned_w,
5508                &Some(TensorMemory::DmaBuf),
5509            ),
5510            Some(2048),
5511            "explicit Dma must pad regardless of runtime DMA availability"
5512        );
5513
5514        // Caller leaves it to the allocator: behaviour depends on
5515        // host-runtime DMA availability. This is the case the fix
5516        // guards against.
5517        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5518        if is_dma_available() {
5519            assert_eq!(
5520                none_result,
5521                Some(2048),
5522                "memory=None + DMA available → pad (will route through DMA)"
5523            );
5524        } else {
5525            assert_eq!(
5526                none_result, None,
5527                "memory=None + DMA unavailable → must NOT pad (would force \
5528                 image_with_stride into a DMA-only allocation that fails). \
5529                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5530            );
5531        }
5532    }
5533
5534    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5535    // deterministic ramp pattern so multiple tests can cross-check output
5536    // without bundling an extra fixture file.
5537    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5538        let mut bytes = Vec::with_capacity((width * height) as usize);
5539        for y in 0..height {
5540            for x in 0..width {
5541                bytes.push(((x + y) & 0xFF) as u8);
5542            }
5543        }
5544        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5545        let mut buf = Vec::new();
5546        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5547            .unwrap();
5548        buf
5549    }
5550
5551    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5552    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5553    /// alignment) must still load via the pitch-padded DMA path. Gated on
5554    /// DMA availability because `image_with_stride` is DMA-only.
5555    #[test]
5556    #[cfg(target_os = "linux")]
5557    fn test_load_png_grey_misaligned_width_dma() {
5558        use crate::test_support::is_dma_available;
5559        if !is_dma_available() {
5560            crate::test_support::report_skip(
5561                "test_load_png_grey_misaligned_width_dma — DMA not available",
5562            );
5563            return;
5564        }
5565        let png = make_grey_png(612, 388);
5566        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5567        assert_eq!(loaded.width(), Some(612));
5568        assert_eq!(loaded.height(), Some(388));
5569        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5570
5571        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5572        // must indirect through row_stride() rather than assume width.
5573        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5574        let stride = loaded.row_stride().unwrap_or(612);
5575        assert!(stride >= 612);
5576        let bytes: &[u8] = &map;
5577        for y in 0..388usize {
5578            for x in 0..612usize {
5579                let expected = ((x + y) & 0xFF) as u8;
5580                let got = bytes[y * stride + x];
5581                assert_eq!(
5582                    got, expected,
5583                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5584                );
5585            }
5586        }
5587    }
5588
5589    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5590    /// platform (no DMA permission requirement) and covers the
5591    /// decoder-native Luma → Grey no-conversion path.
5592    #[test]
5593    fn test_load_png_grey_mem() {
5594        use edgefirst_tensor::TensorMemory;
5595        let png = make_grey_png(612, 100);
5596        let loaded =
5597            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5598                .unwrap();
5599        assert_eq!(loaded.width(), Some(612));
5600        assert_eq!(loaded.height(), Some(100));
5601        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5602        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5603        let bytes: &[u8] = &map;
5604        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5605        assert_eq!(bytes.len(), 612 * 100);
5606        for y in 0..100 {
5607            for x in 0..612 {
5608                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5609            }
5610        }
5611    }
5612
5613    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5614    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5615    /// stay portable to host-side test environments.
5616    #[test]
5617    fn test_load_png_grey_to_rgb_mem() {
5618        use edgefirst_tensor::TensorMemory;
5619        let png = make_grey_png(620, 240);
5620        let loaded =
5621            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5622                .unwrap();
5623        assert_eq!(loaded.width(), Some(620));
5624        assert_eq!(loaded.height(), Some(240));
5625        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5626
5627        // Greyscale promoted to RGB replicates luma into each channel.
5628        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5629        let bytes: &[u8] = &map;
5630        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5631            let expected = ((x + y) & 0xFF) as u8;
5632            let off = (y * 620 + x) * 3;
5633            assert_eq!(bytes[off], expected, "R@{x},{y}");
5634            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5635            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5636        }
5637    }
5638
5639    #[test]
5640    #[cfg(target_os = "linux")]
5641    fn test_g2d_resize() {
5642        if !is_g2d_available() {
5643            crate::test_support::report_skip(
5644                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5645            );
5646            return;
5647        }
5648        if !is_dma_available() {
5649            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5650            return;
5651        }
5652
5653        let dst_width = 640;
5654        let dst_height = 360;
5655        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5656        let src = crate::load_image_test_helper(
5657            &file,
5658            Some(PixelFormat::Rgba),
5659            Some(TensorMemory::DmaBuf),
5660        )
5661        .unwrap();
5662
5663        let g2d_dst = TensorDyn::image(
5664            dst_width,
5665            dst_height,
5666            PixelFormat::Rgba,
5667            DType::U8,
5668            Some(TensorMemory::DmaBuf),
5669            edgefirst_tensor::CpuAccess::ReadWrite,
5670        )
5671        .unwrap();
5672        let mut g2d_converter = G2DProcessor::new().unwrap();
5673        let (result, src, g2d_dst) = convert_img(
5674            &mut g2d_converter,
5675            src,
5676            g2d_dst,
5677            Rotation::None,
5678            Flip::None,
5679            Crop::no_crop(),
5680        );
5681        result.unwrap();
5682
5683        let cpu_dst = TensorDyn::image(
5684            dst_width,
5685            dst_height,
5686            PixelFormat::Rgba,
5687            DType::U8,
5688            None,
5689            edgefirst_tensor::CpuAccess::ReadWrite,
5690        )
5691        .unwrap();
5692        let mut cpu_converter = CPUProcessor::new();
5693        let (result, _src, cpu_dst) = convert_img(
5694            &mut cpu_converter,
5695            src,
5696            cpu_dst,
5697            Rotation::None,
5698            Flip::None,
5699            Crop::no_crop(),
5700        );
5701        result.unwrap();
5702
5703        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5704        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5705        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5706        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5707        // structural gap not exercised by these limited-range fixtures.
5708        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5709    }
5710
5711    #[test]
5712    #[cfg(any(
5713        target_os = "linux",
5714        target_os = "macos",
5715        target_os = "ios",
5716        target_os = "android",
5717        target_os = "windows"
5718    ))]
5719    #[cfg(feature = "opengl")]
5720    fn test_opengl_resize() {
5721        if !is_opengl_available() {
5722            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5723            return;
5724        }
5725
5726        let dst_width = 640;
5727        let dst_height = 360;
5728        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5729        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5730
5731        let cpu_dst = TensorDyn::image(
5732            dst_width,
5733            dst_height,
5734            PixelFormat::Rgba,
5735            DType::U8,
5736            None,
5737            edgefirst_tensor::CpuAccess::ReadWrite,
5738        )
5739        .unwrap();
5740        let mut cpu_converter = CPUProcessor::new();
5741        let (result, src, cpu_dst) = convert_img(
5742            &mut cpu_converter,
5743            src,
5744            cpu_dst,
5745            Rotation::None,
5746            Flip::None,
5747            Crop::no_crop(),
5748        );
5749        result.unwrap();
5750
5751        let mut src = src;
5752        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5753
5754        for _ in 0..5 {
5755            let gl_dst = TensorDyn::image(
5756                dst_width,
5757                dst_height,
5758                PixelFormat::Rgba,
5759                DType::U8,
5760                None,
5761                edgefirst_tensor::CpuAccess::ReadWrite,
5762            )
5763            .unwrap();
5764            let (result, src_back, gl_dst) = convert_img(
5765                &mut gl_converter,
5766                src,
5767                gl_dst,
5768                Rotation::None,
5769                Flip::None,
5770                Crop::no_crop(),
5771            );
5772            result.unwrap();
5773            src = src_back;
5774
5775            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5776        }
5777    }
5778
5779    #[test]
5780    #[cfg(any(
5781        target_os = "linux",
5782        target_os = "macos",
5783        target_os = "ios",
5784        target_os = "android",
5785        target_os = "windows"
5786    ))]
5787    #[cfg(feature = "opengl")]
5788    fn test_opengl_10_threads() {
5789        if !is_opengl_available() {
5790            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5791            return;
5792        }
5793
5794        let handles: Vec<_> = (0..10)
5795            .map(|i| {
5796                std::thread::Builder::new()
5797                    .name(format!("Thread {i}"))
5798                    .spawn(test_opengl_resize)
5799                    .unwrap()
5800            })
5801            .collect();
5802        handles.into_iter().for_each(|h| {
5803            if let Err(e) = h.join() {
5804                std::panic::resume_unwind(e)
5805            }
5806        });
5807    }
5808
5809    #[test]
5810    #[cfg(any(
5811        target_os = "linux",
5812        target_os = "macos",
5813        target_os = "ios",
5814        target_os = "android",
5815        target_os = "windows"
5816    ))]
5817    #[cfg(feature = "opengl")]
5818    fn test_opengl_grey() {
5819        if !is_opengl_available() {
5820            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5821            return;
5822        }
5823
5824        let img = crate::load_image_test_helper(
5825            &edgefirst_bench::testdata::read("grey.jpg"),
5826            Some(PixelFormat::Grey),
5827            None,
5828        )
5829        .unwrap();
5830
5831        let gl_dst = TensorDyn::image(
5832            640,
5833            640,
5834            PixelFormat::Grey,
5835            DType::U8,
5836            None,
5837            edgefirst_tensor::CpuAccess::ReadWrite,
5838        )
5839        .unwrap();
5840        let cpu_dst = TensorDyn::image(
5841            640,
5842            640,
5843            PixelFormat::Grey,
5844            DType::U8,
5845            None,
5846            edgefirst_tensor::CpuAccess::ReadWrite,
5847        )
5848        .unwrap();
5849
5850        let mut converter = CPUProcessor::new();
5851
5852        let (result, img, cpu_dst) = convert_img(
5853            &mut converter,
5854            img,
5855            cpu_dst,
5856            Rotation::None,
5857            Flip::None,
5858            Crop::no_crop(),
5859        );
5860        result.unwrap();
5861
5862        let mut gl = GLProcessorThreaded::new(None).unwrap();
5863        let (result, _img, gl_dst) = convert_img(
5864            &mut gl,
5865            img,
5866            gl_dst,
5867            Rotation::None,
5868            Flip::None,
5869            Crop::no_crop(),
5870        );
5871        result.unwrap();
5872
5873        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5874    }
5875
5876    #[test]
5877    #[cfg(target_os = "linux")]
5878    fn test_g2d_src_crop() {
5879        if !is_g2d_available() {
5880            crate::test_support::report_skip(
5881                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
5882            );
5883            return;
5884        }
5885        if !is_dma_available() {
5886            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5887            return;
5888        }
5889
5890        let dst_width = 640;
5891        let dst_height = 640;
5892        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5893        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5894
5895        let cpu_dst = TensorDyn::image(
5896            dst_width,
5897            dst_height,
5898            PixelFormat::Rgba,
5899            DType::U8,
5900            None,
5901            edgefirst_tensor::CpuAccess::ReadWrite,
5902        )
5903        .unwrap();
5904        let mut cpu_converter = CPUProcessor::new();
5905        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5906        let (result, src, cpu_dst) = convert_img(
5907            &mut cpu_converter,
5908            src,
5909            cpu_dst,
5910            Rotation::None,
5911            Flip::None,
5912            crop,
5913        );
5914        result.unwrap();
5915
5916        let g2d_dst = TensorDyn::image(
5917            dst_width,
5918            dst_height,
5919            PixelFormat::Rgba,
5920            DType::U8,
5921            None,
5922            edgefirst_tensor::CpuAccess::ReadWrite,
5923        )
5924        .unwrap();
5925        let mut g2d_converter = G2DProcessor::new().unwrap();
5926        let (result, _src, g2d_dst) = convert_img(
5927            &mut g2d_converter,
5928            src,
5929            g2d_dst,
5930            Rotation::None,
5931            Flip::None,
5932            crop,
5933        );
5934        result.unwrap();
5935
5936        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5937        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5938        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5939        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5940        // structural gap not exercised by these limited-range fixtures.
5941        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5942    }
5943
5944    #[test]
5945    #[cfg(target_os = "linux")]
5946    fn test_g2d_dst_crop() {
5947        if !is_g2d_available() {
5948            crate::test_support::report_skip(
5949                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
5950            );
5951            return;
5952        }
5953        if !is_dma_available() {
5954            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5955            return;
5956        }
5957
5958        let dst_width = 640;
5959        let dst_height = 640;
5960        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5961        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5962
5963        let cpu_dst = TensorDyn::image(
5964            dst_width,
5965            dst_height,
5966            PixelFormat::Rgba,
5967            DType::U8,
5968            None,
5969            edgefirst_tensor::CpuAccess::ReadWrite,
5970        )
5971        .unwrap();
5972        let mut cpu_converter = CPUProcessor::new();
5973        let crop = Crop::new();
5974        let (result, src, cpu_dst) = convert_img(
5975            &mut cpu_converter,
5976            src,
5977            cpu_dst,
5978            Rotation::None,
5979            Flip::None,
5980            crop,
5981        );
5982        result.unwrap();
5983
5984        let g2d_dst = TensorDyn::image(
5985            dst_width,
5986            dst_height,
5987            PixelFormat::Rgba,
5988            DType::U8,
5989            None,
5990            edgefirst_tensor::CpuAccess::ReadWrite,
5991        )
5992        .unwrap();
5993        let mut g2d_converter = G2DProcessor::new().unwrap();
5994        let (result, _src, g2d_dst) = convert_img(
5995            &mut g2d_converter,
5996            src,
5997            g2d_dst,
5998            Rotation::None,
5999            Flip::None,
6000            crop,
6001        );
6002        result.unwrap();
6003
6004        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6005        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6006        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6007        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6008        // structural gap not exercised by these limited-range fixtures.
6009        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6010    }
6011
6012    #[test]
6013    #[cfg(target_os = "linux")]
6014    fn test_g2d_all_rgba() {
6015        if !is_g2d_available() {
6016            crate::test_support::report_skip(
6017                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
6018            );
6019            return;
6020        }
6021        if !is_dma_available() {
6022            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6023            return;
6024        }
6025
6026        let dst_width = 640;
6027        let dst_height = 640;
6028        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6029        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6030        let src_dyn = src;
6031
6032        let mut cpu_dst = TensorDyn::image(
6033            dst_width,
6034            dst_height,
6035            PixelFormat::Rgba,
6036            DType::U8,
6037            None,
6038            edgefirst_tensor::CpuAccess::ReadWrite,
6039        )
6040        .unwrap();
6041        let mut cpu_converter = CPUProcessor::new();
6042        let mut g2d_dst = TensorDyn::image(
6043            dst_width,
6044            dst_height,
6045            PixelFormat::Rgba,
6046            DType::U8,
6047            None,
6048            edgefirst_tensor::CpuAccess::ReadWrite,
6049        )
6050        .unwrap();
6051        let mut g2d_converter = G2DProcessor::new().unwrap();
6052
6053        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
6054
6055        for rot in [
6056            Rotation::None,
6057            Rotation::Clockwise90,
6058            Rotation::Rotate180,
6059            Rotation::CounterClockwise90,
6060        ] {
6061            cpu_dst
6062                .as_typed::<u8>()
6063                .unwrap()
6064                .map()
6065                .unwrap()
6066                .as_mut_slice()
6067                .fill(114);
6068            g2d_dst
6069                .as_typed::<u8>()
6070                .unwrap()
6071                .map()
6072                .unwrap()
6073                .as_mut_slice()
6074                .fill(114);
6075            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
6076                let mut cpu_dst_dyn = cpu_dst;
6077                cpu_converter
6078                    .convert(
6079                        &src_dyn,
6080                        &mut cpu_dst_dyn,
6081                        Some(crate::ConvertOptions::new().crop(crop)),
6082                    )
6083                    .unwrap();
6084                cpu_dst = {
6085                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
6086                    __t.set_format(PixelFormat::Rgba).unwrap();
6087                    TensorDyn::from(__t)
6088                };
6089
6090                let mut g2d_dst_dyn = g2d_dst;
6091                g2d_converter
6092                    .convert(
6093                        &src_dyn,
6094                        &mut g2d_dst_dyn,
6095                        Some(crate::ConvertOptions::new().crop(crop)),
6096                    )
6097                    .unwrap();
6098                g2d_dst = {
6099                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
6100                    __t.set_format(PixelFormat::Rgba).unwrap();
6101                    TensorDyn::from(__t)
6102                };
6103
6104                compare_images(
6105                    &g2d_dst,
6106                    &cpu_dst,
6107                    0.98,
6108                    &format!("{} {:?} {:?}", function!(), rot, flip),
6109                );
6110            }
6111        }
6112    }
6113
6114    #[test]
6115    #[cfg(any(
6116        target_os = "linux",
6117        target_os = "macos",
6118        target_os = "ios",
6119        target_os = "android",
6120        target_os = "windows"
6121    ))]
6122    #[cfg(feature = "opengl")]
6123    fn test_opengl_src_crop() {
6124        if !is_opengl_available() {
6125            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6126            return;
6127        }
6128
6129        let dst_width = 640;
6130        let dst_height = 360;
6131        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6132        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6133        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
6134
6135        let cpu_dst = TensorDyn::image(
6136            dst_width,
6137            dst_height,
6138            PixelFormat::Rgba,
6139            DType::U8,
6140            None,
6141            edgefirst_tensor::CpuAccess::ReadWrite,
6142        )
6143        .unwrap();
6144        let mut cpu_converter = CPUProcessor::new();
6145        let (result, src, cpu_dst) = convert_img(
6146            &mut cpu_converter,
6147            src,
6148            cpu_dst,
6149            Rotation::None,
6150            Flip::None,
6151            crop,
6152        );
6153        result.unwrap();
6154
6155        let gl_dst = TensorDyn::image(
6156            dst_width,
6157            dst_height,
6158            PixelFormat::Rgba,
6159            DType::U8,
6160            None,
6161            edgefirst_tensor::CpuAccess::ReadWrite,
6162        )
6163        .unwrap();
6164        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6165        let (result, _src, gl_dst) = convert_img(
6166            &mut gl_converter,
6167            src,
6168            gl_dst,
6169            Rotation::None,
6170            Flip::None,
6171            crop,
6172        );
6173        result.unwrap();
6174
6175        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6176    }
6177
6178    #[test]
6179    #[cfg(any(
6180        target_os = "linux",
6181        target_os = "macos",
6182        target_os = "ios",
6183        target_os = "android",
6184        target_os = "windows"
6185    ))]
6186    #[cfg(feature = "opengl")]
6187    fn test_opengl_dst_crop() {
6188        if !is_opengl_available() {
6189            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6190            return;
6191        }
6192
6193        let dst_width = 640;
6194        let dst_height = 640;
6195        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6196        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6197
6198        let cpu_dst = TensorDyn::image(
6199            dst_width,
6200            dst_height,
6201            PixelFormat::Rgba,
6202            DType::U8,
6203            None,
6204            edgefirst_tensor::CpuAccess::ReadWrite,
6205        )
6206        .unwrap();
6207        let mut cpu_converter = CPUProcessor::new();
6208        let crop = Crop::new();
6209        let (result, src, cpu_dst) = convert_img(
6210            &mut cpu_converter,
6211            src,
6212            cpu_dst,
6213            Rotation::None,
6214            Flip::None,
6215            crop,
6216        );
6217        result.unwrap();
6218
6219        let gl_dst = TensorDyn::image(
6220            dst_width,
6221            dst_height,
6222            PixelFormat::Rgba,
6223            DType::U8,
6224            None,
6225            edgefirst_tensor::CpuAccess::ReadWrite,
6226        )
6227        .unwrap();
6228        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6229        let (result, _src, gl_dst) = convert_img(
6230            &mut gl_converter,
6231            src,
6232            gl_dst,
6233            Rotation::None,
6234            Flip::None,
6235            crop,
6236        );
6237        result.unwrap();
6238
6239        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6240    }
6241    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
6242    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
6243    /// padded allocation still gets the pattern at the right columns.
6244    #[cfg(all(target_os = "windows", feature = "opengl"))]
6245    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
6246        let t = TensorDyn::image(
6247            w,
6248            h,
6249            PixelFormat::Rgba,
6250            DType::U8,
6251            Some(TensorMemory::Mem),
6252            edgefirst_tensor::CpuAccess::ReadWrite,
6253        )
6254        .unwrap();
6255        fill_synthetic_rgba(&t);
6256        t
6257    }
6258
6259    /// Every pixel of `actual` equals `expected`, compared through the
6260    /// tensors' own row compaction so a padded texture and a tight `Mem`
6261    /// image compare as images. `compare_images` scores similarity and
6262    /// accepts anything at a 0.0 threshold; this is the exact check.
6263    #[cfg(all(target_os = "windows", feature = "opengl"))]
6264    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
6265        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
6266        let flat = |t: &TensorDyn| {
6267            let typed = t.as_typed::<u8>().expect("u8 image");
6268            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6269            typed.copy_to_flat(&mut out).expect("compact the rows");
6270            out
6271        };
6272        let (a, e) = (flat(actual), flat(expected));
6273        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
6274        assert_eq!(
6275            mismatches,
6276            0,
6277            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
6278            a.len(),
6279            &a[..16.min(a.len())],
6280            &e[..16.min(e.len())]
6281        );
6282    }
6283
6284    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
6285    /// a texture included, at its own row stride.
6286    #[cfg(all(target_os = "windows", feature = "opengl"))]
6287    fn fill_synthetic_rgba(t: &TensorDyn) {
6288        let w = t.width().unwrap();
6289        let h = t.height().unwrap();
6290        let bound = t.as_typed::<u8>().unwrap();
6291        let stride = bound.effective_row_stride().unwrap_or(w * 4);
6292        let mut m = bound.map().unwrap();
6293        let px = m.as_mut_slice();
6294        for y in 0..h {
6295            for x in 0..w {
6296                let o = y * stride + x * 4;
6297                px[o] = x as u8;
6298                px[o + 1] = y as u8;
6299                px[o + 2] = (x + y) as u8;
6300                px[o + 3] = 255;
6301            }
6302        }
6303    }
6304
6305    /// Element-wise comparison of two float images of the same format and
6306    /// dtype.
6307    ///
6308    /// Both sides are read through `copy_to_flat`, the tensor's own row
6309    /// compaction, for the reason `compare_images` does: a Windows texture
6310    /// destination pads its rows, so reading the head of the mapping as a
6311    /// tight image would read padding as values. F16 values are widened with
6312    /// `half::f16::to_f64` before the comparison.
6313    #[cfg(all(target_os = "windows", feature = "opengl"))]
6314    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6315        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6316        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6317        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6318        let values = |t: &TensorDyn| -> Vec<f64> {
6319            let elems: usize = t.shape().iter().product();
6320            match t.dtype() {
6321                DType::F32 => {
6322                    let typed = t.as_typed::<f32>().expect("F32 image");
6323                    let mut bytes = vec![0u8; elems * 4];
6324                    typed
6325                        .copy_to_flat(&mut bytes)
6326                        .expect("compact the padded rows");
6327                    bytes
6328                        .as_chunks::<4>()
6329                        .0
6330                        .iter()
6331                        .map(|c| f32::from_le_bytes(*c) as f64)
6332                        .collect()
6333                }
6334                DType::F16 => {
6335                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6336                    let mut bytes = vec![0u8; elems * 2];
6337                    typed
6338                        .copy_to_flat(&mut bytes)
6339                        .expect("compact the padded rows");
6340                    bytes
6341                        .as_chunks::<2>()
6342                        .0
6343                        .iter()
6344                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6345                        .collect()
6346                }
6347                other => panic!("{what}: {other:?} is not a float dtype"),
6348            }
6349        };
6350        let (av, bv) = (values(a), values(b));
6351        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6352        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6353            assert!(
6354                (x - y).abs() <= tol,
6355                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6356                (x - y).abs()
6357            );
6358        }
6359    }
6360
6361    /// The float PBO readback has to land each destination row at the
6362    /// destination's own pitch, exactly as the u8 PBO readback does.
6363    ///
6364    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6365    /// 3H)` RGBA16F texels, one plane row per surface row, for
6366    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6367    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6368    /// destination declared. A pool tensor narrowed with `configure_image`
6369    /// that keeps the pool's pitch is exactly that destination, so every row
6370    /// after the first landed short.
6371    ///
6372    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6373    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6374    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6375    /// not divide, so those rows are read tight and spread afterwards. The
6376    /// tight stride is the control: it must still take the read it always
6377    /// did.
6378    ///
6379    /// The oracle is the same convert into a tight PBO of the same geometry.
6380    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6381    /// tensor's own row compaction, so it compares logical elements and not
6382    /// mapping offsets.
6383    #[test]
6384    #[cfg(all(target_os = "windows", feature = "opengl"))]
6385    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6386        if !is_opengl_available() {
6387            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6388            return;
6389        }
6390        const W: usize = 64;
6391        const H: usize = 32;
6392        const POOL_W: usize = 96;
6393
6394        let mut gpu = ImageProcessor::new().unwrap();
6395        let support = gpu.supported_render_dtypes();
6396        let src = synthetic_rgba(W, H);
6397
6398        // (format, dtype, supported, the pitches to declare)
6399        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6400            (
6401                PixelFormat::PlanarRgb,
6402                DType::F16,
6403                support.f16,
6404                // Tight plane row, the pool's pitch (a whole texel count),
6405                // and a 4-byte pad that no 8-byte texel divides.
6406                &[W * 2, POOL_W * 2, W * 2 + 4],
6407            ),
6408            (
6409                PixelFormat::Rgb,
6410                DType::F32,
6411                support.f32,
6412                &[W * 3 * 4, POOL_W * 3 * 4],
6413            ),
6414        ];
6415
6416        for (fmt, dtype, supported, strides) in cases {
6417            if !supported {
6418                crate::test_support::report_skip(&format!(
6419                    "{} - {fmt:?}/{dtype:?} not reported",
6420                    function!()
6421                ));
6422                continue;
6423            }
6424            let Some(gl) = gpu.opengl.as_ref() else {
6425                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6426                return;
6427            };
6428            // The oracle: a tight PBO of the target geometry, same route.
6429            let mut reference = gl
6430                .create_pbo_image_dtype(W, H, fmt, dtype)
6431                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight reference PBO: {e:?}"));
6432            assert_eq!(
6433                reference.memory(),
6434                TensorMemory::Pbo,
6435                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6436            );
6437            gpu.convert(&src, &mut reference, None)
6438                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6439
6440            for &stride in strides {
6441                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6442                // Allocate at the pool width, then narrow the geometry and
6443                // declare the pitch, as the u8 stride tests do.
6444                let mut padded = gpu
6445                    .opengl
6446                    .as_ref()
6447                    .expect("GL processor")
6448                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6449                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6450                padded
6451                    .configure_image(W, H, fmt)
6452                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6453                padded
6454                    .set_row_stride(stride)
6455                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6456                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6457
6458                let before = gpu.convert_fallback_count();
6459                gpu.convert(&src, &mut padded, None)
6460                    .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6461                // A CPU fallback writes the destination with the stride-aware
6462                // CPU writers and would pass without the GL readback running.
6463                assert_eq!(
6464                    gpu.convert_fallback_count(),
6465                    before,
6466                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6467                );
6468
6469                let tol = if dtype == DType::F16 {
6470                    1.0 / 1024.0
6471                } else {
6472                    1e-6
6473                };
6474                compare_float_images(&padded, &reference, tol, &label);
6475            }
6476        }
6477    }
6478
6479    /// Every float destination layout the GL backend reports it can render
6480    /// must render zero-copy into the texture and match the CPU converter
6481    /// element for element: F16 and F32 planar (three and four planes),
6482    /// interleaved float RGB, and float RGBA.
6483    ///
6484    /// The destination memory is requested explicitly so each layout is
6485    /// exercised as a texture whatever `create_image`'s own allocation
6486    /// policy would pick for it; the policy itself is covered by
6487    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6488    #[test]
6489    #[cfg(all(target_os = "windows", feature = "opengl"))]
6490    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6491        if !is_opengl_available() {
6492            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6493            return;
6494        }
6495        if !crate::test_support::is_gpu_buffer_available() {
6496            crate::test_support::report_skip(&format!(
6497                "{} - no zero-copy GPU buffers",
6498                function!()
6499            ));
6500            return;
6501        }
6502        let mut gpu = ImageProcessor::new().unwrap();
6503        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6504            backend: ComputeBackend::Cpu,
6505            ..Default::default()
6506        })
6507        .unwrap();
6508        let support = gpu.supported_render_dtypes();
6509        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6510        let src = synthetic_rgba(640, 480);
6511        for (fmt, dtype, need) in [
6512            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6513            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6514            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6515            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6516            (PixelFormat::Rgb, DType::F16, support.f16),
6517            (PixelFormat::Rgb, DType::F32, support.f32),
6518            (PixelFormat::Rgba, DType::F16, support.f16),
6519            (PixelFormat::Rgba, DType::F32, support.f32),
6520        ] {
6521            if !need {
6522                crate::test_support::report_skip(&format!(
6523                    "{} - {fmt:?}/{dtype:?} not reported",
6524                    function!()
6525                ));
6526                continue;
6527            }
6528            let mut dst_gpu = gpu
6529                .create_image(
6530                    640,
6531                    480,
6532                    fmt,
6533                    dtype,
6534                    Some(TensorMemory::DmaBuf),
6535                    edgefirst_tensor::CpuAccess::ReadWrite,
6536                )
6537                .unwrap();
6538            assert_eq!(
6539                dst_gpu.memory(),
6540                TensorMemory::DmaBuf,
6541                "{fmt:?}/{dtype:?} must be a texture"
6542            );
6543            let mut dst_cpu = TensorDyn::image(
6544                640,
6545                480,
6546                fmt,
6547                dtype,
6548                Some(TensorMemory::Mem),
6549                edgefirst_tensor::CpuAccess::ReadWrite,
6550            )
6551            .unwrap();
6552            let before = gpu.convert_fallback_count();
6553            gpu.convert(&src, &mut dst_gpu, None).unwrap();
6554            cpu.convert(&src, &mut dst_cpu, None).unwrap();
6555            assert_eq!(
6556                gpu.convert_fallback_count(),
6557                before,
6558                "{fmt:?}/{dtype:?} fell back to CPU"
6559            );
6560            let tol = if dtype == DType::F16 {
6561                1.0 / 1024.0
6562            } else {
6563                1e-6
6564            };
6565            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6566        }
6567    }
6568
6569    // The tests below wait on a Windows completion fence directly, so they
6570    // need the raw wait call — no crate in this workspace wraps it.
6571    #[cfg(target_os = "windows")]
6572    #[link(name = "kernel32")]
6573    extern "system" {
6574        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6575    }
6576
6577    /// A batch of deferred converts records one distinct fence value per
6578    /// tile and submits once, at `flush`.
6579    ///
6580    /// The values are what a GPU consumer waits on, so they have to stay
6581    /// per-destination and strictly increasing; the submission is what
6582    /// batching exists to spare, so there must be exactly one of it. The
6583    /// destinations are compared against the same convert done standalone,
6584    /// which is what proves the deferred submission did not lose a tile.
6585    #[cfg(all(target_os = "windows", feature = "opengl"))]
6586    #[test]
6587    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6588        use edgefirst_tensor::CpuAccess;
6589
6590        if !crate::test_support::is_gpu_buffer_available() {
6591            crate::test_support::report_skip(&format!(
6592                "{} - no zero-copy GPU buffers",
6593                function!()
6594            ));
6595            return;
6596        }
6597        let mut p = match ImageProcessor::new() {
6598            Ok(p) => p,
6599            Err(e) => {
6600                crate::test_support::report_skip(&format!(
6601                    "{} - ImageProcessor init failed ({e:?})",
6602                    function!()
6603                ));
6604                return;
6605            }
6606        };
6607        let (w, h, tiles) = (640usize, 480usize, 4usize);
6608        let src = synthetic_rgba(w, h);
6609        let mut dsts: Vec<TensorDyn> = (0..tiles)
6610            .map(|_| {
6611                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6612                    .unwrap()
6613            })
6614            .collect();
6615        if dsts[0].memory() != TensorMemory::DmaBuf {
6616            crate::test_support::report_skip(&format!(
6617                "{} - destinations are not textures",
6618                function!()
6619            ));
6620            return;
6621        }
6622        for dst in &mut dsts {
6623            p.convert_deferred(&src, dst, None).unwrap();
6624        }
6625        let values: Vec<u64> = dsts
6626            .iter()
6627            .map(|d| {
6628                d.gpu_completion()
6629                    .unwrap()
6630                    .expect("each deferred convert records its own completion")
6631                    .value
6632            })
6633            .collect();
6634        for pair in values.windows(2) {
6635            assert!(
6636                pair[1] > pair[0],
6637                "the batch allocated {values:?}, which is not one increasing value per tile"
6638            );
6639        }
6640        p.flush().unwrap();
6641        // The one submission the batch owed has happened, so every recorded
6642        // value is reachable.
6643        let dev = edgefirst_tensor::d3d11::device().unwrap();
6644        let last = *values.last().unwrap();
6645        let ev = dev.event_for(last).unwrap();
6646        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6647        // this call.
6648        assert_eq!(
6649            unsafe {
6650                WaitForSingleObject(
6651                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6652                    5000,
6653                )
6654            },
6655            0,
6656            "the batch's newest value {last} never arrived after flush"
6657        );
6658
6659        let mut solo = p
6660            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6661            .unwrap();
6662        p.convert(&src, &mut solo, None).unwrap();
6663        // Read through the tensor's own compaction: a Windows texture's
6664        // staging rows are pitch-padded, so a flat read of the mapping would
6665        // compare padding as pixels.
6666        let flat = |img: &TensorDyn| -> Vec<u8> {
6667            let t = img.as_typed::<u8>().expect("u8 image");
6668            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6669            t.copy_to_flat(&mut out).expect("compact the padded rows");
6670            out
6671        };
6672        let want = flat(&solo);
6673        for (i, dst) in dsts.iter().enumerate() {
6674            assert_eq!(
6675                flat(dst),
6676                want,
6677                "tile {i} does not match a standalone convert"
6678            );
6679        }
6680    }
6681
6682    /// A processor dropped with a batch still open submits what that batch
6683    /// recorded.
6684    ///
6685    /// The values are already published on destinations that outlive the
6686    /// processor, so a consumer may be waiting on one; a deferred signal that
6687    /// is never submitted is a wait nothing can satisfy. `flush` is what
6688    /// normally pays it, and the whole point here is that the caller never
6689    /// calls it.
6690    #[cfg(all(target_os = "windows", feature = "opengl"))]
6691    #[test]
6692    fn windows_dropping_a_processor_submits_an_open_batch() {
6693        use edgefirst_tensor::CpuAccess;
6694
6695        if !crate::test_support::is_gpu_buffer_available() {
6696            crate::test_support::report_skip(&format!(
6697                "{} - no zero-copy GPU buffers",
6698                function!()
6699            ));
6700            return;
6701        }
6702        let src = synthetic_rgba(320, 240);
6703        let recorded = {
6704            let mut p = match ImageProcessor::new() {
6705                Ok(p) => p,
6706                Err(e) => {
6707                    crate::test_support::report_skip(&format!(
6708                        "{} - init failed ({e:?})",
6709                        function!()
6710                    ));
6711                    return;
6712                }
6713            };
6714            let mut dst = p
6715                .create_image(
6716                    320,
6717                    240,
6718                    PixelFormat::Rgba,
6719                    DType::U8,
6720                    None,
6721                    CpuAccess::Read,
6722                )
6723                .unwrap();
6724            if dst.memory() != TensorMemory::DmaBuf {
6725                crate::test_support::report_skip(&format!(
6726                    "{} - destination is not a texture",
6727                    function!()
6728                ));
6729                return;
6730            }
6731            p.convert_deferred(&src, &mut dst, None).unwrap();
6732            // No `flush`: the drop below is what has to settle the batch.
6733            dst.gpu_completion()
6734                .unwrap()
6735                .expect("the deferred convert recorded a completion")
6736                .value
6737        };
6738
6739        let dev = edgefirst_tensor::d3d11::device().unwrap();
6740        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6741        while dev.completed_value() < recorded {
6742            assert!(
6743                std::time::Instant::now() < deadline,
6744                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6745                dev.completed_value()
6746            );
6747            std::thread::yield_now();
6748        }
6749    }
6750
6751    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6752    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6753    /// `convert_with_fence` is available on every platform: on Windows it
6754    /// returns a manual-reset event set when the GPU work completes.
6755    #[cfg(all(target_os = "windows", feature = "opengl"))]
6756    #[test]
6757    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6758        use edgefirst_tensor::CpuAccess;
6759        use std::os::windows::io::AsRawHandle;
6760
6761        if !crate::test_support::is_gpu_buffer_available() {
6762            crate::test_support::report_skip(&format!(
6763                "{} - no zero-copy GPU buffers",
6764                function!()
6765            ));
6766            return;
6767        }
6768        let mut p = ImageProcessor::new().unwrap();
6769        let src = synthetic_rgba(320, 240);
6770        let mut dst = p
6771            .create_image(
6772                320,
6773                240,
6774                PixelFormat::Rgba,
6775                DType::U8,
6776                None,
6777                CpuAccess::Read,
6778            )
6779            .unwrap();
6780        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6781        // answers host memory and there is no fence to wait on; the lanes
6782        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6783        if dst.memory() != TensorMemory::DmaBuf
6784            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6785        {
6786            crate::test_support::report_skip(&format!(
6787                "{} - create_image gave {:?}, not a texture (no GL backend)",
6788                function!(),
6789                dst.memory()
6790            ));
6791            return;
6792        }
6793        assert_eq!(
6794            dst.memory(),
6795            TensorMemory::DmaBuf,
6796            "create_image(None) must prefer a texture on Windows"
6797        );
6798        // Read before the wait: the recorded value must be the one the fence
6799        // names, not an older point the destination happened to carry.
6800        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6801        let fence = p
6802            .convert_with_fence(&src, &mut dst, None)
6803            .unwrap()
6804            .expect("Windows exports a fence");
6805        let recorded = dst
6806            .gpu_completion()
6807            .unwrap()
6808            .expect("the convert recorded a completion")
6809            .value;
6810        assert!(
6811            before.is_none_or(|b| recorded > b),
6812            "the fenced convert recorded a newer value than {before:?}"
6813        );
6814        // One flush and one signal for the whole call: the value the
6815        // destination carries is the newest the device has allocated, so
6816        // nothing was signalled after `record_completion`. Two signals (the
6817        // shape this replaced) would leave `last_signalled` one past it.
6818        assert_eq!(
6819            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6820            recorded,
6821            "a fenced convert must signal exactly once"
6822        );
6823        // SAFETY: `fence` owns a live Win32 event handle returned by
6824        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6825        // which stays valid for the duration of this call because `fence`
6826        // is not dropped until after it.
6827        assert_eq!(
6828            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6829            0,
6830            "fence was not set within 5 seconds"
6831        );
6832        // The event and the destination's own completion name the same
6833        // value, so a consumer may wait on either: the event fired, and the
6834        // device's completed value has reached the value `dst` reports.
6835        assert!(
6836            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6837            "the event fired, so the recorded value {recorded} is complete"
6838        );
6839        assert_eq!(
6840            dst.gpu_completion().unwrap().map(|c| c.value),
6841            Some(recorded),
6842            "the destination still reports the value the event waited on"
6843        );
6844        let mut expected = TensorDyn::image(
6845            320,
6846            240,
6847            PixelFormat::Rgba,
6848            DType::U8,
6849            Some(TensorMemory::Mem),
6850            CpuAccess::ReadWrite,
6851        )
6852        .unwrap();
6853        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6854            backend: ComputeBackend::Cpu,
6855            ..Default::default()
6856        })
6857        .unwrap();
6858        cpu.convert(&src, &mut expected, None).unwrap();
6859        compare_images(&dst, &expected, 0.0, "fenced convert");
6860    }
6861
6862    /// A Windows GL test's destination must be a texture; without a GL backend
6863    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6864    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6865    /// texture-first claim a hard assertion.
6866    #[cfg(all(target_os = "windows", feature = "opengl"))]
6867    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6868        if t.memory() == TensorMemory::DmaBuf {
6869            return true;
6870        }
6871        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6872            panic!(
6873                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6874                t.memory()
6875            );
6876        }
6877        crate::test_support::report_skip(&format!(
6878            "{test} - create_image gave {:?}, not a texture (no GL backend)",
6879            t.memory()
6880        ));
6881        false
6882    }
6883
6884    /// A draw into a texture destination records a completion on it the way
6885    /// a convert does, so a device consumer can wait on the drawn frame. The
6886    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6887    /// so a CPU fallback fails the draw outright instead of leaving the
6888    /// completion empty.
6889    #[cfg(all(target_os = "windows", feature = "opengl"))]
6890    #[test]
6891    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6892        use edgefirst_tensor::{CpuAccess, Segmentation};
6893        use ndarray::Array3;
6894
6895        if !crate::test_support::is_gpu_buffer_available() {
6896            crate::test_support::report_skip(&format!(
6897                "{} - no zero-copy GPU buffers",
6898                function!()
6899            ));
6900            return;
6901        }
6902        let mut p = ImageProcessor::new().unwrap();
6903        let mut dst = p
6904            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6905            .unwrap();
6906        if !texture_or_skip(&dst, function!()) {
6907            return;
6908        }
6909        assert!(
6910            dst.gpu_completion().unwrap().is_none(),
6911            "nothing has written this texture yet"
6912        );
6913
6914        let detect = DetectBox {
6915            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6916            score: 0.99,
6917            label: 0,
6918        };
6919        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6920        let seg = Segmentation {
6921            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6922                .unwrap()
6923                .into(),
6924            xmin: 0.25,
6925            ymin: 0.25,
6926            xmax: 0.75,
6927            ymax: 0.75,
6928        };
6929        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6930            .expect("draw into a read-only texture");
6931
6932        let completion = dst
6933            .gpu_completion()
6934            .unwrap()
6935            .expect("the draw recorded no completion on dst");
6936        assert!(completion.value > 0, "completion value must be non-zero");
6937
6938        // The drawn frame is read back through the texture's staging copy;
6939        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6940        // needs only a read mapping, which the read-only texture allows.
6941        // The corner is cleared and the box centre carries the mask colour.
6942        let mut flat = vec![0u8; 64 * 64 * 4];
6943        dst.as_typed::<u8>()
6944            .unwrap()
6945            .copy_to_flat(&mut flat)
6946            .expect("read the drawn texture");
6947        let px = |x: usize, y: usize| -> [u8; 4] {
6948            let o = (y * 64 + x) * 4;
6949            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6950        };
6951        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6952        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6953    }
6954
6955    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6956    /// completion on a texture destination too.
6957    #[cfg(all(target_os = "windows", feature = "opengl"))]
6958    #[test]
6959    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6960        use edgefirst_tensor::{CpuAccess, Tensor};
6961
6962        if !crate::test_support::is_gpu_buffer_available() {
6963            crate::test_support::report_skip(&format!(
6964                "{} - no zero-copy GPU buffers",
6965                function!()
6966            ));
6967            return;
6968        }
6969        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6970        // coverage pass); that is the same skip the texture guard below gives,
6971        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6972        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6973            Ok(p) => p,
6974            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6975                crate::test_support::report_skip(&format!(
6976                    "{} - no OpenGL backend: {e}",
6977                    function!()
6978                ));
6979                return;
6980            }
6981            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6982        };
6983        let mut dst = p
6984            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6985            .unwrap();
6986        if !texture_or_skip(&dst, function!()) {
6987            return;
6988        }
6989
6990        let detect = DetectBox {
6991            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6992            score: 0.99,
6993            label: 0,
6994        };
6995        // One prototype channel of ones and a positive coefficient: every
6996        // pixel of the box is foreground.
6997        let proto = ProtoData {
6998            mask_coefficients: TensorDyn::F32(
6999                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
7000            ),
7001            protos: TensorDyn::F32(
7002                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
7003            ),
7004            layout: ProtoLayout::Nhwc,
7005        };
7006        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
7007            .expect("forced-GL proto draw into a read-only texture");
7008
7009        let completion = dst
7010            .gpu_completion()
7011            .unwrap()
7012            .expect("the proto draw recorded no completion on dst");
7013        assert!(completion.value > 0, "completion value must be non-zero");
7014    }
7015
7016    /// An empty draw with a host-memory background onto a texture
7017    /// destination leaves the background in the texture, rows in order.
7018    /// The gradient pattern makes a missing or vertically flipped base
7019    /// layer differ from the expected image.
7020    #[cfg(all(target_os = "windows", feature = "opengl"))]
7021    #[test]
7022    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
7023        use edgefirst_tensor::CpuAccess;
7024
7025        if !crate::test_support::is_gpu_buffer_available() {
7026            crate::test_support::report_skip(&format!(
7027                "{} - no zero-copy GPU buffers",
7028                function!()
7029            ));
7030            return;
7031        }
7032        let mut p = ImageProcessor::new().unwrap();
7033        let bg = synthetic_rgba(64, 48);
7034        let mut dst = p
7035            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7036            .unwrap();
7037        if !texture_or_skip(&dst, function!()) {
7038            return;
7039        }
7040        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7041            .expect("empty draw with a host background onto a texture");
7042        assert_pixels_identical(&dst, &bg, "host background onto a texture");
7043        assert!(dst.gpu_completion().unwrap().is_some());
7044    }
7045
7046    /// The same draw with the background in a texture of its own: the
7047    /// base layer is sampled from the background texture directly.
7048    #[cfg(all(target_os = "windows", feature = "opengl"))]
7049    #[test]
7050    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
7051        use edgefirst_tensor::CpuAccess;
7052
7053        if !crate::test_support::is_gpu_buffer_available() {
7054            crate::test_support::report_skip(&format!(
7055                "{} - no zero-copy GPU buffers",
7056                function!()
7057            ));
7058            return;
7059        }
7060        let mut p = ImageProcessor::new().unwrap();
7061        let expected = synthetic_rgba(64, 48);
7062        let bg = p
7063            .create_image(
7064                64,
7065                48,
7066                PixelFormat::Rgba,
7067                DType::U8,
7068                None,
7069                CpuAccess::ReadWrite,
7070            )
7071            .unwrap();
7072        if !texture_or_skip(&bg, function!()) {
7073            return;
7074        }
7075        fill_synthetic_rgba(&bg);
7076        let mut dst = p
7077            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7078            .unwrap();
7079        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
7080        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7081            .expect("empty draw with a texture background onto a texture");
7082        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
7083        assert!(dst.gpu_completion().unwrap().is_some());
7084    }
7085
7086    /// A draw into a `view()` of a texture fills that band and leaves its
7087    /// sibling alone. The GL draw declines a sub-region destination
7088    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
7089    /// the parent, takes it; the zero-copy arm would otherwise have cleared
7090    /// the parent's first rows instead of the view's.
7091    #[cfg(all(target_os = "windows", feature = "opengl"))]
7092    #[test]
7093    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
7094        use edgefirst_tensor::CpuAccess;
7095
7096        if !crate::test_support::is_gpu_buffer_available() {
7097            crate::test_support::report_skip(&format!(
7098                "{} - no zero-copy GPU buffers",
7099                function!()
7100            ));
7101            return;
7102        }
7103        let (w, h) = (32usize, 64usize);
7104        let mut p = ImageProcessor::new().unwrap();
7105        let parent = p
7106            .create_image(
7107                w,
7108                h,
7109                PixelFormat::Rgba,
7110                DType::U8,
7111                None,
7112                CpuAccess::ReadWrite,
7113            )
7114            .unwrap();
7115        if !texture_or_skip(&parent, function!()) {
7116            return;
7117        }
7118        fill_synthetic_rgba(&parent);
7119
7120        // The lower band only. An empty draw with no background clears its
7121        // destination, so a write at the parent's origin would show up as
7122        // cleared pixels in the upper band.
7123        let mut band = parent
7124            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7125            .unwrap();
7126        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7127            .expect("empty draw into a texture view");
7128        drop(band);
7129
7130        let mut flat = vec![0u8; w * h * 4];
7131        parent
7132            .as_typed::<u8>()
7133            .unwrap()
7134            .copy_to_flat(&mut flat)
7135            .expect("read the parent texture");
7136        for y in 0..h {
7137            for x in 0..w {
7138                let o = (y * w + x) * 4;
7139                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
7140                let want = if y < h / 2 {
7141                    [x as u8, y as u8, (x + y) as u8, 255]
7142                } else {
7143                    [0, 0, 0, 0]
7144                };
7145                assert_eq!(got, want, "pixel ({x},{y})");
7146            }
7147        }
7148
7149        // The GL decline is what sent the draw above to the CPU: with GL
7150        // forced there is no CPU arm to fall through to, so it surfaces.
7151        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
7152        let mut band = parent
7153            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7154            .unwrap();
7155        let err = forced
7156            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7157            .expect_err("forced GL must decline a view destination");
7158        assert!(
7159            matches!(err, Error::NotSupported(_)),
7160            "expected NotSupported, got {err:?}"
7161        );
7162    }
7163
7164    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
7165    /// a draw GL declines still renders the caller's palette. The view
7166    /// destination is the reachable decline: `check_draw_dst_placement`
7167    /// refuses it and the CPU backend draws the mask.
7168    #[cfg(all(target_os = "windows", feature = "opengl"))]
7169    #[test]
7170    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
7171        use edgefirst_tensor::{CpuAccess, Segmentation};
7172        use ndarray::Array3;
7173
7174        if !crate::test_support::is_gpu_buffer_available() {
7175            crate::test_support::report_skip(&format!(
7176                "{} - no zero-copy GPU buffers",
7177                function!()
7178            ));
7179            return;
7180        }
7181        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
7182        assert_ne!(
7183            CUSTOM[..3],
7184            DEFAULT_COLORS_U8[0][..3],
7185            "the custom colour must differ from the default palette's, or \
7186             this test cannot tell them apart"
7187        );
7188
7189        let (w, h) = (32usize, 64usize);
7190        let mut p = ImageProcessor::new().unwrap();
7191        let parent = p
7192            .create_image(
7193                w,
7194                h,
7195                PixelFormat::Rgba,
7196                DType::U8,
7197                None,
7198                CpuAccess::ReadWrite,
7199            )
7200            .unwrap();
7201        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7202        // answers host memory and nothing declines the draw; the test needs
7203        // the texture-backed parent so the view is the reachable decline. The
7204        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
7205        if parent.memory() != TensorMemory::DmaBuf
7206            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7207        {
7208            crate::test_support::report_skip(&format!(
7209                "{} - create_image gave {:?}, not a texture (no GL backend)",
7210                function!(),
7211                parent.memory()
7212            ));
7213            return;
7214        }
7215        p.set_class_colors(&[CUSTOM]).unwrap();
7216
7217        let detect = DetectBox {
7218            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7219            score: 0.99,
7220            label: 0,
7221        };
7222        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7223        let seg = Segmentation {
7224            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7225                .unwrap()
7226                .into(),
7227            xmin: 0.25,
7228            ymin: 0.25,
7229            xmax: 0.75,
7230            ymax: 0.75,
7231        };
7232        let mut band = parent
7233            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7234            .unwrap();
7235        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
7236            .expect("draw into a texture view");
7237        drop(band);
7238
7239        let mut flat = vec![0u8; w * h * 4];
7240        parent
7241            .as_typed::<u8>()
7242            .unwrap()
7243            .copy_to_flat(&mut flat)
7244            .expect("read the parent texture");
7245        // The band's centre, inside the box. The mask blend writes RGB only;
7246        // alpha is whatever the base layer left, which is the clear.
7247        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
7248        assert_eq!(
7249            &flat[o..o + 3],
7250            &CUSTOM[..3],
7251            "the CPU backend drew the default palette, so it never received \
7252             set_class_colors"
7253        );
7254    }
7255
7256    /// Two independently-created `ImageProcessor`s, each converting into its
7257    /// own texture destination in lockstep, must not bleed GL state into
7258    /// each other across 200 iterations (the destination created by
7259    /// `create_image(None)` is the texture arm this task adds).
7260    #[cfg(all(target_os = "windows", feature = "opengl"))]
7261    #[test]
7262    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
7263        use edgefirst_tensor::CpuAccess;
7264
7265        if !crate::test_support::is_gpu_buffer_available() {
7266            crate::test_support::report_skip(&format!(
7267                "{} - no zero-copy GPU buffers",
7268                function!()
7269            ));
7270            return;
7271        }
7272        let mut a = ImageProcessor::new().unwrap();
7273        let mut b = ImageProcessor::new().unwrap();
7274        let src_a = synthetic_rgba(512, 512);
7275        let src_b = synthetic_rgba(256, 256);
7276        let mut dst_a = a
7277            .create_image(
7278                512,
7279                512,
7280                PixelFormat::Rgba,
7281                DType::U8,
7282                None,
7283                CpuAccess::Read,
7284            )
7285            .unwrap();
7286        let mut dst_b = b
7287            .create_image(
7288                256,
7289                256,
7290                PixelFormat::Rgba,
7291                DType::U8,
7292                None,
7293                CpuAccess::Read,
7294            )
7295            .unwrap();
7296        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7297            backend: ComputeBackend::Cpu,
7298            ..Default::default()
7299        })
7300        .unwrap();
7301        let mut ref_a = TensorDyn::image(
7302            512,
7303            512,
7304            PixelFormat::Rgba,
7305            DType::U8,
7306            Some(TensorMemory::Mem),
7307            CpuAccess::ReadWrite,
7308        )
7309        .unwrap();
7310        let mut ref_b = TensorDyn::image(
7311            256,
7312            256,
7313            PixelFormat::Rgba,
7314            DType::U8,
7315            Some(TensorMemory::Mem),
7316            CpuAccess::ReadWrite,
7317        )
7318        .unwrap();
7319        cpu.convert(&src_a, &mut ref_a, None).unwrap();
7320        cpu.convert(&src_b, &mut ref_b, None).unwrap();
7321        for _ in 0..200 {
7322            a.convert(&src_a, &mut dst_a, None).unwrap();
7323            b.convert(&src_b, &mut dst_b, None).unwrap();
7324            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7325            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7326        }
7327    }
7328
7329    #[test]
7330    #[cfg(any(
7331        target_os = "linux",
7332        target_os = "macos",
7333        target_os = "ios",
7334        target_os = "android",
7335        target_os = "windows"
7336    ))]
7337    #[cfg(feature = "opengl")]
7338    fn test_opengl_all_rgba() {
7339        if !is_opengl_available() {
7340            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7341            return;
7342        }
7343
7344        let dst_width = 640;
7345        let dst_height = 640;
7346        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7347
7348        let mut cpu_converter = CPUProcessor::new();
7349
7350        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7351
7352        // Every backing this host can allocate: Shm is Unix-only, and the
7353        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7354        // Windows yet).
7355        let mut mem = vec![None, Some(TensorMemory::Mem)];
7356        if edgefirst_tensor::is_shm_available() {
7357            mem.push(Some(TensorMemory::Shm));
7358        }
7359        if crate::test_support::is_gpu_buffer_available() {
7360            mem.push(Some(TensorMemory::DmaBuf));
7361        } else {
7362            crate::test_support::report_skip(&format!(
7363                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7364                function!()
7365            ));
7366        }
7367        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7368        for m in mem {
7369            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7370            let src_dyn = src;
7371
7372            for rot in [
7373                Rotation::None,
7374                Rotation::Clockwise90,
7375                Rotation::Rotate180,
7376                Rotation::CounterClockwise90,
7377            ] {
7378                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7379                    let cpu_dst = TensorDyn::image(
7380                        dst_width,
7381                        dst_height,
7382                        PixelFormat::Rgba,
7383                        DType::U8,
7384                        m,
7385                        edgefirst_tensor::CpuAccess::ReadWrite,
7386                    )
7387                    .unwrap();
7388                    let gl_dst = TensorDyn::image(
7389                        dst_width,
7390                        dst_height,
7391                        PixelFormat::Rgba,
7392                        DType::U8,
7393                        m,
7394                        edgefirst_tensor::CpuAccess::ReadWrite,
7395                    )
7396                    .unwrap();
7397                    cpu_dst
7398                        .as_typed::<u8>()
7399                        .unwrap()
7400                        .map()
7401                        .unwrap()
7402                        .as_mut_slice()
7403                        .fill(114);
7404                    gl_dst
7405                        .as_typed::<u8>()
7406                        .unwrap()
7407                        .map()
7408                        .unwrap()
7409                        .as_mut_slice()
7410                        .fill(114);
7411
7412                    let mut cpu_dst_dyn = cpu_dst;
7413                    cpu_converter
7414                        .convert(
7415                            &src_dyn,
7416                            &mut cpu_dst_dyn,
7417                            Some(crate::ConvertOptions::new().crop(crop)),
7418                        )
7419                        .unwrap();
7420                    let cpu_dst = {
7421                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7422                        __t.set_format(PixelFormat::Rgba).unwrap();
7423                        TensorDyn::from(__t)
7424                    };
7425
7426                    let mut gl_dst_dyn = gl_dst;
7427                    gl_converter
7428                        .convert(
7429                            &src_dyn,
7430                            &mut gl_dst_dyn,
7431                            Some(crate::ConvertOptions::new().crop(crop)),
7432                        )
7433                        .map_err(|e| {
7434                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7435                            e
7436                        })
7437                        .unwrap();
7438                    let gl_dst = {
7439                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7440                        __t.set_format(PixelFormat::Rgba).unwrap();
7441                        TensorDyn::from(__t)
7442                    };
7443
7444                    compare_images(
7445                        &gl_dst,
7446                        &cpu_dst,
7447                        0.98,
7448                        &format!("{} {:?} {:?}", function!(), rot, flip),
7449                    );
7450                }
7451            }
7452        }
7453    }
7454
7455    #[test]
7456    #[cfg(target_os = "linux")]
7457    fn test_cpu_rotate() {
7458        for rot in [
7459            Rotation::Clockwise90,
7460            Rotation::Rotate180,
7461            Rotation::CounterClockwise90,
7462        ] {
7463            test_cpu_rotate_(rot);
7464        }
7465    }
7466
7467    #[cfg(target_os = "linux")]
7468    fn test_cpu_rotate_(rot: Rotation) {
7469        // This test rotates the image 4 times and checks that the image was returned to
7470        // be the same Currently doesn't check if rotations actually rotated in
7471        // right direction
7472        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7473
7474        let unchanged_src =
7475            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7476        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7477
7478        let (dst_width, dst_height) = match rot {
7479            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7480            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7481                (src.height().unwrap(), src.width().unwrap())
7482            }
7483        };
7484
7485        let cpu_dst = TensorDyn::image(
7486            dst_width,
7487            dst_height,
7488            PixelFormat::Rgba,
7489            DType::U8,
7490            None,
7491            edgefirst_tensor::CpuAccess::ReadWrite,
7492        )
7493        .unwrap();
7494        let mut cpu_converter = CPUProcessor::new();
7495
7496        // After rotating 4 times, the image should be the same as the original
7497
7498        let (result, src, cpu_dst) = convert_img(
7499            &mut cpu_converter,
7500            src,
7501            cpu_dst,
7502            rot,
7503            Flip::None,
7504            Crop::no_crop(),
7505        );
7506        result.unwrap();
7507
7508        let (result, cpu_dst, src) = convert_img(
7509            &mut cpu_converter,
7510            cpu_dst,
7511            src,
7512            rot,
7513            Flip::None,
7514            Crop::no_crop(),
7515        );
7516        result.unwrap();
7517
7518        let (result, src, cpu_dst) = convert_img(
7519            &mut cpu_converter,
7520            src,
7521            cpu_dst,
7522            rot,
7523            Flip::None,
7524            Crop::no_crop(),
7525        );
7526        result.unwrap();
7527
7528        let (result, _cpu_dst, src) = convert_img(
7529            &mut cpu_converter,
7530            cpu_dst,
7531            src,
7532            rot,
7533            Flip::None,
7534            Crop::no_crop(),
7535        );
7536        result.unwrap();
7537
7538        compare_images(&src, &unchanged_src, 0.98, function!());
7539    }
7540
7541    #[test]
7542    #[cfg(any(
7543        target_os = "linux",
7544        target_os = "macos",
7545        target_os = "ios",
7546        target_os = "android",
7547        target_os = "windows"
7548    ))]
7549    #[cfg(feature = "opengl")]
7550    fn test_opengl_rotate() {
7551        if !is_opengl_available() {
7552            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7553            return;
7554        }
7555
7556        let size = (1280, 720);
7557        // Same backing list as `test_opengl_all_rgba`.
7558        let mut mem = vec![None, Some(TensorMemory::Mem)];
7559        if edgefirst_tensor::is_shm_available() {
7560            mem.push(Some(TensorMemory::Shm));
7561        }
7562        if crate::test_support::is_gpu_buffer_available() {
7563            mem.push(Some(TensorMemory::DmaBuf));
7564        } else {
7565            crate::test_support::report_skip(&format!(
7566                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7567                function!()
7568            ));
7569        }
7570        for m in mem {
7571            for rot in [
7572                Rotation::Clockwise90,
7573                Rotation::Rotate180,
7574                Rotation::CounterClockwise90,
7575            ] {
7576                test_opengl_rotate_(size, rot, m);
7577            }
7578        }
7579    }
7580
7581    #[cfg(any(
7582        target_os = "linux",
7583        target_os = "macos",
7584        target_os = "ios",
7585        target_os = "android",
7586        target_os = "windows"
7587    ))]
7588    #[cfg(feature = "opengl")]
7589    fn test_opengl_rotate_(
7590        size: (usize, usize),
7591        rot: Rotation,
7592        tensor_memory: Option<TensorMemory>,
7593    ) {
7594        let (dst_width, dst_height) = match rot {
7595            Rotation::None | Rotation::Rotate180 => size,
7596            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7597        };
7598
7599        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7600        let src =
7601            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7602
7603        let cpu_dst = TensorDyn::image(
7604            dst_width,
7605            dst_height,
7606            PixelFormat::Rgba,
7607            DType::U8,
7608            None,
7609            edgefirst_tensor::CpuAccess::ReadWrite,
7610        )
7611        .unwrap();
7612        let mut cpu_converter = CPUProcessor::new();
7613
7614        let (result, mut src, cpu_dst) = convert_img(
7615            &mut cpu_converter,
7616            src,
7617            cpu_dst,
7618            rot,
7619            Flip::None,
7620            Crop::no_crop(),
7621        );
7622        result.unwrap();
7623
7624        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7625
7626        for _ in 0..5 {
7627            let gl_dst = TensorDyn::image(
7628                dst_width,
7629                dst_height,
7630                PixelFormat::Rgba,
7631                DType::U8,
7632                tensor_memory,
7633                edgefirst_tensor::CpuAccess::ReadWrite,
7634            )
7635            .unwrap();
7636            let (result, src_back, gl_dst) = convert_img(
7637                &mut gl_converter,
7638                src,
7639                gl_dst,
7640                rot,
7641                Flip::None,
7642                Crop::no_crop(),
7643            );
7644            result.unwrap();
7645            src = src_back;
7646            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7647        }
7648    }
7649
7650    #[test]
7651    #[cfg(target_os = "linux")]
7652    fn test_g2d_rotate() {
7653        if !is_g2d_available() {
7654            crate::test_support::report_skip(
7655                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7656            );
7657            return;
7658        }
7659        if !is_dma_available() {
7660            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7661            return;
7662        }
7663
7664        let size = (1280, 720);
7665        for rot in [
7666            Rotation::Clockwise90,
7667            Rotation::Rotate180,
7668            Rotation::CounterClockwise90,
7669        ] {
7670            test_g2d_rotate_(size, rot);
7671        }
7672    }
7673
7674    #[cfg(target_os = "linux")]
7675    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7676        let (dst_width, dst_height) = match rot {
7677            Rotation::None | Rotation::Rotate180 => size,
7678            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7679        };
7680
7681        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7682        let src = crate::load_image_test_helper(
7683            &file,
7684            Some(PixelFormat::Rgba),
7685            Some(TensorMemory::DmaBuf),
7686        )
7687        .unwrap();
7688
7689        let cpu_dst = TensorDyn::image(
7690            dst_width,
7691            dst_height,
7692            PixelFormat::Rgba,
7693            DType::U8,
7694            None,
7695            edgefirst_tensor::CpuAccess::ReadWrite,
7696        )
7697        .unwrap();
7698        let mut cpu_converter = CPUProcessor::new();
7699
7700        let (result, src, cpu_dst) = convert_img(
7701            &mut cpu_converter,
7702            src,
7703            cpu_dst,
7704            rot,
7705            Flip::None,
7706            Crop::no_crop(),
7707        );
7708        result.unwrap();
7709
7710        let g2d_dst = TensorDyn::image(
7711            dst_width,
7712            dst_height,
7713            PixelFormat::Rgba,
7714            DType::U8,
7715            Some(TensorMemory::DmaBuf),
7716            edgefirst_tensor::CpuAccess::ReadWrite,
7717        )
7718        .unwrap();
7719        let mut g2d_converter = G2DProcessor::new().unwrap();
7720
7721        let (result, _src, g2d_dst) = convert_img(
7722            &mut g2d_converter,
7723            src,
7724            g2d_dst,
7725            rot,
7726            Flip::None,
7727            Crop::no_crop(),
7728        );
7729        result.unwrap();
7730
7731        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7732        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7733        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7734        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7735        // structural gap not exercised by these limited-range fixtures.
7736        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7737    }
7738
7739    #[test]
7740    fn test_rgba_to_yuyv_resize_cpu() {
7741        let src = load_bytes_to_tensor(
7742            1280,
7743            720,
7744            PixelFormat::Rgba,
7745            None,
7746            &edgefirst_bench::testdata::read("camera720p.rgba"),
7747        )
7748        .unwrap();
7749
7750        let (dst_width, dst_height) = (640, 360);
7751
7752        let dst = TensorDyn::image(
7753            dst_width,
7754            dst_height,
7755            PixelFormat::Yuyv,
7756            DType::U8,
7757            None,
7758            edgefirst_tensor::CpuAccess::ReadWrite,
7759        )
7760        .unwrap();
7761
7762        let dst_through_yuyv = TensorDyn::image(
7763            dst_width,
7764            dst_height,
7765            PixelFormat::Rgba,
7766            DType::U8,
7767            None,
7768            edgefirst_tensor::CpuAccess::ReadWrite,
7769        )
7770        .unwrap();
7771        let dst_direct = TensorDyn::image(
7772            dst_width,
7773            dst_height,
7774            PixelFormat::Rgba,
7775            DType::U8,
7776            None,
7777            edgefirst_tensor::CpuAccess::ReadWrite,
7778        )
7779        .unwrap();
7780
7781        let mut cpu_converter = CPUProcessor::new();
7782
7783        let (result, src, dst) = convert_img(
7784            &mut cpu_converter,
7785            src,
7786            dst,
7787            Rotation::None,
7788            Flip::None,
7789            Crop::no_crop(),
7790        );
7791        result.unwrap();
7792
7793        let (result, _dst, dst_through_yuyv) = convert_img(
7794            &mut cpu_converter,
7795            dst,
7796            dst_through_yuyv,
7797            Rotation::None,
7798            Flip::None,
7799            Crop::no_crop(),
7800        );
7801        result.unwrap();
7802
7803        let (result, _src, dst_direct) = convert_img(
7804            &mut cpu_converter,
7805            src,
7806            dst_direct,
7807            Rotation::None,
7808            Flip::None,
7809            Crop::no_crop(),
7810        );
7811        result.unwrap();
7812
7813        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7814    }
7815
7816    #[test]
7817    #[cfg(any(
7818        target_os = "linux",
7819        target_os = "macos",
7820        target_os = "ios",
7821        target_os = "android",
7822        target_os = "windows"
7823    ))]
7824    #[cfg(feature = "opengl")]
7825    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7826    fn test_rgba_to_yuyv_resize_opengl() {
7827        if !is_opengl_available() {
7828            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7829            return;
7830        }
7831
7832        if !crate::test_support::is_gpu_buffer_available() {
7833            crate::test_support::report_skip(&format!(
7834                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7835                function!()
7836            ));
7837            return;
7838        }
7839
7840        let src = load_bytes_to_tensor(
7841            1280,
7842            720,
7843            PixelFormat::Rgba,
7844            None,
7845            &edgefirst_bench::testdata::read("camera720p.rgba"),
7846        )
7847        .unwrap();
7848
7849        let (dst_width, dst_height) = (640, 360);
7850
7851        let dst = TensorDyn::image(
7852            dst_width,
7853            dst_height,
7854            PixelFormat::Yuyv,
7855            DType::U8,
7856            Some(TensorMemory::DmaBuf),
7857            edgefirst_tensor::CpuAccess::ReadWrite,
7858        )
7859        .unwrap();
7860
7861        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7862
7863        let (result, src, dst) = convert_img(
7864            &mut gl_converter,
7865            src,
7866            dst,
7867            Rotation::None,
7868            Flip::None,
7869            Crop::letterbox([255, 255, 255, 255]),
7870        );
7871        result.unwrap();
7872
7873        std::fs::write(
7874            "rgba_to_yuyv_opengl.yuyv",
7875            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7876        )
7877        .unwrap();
7878        let cpu_dst = TensorDyn::image(
7879            dst_width,
7880            dst_height,
7881            PixelFormat::Yuyv,
7882            DType::U8,
7883            Some(TensorMemory::DmaBuf),
7884            edgefirst_tensor::CpuAccess::ReadWrite,
7885        )
7886        .unwrap();
7887        let (result, _src, cpu_dst) = convert_img(
7888            &mut CPUProcessor::new(),
7889            src,
7890            cpu_dst,
7891            Rotation::None,
7892            Flip::None,
7893            Crop::no_crop(),
7894        );
7895        result.unwrap();
7896
7897        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7898    }
7899
7900    #[test]
7901    #[cfg(target_os = "linux")]
7902    fn test_rgba_to_yuyv_resize_g2d() {
7903        if !is_g2d_available() {
7904            crate::test_support::report_skip(
7905                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
7906            );
7907            return;
7908        }
7909        if !is_dma_available() {
7910            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7911            return;
7912        }
7913
7914        let src = load_bytes_to_tensor(
7915            1280,
7916            720,
7917            PixelFormat::Rgba,
7918            Some(TensorMemory::DmaBuf),
7919            &edgefirst_bench::testdata::read("camera720p.rgba"),
7920        )
7921        .unwrap();
7922
7923        let (dst_width, dst_height) = (1280, 720);
7924
7925        let cpu_dst = TensorDyn::image(
7926            dst_width,
7927            dst_height,
7928            PixelFormat::Yuyv,
7929            DType::U8,
7930            Some(TensorMemory::DmaBuf),
7931            edgefirst_tensor::CpuAccess::ReadWrite,
7932        )
7933        .unwrap();
7934
7935        let g2d_dst = TensorDyn::image(
7936            dst_width,
7937            dst_height,
7938            PixelFormat::Yuyv,
7939            DType::U8,
7940            Some(TensorMemory::DmaBuf),
7941            edgefirst_tensor::CpuAccess::ReadWrite,
7942        )
7943        .unwrap();
7944
7945        let mut g2d_converter = G2DProcessor::new().unwrap();
7946        let crop = Crop::new();
7947
7948        g2d_dst
7949            .as_typed::<u8>()
7950            .unwrap()
7951            .map()
7952            .unwrap()
7953            .as_mut_slice()
7954            .fill(128);
7955        let (result, src, g2d_dst) = convert_img(
7956            &mut g2d_converter,
7957            src,
7958            g2d_dst,
7959            Rotation::None,
7960            Flip::None,
7961            crop,
7962        );
7963        result.unwrap();
7964
7965        let cpu_dst_img = cpu_dst;
7966        cpu_dst_img
7967            .as_typed::<u8>()
7968            .unwrap()
7969            .map()
7970            .unwrap()
7971            .as_mut_slice()
7972            .fill(128);
7973        let (result, _src, cpu_dst) = convert_img(
7974            &mut CPUProcessor::new(),
7975            src,
7976            cpu_dst_img,
7977            Rotation::None,
7978            Flip::None,
7979            crop,
7980        );
7981        result.unwrap();
7982
7983        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7984    }
7985
7986    #[test]
7987    fn test_yuyv_to_rgba_cpu() {
7988        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7989        let src = TensorDyn::image(
7990            1280,
7991            720,
7992            PixelFormat::Yuyv,
7993            DType::U8,
7994            None,
7995            edgefirst_tensor::CpuAccess::ReadWrite,
7996        )
7997        .unwrap();
7998        src.as_typed::<u8>()
7999            .unwrap()
8000            .map()
8001            .unwrap()
8002            .as_mut_slice()
8003            .copy_from_slice(&file);
8004
8005        let dst = TensorDyn::image(
8006            1280,
8007            720,
8008            PixelFormat::Rgba,
8009            DType::U8,
8010            None,
8011            edgefirst_tensor::CpuAccess::ReadWrite,
8012        )
8013        .unwrap();
8014        let mut cpu_converter = CPUProcessor::new();
8015
8016        let (result, _src, dst) = convert_img(
8017            &mut cpu_converter,
8018            src,
8019            dst,
8020            Rotation::None,
8021            Flip::None,
8022            Crop::no_crop(),
8023        );
8024        result.unwrap();
8025
8026        let target_image = TensorDyn::image(
8027            1280,
8028            720,
8029            PixelFormat::Rgba,
8030            DType::U8,
8031            None,
8032            edgefirst_tensor::CpuAccess::ReadWrite,
8033        )
8034        .unwrap();
8035        target_image
8036            .as_typed::<u8>()
8037            .unwrap()
8038            .map()
8039            .unwrap()
8040            .as_mut_slice()
8041            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8042
8043        // CPU path resolves the untagged 720p source to BT.709 limited (height
8044        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8045        compare_images(&dst, &target_image, 0.98, function!());
8046    }
8047
8048    #[test]
8049    fn test_yuyv_to_rgb_cpu() {
8050        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
8051        let src = TensorDyn::image(
8052            1280,
8053            720,
8054            PixelFormat::Yuyv,
8055            DType::U8,
8056            None,
8057            edgefirst_tensor::CpuAccess::ReadWrite,
8058        )
8059        .unwrap();
8060        src.as_typed::<u8>()
8061            .unwrap()
8062            .map()
8063            .unwrap()
8064            .as_mut_slice()
8065            .copy_from_slice(&file);
8066
8067        let dst = TensorDyn::image(
8068            1280,
8069            720,
8070            PixelFormat::Rgb,
8071            DType::U8,
8072            None,
8073            edgefirst_tensor::CpuAccess::ReadWrite,
8074        )
8075        .unwrap();
8076        let mut cpu_converter = CPUProcessor::new();
8077
8078        let (result, _src, dst) = convert_img(
8079            &mut cpu_converter,
8080            src,
8081            dst,
8082            Rotation::None,
8083            Flip::None,
8084            Crop::no_crop(),
8085        );
8086        result.unwrap();
8087
8088        let target_image = TensorDyn::image(
8089            1280,
8090            720,
8091            PixelFormat::Rgb,
8092            DType::U8,
8093            None,
8094            edgefirst_tensor::CpuAccess::ReadWrite,
8095        )
8096        .unwrap();
8097        target_image
8098            .as_typed::<u8>()
8099            .unwrap()
8100            .map()
8101            .unwrap()
8102            .as_mut_slice()
8103            .as_chunks_mut::<3>()
8104            .0
8105            .iter_mut()
8106            .zip(
8107                edgefirst_bench::testdata::read("camera720p.rgba")
8108                    .as_chunks::<4>()
8109                    .0,
8110            )
8111            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8112
8113        // CPU path resolves the untagged 720p source to BT.709 limited (height
8114        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8115        compare_images(&dst, &target_image, 0.98, function!());
8116    }
8117
8118    #[test]
8119    #[cfg(target_os = "linux")]
8120    fn test_yuyv_to_rgba_g2d() {
8121        if !is_g2d_available() {
8122            crate::test_support::report_skip(
8123                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
8124            );
8125            return;
8126        }
8127        if !is_dma_available() {
8128            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8129            return;
8130        }
8131
8132        let src = load_bytes_to_tensor(
8133            1280,
8134            720,
8135            PixelFormat::Yuyv,
8136            None,
8137            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8138        )
8139        .unwrap();
8140
8141        let dst = TensorDyn::image(
8142            1280,
8143            720,
8144            PixelFormat::Rgba,
8145            DType::U8,
8146            Some(TensorMemory::DmaBuf),
8147            edgefirst_tensor::CpuAccess::ReadWrite,
8148        )
8149        .unwrap();
8150        let mut g2d_converter = G2DProcessor::new().unwrap();
8151
8152        let (result, _src, dst) = convert_img(
8153            &mut g2d_converter,
8154            src,
8155            dst,
8156            Rotation::None,
8157            Flip::None,
8158            Crop::no_crop(),
8159        );
8160        result.unwrap();
8161
8162        let target_image = TensorDyn::image(
8163            1280,
8164            720,
8165            PixelFormat::Rgba,
8166            DType::U8,
8167            None,
8168            edgefirst_tensor::CpuAccess::ReadWrite,
8169        )
8170        .unwrap();
8171        target_image
8172            .as_typed::<u8>()
8173            .unwrap()
8174            .map()
8175            .unwrap()
8176            .as_mut_slice()
8177            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8178
8179        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8180        // so the matrix delta vs the reference that forced 0.95 has closed;
8181        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8182        compare_images(&dst, &target_image, 0.98, function!());
8183    }
8184
8185    #[test]
8186    #[cfg(any(
8187        target_os = "linux",
8188        target_os = "macos",
8189        target_os = "ios",
8190        target_os = "android",
8191        target_os = "windows"
8192    ))]
8193    #[cfg(feature = "opengl")]
8194    fn test_yuyv_to_rgba_opengl() {
8195        if !is_opengl_available() {
8196            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8197            return;
8198        }
8199        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
8200        // D3D11 texture on Windows; skips where no such backing exists.
8201        if !crate::test_support::is_gpu_buffer_available() {
8202            crate::test_support::report_skip(&format!(
8203                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8204                function!()
8205            ));
8206            return;
8207        }
8208
8209        let src = load_bytes_to_tensor(
8210            1280,
8211            720,
8212            PixelFormat::Yuyv,
8213            Some(TensorMemory::DmaBuf),
8214            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8215        )
8216        .unwrap();
8217
8218        let dst = TensorDyn::image(
8219            1280,
8220            720,
8221            PixelFormat::Rgba,
8222            DType::U8,
8223            Some(TensorMemory::DmaBuf),
8224            edgefirst_tensor::CpuAccess::ReadWrite,
8225        )
8226        .unwrap();
8227        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8228
8229        let (result, _src, dst) = convert_img(
8230            &mut gl_converter,
8231            src,
8232            dst,
8233            Rotation::None,
8234            Flip::None,
8235            Crop::no_crop(),
8236        );
8237        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8238        // Production ImageProcessor falls back to CPU; this oracle skips.
8239        if let Err(crate::Error::NotSupported(msg)) = &result {
8240            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
8241            return;
8242        }
8243        result.unwrap();
8244
8245        let target_image = TensorDyn::image(
8246            1280,
8247            720,
8248            PixelFormat::Rgba,
8249            DType::U8,
8250            None,
8251            edgefirst_tensor::CpuAccess::ReadWrite,
8252        )
8253        .unwrap();
8254        target_image
8255            .as_typed::<u8>()
8256            .unwrap()
8257            .map()
8258            .unwrap()
8259            .as_mut_slice()
8260            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8261
8262        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8263        // so the matrix delta vs the reference that forced 0.95 has closed;
8264        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8265        compare_images(&dst, &target_image, 0.98, function!());
8266    }
8267
8268    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8269    /// IOSurface backend end-to-end and compares against the same
8270    /// reference image. Skips silently if ANGLE isn't installed so the
8271    /// test suite still passes on CI hosts without the Homebrew tap.
8272    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8273    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8274    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8275    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8276    /// luma round-trips to R=G=B (identity GREY→RGB).
8277    #[test]
8278    #[cfg(target_os = "macos")]
8279    #[cfg(feature = "opengl")]
8280    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8281        let mut proc = match GLProcessorThreaded::new(None) {
8282            Ok(p) => p,
8283            Err(e) => {
8284                crate::test_support::report_skip(&format!(
8285                    "{} — GL engine init failed ({e:?})",
8286                    function!()
8287                ));
8288                return;
8289            }
8290        };
8291
8292        let (w, h) = (16usize, 16usize);
8293        let src = TensorDyn::image(
8294            w,
8295            h,
8296            PixelFormat::Grey,
8297            DType::U8,
8298            Some(TensorMemory::DmaBuf),
8299            edgefirst_tensor::CpuAccess::ReadWrite,
8300        )
8301        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8302        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8303        {
8304            let su8 = src.as_typed::<u8>().unwrap();
8305            let stride = src
8306                .as_typed::<u8>()
8307                .unwrap()
8308                .effective_row_stride()
8309                .unwrap();
8310            let mut m = su8.map().unwrap();
8311            let buf = m.as_mut_slice();
8312            for y in 0..h {
8313                for x in 0..w {
8314                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8315                }
8316            }
8317        }
8318
8319        let dst = TensorDyn::image(
8320            w,
8321            h,
8322            PixelFormat::Rgba,
8323            DType::U8,
8324            Some(TensorMemory::DmaBuf),
8325            edgefirst_tensor::CpuAccess::ReadWrite,
8326        )
8327        .unwrap();
8328        let (result, src_back, dst) = convert_img(
8329            &mut proc,
8330            src,
8331            dst,
8332            Rotation::None,
8333            Flip::None,
8334            Crop::no_crop(),
8335        );
8336        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8337
8338        let src_stride = src_back
8339            .as_typed::<u8>()
8340            .unwrap()
8341            .effective_row_stride()
8342            .unwrap();
8343        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8344        let sbytes = src_map.as_slice();
8345        let dst_stride = dst
8346            .as_typed::<u8>()
8347            .unwrap()
8348            .effective_row_stride()
8349            .unwrap();
8350        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8351        let dbytes = dst_map.as_slice();
8352        for y in 0..h {
8353            for x in 0..w {
8354                let yv = sbytes[y * src_stride + x] as i16;
8355                let p = y * dst_stride + x * 4;
8356                for c in 0..3 {
8357                    assert!(
8358                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8359                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8360                        dbytes[p + c]
8361                    );
8362                }
8363            }
8364        }
8365    }
8366
8367    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8368    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8369    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8370    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8371    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8372    #[test]
8373    #[cfg(target_os = "macos")]
8374    #[cfg(feature = "opengl")]
8375    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8376        let mut gpu = match GLProcessorThreaded::new(None) {
8377            Ok(p) => p,
8378            Err(e) => {
8379                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8380                return;
8381            }
8382        };
8383        let (w, h) = (64usize, 64usize);
8384        let Some(src) = crate::test_support::gpu_buffer_allocated(
8385            TensorDyn::image(
8386                w,
8387                h,
8388                PixelFormat::Nv12,
8389                DType::U8,
8390                Some(TensorMemory::DmaBuf),
8391                edgefirst_tensor::CpuAccess::ReadWrite,
8392            ),
8393            "NV12 IOSurface",
8394        ) else {
8395            return;
8396        };
8397        src.as_typed::<u8>()
8398            .unwrap()
8399            .map()
8400            .unwrap()
8401            .as_mut_slice()
8402            .fill(128); // Y=U=V=128
8403
8404        let Some(dst) = crate::test_support::gpu_buffer_allocated(
8405            TensorDyn::image(
8406                w,
8407                h,
8408                PixelFormat::PlanarRgb,
8409                DType::F16,
8410                Some(TensorMemory::DmaBuf),
8411                edgefirst_tensor::CpuAccess::ReadWrite,
8412            ),
8413            "F16 PlanarRgb IOSurface",
8414        ) else {
8415            return;
8416        };
8417        let mut dst = dst;
8418        // Call convert directly (the convert_img helper restores u8 only).
8419        if let Err(e) = ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, None) {
8420            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8421            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8422            // fail on a capability gap (the same policy as the F16 path tests).
8423            crate::test_support::report_skip(&format!(
8424                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8425                function!()
8426            ));
8427            return;
8428        }
8429        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8430        let map = dt.map().unwrap();
8431        let vals = map.as_slice();
8432        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8433        // mediump YUV math + half-float rounding.
8434        let mut checked = 0usize;
8435        for &v in vals.iter() {
8436            let f = f32::from(v);
8437            assert!(
8438                (0.40..=0.60).contains(&f),
8439                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8440            );
8441            checked += 1;
8442        }
8443        assert!(
8444            checked >= w * h * 3,
8445            "expected >= 3 planes of samples, got {checked}"
8446        );
8447    }
8448
8449    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8450    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8451    /// frame, filled at the preserved physical stride, and converted with a
8452    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8453    /// exactly the orchestrator's preprocess. Guards against the pooled
8454    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8455    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8456    #[test]
8457    #[cfg(target_os = "macos")]
8458    #[cfg(feature = "opengl")]
8459    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8460        let mut gpu = match GLProcessorThreaded::new(None) {
8461            Ok(p) => p,
8462            Err(e) => {
8463                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8464                return;
8465            }
8466        };
8467        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8468        let (fw, fh) = (96usize, 64usize);
8469        let (pool_w, pool_h) = (256usize, 768usize);
8470        let (model_w, model_h) = (128usize, 128usize);
8471
8472        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8473            TensorDyn::image(
8474                pool_w,
8475                pool_h,
8476                PixelFormat::Grey,
8477                DType::U8,
8478                Some(TensorMemory::DmaBuf),
8479                edgefirst_tensor::CpuAccess::ReadWrite,
8480            ),
8481            "R8 pool",
8482        ) else {
8483            return;
8484        };
8485        src.configure_image(fw, fh, PixelFormat::Nv12)
8486            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8487        let stride = src
8488            .as_typed::<u8>()
8489            .unwrap()
8490            .effective_row_stride()
8491            .unwrap();
8492        src.as_typed::<u8>()
8493            .unwrap()
8494            .map()
8495            .unwrap()
8496            .as_mut_slice()
8497            .fill(128); // neutral grey
8498
8499        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8500            TensorDyn::image(
8501                model_w,
8502                model_h,
8503                PixelFormat::PlanarRgb,
8504                DType::F16,
8505                Some(TensorMemory::DmaBuf),
8506                edgefirst_tensor::CpuAccess::ReadWrite,
8507            ),
8508            "F16 PlanarRgb dst",
8509        ) else {
8510            return;
8511        };
8512
8513        // Letterbox crop like the profiler: the backend computes the band.
8514        let _ = model_w;
8515        let crop = Crop::new()
8516            .with_source(Some(Region::new(0, 0, fw, fh)))
8517            .with_fit(Fit::Letterbox {
8518                pad: [0, 0, 0, 255],
8519            });
8520        if let Err(e) = ImageProcessorTrait::convert(
8521            &mut gpu,
8522            &src,
8523            &mut dst,
8524            Some(ConvertOptions::new().crop(crop)),
8525        ) {
8526            crate::test_support::report_skip(&format!(
8527                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8528                function!()
8529            ));
8530            return;
8531        }
8532        let _ = stride;
8533        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8534        // completed and produced finite values (no hang, no NaN garbage).
8535        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8536        let map = dt.map().unwrap();
8537        let any_half = map.as_slice().iter().any(|&v| {
8538            let f = f32::from(v);
8539            (0.40..=0.60).contains(&f)
8540        });
8541        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8542    }
8543
8544    /// Mirrors the orchestrator: the GL processor is created on one thread
8545    /// and `convert()` is called from a *different* thread (the profiler's
8546    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8547    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8548    /// fails loudly rather than hanging the whole test binary.
8549    #[test]
8550    #[cfg(target_os = "macos")]
8551    #[cfg(feature = "opengl")]
8552    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8553        use std::sync::mpsc;
8554        // Public ImageProcessor (Send) created HERE (the main test thread),
8555        // exactly like the orchestrator builds `config.processor` during setup.
8556        let mut proc = match ImageProcessor::new() {
8557            Ok(p) => p,
8558            Err(e) => {
8559                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8560                return;
8561            }
8562        };
8563        let (fw, fh) = (96usize, 64usize);
8564        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8565            TensorDyn::image(
8566                256,
8567                768,
8568                PixelFormat::Grey,
8569                DType::U8,
8570                Some(TensorMemory::DmaBuf),
8571                edgefirst_tensor::CpuAccess::ReadWrite,
8572            ),
8573            "NV12 R8 pool",
8574        ) else {
8575            return;
8576        };
8577        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8578        src.as_typed::<u8>()
8579            .unwrap()
8580            .map()
8581            .unwrap()
8582            .as_mut_slice()
8583            .fill(128);
8584        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8585            TensorDyn::image(
8586                128,
8587                128,
8588                PixelFormat::PlanarRgb,
8589                DType::F16,
8590                Some(TensorMemory::DmaBuf),
8591                edgefirst_tensor::CpuAccess::ReadWrite,
8592            ),
8593            "F16 PlanarRgb dst",
8594        ) else {
8595            return;
8596        };
8597        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8598
8599        // ...then MOVED to a worker thread where convert() runs — exactly the
8600        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8601        let (tx, rx) = mpsc::channel::<bool>();
8602        let worker = std::thread::spawn(move || {
8603            let _ = ImageProcessorTrait::convert(
8604                &mut proc,
8605                &src,
8606                &mut dst,
8607                Some(ConvertOptions::new().crop(crop)),
8608            );
8609            let _ = tx.send(true);
8610        });
8611        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8612            Ok(_) => { let _ = worker.join(); }
8613            Err(_) => panic!(
8614                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8615            ),
8616        }
8617    }
8618
8619    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8620    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8621    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8622    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8623    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8624    /// (no runaway) over many iterations.
8625    #[test]
8626    #[cfg(target_os = "macos")]
8627    #[cfg(feature = "opengl")]
8628    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8629        let mut gpu = match GLProcessorThreaded::new(None) {
8630            Ok(p) => p,
8631            Err(e) => {
8632                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8633                return;
8634            }
8635        };
8636        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8637        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8638        let (max_w, max_h) = (640usize, 640usize);
8639        let depth = 4usize;
8640        let mut srcs = Vec::new();
8641        let mut dsts = Vec::new();
8642        for _ in 0..depth {
8643            let Some(src) = crate::test_support::gpu_buffer_allocated(
8644                TensorDyn::image(
8645                    max_w,
8646                    max_h * 3,
8647                    PixelFormat::Grey,
8648                    DType::U8,
8649                    Some(TensorMemory::DmaBuf),
8650                    edgefirst_tensor::CpuAccess::ReadWrite,
8651                ),
8652                "R8 pool",
8653            ) else {
8654                return;
8655            };
8656            srcs.push(src);
8657            let Some(dst) = crate::test_support::gpu_buffer_allocated(
8658                TensorDyn::image(
8659                    640,
8660                    640,
8661                    PixelFormat::PlanarRgb,
8662                    DType::F16,
8663                    Some(TensorMemory::DmaBuf),
8664                    edgefirst_tensor::CpuAccess::ReadWrite,
8665                ),
8666                "F16 PlanarRgb dst",
8667            ) else {
8668                return;
8669            };
8670            dsts.push(dst);
8671        }
8672        // COCO-like assorted frame sizes (all ≤ max), cycled.
8673        let sizes = [
8674            (640, 480),
8675            (500, 375),
8676            (640, 427),
8677            (333, 500),
8678            (480, 640),
8679            (612, 612),
8680            (428, 640),
8681            (576, 432),
8682        ];
8683        let mut first_ms = 0f64;
8684        let mut last_ms = 0f64;
8685        let iters = 40usize;
8686        for i in 0..iters {
8687            let (fw, fh) = sizes[i % sizes.len()];
8688            let src = &mut srcs[i % depth];
8689            let dst = &mut dsts[i % depth];
8690            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8691            src.as_typed::<u8>()
8692                .unwrap()
8693                .map()
8694                .unwrap()
8695                .as_mut_slice()
8696                .fill(128);
8697            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8698            let t0 = std::time::Instant::now();
8699            ImageProcessorTrait::convert(
8700                &mut gpu,
8701                src,
8702                dst,
8703                Some(ConvertOptions::new().crop(crop)),
8704            )
8705            .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8706            let ms = t0.elapsed().as_secs_f64() * 1e3;
8707            if i == 2 {
8708                first_ms = ms;
8709            }
8710            if i == iters - 1 {
8711                last_ms = ms;
8712            }
8713        }
8714        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8715        assert!(
8716            last_ms < first_ms * 5.0 + 5.0,
8717            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8718        );
8719    }
8720
8721    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8722    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8723    /// IOSurface source and a Mem source from the same logical YUV pattern
8724    /// (each at its own row stride), converts the IOSurface on the GPU and the
8725    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8726    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8727    #[test]
8728    #[cfg(target_os = "macos")]
8729    #[cfg(feature = "opengl")]
8730    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8731        let mut gpu = match GLProcessorThreaded::new(None) {
8732            Ok(p) => p,
8733            Err(e) => {
8734                crate::test_support::report_skip(&format!(
8735                    "{} — GL engine init failed ({e:?})",
8736                    function!()
8737                ));
8738                return;
8739            }
8740        };
8741        let mut cpu = CPUProcessor::new();
8742
8743        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8744        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8745        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8746        // plane starts at `h * stride`; each chroma row advances
8747        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8748        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8749        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8750        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8751        //
8752        // Takes explicit w/h so the closure can be reused across multiple frame
8753        // sizes (even and odd) without capturing a fixed outer variable.
8754        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8755            for y in 0..h {
8756                for x in 0..w {
8757                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8758                }
8759            }
8760            let (cw, ch, uv_grid_rows) = match fmt {
8761                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8762                PixelFormat::Nv16 => (w / 2, h, 1usize),
8763                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8764            };
8765            let uv_plane = h * stride;
8766            for cy in 0..ch {
8767                for cx in 0..cw {
8768                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8769                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8770                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8771                }
8772            }
8773        };
8774
8775        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8776            for (w, h) in [
8777                (16usize, 16usize), // original even-dim case
8778                (15, 16),           // odd-W
8779                (16, 15),           // odd-H
8780            ] {
8781                let mem = TensorDyn::image(
8782                    w,
8783                    h,
8784                    fmt,
8785                    DType::U8,
8786                    None,
8787                    edgefirst_tensor::CpuAccess::ReadWrite,
8788                )
8789                .unwrap();
8790                let mem_stride = mem
8791                    .as_typed::<u8>()
8792                    .unwrap()
8793                    .effective_row_stride()
8794                    .unwrap();
8795                fill(
8796                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8797                    mem_stride,
8798                    fmt,
8799                    w,
8800                    h,
8801                );
8802                let cpu_dst = TensorDyn::image(
8803                    w,
8804                    h,
8805                    PixelFormat::Rgba,
8806                    DType::U8,
8807                    None,
8808                    edgefirst_tensor::CpuAccess::ReadWrite,
8809                )
8810                .unwrap();
8811                let (r, _s, cpu_dst) = convert_img(
8812                    &mut cpu,
8813                    mem,
8814                    cpu_dst,
8815                    Rotation::None,
8816                    Flip::None,
8817                    Crop::no_crop(),
8818                );
8819                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8820
8821                let ios = TensorDyn::image(
8822                    w,
8823                    h,
8824                    fmt,
8825                    DType::U8,
8826                    Some(TensorMemory::DmaBuf),
8827                    edgefirst_tensor::CpuAccess::ReadWrite,
8828                )
8829                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8830                let ios_stride = ios
8831                    .as_typed::<u8>()
8832                    .unwrap()
8833                    .effective_row_stride()
8834                    .unwrap();
8835                fill(
8836                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8837                    ios_stride,
8838                    fmt,
8839                    w,
8840                    h,
8841                );
8842                let gpu_dst = TensorDyn::image(
8843                    w,
8844                    h,
8845                    PixelFormat::Rgba,
8846                    DType::U8,
8847                    Some(TensorMemory::DmaBuf),
8848                    edgefirst_tensor::CpuAccess::ReadWrite,
8849                )
8850                .unwrap();
8851                let (r, _s, gpu_dst) = convert_img(
8852                    &mut gpu,
8853                    ios,
8854                    gpu_dst,
8855                    Rotation::None,
8856                    Flip::None,
8857                    Crop::no_crop(),
8858                );
8859                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8860
8861                let cs = cpu_dst
8862                    .as_typed::<u8>()
8863                    .unwrap()
8864                    .effective_row_stride()
8865                    .unwrap();
8866                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8867                let cb = cmap.as_slice();
8868                let gs = gpu_dst
8869                    .as_typed::<u8>()
8870                    .unwrap()
8871                    .effective_row_stride()
8872                    .unwrap();
8873                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8874                let gb = gmap.as_slice();
8875                let mut max_d = 0i16;
8876                for y in 0..h {
8877                    for x in 0..w {
8878                        for c in 0..3 {
8879                            let cv = cb[y * cs + x * 4 + c] as i16;
8880                            let gv = gb[y * gs + x * 4 + c] as i16;
8881                            max_d = max_d.max((cv - gv).abs());
8882                        }
8883                    }
8884                }
8885                assert!(
8886                    max_d <= 3,
8887                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8888                );
8889            }
8890        }
8891    }
8892
8893    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8894    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8895    /// the frame's even width) is reconfigured to each NV frame, filled at the
8896    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8897    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8898    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8899    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8900    #[test]
8901    #[cfg(target_os = "macos")]
8902    #[cfg(feature = "opengl")]
8903    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8904        let mut gpu = match GLProcessorThreaded::new(None) {
8905            Ok(p) => p,
8906            Err(e) => {
8907                crate::test_support::report_skip(&format!(
8908                    "{} — GL engine init failed ({e:?})",
8909                    function!()
8910                ));
8911                return;
8912            }
8913        };
8914        let mut cpu = CPUProcessor::new();
8915        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8916        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8917        let (pool_w, pool_h) = (256usize, 256usize);
8918
8919        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8920        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8921        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8922        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8923            for y in 0..h {
8924                for x in 0..w {
8925                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8926                }
8927            }
8928            let (cw, ch, uv_grid_rows) = match fmt {
8929                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8930                PixelFormat::Nv16 => (w / 2, h, 1usize),
8931                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8932            };
8933            let uv_plane = h * stride;
8934            for cy in 0..ch {
8935                for cx in 0..cw {
8936                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8937                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8938                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8939                }
8940            }
8941        };
8942
8943        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8944            for (w, h) in [
8945                (40usize, 24usize), // original even-dim case
8946                (15, 16),           // odd-W
8947                (16, 15),           // odd-H
8948            ] {
8949                // `ew` is the minimum even extent; the pool stride must exceed it
8950                // to actually exercise the physical-stride shader decoupling.
8951                let ew = w.next_multiple_of(2);
8952
8953                // CPU reference from a tightly-packed Mem tensor at the frame size.
8954                let mem = TensorDyn::image(
8955                    w,
8956                    h,
8957                    fmt,
8958                    DType::U8,
8959                    None,
8960                    edgefirst_tensor::CpuAccess::ReadWrite,
8961                )
8962                .unwrap();
8963                let mem_stride = mem
8964                    .as_typed::<u8>()
8965                    .unwrap()
8966                    .effective_row_stride()
8967                    .unwrap();
8968                fill(
8969                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8970                    mem_stride,
8971                    fmt,
8972                    w,
8973                    h,
8974                );
8975                let cpu_dst = TensorDyn::image(
8976                    w,
8977                    h,
8978                    PixelFormat::Rgba,
8979                    DType::U8,
8980                    None,
8981                    edgefirst_tensor::CpuAccess::ReadWrite,
8982                )
8983                .unwrap();
8984                let (r, _s, cpu_dst) = convert_img(
8985                    &mut cpu,
8986                    mem,
8987                    cpu_dst,
8988                    Rotation::None,
8989                    Flip::None,
8990                    Crop::no_crop(),
8991                );
8992                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8993
8994                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8995                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8996                // tensor's row stride; the fill writes the frame at that stride.
8997                let Some(mut ios) = crate::test_support::gpu_buffer_allocated(
8998                    TensorDyn::image(
8999                        pool_w,
9000                        pool_h,
9001                        PixelFormat::Grey,
9002                        DType::U8,
9003                        Some(TensorMemory::DmaBuf),
9004                        edgefirst_tensor::CpuAccess::ReadWrite,
9005                    ),
9006                    "R8 pool IOSurface",
9007                ) else {
9008                    return;
9009                };
9010                ios.configure_image(w, h, fmt)
9011                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
9012                let ios_stride = ios
9013                    .as_typed::<u8>()
9014                    .unwrap()
9015                    .effective_row_stride()
9016                    .unwrap();
9017                assert!(
9018                    ios_stride > ew,
9019                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
9020                     (test must exercise padding)"
9021                );
9022                fill(
9023                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9024                    ios_stride,
9025                    fmt,
9026                    w,
9027                    h,
9028                );
9029
9030                let gpu_dst = TensorDyn::image(
9031                    w,
9032                    h,
9033                    PixelFormat::Rgba,
9034                    DType::U8,
9035                    Some(TensorMemory::DmaBuf),
9036                    edgefirst_tensor::CpuAccess::ReadWrite,
9037                )
9038                .unwrap();
9039                let (r, _s, gpu_dst) = convert_img(
9040                    &mut gpu,
9041                    ios,
9042                    gpu_dst,
9043                    Rotation::None,
9044                    Flip::None,
9045                    Crop::no_crop(),
9046                );
9047                r.unwrap_or_else(|e| {
9048                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
9049                });
9050
9051                let cs = cpu_dst
9052                    .as_typed::<u8>()
9053                    .unwrap()
9054                    .effective_row_stride()
9055                    .unwrap();
9056                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9057                let cb = cmap.as_slice();
9058                let gs = gpu_dst
9059                    .as_typed::<u8>()
9060                    .unwrap()
9061                    .effective_row_stride()
9062                    .unwrap();
9063                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9064                let gb = gmap.as_slice();
9065                let mut max_d = 0i16;
9066                for y in 0..h {
9067                    for x in 0..w {
9068                        for c in 0..3 {
9069                            let cv = cb[y * cs + x * 4 + c] as i16;
9070                            let gv = gb[y * gs + x * 4 + c] as i16;
9071                            max_d = max_d.max((cv - gv).abs());
9072                        }
9073                    }
9074                }
9075                assert!(
9076                    max_d <= 3,
9077                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
9078                );
9079            }
9080        }
9081    }
9082
9083    #[test]
9084    #[cfg(target_os = "macos")]
9085    #[cfg(feature = "opengl")]
9086    fn test_yuyv_to_rgba_opengl_macos() {
9087        let mut proc = match GLProcessorThreaded::new(None) {
9088            Ok(p) => p,
9089            Err(e) => {
9090                crate::test_support::report_skip(&format!(
9091                    "{} — GL engine init failed ({e:?}). \
9092                     Install ANGLE via `brew install startergo/angle/angle` \
9093                     and re-sign per README.md § macOS GPU Acceleration to \
9094                     run this test.",
9095                    function!()
9096                ));
9097                return;
9098            }
9099        };
9100
9101        let src = load_bytes_to_tensor(
9102            1280,
9103            720,
9104            PixelFormat::Yuyv,
9105            Some(TensorMemory::DmaBuf),
9106            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9107        )
9108        .unwrap();
9109
9110        let dst = TensorDyn::image(
9111            1280,
9112            720,
9113            PixelFormat::Rgba,
9114            DType::U8,
9115            Some(TensorMemory::DmaBuf),
9116            edgefirst_tensor::CpuAccess::ReadWrite,
9117        )
9118        .unwrap();
9119
9120        let (result, _src, dst) = convert_img(
9121            &mut proc,
9122            src,
9123            dst,
9124            Rotation::None,
9125            Flip::None,
9126            Crop::no_crop(),
9127        );
9128        result.unwrap();
9129
9130        let target_image = TensorDyn::image(
9131            1280,
9132            720,
9133            PixelFormat::Rgba,
9134            DType::U8,
9135            None,
9136            edgefirst_tensor::CpuAccess::ReadWrite,
9137        )
9138        .unwrap();
9139        target_image
9140            .as_typed::<u8>()
9141            .unwrap()
9142            .map()
9143            .unwrap()
9144            .as_mut_slice()
9145            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9146
9147        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
9148        // camera720p source resolves to BT.709 limited (matching the BT.709
9149        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
9150        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
9151        compare_images(&dst, &target_image, 0.98, function!());
9152    }
9153
9154    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
9155    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
9156    /// both filled with a synthetic mid-grey pattern. Validates the
9157    /// shader math at the chroma-pairing boundary on small textures
9158    /// and exercises the IOSurface bytes-per-row alignment path at 4K
9159    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
9160    ///
9161    /// Resolutions below 32 pixels wide aren't tested because the
9162    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
9163    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
9164    /// which exercises a sampling pattern that's ANGLE-version
9165    /// dependent rather than HAL-correctness dependent.
9166    ///
9167    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
9168    /// pixel-exact correctness against a reference image at 720p) by
9169    /// ensuring the pipeline does not crash or produce gross errors at
9170    /// resolution extremes. Pixel-exact validation at 4K would require
9171    /// a 30 MB reference file we don't want to bundle.
9172    #[test]
9173    #[cfg(target_os = "macos")]
9174    #[cfg(feature = "opengl")]
9175    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
9176        let mut proc = match GLProcessorThreaded::new(None) {
9177            Ok(p) => p,
9178            Err(e) => {
9179                crate::test_support::report_skip(&format!(
9180                    "{} — GL engine init failed ({e:?})",
9181                    function!()
9182                ));
9183                return;
9184            }
9185        };
9186
9187        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
9188            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
9189            // chroma) → RGB grey at the output.
9190            let bytes_per_row = w * 2;
9191            let mut yuyv = vec![0u8; bytes_per_row * h];
9192            for chunk in yuyv.as_chunks_mut::<4>().0 {
9193                chunk[0] = 128; // Y0
9194                chunk[1] = 128; // U
9195                chunk[2] = 128; // Y1
9196                chunk[3] = 128; // V
9197            }
9198
9199            let src =
9200                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9201                    .unwrap();
9202
9203            let dst = TensorDyn::image(
9204                w,
9205                h,
9206                PixelFormat::Rgba,
9207                DType::U8,
9208                Some(TensorMemory::DmaBuf),
9209                edgefirst_tensor::CpuAccess::ReadWrite,
9210            )
9211            .unwrap();
9212
9213            let (result, _src, dst) = convert_img(
9214                &mut proc,
9215                src,
9216                dst,
9217                Rotation::None,
9218                Flip::None,
9219                Crop::no_crop(),
9220            );
9221            result.expect("GL convert should succeed at this resolution");
9222
9223            // The neutral-chroma input must produce a near-grey output;
9224            // BT.709 limited-range maps Y=128/UV=128 → roughly
9225            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
9226            // rounding.
9227            let dst_u8 = dst.as_typed::<u8>().unwrap();
9228            let dst_map = dst_u8.map().unwrap();
9229            let dst_bytes = dst_map.as_slice();
9230            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9231            for px in dst_bytes.as_chunks::<4>().0 {
9232                for (i, &c) in px[..3].iter().enumerate() {
9233                    assert!(
9234                        (120..=140).contains(&c),
9235                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9236                        function!(),
9237                    );
9238                }
9239                assert_eq!(px[3], 255, "alpha must be 1.0");
9240            }
9241        }
9242    }
9243
9244    /// Verify that two consecutive convert() calls on the same source
9245    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9246    /// added with the macOS GL backend hardening — without it, each
9247    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9248    ///
9249    /// This is a behaviour test rather than a perf test (the timing
9250    /// difference is 100-200µs which is too noisy to assert on); we
9251    /// check that the second call succeeds and produces a result
9252    /// identical to the first.
9253    #[test]
9254    #[cfg(target_os = "macos")]
9255    #[cfg(feature = "opengl")]
9256    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9257        let mut proc = match GLProcessorThreaded::new(None) {
9258            Ok(p) => p,
9259            Err(e) => {
9260                crate::test_support::report_skip(&format!(
9261                    "{} — GL engine init failed ({e:?})",
9262                    function!()
9263                ));
9264                return;
9265            }
9266        };
9267
9268        // Allocate one source + one destination, run convert twice.
9269        let mut yuyv = vec![0u8; 64 * 32 * 2];
9270        for chunk in yuyv.as_chunks_mut::<4>().0 {
9271            chunk[0] = 200;
9272            chunk[1] = 100;
9273            chunk[2] = 200;
9274            chunk[3] = 156;
9275        }
9276        let src =
9277            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9278                .unwrap();
9279        let dst = TensorDyn::image(
9280            64,
9281            32,
9282            PixelFormat::Rgba,
9283            DType::U8,
9284            Some(TensorMemory::DmaBuf),
9285            edgefirst_tensor::CpuAccess::ReadWrite,
9286        )
9287        .unwrap();
9288
9289        let (r1, src, dst) = convert_img(
9290            &mut proc,
9291            src,
9292            dst,
9293            Rotation::None,
9294            Flip::None,
9295            Crop::no_crop(),
9296        );
9297        r1.unwrap();
9298        let first: Vec<u8> = dst
9299            .as_typed::<u8>()
9300            .unwrap()
9301            .map()
9302            .unwrap()
9303            .as_slice()
9304            .to_vec();
9305
9306        let (r2, _src, dst) = convert_img(
9307            &mut proc,
9308            src,
9309            dst,
9310            Rotation::None,
9311            Flip::None,
9312            Crop::no_crop(),
9313        );
9314        r2.unwrap();
9315        let second: Vec<u8> = dst
9316            .as_typed::<u8>()
9317            .unwrap()
9318            .map()
9319            .unwrap()
9320            .as_slice()
9321            .to_vec();
9322
9323        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9324    }
9325
9326    /// Steady-state import gate (macOS half of the Linux
9327    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9328    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9329    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9330    /// stay flat while hits grow. Counter-based hardening of
9331    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9332    /// re-imports per frame passes the pixel-equality test but fails this.
9333    #[test]
9334    #[cfg(target_os = "macos")]
9335    #[cfg(feature = "opengl")]
9336    fn test_macos_gl_pbuffer_cache_steady_state() {
9337        let mut proc = match GLProcessorThreaded::new(None) {
9338            Ok(p) => p,
9339            Err(e) => {
9340                crate::test_support::report_skip(&format!(
9341                    "{} — GL engine init failed ({e:?})",
9342                    function!()
9343                ));
9344                return;
9345            }
9346        };
9347
9348        let (w, h) = (64usize, 32usize);
9349        const POOL: usize = 3;
9350        const FRAMES: usize = 100;
9351
9352        let yuyv = vec![128u8; w * h * 2];
9353        let pool: Vec<TensorDyn> = (0..POOL)
9354            .map(|_| {
9355                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9356                    .unwrap()
9357            })
9358            .collect();
9359        let mut dst = TensorDyn::image(
9360            w,
9361            h,
9362            PixelFormat::Rgba,
9363            DType::U8,
9364            Some(TensorMemory::DmaBuf),
9365            edgefirst_tensor::CpuAccess::ReadWrite,
9366        )
9367        .unwrap();
9368
9369        // Warmup: two passes over the pool import every surface once.
9370        for src in pool.iter().cycle().take(POOL * 2) {
9371            proc.convert(src, &mut dst, None).unwrap();
9372        }
9373        let warm = proc.egl_cache_stats().unwrap();
9374
9375        for src in pool.iter().cycle().take(FRAMES) {
9376            proc.convert(src, &mut dst, None).unwrap();
9377        }
9378        let steady = proc.egl_cache_stats().unwrap();
9379
9380        assert_eq!(
9381            warm.total_misses(),
9382            steady.total_misses(),
9383            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9384        );
9385        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits + s.nv_uv.hits;
9386        assert!(
9387            hits(&steady) - hits(&warm) >= FRAMES as u64,
9388            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9389            hits(&steady) - hits(&warm)
9390        );
9391    }
9392
9393    /// Backend assertion for the F16 zero-copy path: when the GL backend
9394    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9395    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9396    /// engine — proven by the engine's import-cache counters moving, not
9397    /// just by output correctness. This is the guard against the
9398    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9399    /// destination keeps every output-correctness test green while
9400    /// quietly running ~10× slower on CPU; only a backend observable
9401    /// catches it. Skips ONLY when GL itself is unavailable or the
9402    /// configuration lacks F16 — a convert error or a CPU-routed convert
9403    /// with the capability present is a FAILURE.
9404    #[test]
9405    #[cfg(target_os = "macos")]
9406    #[cfg(feature = "opengl")]
9407    fn test_macos_gl_f16_planar_is_gl_backed() {
9408        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9409        let Some(ref gl) = proc.opengl else {
9410            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9411            return;
9412        };
9413        if !gl.supported_render_dtypes().f16 {
9414            crate::test_support::report_skip(&format!(
9415                "{} — configuration lacks F16 color-buffer support",
9416                function!()
9417            ));
9418            return;
9419        }
9420        let stats_before = gl.egl_cache_stats().expect("cache stats");
9421
9422        let src = TensorDyn::image(
9423            1280,
9424            720,
9425            PixelFormat::Nv12,
9426            DType::U8,
9427            Some(TensorMemory::DmaBuf),
9428            edgefirst_tensor::CpuAccess::ReadWrite,
9429        )
9430        .unwrap();
9431        {
9432            let t = src.as_typed::<u8>().unwrap();
9433            let mut m = t.map().unwrap();
9434            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9435                *b = ((i * 31) % 211) as u8;
9436            }
9437        }
9438        let mut dst = TensorDyn::image(
9439            640,
9440            640,
9441            PixelFormat::PlanarRgb,
9442            DType::F16,
9443            Some(TensorMemory::DmaBuf),
9444            edgefirst_tensor::CpuAccess::ReadWrite,
9445        )
9446        .unwrap();
9447
9448        proc.convert(
9449            &src,
9450            &mut dst,
9451            Some(crate::ConvertOptions::new().crop(Crop::letterbox([114, 114, 114, 255]))),
9452        )
9453        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9454        let stats_after = proc
9455            .opengl
9456            .as_ref()
9457            .expect("GL backend present")
9458            .egl_cache_stats()
9459            .expect("cache stats");
9460        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9461        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9462        // convert that imported its source, failed mid-engine, and fell back
9463        // to CPU (1 miss) cannot satisfy this gate.
9464        assert!(
9465            stats_after.total_misses() >= stats_before.total_misses() + 2,
9466            "convert succeeded but the GL engine did not import both the \
9467             source and the F16 destination — the work did not (fully) run \
9468             on the GL backend (silent CPU fallback); misses before={} after={}",
9469            stats_before.total_misses(),
9470            stats_after.total_misses()
9471        );
9472    }
9473
9474    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9475    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9476    /// render). New on every platform with F16 render support — macOS
9477    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9478    /// backend's reference within the float-path tolerance.
9479    #[test]
9480    #[cfg(feature = "opengl")]
9481    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9482        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9483            backend: ComputeBackend::OpenGl,
9484            ..Default::default()
9485        }) {
9486            Ok(p) if p.opengl.is_some() => p,
9487            _ => {
9488                crate::test_support::report_skip(&format!(
9489                    "{} — GL backend unavailable",
9490                    function!()
9491                ));
9492                return;
9493            }
9494        };
9495        if !gl
9496            .opengl
9497            .as_ref()
9498            .map(|g| g.supported_render_dtypes().f16)
9499            .unwrap_or(false)
9500        {
9501            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9502            return;
9503        }
9504        let mem = if crate::test_support::is_gpu_buffer_available() {
9505            TensorMemory::DmaBuf
9506        } else {
9507            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9508            return;
9509        };
9510
9511        let src = TensorDyn::image(
9512            1280,
9513            720,
9514            PixelFormat::Nv12,
9515            DType::U8,
9516            Some(mem),
9517            edgefirst_tensor::CpuAccess::ReadWrite,
9518        )
9519        .unwrap();
9520        {
9521            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9522            // chroma with different kernels (nearest vs bilinear), which
9523            // legitimately diverges on per-texel chroma noise. Gradients
9524            // keep that kernel difference sub-LSB while still exercising
9525            // the full matrix math and the letterbox geometry.
9526            let t = src.as_typed::<u8>().unwrap();
9527            let mut m = t.map().unwrap();
9528            let buf = m.as_mut_slice();
9529            let (w, h) = (1280usize, 720usize);
9530            for y in 0..h {
9531                for x in 0..w {
9532                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9533                }
9534            }
9535            for y in 0..(h / 2) {
9536                for x in 0..(w / 2) {
9537                    let o = h * w + y * w + 2 * x;
9538                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9539                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9540                }
9541            }
9542        }
9543        let crop = Crop::letterbox([114, 114, 114, 255]);
9544        let mut gl_dst = TensorDyn::image(
9545            640,
9546            640,
9547            PixelFormat::PlanarRgb,
9548            DType::F16,
9549            Some(mem),
9550            edgefirst_tensor::CpuAccess::ReadWrite,
9551        )
9552        .unwrap();
9553        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9554        // silently falls back to CPU on a GL error, turning this oracle into
9555        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9556        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9557        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9558        // take the production CPU fallback; this oracle cannot run there.
9559        match gl.opengl.as_mut().expect("GL backend present").convert(
9560            &src,
9561            &mut gl_dst,
9562            Some(crate::ConvertOptions::new().crop(crop)),
9563        ) {
9564            Ok(()) => {}
9565            Err(crate::Error::NotSupported(msg)) => {
9566                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9567                return;
9568            }
9569            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9570        }
9571
9572        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9573            backend: ComputeBackend::Cpu,
9574            ..Default::default()
9575        })
9576        .unwrap();
9577        let mut cpu_dst = TensorDyn::image(
9578            640,
9579            640,
9580            PixelFormat::PlanarRgb,
9581            DType::F16,
9582            Some(TensorMemory::Mem),
9583            edgefirst_tensor::CpuAccess::ReadWrite,
9584        )
9585        .unwrap();
9586        cpu.convert(
9587            &src,
9588            &mut cpu_dst,
9589            Some(crate::ConvertOptions::new().crop(crop)),
9590        )
9591        .expect("CPU reference convert");
9592
9593        let g = gl_dst
9594            .as_typed::<half::f16>()
9595            .unwrap()
9596            .map()
9597            .unwrap()
9598            .as_slice()
9599            .to_vec();
9600        let c = cpu_dst
9601            .as_typed::<half::f16>()
9602            .unwrap()
9603            .map()
9604            .unwrap()
9605            .as_slice()
9606            .to_vec();
9607        assert_eq!(g.len(), c.len());
9608        let mut max_diff = 0.0f32;
9609        let mut max_at = 0usize;
9610        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9611            let d = (a.to_f32() - b.to_f32()).abs();
9612            if d > max_diff {
9613                max_diff = d;
9614                max_at = i;
9615            }
9616        }
9617        // Localize: plane (R/G/B), row, col of the worst element.
9618        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9619        let (row, col) = (rem / 640, rem % 640);
9620        eprintln!(
9621            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9622             gl={} cpu={}",
9623            g[max_at].to_f32(),
9624            c[max_at].to_f32()
9625        );
9626        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9627        // CPU's direct path: allow a few 8-bit steps of divergence.
9628        assert!(
9629            max_diff <= 4.0 / 255.0 + 1e-3,
9630            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9631        );
9632    }
9633
9634    /// Zero-copy source → heap destination through the GL engine,
9635    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9636    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9637    /// the macOS `glReadnPixels` failure: imported source, rendered,
9638    /// then errored at the heap readback). RGBA→BGRA so the convert is
9639    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9640    /// (Vivante's NV fast path legitimately diverges from the CPU
9641    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9642    /// readbacks).
9643    #[test]
9644    #[cfg(feature = "opengl")]
9645    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9646        let mut proc = match ImageProcessor::new() {
9647            Ok(p) if p.opengl.is_some() => p,
9648            _ => {
9649                crate::test_support::report_skip(&format!(
9650                    "{} — GL backend unavailable",
9651                    function!()
9652                ));
9653                return;
9654            }
9655        };
9656        if !crate::test_support::is_gpu_buffer_available() {
9657            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9658            return;
9659        }
9660
9661        let src = TensorDyn::image(
9662            1280,
9663            720,
9664            PixelFormat::Rgba,
9665            DType::U8,
9666            Some(TensorMemory::DmaBuf),
9667            edgefirst_tensor::CpuAccess::ReadWrite,
9668        )
9669        .unwrap();
9670        {
9671            let t = src.as_typed::<u8>().unwrap();
9672            let mut m = t.map().unwrap();
9673            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9674                *b = ((i * 31) % 211) as u8;
9675            }
9676        }
9677        let mut gl_dst = TensorDyn::image(
9678            1280,
9679            720,
9680            PixelFormat::Bgra,
9681            DType::U8,
9682            Some(TensorMemory::Mem),
9683            edgefirst_tensor::CpuAccess::ReadWrite,
9684        )
9685        .unwrap();
9686        proc.opengl
9687            .as_mut()
9688            .expect("GL backend present")
9689            .convert(&src, &mut gl_dst, None)
9690            .expect("zero-copy src → heap dst GL convert");
9691
9692        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9693            backend: ComputeBackend::Cpu,
9694            ..Default::default()
9695        })
9696        .unwrap();
9697        let mut cpu_dst = TensorDyn::image(
9698            1280,
9699            720,
9700            PixelFormat::Bgra,
9701            DType::U8,
9702            Some(TensorMemory::Mem),
9703            edgefirst_tensor::CpuAccess::ReadWrite,
9704        )
9705        .unwrap();
9706        cpu.convert(&src, &mut cpu_dst, None)
9707            .expect("CPU reference convert");
9708
9709        let g = gl_dst
9710            .as_typed::<u8>()
9711            .unwrap()
9712            .map()
9713            .unwrap()
9714            .as_slice()
9715            .to_vec();
9716        let c = cpu_dst
9717            .as_typed::<u8>()
9718            .unwrap()
9719            .map()
9720            .unwrap()
9721            .as_slice()
9722            .to_vec();
9723        assert_eq!(g.len(), c.len());
9724        let max_diff = g
9725            .iter()
9726            .zip(c.iter())
9727            .map(|(a, b)| a.abs_diff(*b))
9728            .max()
9729            .unwrap();
9730        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9731        // only rounding slack.
9732        assert!(
9733            max_diff <= 2,
9734            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9735        );
9736    }
9737
9738    #[test]
9739    #[cfg(target_os = "linux")]
9740    fn test_yuyv_to_rgb_g2d() {
9741        if !is_g2d_available() {
9742            crate::test_support::report_skip(
9743                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9744            );
9745            return;
9746        }
9747        if !is_dma_available() {
9748            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9749            return;
9750        }
9751
9752        let src = load_bytes_to_tensor(
9753            1280,
9754            720,
9755            PixelFormat::Yuyv,
9756            None,
9757            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9758        )
9759        .unwrap();
9760
9761        let g2d_dst = TensorDyn::image(
9762            1280,
9763            720,
9764            PixelFormat::Rgb,
9765            DType::U8,
9766            Some(TensorMemory::DmaBuf),
9767            edgefirst_tensor::CpuAccess::ReadWrite,
9768        )
9769        .unwrap();
9770        let mut g2d_converter = G2DProcessor::new().unwrap();
9771
9772        let (result, src, g2d_dst) = convert_img(
9773            &mut g2d_converter,
9774            src,
9775            g2d_dst,
9776            Rotation::None,
9777            Flip::None,
9778            Crop::no_crop(),
9779        );
9780        result.unwrap();
9781
9782        let cpu_dst = TensorDyn::image(
9783            1280,
9784            720,
9785            PixelFormat::Rgb,
9786            DType::U8,
9787            None,
9788            edgefirst_tensor::CpuAccess::ReadWrite,
9789        )
9790        .unwrap();
9791        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9792
9793        let (result, _src, cpu_dst) = convert_img(
9794            &mut cpu_converter,
9795            src,
9796            cpu_dst,
9797            Rotation::None,
9798            Flip::None,
9799            Crop::no_crop(),
9800        );
9801        result.unwrap();
9802
9803        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9804        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9805        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9806        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9807        // structural gap not exercised by these limited-range fixtures.
9808        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9809    }
9810
9811    #[test]
9812    #[cfg(target_os = "linux")]
9813    fn test_yuyv_to_yuyv_resize_g2d() {
9814        if !is_g2d_available() {
9815            crate::test_support::report_skip(
9816                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9817            );
9818            return;
9819        }
9820        if !is_dma_available() {
9821            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9822            return;
9823        }
9824
9825        let src = load_bytes_to_tensor(
9826            1280,
9827            720,
9828            PixelFormat::Yuyv,
9829            None,
9830            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9831        )
9832        .unwrap();
9833
9834        let g2d_dst = TensorDyn::image(
9835            600,
9836            400,
9837            PixelFormat::Yuyv,
9838            DType::U8,
9839            Some(TensorMemory::DmaBuf),
9840            edgefirst_tensor::CpuAccess::ReadWrite,
9841        )
9842        .unwrap();
9843        let mut g2d_converter = G2DProcessor::new().unwrap();
9844
9845        let (result, src, g2d_dst) = convert_img(
9846            &mut g2d_converter,
9847            src,
9848            g2d_dst,
9849            Rotation::None,
9850            Flip::None,
9851            Crop::no_crop(),
9852        );
9853        result.unwrap();
9854
9855        let cpu_dst = TensorDyn::image(
9856            600,
9857            400,
9858            PixelFormat::Yuyv,
9859            DType::U8,
9860            None,
9861            edgefirst_tensor::CpuAccess::ReadWrite,
9862        )
9863        .unwrap();
9864        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9865
9866        let (result, _src, cpu_dst) = convert_img(
9867            &mut cpu_converter,
9868            src,
9869            cpu_dst,
9870            Rotation::None,
9871            Flip::None,
9872            Crop::no_crop(),
9873        );
9874        result.unwrap();
9875
9876        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9877        // diverges from the CPU reference by enough that the similarity score
9878        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9879        // The threshold is held at 0.85 so the test guards against gross
9880        // regressions while tolerating the driver's inherent colorimetry error.
9881        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9882        eprintln!(
9883            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9884             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9885        );
9886        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9887    }
9888
9889    #[test]
9890    fn test_yuyv_to_rgba_resize_cpu() {
9891        let src = load_bytes_to_tensor(
9892            1280,
9893            720,
9894            PixelFormat::Yuyv,
9895            None,
9896            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9897        )
9898        .unwrap();
9899
9900        let (dst_width, dst_height) = (960, 540);
9901
9902        let dst = TensorDyn::image(
9903            dst_width,
9904            dst_height,
9905            PixelFormat::Rgba,
9906            DType::U8,
9907            None,
9908            edgefirst_tensor::CpuAccess::ReadWrite,
9909        )
9910        .unwrap();
9911        let mut cpu_converter = CPUProcessor::new();
9912
9913        let (result, _src, dst) = convert_img(
9914            &mut cpu_converter,
9915            src,
9916            dst,
9917            Rotation::None,
9918            Flip::None,
9919            Crop::no_crop(),
9920        );
9921        result.unwrap();
9922
9923        let dst_target = TensorDyn::image(
9924            dst_width,
9925            dst_height,
9926            PixelFormat::Rgba,
9927            DType::U8,
9928            None,
9929            edgefirst_tensor::CpuAccess::ReadWrite,
9930        )
9931        .unwrap();
9932        let src_target = load_bytes_to_tensor(
9933            1280,
9934            720,
9935            PixelFormat::Rgba,
9936            None,
9937            &edgefirst_bench::testdata::read("camera720p.rgba"),
9938        )
9939        .unwrap();
9940        let (result, _src_target, dst_target) = convert_img(
9941            &mut cpu_converter,
9942            src_target,
9943            dst_target,
9944            Rotation::None,
9945            Flip::None,
9946            Crop::no_crop(),
9947        );
9948        result.unwrap();
9949
9950        // CPU path resolves the untagged 720p source to BT.709 limited (height
9951        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9952        compare_images(&dst, &dst_target, 0.98, function!());
9953    }
9954
9955    #[test]
9956    #[cfg(target_os = "linux")]
9957    fn test_yuyv_to_rgba_crop_flip_g2d() {
9958        if !is_g2d_available() {
9959            crate::test_support::report_skip(
9960                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
9961            );
9962            return;
9963        }
9964        if !is_dma_available() {
9965            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9966            return;
9967        }
9968
9969        let src = load_bytes_to_tensor(
9970            1280,
9971            720,
9972            PixelFormat::Yuyv,
9973            Some(TensorMemory::DmaBuf),
9974            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9975        )
9976        .unwrap();
9977
9978        let (dst_width, dst_height) = (640, 640);
9979
9980        let dst_g2d = TensorDyn::image(
9981            dst_width,
9982            dst_height,
9983            PixelFormat::Rgba,
9984            DType::U8,
9985            Some(TensorMemory::DmaBuf),
9986            edgefirst_tensor::CpuAccess::ReadWrite,
9987        )
9988        .unwrap();
9989        let mut g2d_converter = G2DProcessor::new().unwrap();
9990        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9991
9992        let (result, src, dst_g2d) = convert_img(
9993            &mut g2d_converter,
9994            src,
9995            dst_g2d,
9996            Rotation::None,
9997            Flip::Horizontal,
9998            crop,
9999        );
10000        result.unwrap();
10001
10002        let dst_cpu = TensorDyn::image(
10003            dst_width,
10004            dst_height,
10005            PixelFormat::Rgba,
10006            DType::U8,
10007            Some(TensorMemory::DmaBuf),
10008            edgefirst_tensor::CpuAccess::ReadWrite,
10009        )
10010        .unwrap();
10011        let mut cpu_converter = CPUProcessor::new();
10012
10013        let (result, _src, dst_cpu) = convert_img(
10014            &mut cpu_converter,
10015            src,
10016            dst_cpu,
10017            Rotation::None,
10018            Flip::Horizontal,
10019            crop,
10020        );
10021        result.unwrap();
10022        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10023        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10024        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10025        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10026        // structural gap not exercised by these limited-range fixtures.
10027        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
10028    }
10029
10030    #[test]
10031    #[cfg(any(
10032        target_os = "linux",
10033        target_os = "macos",
10034        target_os = "ios",
10035        target_os = "android",
10036        target_os = "windows"
10037    ))]
10038    #[cfg(feature = "opengl")]
10039    fn test_yuyv_to_rgba_crop_flip_opengl() {
10040        if !is_opengl_available() {
10041            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10042            return;
10043        }
10044
10045        if !crate::test_support::is_gpu_buffer_available() {
10046            crate::test_support::report_skip(&format!(
10047                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10048                function!()
10049            ));
10050            return;
10051        }
10052
10053        let src = load_bytes_to_tensor(
10054            1280,
10055            720,
10056            PixelFormat::Yuyv,
10057            Some(TensorMemory::DmaBuf),
10058            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10059        )
10060        .unwrap();
10061
10062        let (dst_width, dst_height) = (640, 640);
10063
10064        let dst_gl = TensorDyn::image(
10065            dst_width,
10066            dst_height,
10067            PixelFormat::Rgba,
10068            DType::U8,
10069            Some(TensorMemory::DmaBuf),
10070            edgefirst_tensor::CpuAccess::ReadWrite,
10071        )
10072        .unwrap();
10073        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10074        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
10075
10076        let (result, src, dst_gl) = convert_img(
10077            &mut gl_converter,
10078            src,
10079            dst_gl,
10080            Rotation::None,
10081            Flip::Horizontal,
10082            crop,
10083        );
10084        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
10085        if let Err(crate::Error::NotSupported(msg)) = &result {
10086            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
10087            return;
10088        }
10089        result.unwrap();
10090
10091        let dst_cpu = TensorDyn::image(
10092            dst_width,
10093            dst_height,
10094            PixelFormat::Rgba,
10095            DType::U8,
10096            Some(TensorMemory::DmaBuf),
10097            edgefirst_tensor::CpuAccess::ReadWrite,
10098        )
10099        .unwrap();
10100        let mut cpu_converter = CPUProcessor::new();
10101
10102        let (result, _src, dst_cpu) = convert_img(
10103            &mut cpu_converter,
10104            src,
10105            dst_cpu,
10106            Rotation::None,
10107            Flip::Horizontal,
10108            crop,
10109        );
10110        result.unwrap();
10111        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
10112        // YUV color-space/sample-range hints, so the matrix delta that forced
10113        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
10114        // on the GPU lanes).
10115        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
10116    }
10117
10118    #[test]
10119    fn test_vyuy_to_rgba_cpu() {
10120        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10121        let src = TensorDyn::image(
10122            1280,
10123            720,
10124            PixelFormat::Vyuy,
10125            DType::U8,
10126            None,
10127            edgefirst_tensor::CpuAccess::ReadWrite,
10128        )
10129        .unwrap();
10130        src.as_typed::<u8>()
10131            .unwrap()
10132            .map()
10133            .unwrap()
10134            .as_mut_slice()
10135            .copy_from_slice(&file);
10136
10137        let dst = TensorDyn::image(
10138            1280,
10139            720,
10140            PixelFormat::Rgba,
10141            DType::U8,
10142            None,
10143            edgefirst_tensor::CpuAccess::ReadWrite,
10144        )
10145        .unwrap();
10146        let mut cpu_converter = CPUProcessor::new();
10147
10148        let (result, _src, dst) = convert_img(
10149            &mut cpu_converter,
10150            src,
10151            dst,
10152            Rotation::None,
10153            Flip::None,
10154            Crop::no_crop(),
10155        );
10156        result.unwrap();
10157
10158        let target_image = TensorDyn::image(
10159            1280,
10160            720,
10161            PixelFormat::Rgba,
10162            DType::U8,
10163            None,
10164            edgefirst_tensor::CpuAccess::ReadWrite,
10165        )
10166        .unwrap();
10167        target_image
10168            .as_typed::<u8>()
10169            .unwrap()
10170            .map()
10171            .unwrap()
10172            .as_mut_slice()
10173            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10174
10175        // CPU path resolves the untagged 720p source to BT.709 limited (height
10176        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10177        compare_images(&dst, &target_image, 0.98, function!());
10178    }
10179
10180    #[test]
10181    fn test_vyuy_to_rgb_cpu() {
10182        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10183        let src = TensorDyn::image(
10184            1280,
10185            720,
10186            PixelFormat::Vyuy,
10187            DType::U8,
10188            None,
10189            edgefirst_tensor::CpuAccess::ReadWrite,
10190        )
10191        .unwrap();
10192        src.as_typed::<u8>()
10193            .unwrap()
10194            .map()
10195            .unwrap()
10196            .as_mut_slice()
10197            .copy_from_slice(&file);
10198
10199        let dst = TensorDyn::image(
10200            1280,
10201            720,
10202            PixelFormat::Rgb,
10203            DType::U8,
10204            None,
10205            edgefirst_tensor::CpuAccess::ReadWrite,
10206        )
10207        .unwrap();
10208        let mut cpu_converter = CPUProcessor::new();
10209
10210        let (result, _src, dst) = convert_img(
10211            &mut cpu_converter,
10212            src,
10213            dst,
10214            Rotation::None,
10215            Flip::None,
10216            Crop::no_crop(),
10217        );
10218        result.unwrap();
10219
10220        let target_image = TensorDyn::image(
10221            1280,
10222            720,
10223            PixelFormat::Rgb,
10224            DType::U8,
10225            None,
10226            edgefirst_tensor::CpuAccess::ReadWrite,
10227        )
10228        .unwrap();
10229        target_image
10230            .as_typed::<u8>()
10231            .unwrap()
10232            .map()
10233            .unwrap()
10234            .as_mut_slice()
10235            .as_chunks_mut::<3>()
10236            .0
10237            .iter_mut()
10238            .zip(
10239                edgefirst_bench::testdata::read("camera720p.rgba")
10240                    .as_chunks::<4>()
10241                    .0,
10242            )
10243            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10244
10245        // CPU path resolves the untagged 720p source to BT.709 limited (height
10246        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10247        compare_images(&dst, &target_image, 0.98, function!());
10248    }
10249
10250    #[test]
10251    #[cfg(target_os = "linux")]
10252    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10253    fn test_vyuy_to_rgba_g2d() {
10254        if !is_g2d_available() {
10255            crate::test_support::report_skip(
10256                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10257            );
10258            return;
10259        }
10260        if !is_dma_available() {
10261            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10262            return;
10263        }
10264
10265        let src = load_bytes_to_tensor(
10266            1280,
10267            720,
10268            PixelFormat::Vyuy,
10269            None,
10270            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10271        )
10272        .unwrap();
10273
10274        let dst = TensorDyn::image(
10275            1280,
10276            720,
10277            PixelFormat::Rgba,
10278            DType::U8,
10279            Some(TensorMemory::DmaBuf),
10280            edgefirst_tensor::CpuAccess::ReadWrite,
10281        )
10282        .unwrap();
10283        let mut g2d_converter = G2DProcessor::new().unwrap();
10284
10285        let (result, _src, dst) = convert_img(
10286            &mut g2d_converter,
10287            src,
10288            dst,
10289            Rotation::None,
10290            Flip::None,
10291            Crop::no_crop(),
10292        );
10293        match result {
10294            Err(Error::G2D(_)) => {
10295                crate::test_support::report_skip(
10296                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10297                );
10298                return;
10299            }
10300            r => r.unwrap(),
10301        }
10302
10303        let target_image = TensorDyn::image(
10304            1280,
10305            720,
10306            PixelFormat::Rgba,
10307            DType::U8,
10308            None,
10309            edgefirst_tensor::CpuAccess::ReadWrite,
10310        )
10311        .unwrap();
10312        target_image
10313            .as_typed::<u8>()
10314            .unwrap()
10315            .map()
10316            .unwrap()
10317            .as_mut_slice()
10318            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10319
10320        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10321        // so the matrix delta vs the reference that forced 0.95 has closed;
10322        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10323        compare_images(&dst, &target_image, 0.98, function!());
10324    }
10325
10326    #[test]
10327    #[cfg(target_os = "linux")]
10328    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10329    fn test_vyuy_to_rgb_g2d() {
10330        if !is_g2d_available() {
10331            crate::test_support::report_skip(
10332                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10333            );
10334            return;
10335        }
10336        if !is_dma_available() {
10337            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10338            return;
10339        }
10340
10341        let src = load_bytes_to_tensor(
10342            1280,
10343            720,
10344            PixelFormat::Vyuy,
10345            None,
10346            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10347        )
10348        .unwrap();
10349
10350        let g2d_dst = TensorDyn::image(
10351            1280,
10352            720,
10353            PixelFormat::Rgb,
10354            DType::U8,
10355            Some(TensorMemory::DmaBuf),
10356            edgefirst_tensor::CpuAccess::ReadWrite,
10357        )
10358        .unwrap();
10359        let mut g2d_converter = G2DProcessor::new().unwrap();
10360
10361        let (result, src, g2d_dst) = convert_img(
10362            &mut g2d_converter,
10363            src,
10364            g2d_dst,
10365            Rotation::None,
10366            Flip::None,
10367            Crop::no_crop(),
10368        );
10369        match result {
10370            Err(Error::G2D(_)) => {
10371                crate::test_support::report_skip(
10372                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10373                );
10374                return;
10375            }
10376            r => r.unwrap(),
10377        }
10378
10379        let cpu_dst = TensorDyn::image(
10380            1280,
10381            720,
10382            PixelFormat::Rgb,
10383            DType::U8,
10384            None,
10385            edgefirst_tensor::CpuAccess::ReadWrite,
10386        )
10387        .unwrap();
10388        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10389
10390        let (result, _src, cpu_dst) = convert_img(
10391            &mut cpu_converter,
10392            src,
10393            cpu_dst,
10394            Rotation::None,
10395            Flip::None,
10396            Crop::no_crop(),
10397        );
10398        result.unwrap();
10399
10400        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10401        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10402        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10403        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10404        // structural gap not exercised by these limited-range fixtures.
10405        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10406    }
10407
10408    #[test]
10409    #[cfg(any(
10410        target_os = "linux",
10411        target_os = "macos",
10412        target_os = "ios",
10413        target_os = "android",
10414        target_os = "windows"
10415    ))]
10416    #[cfg(feature = "opengl")]
10417    fn test_vyuy_to_rgba_opengl() {
10418        if !is_opengl_available() {
10419            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10420            return;
10421        }
10422        if !crate::test_support::is_gpu_buffer_available() {
10423            crate::test_support::report_skip(&format!(
10424                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10425                function!()
10426            ));
10427            return;
10428        }
10429
10430        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on macOS/iOS.
10431        let expect = if cfg!(target_os = "linux") {
10432            crate::test_support::Expect::Succeeds
10433        } else {
10434            crate::test_support::Expect::MayFail("no zero-copy VYUY mapping on this platform")
10435        };
10436        let Some(src) = crate::test_support::allocated(
10437            load_bytes_to_tensor(
10438                1280,
10439                720,
10440                PixelFormat::Vyuy,
10441                Some(TensorMemory::DmaBuf),
10442                &edgefirst_bench::testdata::read("camera720p.vyuy"),
10443            ),
10444            "zero-copy VYUY source",
10445            expect,
10446        ) else {
10447            return;
10448        };
10449
10450        let dst = TensorDyn::image(
10451            1280,
10452            720,
10453            PixelFormat::Rgba,
10454            DType::U8,
10455            Some(TensorMemory::DmaBuf),
10456            edgefirst_tensor::CpuAccess::ReadWrite,
10457        )
10458        .unwrap();
10459        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10460
10461        let (result, _src, dst) = convert_img(
10462            &mut gl_converter,
10463            src,
10464            dst,
10465            Rotation::None,
10466            Flip::None,
10467            Crop::no_crop(),
10468        );
10469        match result {
10470            Err(Error::NotSupported(_)) => {
10471                crate::test_support::report_skip(&format!(
10472                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10473                    function!()
10474                ));
10475                return;
10476            }
10477            r => r.unwrap(),
10478        }
10479
10480        let target_image = TensorDyn::image(
10481            1280,
10482            720,
10483            PixelFormat::Rgba,
10484            DType::U8,
10485            None,
10486            edgefirst_tensor::CpuAccess::ReadWrite,
10487        )
10488        .unwrap();
10489        target_image
10490            .as_typed::<u8>()
10491            .unwrap()
10492            .map()
10493            .unwrap()
10494            .as_mut_slice()
10495            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10496
10497        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10498        // so the matrix delta vs the reference that forced 0.95 has closed;
10499        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10500        compare_images(&dst, &target_image, 0.98, function!());
10501    }
10502
10503    #[test]
10504    fn test_nv12_to_rgba_cpu() {
10505        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10506        let src = TensorDyn::image(
10507            1280,
10508            720,
10509            PixelFormat::Nv12,
10510            DType::U8,
10511            None,
10512            edgefirst_tensor::CpuAccess::ReadWrite,
10513        )
10514        .unwrap();
10515        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10516            .copy_from_slice(&file);
10517
10518        let dst = TensorDyn::image(
10519            1280,
10520            720,
10521            PixelFormat::Rgba,
10522            DType::U8,
10523            None,
10524            edgefirst_tensor::CpuAccess::ReadWrite,
10525        )
10526        .unwrap();
10527        let mut cpu_converter = CPUProcessor::new();
10528
10529        let (result, _src, dst) = convert_img(
10530            &mut cpu_converter,
10531            src,
10532            dst,
10533            Rotation::None,
10534            Flip::None,
10535            Crop::no_crop(),
10536        );
10537        result.unwrap();
10538
10539        let target_image = crate::load_image_test_helper(
10540            &edgefirst_bench::testdata::read("zidane.jpg"),
10541            Some(PixelFormat::Rgba),
10542            None,
10543        )
10544        .unwrap();
10545
10546        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10547        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10548        // decode), so it differs slightly from the RGBA derived from the
10549        // separate `zidane.nv12` fixture.
10550        compare_images(&dst, &target_image, 0.95, function!());
10551    }
10552
10553    #[test]
10554    fn test_nv12_odd_height_to_rgb_cpu() {
10555        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10556        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10557        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10558        // must convert to a uniform grey RGB, exercising the odd-height
10559        // chroma-row count and the logical-height derivation in convert.
10560        // (Odd *width* is rounded to an even buffer at allocation, so it is
10561        // covered by the decode integration tests rather than here.)
10562        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10563        // DMA on i.MX, which would leave the dst's row padding unconverted and
10564        // break the flat byte scan below).
10565        let mut src = TensorDyn::image(
10566            8,
10567            5,
10568            PixelFormat::Nv12,
10569            DType::U8,
10570            Some(TensorMemory::Mem),
10571            edgefirst_tensor::CpuAccess::ReadWrite,
10572        )
10573        .unwrap();
10574        assert_eq!(src.shape(), &[8, 8]);
10575        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10576        src.as_typed::<u8>()
10577            .unwrap()
10578            .map()
10579            .unwrap()
10580            .as_mut_slice()
10581            .fill(128);
10582        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10583        // identity this test asserts). Without a tag, the colorimetry heuristic
10584        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10585        src.set_colorimetry(Some(
10586            edgefirst_tensor::Colorimetry::default()
10587                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10588                .with_range(edgefirst_tensor::ColorRange::Full),
10589        ));
10590
10591        let dst = TensorDyn::image(
10592            8,
10593            5,
10594            PixelFormat::Rgb,
10595            DType::U8,
10596            Some(TensorMemory::Mem),
10597            edgefirst_tensor::CpuAccess::ReadWrite,
10598        )
10599        .unwrap();
10600        let mut cpu_converter = CPUProcessor::new();
10601        let (result, _src, dst) = convert_img(
10602            &mut cpu_converter,
10603            src,
10604            dst,
10605            Rotation::None,
10606            Flip::None,
10607            Crop::no_crop(),
10608        );
10609        result.unwrap();
10610
10611        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10612        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10613        for (i, &b) in map.as_slice().iter().enumerate() {
10614            assert!(
10615                (b as i16 - 128).abs() <= 2,
10616                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10617            );
10618        }
10619    }
10620
10621    #[test]
10622    fn test_nv24_to_rgb_cpu() {
10623        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10624        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10625        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10626        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10627        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10628        let mut src = TensorDyn::image(
10629            8,
10630            4,
10631            PixelFormat::Nv24,
10632            DType::U8,
10633            Some(TensorMemory::Mem),
10634            edgefirst_tensor::CpuAccess::ReadWrite,
10635        )
10636        .unwrap();
10637        assert_eq!(src.shape(), &[12, 8]);
10638        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10639        src.as_typed::<u8>()
10640            .unwrap()
10641            .map()
10642            .unwrap()
10643            .as_mut_slice()
10644            .fill(128);
10645        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10646        // the heuristic picks limited range and Y=128 expands to ~131.
10647        src.set_colorimetry(Some(
10648            edgefirst_tensor::Colorimetry::default()
10649                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10650                .with_range(edgefirst_tensor::ColorRange::Full),
10651        ));
10652
10653        let dst = TensorDyn::image(
10654            8,
10655            4,
10656            PixelFormat::Rgb,
10657            DType::U8,
10658            Some(TensorMemory::Mem),
10659            edgefirst_tensor::CpuAccess::ReadWrite,
10660        )
10661        .unwrap();
10662        let mut cpu_converter = CPUProcessor::new();
10663        let (result, _src, dst) = convert_img(
10664            &mut cpu_converter,
10665            src,
10666            dst,
10667            Rotation::None,
10668            Flip::None,
10669            Crop::no_crop(),
10670        );
10671        result.unwrap();
10672
10673        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10674        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10675        for (i, &b) in map.as_slice().iter().enumerate() {
10676            assert!(
10677                (b as i16 - 128).abs() <= 2,
10678                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10679            );
10680        }
10681    }
10682
10683    #[test]
10684    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10685        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10686        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10687        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10688        // distinct RGB triples, proving the CPU path honours the source's tagged
10689        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10690        // falls through to this CPU path; QA F9.)
10691        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10692        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10693            let mut src = TensorDyn::image(
10694                8,
10695                4,
10696                PixelFormat::Nv12,
10697                DType::U8,
10698                Some(TensorMemory::Mem),
10699                edgefirst_tensor::CpuAccess::ReadWrite,
10700            )
10701            .unwrap();
10702            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10703            assert_eq!(src.shape(), &[6, 8]);
10704            {
10705                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10706                let buf = map.as_mut_slice();
10707                buf[..32].fill(120); // Y
10708                for px in buf[32..].as_chunks_mut::<2>().0 {
10709                    px[0] = 180; // U / Cb
10710                    px[1] = 64; // V / Cr
10711                }
10712            }
10713            // Range held constant (Limited) across all three so only the encoding
10714            // matrix varies between runs.
10715            src.set_colorimetry(Some(
10716                edgefirst_tensor::Colorimetry::default()
10717                    .with_encoding(enc)
10718                    .with_range(edgefirst_tensor::ColorRange::Limited),
10719            ));
10720            let dst = TensorDyn::image(
10721                8,
10722                4,
10723                PixelFormat::Rgb,
10724                DType::U8,
10725                Some(TensorMemory::Mem),
10726                edgefirst_tensor::CpuAccess::ReadWrite,
10727            )
10728            .unwrap();
10729            let mut cpu = CPUProcessor::new();
10730            let (result, _src, dst) = convert_img(
10731                &mut cpu,
10732                src,
10733                dst,
10734                Rotation::None,
10735                Flip::None,
10736                Crop::no_crop(),
10737            );
10738            result.unwrap();
10739            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10740            let s = map.as_slice();
10741            [s[0], s[1], s[2]]
10742        }
10743
10744        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10745        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10746        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10747
10748        assert_ne!(
10749            bt2020, bt601,
10750            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10751        );
10752        assert_ne!(
10753            bt2020, bt709,
10754            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10755        );
10756        assert_ne!(
10757            bt601, bt709,
10758            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10759        );
10760    }
10761
10762    #[test]
10763    fn test_nv12_to_rgb_cpu() {
10764        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10765        let src = TensorDyn::image(
10766            1280,
10767            720,
10768            PixelFormat::Nv12,
10769            DType::U8,
10770            None,
10771            edgefirst_tensor::CpuAccess::ReadWrite,
10772        )
10773        .unwrap();
10774        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10775            .copy_from_slice(&file);
10776
10777        let dst = TensorDyn::image(
10778            1280,
10779            720,
10780            PixelFormat::Rgb,
10781            DType::U8,
10782            None,
10783            edgefirst_tensor::CpuAccess::ReadWrite,
10784        )
10785        .unwrap();
10786        let mut cpu_converter = CPUProcessor::new();
10787
10788        let (result, _src, dst) = convert_img(
10789            &mut cpu_converter,
10790            src,
10791            dst,
10792            Rotation::None,
10793            Flip::None,
10794            Crop::no_crop(),
10795        );
10796        result.unwrap();
10797
10798        let target_image = crate::load_image_test_helper(
10799            &edgefirst_bench::testdata::read("zidane.jpg"),
10800            Some(PixelFormat::Rgb),
10801            None,
10802        )
10803        .unwrap();
10804
10805        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10806        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10807        // decode), so it differs slightly from the RGB derived from the
10808        // separate `zidane.nv12` fixture.
10809        compare_images(&dst, &target_image, 0.95, function!());
10810    }
10811
10812    #[test]
10813    fn test_nv12_to_grey_cpu() {
10814        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10815        let src = TensorDyn::image(
10816            1280,
10817            720,
10818            PixelFormat::Nv12,
10819            DType::U8,
10820            None,
10821            edgefirst_tensor::CpuAccess::ReadWrite,
10822        )
10823        .unwrap();
10824        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10825            .copy_from_slice(&file);
10826
10827        let dst = TensorDyn::image(
10828            1280,
10829            720,
10830            PixelFormat::Grey,
10831            DType::U8,
10832            None,
10833            edgefirst_tensor::CpuAccess::ReadWrite,
10834        )
10835        .unwrap();
10836        let mut cpu_converter = CPUProcessor::new();
10837
10838        let (result, _src, dst) = convert_img(
10839            &mut cpu_converter,
10840            src,
10841            dst,
10842            Rotation::None,
10843            Flip::None,
10844            Crop::no_crop(),
10845        );
10846        result.unwrap();
10847
10848        let target_image = crate::load_image_test_helper(
10849            &edgefirst_bench::testdata::read("zidane.jpg"),
10850            Some(PixelFormat::Grey),
10851            None,
10852        )
10853        .unwrap();
10854
10855        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10856        // the colour JPEG decoded to native NV12 and then converted to GREY
10857        // (was a direct JPEG → GREY decode), so it differs slightly from the
10858        // grey derived from the `zidane.nv12` fixture.
10859        compare_images(&dst, &target_image, 0.95, function!());
10860    }
10861
10862    #[test]
10863    fn test_nv12_to_yuyv_cpu() {
10864        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10865        let src = TensorDyn::image(
10866            1280,
10867            720,
10868            PixelFormat::Nv12,
10869            DType::U8,
10870            None,
10871            edgefirst_tensor::CpuAccess::ReadWrite,
10872        )
10873        .unwrap();
10874        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10875            .copy_from_slice(&file);
10876
10877        let dst = TensorDyn::image(
10878            1280,
10879            720,
10880            PixelFormat::Yuyv,
10881            DType::U8,
10882            None,
10883            edgefirst_tensor::CpuAccess::ReadWrite,
10884        )
10885        .unwrap();
10886        let mut cpu_converter = CPUProcessor::new();
10887
10888        let (result, _src, dst) = convert_img(
10889            &mut cpu_converter,
10890            src,
10891            dst,
10892            Rotation::None,
10893            Flip::None,
10894            Crop::no_crop(),
10895        );
10896        result.unwrap();
10897
10898        let target_image = crate::load_image_test_helper(
10899            &edgefirst_bench::testdata::read("zidane.jpg"),
10900            Some(PixelFormat::Rgb),
10901            None,
10902        )
10903        .unwrap();
10904
10905        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10906        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10907        // decode), so it differs slightly from the YUYV-sourced frame derived
10908        // from the separate `zidane.nv12` fixture.
10909        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10910    }
10911
10912    #[test]
10913    fn test_cpu_resize_nv16() {
10914        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10915        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10916
10917        let cpu_nv16_dst = TensorDyn::image(
10918            640,
10919            640,
10920            PixelFormat::Nv16,
10921            DType::U8,
10922            None,
10923            edgefirst_tensor::CpuAccess::ReadWrite,
10924        )
10925        .unwrap();
10926        let cpu_rgb_dst = TensorDyn::image(
10927            640,
10928            640,
10929            PixelFormat::Rgb,
10930            DType::U8,
10931            None,
10932            edgefirst_tensor::CpuAccess::ReadWrite,
10933        )
10934        .unwrap();
10935        let mut cpu_converter = CPUProcessor::new();
10936        let crop = Crop::letterbox([255, 128, 0, 255]);
10937
10938        let (result, src, cpu_nv16_dst) = convert_img(
10939            &mut cpu_converter,
10940            src,
10941            cpu_nv16_dst,
10942            Rotation::None,
10943            Flip::None,
10944            crop,
10945        );
10946        result.unwrap();
10947
10948        let (result, _src, cpu_rgb_dst) = convert_img(
10949            &mut cpu_converter,
10950            src,
10951            cpu_rgb_dst,
10952            Rotation::None,
10953            Flip::None,
10954            crop,
10955        );
10956        result.unwrap();
10957        compare_images_convert_to_rgb(
10958            &cpu_nv16_dst,
10959            &cpu_rgb_dst,
10960            yuv_threshold(0.99),
10961            function!(),
10962        );
10963    }
10964
10965    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10966    /// conversion.
10967    ///
10968    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10969    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10970    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10971    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10972    /// lower-precision integer approximation with a documented error of up to
10973    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10974    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10975    /// 0.99 on an A53, while every `rdm` core passes.
10976    ///
10977    /// No CI runner is A53-class, so this only reproduces on target.
10978    ///
10979    /// Relaxing per-core keeps the assertion tight where the precise path
10980    /// runs, instead of loosening it everywhere to accommodate one core.
10981    fn yuv_threshold(balanced: f64) -> f64 {
10982        #[cfg(target_arch = "aarch64")]
10983        {
10984            if !std::arch::is_aarch64_feature_detected!("rdm") {
10985                return balanced - 0.01;
10986            }
10987        }
10988        balanced
10989    }
10990
10991    fn load_bytes_to_tensor(
10992        width: usize,
10993        height: usize,
10994        format: PixelFormat,
10995        memory: Option<TensorMemory>,
10996        bytes: &[u8],
10997    ) -> Result<TensorDyn, Error> {
10998        let src = TensorDyn::image(
10999            width,
11000            height,
11001            format,
11002            DType::U8,
11003            memory,
11004            edgefirst_tensor::CpuAccess::ReadWrite,
11005        )?;
11006        src.as_typed::<u8>()
11007            .unwrap()
11008            .map()?
11009            .as_mut_slice()
11010            .copy_from_slice(bytes);
11011        Ok(src)
11012    }
11013
11014    // DEDUP: this function is also defined verbatim in
11015    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
11016    // must be kept in sync. Cross-module sharing would require either a
11017    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
11018    // separate test-utils crate — both are disproportionate for a single
11019    // helper. If the implementation ever diverges, extract to a shared
11020    // `test_helpers` module in this crate.
11021    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
11022        assert_eq!(img1.height(), img2.height(), "Heights differ");
11023        assert_eq!(img1.width(), img2.width(), "Widths differ");
11024        assert_eq!(
11025            img1.format().unwrap(),
11026            img2.format().unwrap(),
11027            "PixelFormat differ"
11028        );
11029        assert!(
11030            matches!(
11031                img1.format().unwrap(),
11032                PixelFormat::Rgb
11033                    | PixelFormat::Rgba
11034                    | PixelFormat::Grey
11035                    | PixelFormat::PlanarRgb
11036                    | PixelFormat::PlanarRgba
11037            ),
11038            "format must be Rgb or Rgba for comparison"
11039        );
11040
11041        // A pitch-padded destination -- the D3D11 staging texture behind a
11042        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
11043        // -- spaces its rows at `effective_row_stride()`, so reading the head
11044        // of the mapping as a tight image would read padding as pixels.
11045        // `copy_to_flat` is the tensor's own compaction and degenerates to one
11046        // memcpy when the tensor is tight.
11047        fn flat(img: &TensorDyn) -> Vec<u8> {
11048            let t = img.as_typed::<u8>().expect("u8 image");
11049            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
11050            t.copy_to_flat(&mut out).expect("compact the padded rows");
11051            out
11052        }
11053
11054        let image1 = match img1.format().unwrap() {
11055            PixelFormat::Rgb => image::RgbImage::from_vec(
11056                img1.width().unwrap() as u32,
11057                img1.height().unwrap() as u32,
11058                flat(img1),
11059            )
11060            .unwrap(),
11061            PixelFormat::Rgba => image::RgbaImage::from_vec(
11062                img1.width().unwrap() as u32,
11063                img1.height().unwrap() as u32,
11064                flat(img1),
11065            )
11066            .unwrap()
11067            .convert(),
11068            PixelFormat::Grey => image::GrayImage::from_vec(
11069                img1.width().unwrap() as u32,
11070                img1.height().unwrap() as u32,
11071                flat(img1),
11072            )
11073            .unwrap()
11074            .convert(),
11075            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11076                img1.width().unwrap() as u32,
11077                (img1.height().unwrap() * img1.format().unwrap().channels()) as u32,
11078                flat(img1),
11079            )
11080            .unwrap()
11081            .convert(),
11082            _ => return,
11083        };
11084
11085        let image2 = match img2.format().unwrap() {
11086            PixelFormat::Rgb => image::RgbImage::from_vec(
11087                img2.width().unwrap() as u32,
11088                img2.height().unwrap() as u32,
11089                flat(img2),
11090            )
11091            .unwrap(),
11092            PixelFormat::Rgba => image::RgbaImage::from_vec(
11093                img2.width().unwrap() as u32,
11094                img2.height().unwrap() as u32,
11095                flat(img2),
11096            )
11097            .unwrap()
11098            .convert(),
11099            PixelFormat::Grey => image::GrayImage::from_vec(
11100                img2.width().unwrap() as u32,
11101                img2.height().unwrap() as u32,
11102                flat(img2),
11103            )
11104            .unwrap()
11105            .convert(),
11106            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11107                img2.width().unwrap() as u32,
11108                (img2.height().unwrap() * img2.format().unwrap().channels()) as u32,
11109                flat(img2),
11110            )
11111            .unwrap()
11112            .convert(),
11113            _ => return,
11114        };
11115
11116        let similarity = image_compare::rgb_similarity_structure(
11117            &image_compare::Algorithm::RootMeanSquared,
11118            &image1,
11119            &image2,
11120        )
11121        .expect("Image Comparison failed");
11122        if similarity.score < threshold {
11123            // image1.save(format!("{name}_1.png"));
11124            // image2.save(format!("{name}_2.png"));
11125            similarity
11126                .image
11127                .to_color_map()
11128                .save(format!("{name}.png"))
11129                .unwrap();
11130            panic!(
11131                "{name}: converted image and target image have similarity score too low: {} < {}",
11132                similarity.score, threshold
11133            )
11134        }
11135    }
11136
11137    fn compare_images_convert_to_rgb(
11138        img1: &TensorDyn,
11139        img2: &TensorDyn,
11140        threshold: f64,
11141        name: &str,
11142    ) {
11143        assert_eq!(img1.height(), img2.height(), "Heights differ");
11144        assert_eq!(img1.width(), img2.width(), "Widths differ");
11145
11146        let mut img_rgb1 = TensorDyn::image(
11147            img1.width().unwrap(),
11148            img1.height().unwrap(),
11149            PixelFormat::Rgb,
11150            DType::U8,
11151            Some(TensorMemory::Mem),
11152            edgefirst_tensor::CpuAccess::ReadWrite,
11153        )
11154        .unwrap();
11155        let mut img_rgb2 = TensorDyn::image(
11156            img1.width().unwrap(),
11157            img1.height().unwrap(),
11158            PixelFormat::Rgb,
11159            DType::U8,
11160            Some(TensorMemory::Mem),
11161            edgefirst_tensor::CpuAccess::ReadWrite,
11162        )
11163        .unwrap();
11164        let mut __cv = CPUProcessor::default();
11165        let r1 = __cv.convert(img1, &mut img_rgb1, None);
11166        let r2 = __cv.convert(img2, &mut img_rgb2, None);
11167        if r1.is_err() || r2.is_err() {
11168            // Fallback: compare raw bytes as greyscale strip
11169            let w = img1.width().unwrap() as u32;
11170            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11171            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11172            let h1 = (data1.len() as u32) / w;
11173            let h2 = (data2.len() as u32) / w;
11174            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
11175            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
11176            let similarity = image_compare::gray_similarity_structure(
11177                &image_compare::Algorithm::RootMeanSquared,
11178                &g1,
11179                &g2,
11180            )
11181            .expect("Image Comparison failed");
11182            if similarity.score < threshold {
11183                panic!(
11184                    "{name}: converted image and target image have similarity score too low: {} < {}",
11185                    similarity.score, threshold
11186                )
11187            }
11188            return;
11189        }
11190
11191        let image1 = image::RgbImage::from_vec(
11192            img_rgb1.width().unwrap() as u32,
11193            img_rgb1.height().unwrap() as u32,
11194            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11195        )
11196        .unwrap();
11197
11198        let image2 = image::RgbImage::from_vec(
11199            img_rgb2.width().unwrap() as u32,
11200            img_rgb2.height().unwrap() as u32,
11201            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11202        )
11203        .unwrap();
11204
11205        let similarity = image_compare::rgb_similarity_structure(
11206            &image_compare::Algorithm::RootMeanSquared,
11207            &image1,
11208            &image2,
11209        )
11210        .expect("Image Comparison failed");
11211        if similarity.score < threshold {
11212            // image1.save(format!("{name}_1.png"));
11213            // image2.save(format!("{name}_2.png"));
11214            similarity
11215                .image
11216                .to_color_map()
11217                .save(format!("{name}.png"))
11218                .unwrap();
11219            panic!(
11220                "{name}: converted image and target image have similarity score too low: {} < {}",
11221                similarity.score, threshold
11222            )
11223        }
11224    }
11225
11226    // =========================================================================
11227    // PixelFormat::Nv12 Format Tests
11228    // =========================================================================
11229
11230    #[test]
11231    fn test_nv12_image_creation() {
11232        let width = 640;
11233        let height = 480;
11234        let img = TensorDyn::image(
11235            width,
11236            height,
11237            PixelFormat::Nv12,
11238            DType::U8,
11239            None,
11240            edgefirst_tensor::CpuAccess::ReadWrite,
11241        )
11242        .unwrap();
11243
11244        assert_eq!(img.width(), Some(width));
11245        assert_eq!(img.height(), Some(height));
11246        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11247        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11248        assert_eq!(
11249            img.as_typed::<u8>().unwrap().shape(),
11250            &[height * 3 / 2, width]
11251        );
11252    }
11253
11254    #[test]
11255    fn test_nv12_channels() {
11256        let img = TensorDyn::image(
11257            640,
11258            480,
11259            PixelFormat::Nv12,
11260            DType::U8,
11261            None,
11262            edgefirst_tensor::CpuAccess::ReadWrite,
11263        )
11264        .unwrap();
11265        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11266        assert_eq!(img.format().unwrap().channels(), 1);
11267    }
11268
11269    // =========================================================================
11270    // Tensor Format Metadata Tests
11271    // =========================================================================
11272
11273    #[test]
11274    fn test_tensor_set_format_planar() {
11275        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11276        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11277        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11278        assert_eq!(tensor.width(), Some(640));
11279        assert_eq!(tensor.height(), Some(480));
11280    }
11281
11282    #[test]
11283    fn test_tensor_set_format_interleaved() {
11284        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11285        tensor.set_format(PixelFormat::Rgba).unwrap();
11286        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11287        assert_eq!(tensor.width(), Some(640));
11288        assert_eq!(tensor.height(), Some(480));
11289    }
11290
11291    #[test]
11292    fn test_tensordyn_image_rgb() {
11293        let img = TensorDyn::image(
11294            640,
11295            480,
11296            PixelFormat::Rgb,
11297            DType::U8,
11298            None,
11299            edgefirst_tensor::CpuAccess::ReadWrite,
11300        )
11301        .unwrap();
11302        assert_eq!(img.width(), Some(640));
11303        assert_eq!(img.height(), Some(480));
11304        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11305    }
11306
11307    #[test]
11308    fn test_tensordyn_image_planar_rgb() {
11309        let img = TensorDyn::image(
11310            640,
11311            480,
11312            PixelFormat::PlanarRgb,
11313            DType::U8,
11314            None,
11315            edgefirst_tensor::CpuAccess::ReadWrite,
11316        )
11317        .unwrap();
11318        assert_eq!(img.width(), Some(640));
11319        assert_eq!(img.height(), Some(480));
11320        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11321    }
11322
11323    #[test]
11324    fn test_rgb_int8_format() {
11325        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11326        let img = TensorDyn::image(
11327            1280,
11328            720,
11329            PixelFormat::Rgb,
11330            DType::I8,
11331            Some(TensorMemory::Mem),
11332            edgefirst_tensor::CpuAccess::ReadWrite,
11333        )
11334        .unwrap();
11335        assert_eq!(img.width(), Some(1280));
11336        assert_eq!(img.height(), Some(720));
11337        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11338        assert_eq!(img.dtype(), DType::I8);
11339    }
11340
11341    #[test]
11342    fn test_planar_rgb_int8_format() {
11343        let img = TensorDyn::image(
11344            1280,
11345            720,
11346            PixelFormat::PlanarRgb,
11347            DType::I8,
11348            Some(TensorMemory::Mem),
11349            edgefirst_tensor::CpuAccess::ReadWrite,
11350        )
11351        .unwrap();
11352        assert_eq!(img.width(), Some(1280));
11353        assert_eq!(img.height(), Some(720));
11354        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11355        assert_eq!(img.dtype(), DType::I8);
11356    }
11357
11358    #[test]
11359    fn test_rgb_from_tensor() {
11360        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11361        tensor.set_format(PixelFormat::Rgb).unwrap();
11362        let img = TensorDyn::from(tensor);
11363        assert_eq!(img.width(), Some(1280));
11364        assert_eq!(img.height(), Some(720));
11365        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11366    }
11367
11368    #[test]
11369    fn test_planar_rgb_from_tensor() {
11370        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11371        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11372        let img = TensorDyn::from(tensor);
11373        assert_eq!(img.width(), Some(1280));
11374        assert_eq!(img.height(), Some(720));
11375        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11376    }
11377
11378    #[test]
11379    fn test_dtype_determines_int8() {
11380        // DType::I8 indicates int8 data
11381        let u8_img = TensorDyn::image(
11382            64,
11383            64,
11384            PixelFormat::Rgb,
11385            DType::U8,
11386            None,
11387            edgefirst_tensor::CpuAccess::ReadWrite,
11388        )
11389        .unwrap();
11390        let i8_img = TensorDyn::image(
11391            64,
11392            64,
11393            PixelFormat::Rgb,
11394            DType::I8,
11395            None,
11396            edgefirst_tensor::CpuAccess::ReadWrite,
11397        )
11398        .unwrap();
11399        assert_eq!(u8_img.dtype(), DType::U8);
11400        assert_eq!(i8_img.dtype(), DType::I8);
11401    }
11402
11403    #[test]
11404    fn test_pixel_layout_packed_vs_planar() {
11405        // Packed vs planar layout classification
11406        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11407        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11408        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11409        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11410    }
11411
11412    /// Integration test that exercises the PBO-to-PBO convert path.
11413    /// A PBO cannot hand out a host address that outlives its map guard, and
11414    /// the error has to say why rather than reading as unfinished work.
11415    ///
11416    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11417    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11418    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11419    /// would mean holding the buffer mapped across GL work, which is the
11420    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11421    /// answer, but that is a buffer-allocation feature, not wiring.
11422    #[cfg(target_os = "linux")]
11423    #[cfg(feature = "opengl")]
11424    #[test]
11425    fn pbo_reports_why_it_cannot_pin() {
11426        use edgefirst_tensor::{Error, TensorTrait};
11427
11428        let converter = ImageProcessor::new().unwrap();
11429        let is_pbo = converter
11430            .opengl
11431            .as_ref()
11432            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11433        if !is_pbo {
11434            crate::test_support::report_skip(
11435                "pbo_reports_why_it_cannot_pin - transfer backend is not PBO",
11436            );
11437            return;
11438        }
11439
11440        let t = converter
11441            .create_image(
11442                64,
11443                64,
11444                PixelFormat::Rgba,
11445                DType::U8,
11446                None,
11447                edgefirst_tensor::CpuAccess::ReadWrite,
11448            )
11449            .unwrap();
11450        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11451        let t = t.as_typed::<u8>().unwrap();
11452
11453        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11454            Err(Error::NotImplemented(m)) => {
11455                assert!(
11456                    m.contains("glMapBufferRange"),
11457                    "must name the constraint: {m}"
11458                );
11459                assert!(m.contains("map()"), "must point at the supported API: {m}");
11460            }
11461            other => panic!("expected an explained NotImplemented, got {other:?}"),
11462        }
11463        // Sync is unavailable for the same reason, and must not silently succeed.
11464        assert!(matches!(
11465            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11466            Err(Error::NotImplemented(_))
11467        ));
11468
11469        // The supported path still works, which is what makes the refusal safe.
11470        let g = t.map().expect("map must work where pin cannot");
11471        assert!(!g.as_slice().is_empty());
11472    }
11473
11474    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11475    /// then converts between them. Skipped when GL is unavailable or the
11476    /// backend is not PBO (e.g. DMA-buf systems).
11477    #[cfg(target_os = "linux")]
11478    #[cfg(feature = "opengl")]
11479    #[test]
11480    fn test_convert_pbo_to_pbo() {
11481        let mut converter = ImageProcessor::new().unwrap();
11482
11483        // Skip if GL is not available or backend is not PBO
11484        let is_pbo = converter
11485            .opengl
11486            .as_ref()
11487            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11488        if !is_pbo {
11489            crate::test_support::report_skip(
11490                "test_convert_pbo_to_pbo - transfer backend is not PBO",
11491            );
11492            return;
11493        }
11494
11495        let src_w = 640;
11496        let src_h = 480;
11497        let dst_w = 320;
11498        let dst_h = 240;
11499
11500        // Create PBO-backed source image
11501        let pbo_src = converter
11502            .create_image(
11503                src_w,
11504                src_h,
11505                PixelFormat::Rgba,
11506                DType::U8,
11507                None,
11508                edgefirst_tensor::CpuAccess::ReadWrite,
11509            )
11510            .unwrap();
11511        assert_eq!(
11512            pbo_src.as_typed::<u8>().unwrap().memory(),
11513            TensorMemory::Pbo,
11514            "create_image should produce a PBO tensor"
11515        );
11516
11517        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11518        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11519        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11520
11521        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11522        let mem_src = TensorDyn::image(
11523            src_w,
11524            src_h,
11525            PixelFormat::Rgba,
11526            DType::U8,
11527            Some(TensorMemory::Mem),
11528            edgefirst_tensor::CpuAccess::ReadWrite,
11529        )
11530        .unwrap();
11531        let (result, _jpeg_src, mem_src) = convert_img(
11532            &mut CPUProcessor::new(),
11533            jpeg_src,
11534            mem_src,
11535            Rotation::None,
11536            Flip::None,
11537            Crop::no_crop(),
11538        );
11539        result.unwrap();
11540
11541        // Copy pixel data into the PBO source by mapping it
11542        {
11543            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11544            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11545            pbo_map.copy_from_slice(&src_data);
11546        }
11547
11548        // Create PBO-backed destination image
11549        let pbo_dst = converter
11550            .create_image(
11551                dst_w,
11552                dst_h,
11553                PixelFormat::Rgba,
11554                DType::U8,
11555                None,
11556                edgefirst_tensor::CpuAccess::ReadWrite,
11557            )
11558            .unwrap();
11559        assert_eq!(
11560            pbo_dst.as_typed::<u8>().unwrap().memory(),
11561            TensorMemory::Pbo
11562        );
11563
11564        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11565        let mut pbo_dst = pbo_dst;
11566        let result = converter.convert(&pbo_src, &mut pbo_dst, None);
11567        result.unwrap();
11568
11569        // Verify: compare with CPU-only conversion of the same input
11570        let cpu_dst = TensorDyn::image(
11571            dst_w,
11572            dst_h,
11573            PixelFormat::Rgba,
11574            DType::U8,
11575            Some(TensorMemory::Mem),
11576            edgefirst_tensor::CpuAccess::ReadWrite,
11577        )
11578        .unwrap();
11579        let (result, _mem_src, cpu_dst) = convert_img(
11580            &mut CPUProcessor::new(),
11581            mem_src,
11582            cpu_dst,
11583            Rotation::None,
11584            Flip::None,
11585            Crop::no_crop(),
11586        );
11587        result.unwrap();
11588
11589        let pbo_dst_img = {
11590            let mut __t = pbo_dst.into_u8().unwrap();
11591            __t.set_format(PixelFormat::Rgba).unwrap();
11592            TensorDyn::from(__t)
11593        };
11594        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11595        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11596    }
11597
11598    #[test]
11599    fn test_image_bgra() {
11600        let img = TensorDyn::image(
11601            640,
11602            480,
11603            PixelFormat::Bgra,
11604            DType::U8,
11605            Some(edgefirst_tensor::TensorMemory::Mem),
11606            edgefirst_tensor::CpuAccess::ReadWrite,
11607        )
11608        .unwrap();
11609        assert_eq!(img.width(), Some(640));
11610        assert_eq!(img.height(), Some(480));
11611        assert_eq!(img.format().unwrap().channels(), 4);
11612        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11613    }
11614
11615    // ========================================================================
11616    // Tests for EDGEFIRST_FORCE_BACKEND env var
11617    // ========================================================================
11618
11619    #[test]
11620    fn test_force_backend_cpu() {
11621        let _lock = acquire_env_lock();
11622        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11623        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11624        let converter = ImageProcessor::new().unwrap();
11625        assert!(converter.cpu.is_some());
11626        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11627    }
11628
11629    #[test]
11630    fn test_force_backend_invalid() {
11631        let _lock = acquire_env_lock();
11632        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11633        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11634        let result = ImageProcessor::new();
11635        assert!(
11636            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11637            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11638        );
11639    }
11640
11641    #[test]
11642    fn test_force_backend_unset() {
11643        let _lock = acquire_env_lock();
11644        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11645        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11646        let converter = ImageProcessor::new().unwrap();
11647        assert!(converter.forced_backend.is_none());
11648    }
11649
11650    // ========================================================================
11651    // Tests for hybrid mask path error handling
11652    // ========================================================================
11653
11654    #[test]
11655    fn test_draw_proto_masks_no_cpu_returns_error() {
11656        // Serialize against all other env-var-mutating tests.
11657        let _lock = acquire_env_lock();
11658        let _guard = EnvGuard::snapshot(&[
11659            "EDGEFIRST_FORCE_BACKEND",
11660            "EDGEFIRST_DISABLE_GL",
11661            "EDGEFIRST_DISABLE_G2D",
11662            "EDGEFIRST_DISABLE_CPU",
11663        ]);
11664
11665        // Disable all backends so cpu.is_none() after construction.
11666        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11667        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11668        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11669
11670        let mut converter = ImageProcessor::new().unwrap();
11671        assert!(converter.cpu.is_none(), "CPU should be disabled");
11672
11673        let dst = TensorDyn::image(
11674            640,
11675            480,
11676            PixelFormat::Rgba,
11677            DType::U8,
11678            Some(TensorMemory::Mem),
11679            edgefirst_tensor::CpuAccess::ReadWrite,
11680        )
11681        .unwrap();
11682        let mut dst_dyn = dst;
11683        let det = [DetectBox {
11684            bbox: edgefirst_tensor::BoundingBox {
11685                xmin: 0.1,
11686                ymin: 0.1,
11687                xmax: 0.5,
11688                ymax: 0.5,
11689            },
11690            score: 0.9,
11691            label: 0,
11692        }];
11693        let proto_data = {
11694            use edgefirst_tensor::{Tensor, TensorDyn};
11695            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11696            let protos_t =
11697                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11698            ProtoData {
11699                mask_coefficients: TensorDyn::F32(coeff_t),
11700                protos: TensorDyn::F32(protos_t),
11701                layout: ProtoLayout::Nhwc,
11702            }
11703        };
11704        let result =
11705            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11706        assert!(
11707            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11708            "draw_proto_masks without CPU should return Internal error: {result:?}"
11709        );
11710    }
11711
11712    #[test]
11713    fn test_draw_proto_masks_cpu_fallback_works() {
11714        // Force CPU-only backend to ensure the CPU fallback path executes.
11715        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11716        let _lock = acquire_env_lock();
11717        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11718        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11719        let mut converter = ImageProcessor::new().unwrap();
11720        assert!(converter.cpu.is_some());
11721
11722        let dst = TensorDyn::image(
11723            64,
11724            64,
11725            PixelFormat::Rgba,
11726            DType::U8,
11727            Some(TensorMemory::Mem),
11728            edgefirst_tensor::CpuAccess::ReadWrite,
11729        )
11730        .unwrap();
11731        let mut dst_dyn = dst;
11732        let det = [DetectBox {
11733            bbox: edgefirst_tensor::BoundingBox {
11734                xmin: 0.1,
11735                ymin: 0.1,
11736                xmax: 0.5,
11737                ymax: 0.5,
11738            },
11739            score: 0.9,
11740            label: 0,
11741        }];
11742        let proto_data = {
11743            use edgefirst_tensor::{Tensor, TensorDyn};
11744            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11745            let protos_t =
11746                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11747            ProtoData {
11748                mask_coefficients: TensorDyn::F32(coeff_t),
11749                protos: TensorDyn::F32(protos_t),
11750                layout: ProtoLayout::Nhwc,
11751            }
11752        };
11753        let result =
11754            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11755        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11756    }
11757
11758    // ============================================================
11759    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11760    // verified tests. Exercises each backend against the full
11761    // output-contract matrix:
11762    //
11763    //   | detections | background | expected dst             |
11764    //   |------------|------------|--------------------------|
11765    //   | empty      | none       | fully cleared (0x00)     |
11766    //   | empty      | set        | fully equal to bg        |
11767    //   | set        | none       | cleared outside box +    |
11768    //   |            |            | mask-coloured inside     |
11769    //   | set        | set        | bg outside box + mask    |
11770    //   |            |            | blended inside           |
11771    //
11772    // Every test pre-fills dst with a non-zero "dirty" pattern so
11773    // that any silent `return Ok(())` leaks the pattern into the
11774    // asserted output and fails loudly.
11775    // ============================================================
11776
11777    // =========================================================================
11778    // Env-var serialisation helpers
11779    //
11780    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11781    // ENV_MUTEX for their full duration.  This single mutex serialises
11782    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11783    // test_force_backend_*, with_force_backend, and with_env — preventing
11784    // any two of them from racing in a parallel `cargo test` run.
11785    // =========================================================================
11786
11787    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11788    /// kept alive for the entire duration of the test body.
11789    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11790        use std::sync::{Mutex, OnceLock};
11791        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11792        ENV_MUTEX
11793            .get_or_init(|| Mutex::new(()))
11794            .lock()
11795            .unwrap_or_else(|e| e.into_inner())
11796    }
11797
11798    /// RAII guard that snapshots a set of env vars on construction and
11799    /// restores them on `Drop`, even if the test panics.
11800    struct EnvGuard {
11801        vars: Vec<(&'static str, Option<String>)>,
11802    }
11803
11804    impl EnvGuard {
11805        /// Snapshot the current values of `names`.  Call this while holding
11806        /// the env lock (the lock is not taken here — that is the caller's
11807        /// responsibility so the lock scope can be wider than the guard).
11808        fn snapshot(names: &[&'static str]) -> Self {
11809            Self {
11810                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11811            }
11812        }
11813    }
11814
11815    impl Drop for EnvGuard {
11816        fn drop(&mut self) {
11817            for (k, v) in &self.vars {
11818                match v {
11819                    Some(s) => unsafe { std::env::set_var(k, s) },
11820                    None => unsafe { std::env::remove_var(k) },
11821                }
11822            }
11823        }
11824    }
11825
11826    /// `EDGEFIRST_INTERPOLATION` accepts `nearest` and `bilinear` in any
11827    /// case and ignores anything else.
11828    #[test]
11829    fn test_interpolation_env_parse() {
11830        let _lock = acquire_env_lock();
11831        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11832        for (value, want) in [
11833            (Some("nearest"), Some(Interpolation::Nearest)),
11834            (Some(" BILINEAR "), Some(Interpolation::Bilinear)),
11835            (Some("lanczos"), None),
11836            (Some(""), None),
11837            (None, None),
11838        ] {
11839            match value {
11840                Some(v) => unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", v) },
11841                None => unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") },
11842            }
11843            assert_eq!(
11844                Interpolation::from_env(),
11845                want,
11846                "EDGEFIRST_INTERPOLATION={value:?}"
11847            );
11848        }
11849    }
11850
11851    /// `ImageProcessorConfig::interpolation` reaches the CPU backend, and
11852    /// `EDGEFIRST_INTERPOLATION` overrides it.
11853    #[test]
11854    fn test_interpolation_config_and_env_reach_cpu() {
11855        let _lock = acquire_env_lock();
11856        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11857        let mut checker = Vec::with_capacity(8 * 8 * 4);
11858        for y in 0..8 {
11859            for x in 0..8 {
11860                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
11861                checker.extend_from_slice(&[v, v, v, 255]);
11862            }
11863        }
11864        let resize = |processor: &mut dyn ImageProcessorTrait| {
11865            let mut src = TensorDyn::image(
11866                8,
11867                8,
11868                PixelFormat::Rgba,
11869                DType::U8,
11870                Some(TensorMemory::Mem),
11871                edgefirst_tensor::CpuAccess::ReadWrite,
11872            )
11873            .unwrap();
11874            src.as_u8_mut()
11875                .unwrap()
11876                .map_mut()
11877                .unwrap()
11878                .as_mut_slice()
11879                .copy_from_slice(&checker);
11880            let mut dst = TensorDyn::image(
11881                12,
11882                12,
11883                PixelFormat::Rgba,
11884                DType::U8,
11885                Some(TensorMemory::Mem),
11886                edgefirst_tensor::CpuAccess::ReadWrite,
11887            )
11888            .unwrap();
11889            processor.convert(&src, &mut dst, None).unwrap();
11890            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
11891        };
11892        let nearest = resize(&mut CPUProcessor::new_nearest());
11893        let bilinear = resize(&mut CPUProcessor::new());
11894        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
11895        let config = |interpolation| ImageProcessorConfig {
11896            backend: ComputeBackend::Cpu,
11897            interpolation,
11898            ..Default::default()
11899        };
11900
11901        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
11902        let mut p = ImageProcessor::with_config(config(Interpolation::Nearest)).unwrap();
11903        assert_eq!(resize(&mut p), nearest, "config Nearest");
11904        p.set_interpolation(Interpolation::Bilinear).unwrap();
11905        assert_eq!(resize(&mut p), bilinear, "set_interpolation(Bilinear)");
11906
11907        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "nearest") };
11908        let mut p = ImageProcessor::with_config(config(Interpolation::Bilinear)).unwrap();
11909        assert_eq!(
11910            resize(&mut p),
11911            nearest,
11912            "EDGEFIRST_INTERPOLATION pins Nearest"
11913        );
11914        p.set_interpolation(Interpolation::Bilinear).unwrap();
11915        assert_eq!(
11916            resize(&mut p),
11917            nearest,
11918            "the env pin outlives set_interpolation"
11919        );
11920    }
11921
11922    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
11923    /// removed), restoring the prior value afterward. Tests are env-
11924    /// serialized via the process-wide `ENV_MUTEX`.
11925    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
11926        let _lock = acquire_env_lock();
11927        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11928        match value {
11929            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
11930            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
11931        }
11932        body()
11933    }
11934
11935    /// Allocate an RGBA image tensor and pre-fill every byte with a
11936    /// distinctive non-zero pattern. Any test that relies on the old
11937    /// "dst is already cleared" assumption will see this pattern leak
11938    /// through to the output and fail.
11939    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
11940        let dst = TensorDyn::image(
11941            w,
11942            h,
11943            PixelFormat::Rgba,
11944            DType::U8,
11945            mem,
11946            edgefirst_tensor::CpuAccess::ReadWrite,
11947        )
11948        .unwrap();
11949        {
11950            use edgefirst_tensor::TensorMapTrait;
11951            let u8t = dst.as_typed::<u8>().unwrap();
11952            let mut map = u8t.map().unwrap();
11953            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
11954                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
11955            }
11956        }
11957        dst
11958    }
11959
11960    /// Allocate an RGBA background filled with a constant colour.
11961    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
11962        let bg = TensorDyn::image(
11963            w,
11964            h,
11965            PixelFormat::Rgba,
11966            DType::U8,
11967            mem,
11968            edgefirst_tensor::CpuAccess::ReadWrite,
11969        )
11970        .unwrap();
11971        {
11972            use edgefirst_tensor::TensorMapTrait;
11973            let u8t = bg.as_typed::<u8>().unwrap();
11974            let mut map = u8t.map().unwrap();
11975            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
11976                chunk.copy_from_slice(&rgba);
11977            }
11978        }
11979        bg
11980    }
11981
11982    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
11983        use edgefirst_tensor::TensorMapTrait;
11984        let w = dst.width().unwrap();
11985        let off = (y * w + x) * 4;
11986        let u8t = dst.as_typed::<u8>().unwrap();
11987        let map = u8t.map().unwrap();
11988        let s = map.as_slice();
11989        [s[off], s[off + 1], s[off + 2], s[off + 3]]
11990    }
11991
11992    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
11993        use edgefirst_tensor::TensorMapTrait;
11994        let u8t = dst.as_typed::<u8>().unwrap();
11995        let map = u8t.map().unwrap();
11996        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
11997            assert_eq!(
11998                chunk, &expected,
11999                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
12000            );
12001        }
12002    }
12003
12004    /// Scenario 1: empty detections, empty segmentation, no background
12005    /// → dst must be fully cleared to 0x00000000.
12006    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
12007        let mut dst = make_dirty_dst(64, 64, None);
12008        processor
12009            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12010            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
12011        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
12012
12013        let mut dst = make_dirty_dst(64, 64, None);
12014        let proto = {
12015            use edgefirst_tensor::{Tensor, TensorDyn};
12016            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12017            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12018            let protos_t =
12019                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12020            ProtoData {
12021                mask_coefficients: TensorDyn::F32(coeff_t),
12022                protos: TensorDyn::F32(protos_t),
12023                layout: ProtoLayout::Nhwc,
12024            }
12025        };
12026        processor
12027            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
12028            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
12029        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
12030    }
12031
12032    /// Scenario 2: empty detections, empty segmentation, background set
12033    /// → dst must be fully equal to bg.
12034    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
12035        let bg_color = [42, 99, 200, 255];
12036        let bg = make_bg(64, 64, None, bg_color);
12037        let overlay = MaskOverlay::new().with_background(&bg);
12038
12039        let mut dst = make_dirty_dst(64, 64, None);
12040        processor
12041            .draw_decoded_masks(&mut dst, &[], &[], overlay)
12042            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
12043        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
12044
12045        let mut dst = make_dirty_dst(64, 64, None);
12046        let proto = {
12047            use edgefirst_tensor::{Tensor, TensorDyn};
12048            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12049            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12050            let protos_t =
12051                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12052            ProtoData {
12053                mask_coefficients: TensorDyn::F32(coeff_t),
12054                protos: TensorDyn::F32(protos_t),
12055                layout: ProtoLayout::Nhwc,
12056            }
12057        };
12058        processor
12059            .draw_proto_masks(&mut dst, &[], &proto, overlay)
12060            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
12061        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
12062    }
12063
12064    /// Scenario 3: one detection with a fully-opaque segmentation fill,
12065    /// no background → outside the box dst must be 0x00, inside it must
12066    /// be a non-zero mask colour (the render_segmentation output).
12067    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
12068        use edgefirst_tensor::Segmentation;
12069        use ndarray::Array3;
12070        processor
12071            .set_class_colors(&[[200, 80, 40, 255]])
12072            .expect("set_class_colors");
12073
12074        let detect = DetectBox {
12075            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12076            score: 0.99,
12077            label: 0,
12078        };
12079        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12080        let seg = Segmentation {
12081            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12082                .unwrap()
12083                .into(),
12084            xmin: 0.25,
12085            ymin: 0.25,
12086            xmax: 0.75,
12087            ymax: 0.75,
12088        };
12089
12090        let mut dst = make_dirty_dst(64, 64, None);
12091        processor
12092            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
12093            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
12094
12095        // Outside the bbox (corner): must be cleared black.
12096        let corner = pixel_at(&dst, 2, 2);
12097        assert_eq!(
12098            corner,
12099            [0, 0, 0, 0],
12100            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
12101        );
12102        // Inside the bbox (center): the mask colour must be visible.
12103        // Any non-zero pixel is acceptable — exact rendering varies
12104        // between backends (GL smoothstep, CPU nearest).
12105        let center = pixel_at(&dst, 32, 32);
12106        assert!(
12107            center != [0, 0, 0, 0],
12108            "{case}/decoded: center (32,32) was not coloured: {center:?}"
12109        );
12110    }
12111
12112    /// Scenario 4: detection + background. Outside the box must match
12113    /// bg; inside the box must NOT match bg (mask blended on top).
12114    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
12115        use edgefirst_tensor::Segmentation;
12116        use ndarray::Array3;
12117        processor
12118            .set_class_colors(&[[200, 80, 40, 255]])
12119            .expect("set_class_colors");
12120        let bg_color = [10, 20, 30, 255];
12121        let bg = make_bg(64, 64, None, bg_color);
12122
12123        let detect = DetectBox {
12124            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12125            score: 0.99,
12126            label: 0,
12127        };
12128        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12129        let seg = Segmentation {
12130            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12131                .unwrap()
12132                .into(),
12133            xmin: 0.25,
12134            ymin: 0.25,
12135            xmax: 0.75,
12136            ymax: 0.75,
12137        };
12138
12139        let overlay = MaskOverlay::new().with_background(&bg);
12140        let mut dst = make_dirty_dst(64, 64, None);
12141        processor
12142            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
12143            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
12144
12145        // Outside the bbox (corner): bg colour.
12146        let corner = pixel_at(&dst, 2, 2);
12147        assert_eq!(
12148            corner, bg_color,
12149            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
12150        );
12151        // Inside the bbox (center): mask blended on bg, must differ from
12152        // pure bg (alpha-blend with mask colour produces a distinct shade).
12153        let center = pixel_at(&dst, 32, 32);
12154        assert!(
12155            center != bg_color,
12156            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
12157        );
12158    }
12159
12160    /// Run all 4 scenarios against the processor. Skip gracefully if
12161    /// construction fails (backend unavailable on this host).
12162    fn run_all_scenarios(
12163        force_backend: Option<&'static str>,
12164        case: &'static str,
12165        require_dma_for_bg: bool,
12166    ) {
12167        if require_dma_for_bg && !crate::test_support::is_dma_available() {
12168            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
12169            return;
12170        }
12171        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
12172        let mut processor = match processor_result {
12173            Ok(p) => p,
12174            Err(e) => {
12175                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
12176                return;
12177            }
12178        };
12179        scenario_empty_no_bg(&mut processor, case);
12180        scenario_empty_with_bg(&mut processor, case);
12181        scenario_detect_no_bg(&mut processor, case);
12182        scenario_detect_with_bg(&mut processor, case);
12183    }
12184
12185    #[test]
12186    fn test_draw_masks_4_scenarios_cpu() {
12187        run_all_scenarios(Some("cpu"), "cpu", false);
12188    }
12189
12190    #[test]
12191    fn test_draw_masks_4_scenarios_auto() {
12192        run_all_scenarios(None, "auto", false);
12193    }
12194
12195    #[cfg(any(
12196        target_os = "linux",
12197        target_os = "macos",
12198        target_os = "ios",
12199        target_os = "android",
12200        target_os = "windows"
12201    ))]
12202    #[cfg(feature = "opengl")]
12203    #[test]
12204    fn test_draw_masks_4_scenarios_opengl() {
12205        run_all_scenarios(Some("opengl"), "opengl", false);
12206    }
12207
12208    /// G2D forced backend: exercises the zero-detection empty-frame
12209    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
12210    /// detections) expect `NotImplemented` since G2D has no rasterizer
12211    /// for boxes / masks.
12212    #[cfg(target_os = "linux")]
12213    #[test]
12214    fn test_draw_masks_zero_detection_g2d_forced() {
12215        if !crate::test_support::is_dma_available() {
12216            crate::test_support::report_skip("g2d forced — DMA not available on this host");
12217            return;
12218        }
12219        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
12220        let mut processor = match processor_result {
12221            Ok(p) => p,
12222            Err(e) => {
12223                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
12224                return;
12225            }
12226        };
12227
12228        // Case 1: empty + no bg. G2D requires DMA-backed dst.
12229        let mut dst = TensorDyn::image(
12230            64,
12231            64,
12232            PixelFormat::Rgba,
12233            DType::U8,
12234            Some(TensorMemory::DmaBuf),
12235            edgefirst_tensor::CpuAccess::ReadWrite,
12236        )
12237        .unwrap();
12238        {
12239            use edgefirst_tensor::TensorMapTrait;
12240            let u8t = dst.as_typed_mut::<u8>().unwrap();
12241            let mut map = u8t.map().unwrap();
12242            map.as_mut_slice().fill(0xBB);
12243        }
12244        processor
12245            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12246            .expect("g2d empty+no-bg");
12247        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
12248
12249        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
12250        let bg_color = [7, 11, 13, 255];
12251        let bg = {
12252            let t = TensorDyn::image(
12253                64,
12254                64,
12255                PixelFormat::Rgba,
12256                DType::U8,
12257                Some(TensorMemory::DmaBuf),
12258                edgefirst_tensor::CpuAccess::ReadWrite,
12259            )
12260            .unwrap();
12261            {
12262                use edgefirst_tensor::TensorMapTrait;
12263                let u8t = t.as_typed::<u8>().unwrap();
12264                let mut map = u8t.map().unwrap();
12265                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12266                    chunk.copy_from_slice(&bg_color);
12267                }
12268            }
12269            t
12270        };
12271        let mut dst = TensorDyn::image(
12272            64,
12273            64,
12274            PixelFormat::Rgba,
12275            DType::U8,
12276            Some(TensorMemory::DmaBuf),
12277            edgefirst_tensor::CpuAccess::ReadWrite,
12278        )
12279        .unwrap();
12280        {
12281            use edgefirst_tensor::TensorMapTrait;
12282            let u8t = dst.as_typed_mut::<u8>().unwrap();
12283            let mut map = u8t.map().unwrap();
12284            map.as_mut_slice().fill(0x55);
12285        }
12286        processor
12287            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
12288            .expect("g2d empty+bg");
12289        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
12290
12291        // Case 3 and 4: detect present — must return NotImplemented.
12292        let detect = DetectBox {
12293            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12294            score: 0.9,
12295            label: 0,
12296        };
12297        let mut dst = TensorDyn::image(
12298            64,
12299            64,
12300            PixelFormat::Rgba,
12301            DType::U8,
12302            Some(TensorMemory::DmaBuf),
12303            edgefirst_tensor::CpuAccess::ReadWrite,
12304        )
12305        .unwrap();
12306        let err = processor
12307            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12308            .expect_err("g2d must reject detect-present draw_decoded_masks");
12309        assert!(
12310            matches!(err, Error::NotImplemented(_)),
12311            "g2d case3 wrong error: {err:?}"
12312        );
12313    }
12314
12315    #[test]
12316    fn test_set_format_then_cpu_convert() {
12317        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12318        // test_force_backend_* and test_disable_env_var.
12319        let _lock = acquire_env_lock();
12320        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12321        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12322        let mut processor = ImageProcessor::new().unwrap();
12323
12324        // Load a source image
12325        let image = edgefirst_bench::testdata::read("zidane.jpg");
12326        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12327
12328        // Create a raw tensor, then attach format — simulating the from_fd workflow
12329        let mut dst =
12330            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12331        dst.set_format(PixelFormat::Rgb).unwrap();
12332
12333        // Convert should work with the set_format-annotated tensor
12334        processor.convert(&src, &mut dst, None).unwrap();
12335
12336        // Verify format survived conversion
12337        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12338        assert_eq!(dst.width(), Some(640));
12339        assert_eq!(dst.height(), Some(640));
12340    }
12341
12342    /// Verify that creating multiple ImageProcessors on the same thread and
12343    /// performing a resize on each does not deadlock or error.
12344    ///
12345    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12346    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12347    #[test]
12348    fn test_multiple_image_processors_same_thread() {
12349        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12350        // seen by ImageProcessor::new() calls during this test.
12351        let _lock = acquire_env_lock();
12352        let mut processors: Vec<ImageProcessor> = (0..4)
12353            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12354            .collect();
12355
12356        for proc in &mut processors {
12357            let src = proc
12358                .create_image(
12359                    128,
12360                    128,
12361                    PixelFormat::Rgb,
12362                    DType::U8,
12363                    None,
12364                    edgefirst_tensor::CpuAccess::ReadWrite,
12365                )
12366                .expect("create src failed");
12367            let mut dst = proc
12368                .create_image(
12369                    64,
12370                    64,
12371                    PixelFormat::Rgb,
12372                    DType::U8,
12373                    None,
12374                    edgefirst_tensor::CpuAccess::ReadWrite,
12375                )
12376                .expect("create dst failed");
12377            proc.convert(&src, &mut dst, None).expect("convert failed");
12378            assert_eq!(dst.width(), Some(64));
12379            assert_eq!(dst.height(), Some(64));
12380        }
12381    }
12382
12383    /// Verify that creating ImageProcessors on separate threads and performing
12384    /// a resize on each does not deadlock or error.
12385    ///
12386    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12387    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12388    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12389    #[test]
12390    fn test_multiple_image_processors_separate_threads() {
12391        use std::sync::mpsc;
12392        use std::time::Duration;
12393
12394        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12395        // EGL context teardown — four processors spun up on four threads here
12396        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12397        // panic). The bug is the driver's, not the HAL's; this test is kept so
12398        // it still exercises the multi-context path on every other GPU. The
12399        // board lane and scripts/on-target-test.sh set
12400        // EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS on any board with /dev/galcore
12401        // to skip just this case there, while the run stays red anywhere else
12402        // a regression appears. (Skip, not #[ignore]: the platform is decided
12403        // at runtime, not compile time.)
12404        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12405            crate::test_support::report_skip(
12406                "test_multiple_image_processors_separate_threads — known Vivante \
12407                 GC7000UL concurrent-EGL-teardown double-free \
12408                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12409            );
12410            return;
12411        }
12412
12413        const TIMEOUT: Duration = Duration::from_secs(60);
12414
12415        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12416        // calls made inside the spawned threads during this test.
12417        let _lock = acquire_env_lock();
12418
12419        let (tx, rx) = mpsc::channel::<()>();
12420
12421        std::thread::spawn(move || {
12422            let handles: Vec<_> = (0..4)
12423                .map(|i| {
12424                    std::thread::spawn(move || {
12425                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12426                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12427                        });
12428                        let src = proc
12429                            .create_image(
12430                                128,
12431                                128,
12432                                PixelFormat::Rgb,
12433                                DType::U8,
12434                                None,
12435                                edgefirst_tensor::CpuAccess::ReadWrite,
12436                            )
12437                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12438                        let mut dst = proc
12439                            .create_image(
12440                                64,
12441                                64,
12442                                PixelFormat::Rgb,
12443                                DType::U8,
12444                                None,
12445                                edgefirst_tensor::CpuAccess::ReadWrite,
12446                            )
12447                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12448                        proc.convert(&src, &mut dst, None)
12449                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12450                        assert_eq!(dst.width(), Some(64));
12451                        assert_eq!(dst.height(), Some(64));
12452                    })
12453                })
12454                .collect();
12455
12456            for (i, h) in handles.into_iter().enumerate() {
12457                h.join()
12458                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12459            }
12460
12461            let _ = tx.send(());
12462        });
12463
12464        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12465            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12466        });
12467    }
12468
12469    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12470    /// all operate concurrently without deadlocking each other.
12471    ///
12472    /// All processors are created first, then a barrier synchronizes them so
12473    /// they all start converting at the same instant — maximizing contention.
12474    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12475    #[test]
12476    fn test_image_processors_concurrent_operations() {
12477        use std::sync::{mpsc, Arc, Barrier};
12478        use std::time::Duration;
12479
12480        const N: usize = 4;
12481        const ROUNDS: usize = 10;
12482        const TIMEOUT: Duration = Duration::from_secs(60);
12483
12484        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12485        // calls made inside the spawned threads during this test.
12486        let _lock = acquire_env_lock();
12487
12488        let (tx, rx) = mpsc::channel::<()>();
12489
12490        std::thread::spawn(move || {
12491            let barrier = Arc::new(Barrier::new(N));
12492
12493            let handles: Vec<_> = (0..N)
12494                .map(|i| {
12495                    let barrier = Arc::clone(&barrier);
12496                    std::thread::spawn(move || {
12497                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12498                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12499                        });
12500
12501                        // All threads wait here until every processor is initialized.
12502                        barrier.wait();
12503
12504                        // Now all 4 hammer the GPU concurrently.
12505                        for round in 0..ROUNDS {
12506                            let src = proc
12507                                .create_image(
12508                                    128,
12509                                    128,
12510                                    PixelFormat::Rgb,
12511                                    DType::U8,
12512                                    None,
12513                                    edgefirst_tensor::CpuAccess::ReadWrite,
12514                                )
12515                                .unwrap_or_else(|e| {
12516                                    panic!("create src failed on thread {i} round {round}: {e}")
12517                                });
12518                            let mut dst = proc
12519                                .create_image(
12520                                    64,
12521                                    64,
12522                                    PixelFormat::Rgb,
12523                                    DType::U8,
12524                                    None,
12525                                    edgefirst_tensor::CpuAccess::ReadWrite,
12526                                )
12527                                .unwrap_or_else(|e| {
12528                                    panic!("create dst failed on thread {i} round {round}: {e}")
12529                                });
12530                            proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
12531                                panic!("convert failed on thread {i} round {round}: {e}")
12532                            });
12533                            assert_eq!(dst.width(), Some(64));
12534                            assert_eq!(dst.height(), Some(64));
12535                        }
12536                    })
12537                })
12538                .collect();
12539
12540            for (i, h) in handles.into_iter().enumerate() {
12541                h.join()
12542                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12543            }
12544
12545            let _ = tx.send(());
12546        });
12547
12548        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12549            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12550        });
12551    }
12552
12553    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12554    /// threads, each with its own GL context and worker, converting
12555    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12556    /// every output must byte-match the thread's own pre-barrier sequential
12557    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12558    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12559    /// GPU; on Vivante they serialize via the Full policy and the test still
12560    /// must pass. Distinct inputs make any cross-processor state leakage
12561    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12562    /// diff rather than a coincidental match — proven by a scratch
12563    /// cross-wire run (neighbor's input post-oracle) failing on every
12564    /// thread with ~53% of bytes diverged.
12565    ///
12566    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12567    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12568    /// can abort intermittently on concurrent multi-processor lifecycles
12569    /// regardless of locking (P0 spike: reproduces fully serialized).
12570    #[test]
12571    fn test_parallel_processors_unique_outputs() {
12572        use std::sync::{mpsc, Arc, Barrier};
12573        use std::time::Duration;
12574
12575        const N: usize = 4;
12576        const ROUNDS: usize = 25;
12577        const TIMEOUT: Duration = Duration::from_secs(60);
12578
12579        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12580            crate::test_support::report_skip(
12581                "test_parallel_processors_unique_outputs — known Vivante \
12582                 GC7000UL concurrent-multi-processor driver abort \
12583                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12584            );
12585            return;
12586        }
12587
12588        let _lock = acquire_env_lock();
12589        // Probed here, where the policy can name the test; worker threads are unnamed.
12590        let use_dma = crate::test_support::is_dma_available();
12591        if !use_dma {
12592            crate::test_support::report_skip(&format!(
12593                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
12594                function!()
12595            ));
12596        }
12597        let (tx, rx) = mpsc::channel::<()>();
12598
12599        std::thread::spawn(move || {
12600            let barrier = Arc::new(Barrier::new(N));
12601            let handles: Vec<_> = (0..N)
12602                .map(|i| {
12603                    let barrier = Arc::clone(&barrier);
12604                    std::thread::spawn(move || {
12605                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12606                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12607                        });
12608                        // CI-weight geometry (llvmpipe renders on the CPU).
12609                        let (w, h) = (640usize, 480usize);
12610                        let mem = if use_dma {
12611                            Some(TensorMemory::DmaBuf)
12612                        } else {
12613                            Some(TensorMemory::Mem)
12614                        };
12615                        let src = proc
12616                            .create_image(
12617                                w,
12618                                h,
12619                                PixelFormat::Nv12,
12620                                DType::U8,
12621                                mem,
12622                                edgefirst_tensor::CpuAccess::ReadWrite,
12623                            )
12624                            .unwrap();
12625                        {
12626                            let t = src.as_typed::<u8>().unwrap();
12627                            let mut m = t.map().unwrap();
12628                            let s = m.as_mut_slice();
12629                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12630                                *b = ((i * 53 + j) % 200 + 16) as u8;
12631                            }
12632                            for b in &mut s[w * h..] {
12633                                *b = (80 + i * 24) as u8;
12634                            }
12635                        }
12636                        let lb = Crop::letterbox([114, 114, 114, 255]);
12637                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12638                            let mut dst = proc
12639                                .create_image(
12640                                    320,
12641                                    320,
12642                                    PixelFormat::Rgba,
12643                                    DType::U8,
12644                                    mem,
12645                                    edgefirst_tensor::CpuAccess::ReadWrite,
12646                                )
12647                                .unwrap();
12648                            proc.convert(
12649                                &src,
12650                                &mut dst,
12651                                Some(crate::ConvertOptions::new().crop(lb)),
12652                            )
12653                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12654                            let t = dst.as_typed::<u8>().unwrap();
12655                            let m = t.map().unwrap();
12656                            m.as_slice().to_vec()
12657                        };
12658
12659                        let oracle = convert_once(&mut proc);
12660                        barrier.wait();
12661                        for round in 0..ROUNDS {
12662                            let out = convert_once(&mut proc);
12663                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12664                            assert!(
12665                                diffs == 0,
12666                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12667                                 from this processor's own oracle — cross-processor \
12668                                 GL state leakage under parallel execution",
12669                                oracle.len()
12670                            );
12671                        }
12672                    })
12673                })
12674                .collect();
12675
12676            for (i, h) in handles.into_iter().enumerate() {
12677                h.join()
12678                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12679            }
12680            let _ = tx.send(());
12681        });
12682
12683        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12684            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12685        });
12686    }
12687
12688    /// Heavy on-demand stressor for the GL serialization policy: 4
12689    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12690    /// converts (DMA where available); every output must byte-match the
12691    /// thread's own pre-barrier sequential oracle from the same processor.
12692    /// Ignored by default (heavy; board tool — the CI-weight version is
12693    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12694    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12695    ///   <test binary> stress_parallel_processors_oracle --ignored
12696    #[test]
12697    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12698    fn stress_parallel_processors_oracle() {
12699        use std::sync::{mpsc, Arc, Barrier};
12700        use std::time::Duration;
12701
12702        const N: usize = 4;
12703        const ROUNDS: usize = 200;
12704        const TIMEOUT: Duration = Duration::from_secs(600);
12705
12706        let _lock = acquire_env_lock();
12707        // Probed here, where the policy can name the test; worker threads are unnamed.
12708        let use_dma = crate::test_support::is_dma_available();
12709        if !use_dma {
12710            crate::test_support::report_skip(&format!(
12711                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
12712                function!()
12713            ));
12714        }
12715        let (tx, rx) = mpsc::channel::<()>();
12716
12717        std::thread::spawn(move || {
12718            let barrier = Arc::new(Barrier::new(N));
12719            let handles: Vec<_> = (0..N)
12720                .map(|i| {
12721                    let barrier = Arc::clone(&barrier);
12722                    std::thread::spawn(move || {
12723                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12724                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12725                        });
12726                        let (w, h) = (1280usize, 720usize);
12727                        let mem = if use_dma {
12728                            Some(TensorMemory::DmaBuf)
12729                        } else {
12730                            Some(TensorMemory::Mem)
12731                        };
12732
12733                        // Per-thread-distinct synthetic NV12 so cross-wired
12734                        // GL state between processors shows up as a byte diff.
12735                        let src = proc
12736                            .create_image(
12737                                w,
12738                                h,
12739                                PixelFormat::Nv12,
12740                                DType::U8,
12741                                mem,
12742                                edgefirst_tensor::CpuAccess::ReadWrite,
12743                            )
12744                            .unwrap();
12745                        {
12746                            let t = src.as_typed::<u8>().unwrap();
12747                            let mut m = t.map().unwrap();
12748                            let s = m.as_mut_slice();
12749                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12750                                *b = ((i * 37 + j) % 200 + 16) as u8;
12751                            }
12752                            for b in &mut s[w * h..] {
12753                                *b = (96 + i * 16) as u8;
12754                            }
12755                        }
12756                        let lb = Crop::letterbox([114, 114, 114, 255]);
12757
12758                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12759                            let mut dst = proc
12760                                .create_image(
12761                                    640,
12762                                    640,
12763                                    PixelFormat::Rgb,
12764                                    DType::U8,
12765                                    mem,
12766                                    edgefirst_tensor::CpuAccess::ReadWrite,
12767                                )
12768                                .unwrap();
12769                            proc.convert(
12770                                &src,
12771                                &mut dst,
12772                                Some(crate::ConvertOptions::new().crop(lb)),
12773                            )
12774                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12775                            let t = dst.as_typed::<u8>().unwrap();
12776                            let m = t.map().unwrap();
12777                            m.as_slice().to_vec()
12778                        };
12779
12780                        let oracle = convert_once(&mut proc);
12781                        barrier.wait();
12782                        for round in 0..ROUNDS {
12783                            let out = convert_once(&mut proc);
12784                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12785                            assert!(
12786                                diffs == 0,
12787                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12788                                 from the pre-barrier oracle",
12789                                oracle.len()
12790                            );
12791                        }
12792                    })
12793                })
12794                .collect();
12795
12796            for (i, h) in handles.into_iter().enumerate() {
12797                h.join()
12798                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12799            }
12800            let _ = tx.send(());
12801        });
12802
12803        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12804            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12805        });
12806    }
12807
12808    // =========================================================================
12809    // F16 / F32 auto-chain fallback integration tests
12810    // =========================================================================
12811
12812    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12813    /// combo the GL path does NOT cover.
12814    ///
12815    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12816    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12817    /// chain falls through to the CPU float path. Before commit 868a7649
12818    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12819    /// must return `Ok` with output in `[0, 1]` and all values finite.
12820    #[test]
12821    fn convert_f32_auto_never_errors_non_gl_combo() {
12822        const W: usize = 64;
12823        const H: usize = 64;
12824
12825        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
12826        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
12827        let src = TensorDyn::image(
12828            W,
12829            H,
12830            PixelFormat::Yuyv,
12831            DType::U8,
12832            Some(TensorMemory::Mem),
12833            edgefirst_tensor::CpuAccess::ReadWrite,
12834        )
12835        .unwrap();
12836        {
12837            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12838            let data = map.as_mut_slice();
12839            for chunk in data.as_chunks_mut::<4>().0 {
12840                chunk[0] = 128; // Y0
12841                chunk[1] = 128; // U
12842                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
12843                chunk[3] = 128; // V
12844            }
12845        }
12846
12847        let mut dst = TensorDyn::image(
12848            W,
12849            H,
12850            PixelFormat::Rgb,
12851            DType::F32,
12852            Some(TensorMemory::Mem),
12853            edgefirst_tensor::CpuAccess::ReadWrite,
12854        )
12855        .unwrap();
12856
12857        let mut proc = ImageProcessor::new().unwrap();
12858        let result = proc.convert(&src, &mut dst, None);
12859        assert!(
12860            result.is_ok(),
12861            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
12862            result.err()
12863        );
12864
12865        // Verify all output values are finite and in [0, 1].
12866        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
12867        let floats = map.as_slice();
12868        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
12869        for (i, &v) in floats.iter().enumerate() {
12870            assert!(
12871                v.is_finite() && (0.0..=1.0).contains(&v),
12872                "output[{i}]={v} is not finite or not in [0,1]"
12873            );
12874        }
12875
12876        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
12877        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
12878        // path never wrote to it) this assertion catches the regression.
12879        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
12880        assert!(
12881            first_non_zero.is_some(),
12882            "all-zero output detected — CPU path likely did not write to the destination buffer"
12883        );
12884        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
12885        // (which carries luma for a near-grey YUV source).
12886        let r0 = floats[0];
12887        assert!(
12888            (r0 - 0.502_f32).abs() < 0.05,
12889            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
12890        );
12891    }
12892
12893    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
12894    ///
12895    /// Uses a source with clearly distinct per-channel values so a
12896    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
12897    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
12898    #[test]
12899    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
12900    // `{ backend, ..Default::default() }` is a genuine update on Linux but
12901    // covers no remaining fields on macOS, where `clippy::needless_update`
12902    // then fires. `allow` (not `expect`) because the lint is platform-
12903    // conditional — it does not fire on Linux.
12904    #[allow(clippy::needless_update)]
12905    fn convert_f16_forced_cpu_correct() {
12906        const W: usize = 16;
12907        const H: usize = 16;
12908        const TOL: f32 = 1.0 / 512.0; // 2^-9
12909
12910        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
12911        let src = TensorDyn::image(
12912            W,
12913            H,
12914            PixelFormat::Rgba,
12915            DType::U8,
12916            Some(TensorMemory::Mem),
12917            edgefirst_tensor::CpuAccess::ReadWrite,
12918        )
12919        .unwrap();
12920        {
12921            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
12922            let data = map.as_mut_slice();
12923            for y in 0..H {
12924                for x in 0..W {
12925                    let i = y * W + x;
12926                    data[i * 4] = (50 + x) as u8; // R: 50..65
12927                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
12928                    data[i * 4 + 2] = 200; // B: constant
12929                    data[i * 4 + 3] = 255;
12930                }
12931            }
12932        }
12933
12934        let mut dst = TensorDyn::image(
12935            W,
12936            H,
12937            PixelFormat::PlanarRgb,
12938            DType::F16,
12939            Some(TensorMemory::Mem),
12940            edgefirst_tensor::CpuAccess::ReadWrite,
12941        )
12942        .unwrap();
12943
12944        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
12945            backend: ComputeBackend::Cpu,
12946            ..Default::default()
12947        })
12948        .unwrap();
12949        proc.convert(&src, &mut dst, None)
12950            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
12951
12952        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
12953        let src_bytes = src_map.as_slice();
12954        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
12955        let dst_halfs = dst_map.as_slice();
12956
12957        let plane = W * H;
12958        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
12959
12960        for y in 0..H {
12961            for x in 0..W {
12962                let i = y * W + x;
12963                let r_exp = src_bytes[i * 4] as f32 / 255.0;
12964                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
12965                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
12966
12967                let r_got = dst_halfs[i].to_f32();
12968                let g_got = dst_halfs[plane + i].to_f32();
12969                let b_got = dst_halfs[2 * plane + i].to_f32();
12970
12971                assert!(
12972                    (r_got - r_exp).abs() <= TOL,
12973                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
12974                );
12975                assert!(
12976                    (g_got - g_exp).abs() <= TOL,
12977                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
12978                );
12979                assert!(
12980                    (b_got - b_exp).abs() <= TOL,
12981                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
12982                );
12983
12984                // Catch plane-swap: R and G must differ (they have different formulas).
12985                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
12986                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
12987                }
12988            }
12989        }
12990    }
12991
12992    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
12993    /// rotation set.
12994    ///
12995    /// The GL float render path rejects any call where rotation ≠ None,
12996    /// returning an error that causes the chain to continue. Before the CPU
12997    /// float fallback this would have produced an error at the end of the
12998    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
12999    #[test]
13000    fn convert_f32_with_rotation_falls_back() {
13001        const W: usize = 16;
13002        const H: usize = 16;
13003
13004        // RGBA8 source with a known gradient (distinct per-channel values).
13005        let src = TensorDyn::image(
13006            W,
13007            H,
13008            PixelFormat::Rgba,
13009            DType::U8,
13010            Some(TensorMemory::Mem),
13011            edgefirst_tensor::CpuAccess::ReadWrite,
13012        )
13013        .unwrap();
13014        {
13015            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13016            let data = map.as_mut_slice();
13017            for y in 0..H {
13018                for x in 0..W {
13019                    let i = y * W + x;
13020                    data[i * 4] = (x * 16) as u8; // R
13021                    data[i * 4 + 1] = (y * 16) as u8; // G
13022                    data[i * 4 + 2] = 128; // B
13023                    data[i * 4 + 3] = 255;
13024                }
13025            }
13026        }
13027
13028        // Rotation swaps W and H, so dst is [W, H] (H×W output).
13029        let mut dst = TensorDyn::image(
13030            H, // dst W = src H after 90° rotation
13031            W, // dst H = src W after 90° rotation
13032            PixelFormat::Rgb,
13033            DType::F32,
13034            Some(TensorMemory::Mem),
13035            edgefirst_tensor::CpuAccess::ReadWrite,
13036        )
13037        .unwrap();
13038
13039        let mut proc = ImageProcessor::new().unwrap();
13040        let result = proc.convert(
13041            &src,
13042            &mut dst,
13043            Some(crate::ConvertOptions::new().rotation(Rotation::Clockwise90)),
13044        );
13045        assert!(
13046            result.is_ok(),
13047            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
13048            result.err()
13049        );
13050
13051        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13052        let floats = map.as_slice();
13053        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
13054        for (i, &v) in floats.iter().enumerate() {
13055            assert!(
13056                v.is_finite() && (0.0..=1.0).contains(&v),
13057                "output[{i}]={v} is not finite or not in [0,1]"
13058            );
13059        }
13060    }
13061
13062    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
13063    ///
13064    /// Converts the same RGBA8 source via the `OpenGl` backend and the
13065    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
13066    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
13067    /// or F16 render is unavailable, and when the host cannot hold the
13068    /// zero-copy F16 destination the GL float route needs.
13069    ///
13070    /// **The GL route is asserted, not assumed** (issue #179).
13071    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
13072    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
13073    /// serves the same convert. Both sides of the comparison would then be
13074    /// the CPU's answer and the parity claim would be a tautology — which is
13075    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
13076    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
13077    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
13078    /// an extension query, not a completed render. `convert_fallback_count()`
13079    /// staying flat across the GL convert is the proof, and a decline is
13080    /// reported as a skip rather than a failure because it is a property of
13081    /// the host's driver, not of this code — except under
13082    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
13083    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
13084    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
13085    /// come up. A lane that has asserted it has GL should not be quietly
13086    /// losing this test's coverage.
13087    #[test]
13088    #[cfg(all(
13089        any(
13090            target_os = "linux",
13091            target_os = "macos",
13092            target_os = "ios",
13093            target_os = "android",
13094            target_os = "windows"
13095        ),
13096        feature = "opengl"
13097    ))]
13098    fn convert_f16_gl_cpu_parity_identity() {
13099        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
13100        // passes but deliberately hobbles pass 1 (unsigned binaries, the
13101        // ANGLE dlopen gate closed). Excluded there the same way
13102        // `gl_backend_available_canary` excludes it, so the gate below
13103        // enforces in pass 2 only.
13104        #[cfg(target_os = "macos")]
13105        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
13106            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
13107        {
13108            crate::test_support::report_skip(
13109                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
13110            );
13111            return;
13112        }
13113        if !is_opengl_available() {
13114            crate::test_support::report_skip(
13115                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
13116            );
13117            return;
13118        }
13119
13120        // 32 F16 pixels make a 64-byte row, the pitch IOSurface requires for a planar destination.
13121        const W: usize = 32;
13122        const H: usize = 16;
13123        const TOL: f32 = 1.0 / 256.0; // 2^-8
13124
13125        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
13126        let src = TensorDyn::image(
13127            W,
13128            H,
13129            PixelFormat::Rgba,
13130            DType::U8,
13131            Some(TensorMemory::Mem),
13132            edgefirst_tensor::CpuAccess::ReadWrite,
13133        )
13134        .unwrap();
13135        {
13136            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13137            let data = map.as_mut_slice();
13138            for y in 0..H {
13139                for x in 0..W {
13140                    let i = y * W + x;
13141                    data[i * 4] = (40 + x) as u8; // R
13142                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
13143                    data[i * 4 + 2] = 180; // B
13144                    data[i * 4 + 3] = 255;
13145                }
13146            }
13147        }
13148
13149        // GL path.
13150        let gl_result = {
13151            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
13152                backend: ComputeBackend::OpenGl,
13153                ..Default::default()
13154            }) {
13155                Ok(p) => p,
13156                Err(e) => {
13157                    crate::test_support::report_skip(&format!(
13158                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
13159                    ));
13160                    return;
13161                }
13162            };
13163
13164            if !gl_proc.supported_render_dtypes().f16 {
13165                crate::test_support::report_skip(
13166                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
13167                );
13168                return;
13169            }
13170
13171            // A destination the GL float route actually serves. A
13172            // `TensorMemory::Mem` float destination has no arm in
13173            // `float_dispatch::classify_float_render` at all: it classifies as
13174            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
13175            // CPU serves every convert. With a host destination the GL half
13176            // below was therefore unreachable and this test could only ever
13177            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
13178            // is the zero-copy float tuple EVERY platform's capability set
13179            // serves: it is unconditional in `classify_float_render`, so it
13180            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
13181            // declare as well as the `All` set Windows declares.
13182            //
13183            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
13184            // portable name for the platform-native zero-copy GPU buffer --
13185            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
13186            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
13187            // and the classifier matches on that one variant for all of them,
13188            // so this needs no `cfg`. Where the platform cannot hold one the
13189            // allocation fails or falls back, and both are reported below.
13190            // Checked before the HAL_TEST_REQUIRE_GL gate below, which covers a declined destination.
13191            let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
13192                TensorDyn::image(
13193                    W,
13194                    H,
13195                    PixelFormat::PlanarRgb,
13196                    DType::F16,
13197                    Some(TensorMemory::DmaBuf),
13198                    edgefirst_tensor::CpuAccess::ReadWrite,
13199                ),
13200                "zero-copy F16 PlanarRgb destination",
13201            ) else {
13202                return;
13203            };
13204            if dst.memory() != TensorMemory::DmaBuf {
13205                crate::test_support::report_skip(&format!(
13206                    "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
13207                     destination fell back to {:?}",
13208                    dst.memory()
13209                ));
13210                return;
13211            }
13212            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
13213            let fallbacks_before = gl_proc.convert_fallback_count();
13214            match gl_proc.convert(&src, &mut dst, None) {
13215                Ok(()) => {
13216                    if gl_proc.convert_fallback_count() != fallbacks_before {
13217                        assert!(
13218                            !require_gl,
13219                            "HAL_TEST_REQUIRE_GL=1 but \
13220                             convert_f16_gl_cpu_parity_identity would have skipped \
13221                             while reporting success: the GL F16 path declined and \
13222                             the CPU served the convert"
13223                        );
13224                        crate::test_support::report_skip(
13225                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
13226                             declined and the CPU served the convert; comparing that \
13227                             against the forced-CPU result would prove nothing",
13228                        );
13229                        return;
13230                    }
13231                    dst
13232                }
13233                Err(e) => {
13234                    crate::test_support::report_skip(&format!(
13235                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
13236                    ));
13237                    return;
13238                }
13239            }
13240        };
13241
13242        // CPU path.
13243        let cpu_result = {
13244            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
13245                backend: ComputeBackend::Cpu,
13246                ..Default::default()
13247            })
13248            .unwrap();
13249            let mut dst = TensorDyn::image(
13250                W,
13251                H,
13252                PixelFormat::PlanarRgb,
13253                DType::F16,
13254                Some(TensorMemory::Mem),
13255                edgefirst_tensor::CpuAccess::ReadWrite,
13256            )
13257            .unwrap();
13258            cpu_proc
13259                .convert(&src, &mut dst, None)
13260                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13261            dst
13262        };
13263
13264        // Compare element-wise. Both sides are read through `copy_to_flat`:
13265        // the GL destination is a packed zero-copy surface whose rows can be
13266        // spaced at a driver pitch, so its mapping is not a flat run of the
13267        // logical elements.
13268        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
13269            let typed = t.as_typed::<half::f16>().expect("F16 image");
13270            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
13271            typed
13272                .copy_to_flat(&mut bytes)
13273                .expect("compact any padded rows");
13274            bytes
13275                .as_chunks::<2>()
13276                .0
13277                .iter()
13278                .map(|&c| half::f16::from_le_bytes(c))
13279                .collect()
13280        };
13281        let gl_halfs = flat_f16(&gl_result);
13282        let cpu_halfs = flat_f16(&cpu_result);
13283
13284        assert_eq!(
13285            gl_halfs.len(),
13286            cpu_halfs.len(),
13287            "GL and CPU output sizes differ"
13288        );
13289
13290        let plane = W * H;
13291        let channel_names = ["R", "G", "B"];
13292        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
13293            let gl_v = gl_h.to_f32();
13294            let cpu_v = cpu_h.to_f32();
13295            let err = (gl_v - cpu_v).abs();
13296            let ch = channel_names[idx / plane];
13297            let pixel = idx % plane;
13298            assert!(
13299                err <= TOL,
13300                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13301            );
13302        }
13303    }
13304
13305    // =========================================================================
13306    // GAP-1: supported_render_dtypes() Linux smoke test
13307    // =========================================================================
13308
13309    /// Exercises the real Linux/Windows GL path that reads
13310    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13311    /// available (CI/host without a GPU).
13312    #[test]
13313    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13314    fn supported_render_dtypes_linux_smoke() {
13315        let proc = match ImageProcessor::new() {
13316            Ok(p) => p,
13317            Err(e) => {
13318                crate::test_support::report_skip(&format!(
13319                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13320                ));
13321                return;
13322            }
13323        };
13324        if proc.opengl.is_none() {
13325            crate::test_support::report_skip(
13326                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13327            );
13328            return;
13329        }
13330        // The call must complete without panicking or deadlocking.
13331        let support = proc.supported_render_dtypes();
13332        eprintln!(
13333            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13334            support.f16, support.f32
13335        );
13336        // No assertion on the specific values — they are hardware-dependent.
13337    }
13338
13339    // =========================================================================
13340    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13341    // =========================================================================
13342
13343    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13344    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13345    /// The auto-chain must transparently fall through to the CPU path, which has
13346    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13347    #[test]
13348    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13349        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13350        const H: usize = 16;
13351
13352        // RGBA8 source filled with a flat mid-grey.
13353        let src = TensorDyn::image(
13354            W,
13355            H,
13356            PixelFormat::Rgba,
13357            DType::U8,
13358            Some(TensorMemory::Mem),
13359            edgefirst_tensor::CpuAccess::ReadWrite,
13360        )
13361        .unwrap();
13362        {
13363            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13364            let data = map.as_mut_slice();
13365            for chunk in data.as_chunks_mut::<4>().0 {
13366                chunk[0] = 128;
13367                chunk[1] = 64;
13368                chunk[2] = 200;
13369                chunk[3] = 255;
13370            }
13371        }
13372
13373        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13374        // to exercise the fallback chain without hardware dependency).
13375        let mut dst = TensorDyn::image(
13376            W,
13377            H,
13378            PixelFormat::PlanarRgb,
13379            DType::F16,
13380            Some(TensorMemory::Mem),
13381            edgefirst_tensor::CpuAccess::ReadWrite,
13382        )
13383        .unwrap();
13384
13385        // Use the default auto-chain so the GL path can attempt and reject,
13386        // then the CPU path succeeds.
13387        let mut proc = ImageProcessor::new().unwrap();
13388        let result = proc.convert(&src, &mut dst, None);
13389        assert!(
13390            result.is_ok(),
13391            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13392            result.err()
13393        );
13394
13395        // All output values must be finite and in [0, 1].
13396        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13397        let halfs = map.as_slice();
13398        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13399        for (i, h) in halfs.iter().enumerate() {
13400            let v = h.to_f32();
13401            assert!(
13402                v.is_finite() && (0.0..=1.0).contains(&v),
13403                "output[{i}]={v} is not finite or not in [0,1]"
13404            );
13405        }
13406    }
13407
13408    // =========================================================================
13409    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13410    // =========================================================================
13411
13412    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13413    ///
13414    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13415    /// which was untested. A wrong intermediate format selection silently
13416    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13417    #[test]
13418    // Linux-only `egl_display` field makes `..Default::default()` needless
13419    // on macOS only; see `convert_f16_forced_cpu_correct`.
13420    #[allow(clippy::needless_update)]
13421    fn convert_nv12_to_rgb_f32_cpu() {
13422        const W: usize = 16;
13423        const H: usize = 16; // must be even for NV12
13424
13425        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13426        let src = TensorDyn::image(
13427            W,
13428            H,
13429            PixelFormat::Nv12,
13430            DType::U8,
13431            Some(TensorMemory::Mem),
13432            edgefirst_tensor::CpuAccess::ReadWrite,
13433        )
13434        .unwrap();
13435        {
13436            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13437            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13438        }
13439
13440        let mut dst = TensorDyn::image(
13441            W,
13442            H,
13443            PixelFormat::Rgb,
13444            DType::F32,
13445            Some(TensorMemory::Mem),
13446            edgefirst_tensor::CpuAccess::ReadWrite,
13447        )
13448        .unwrap();
13449
13450        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13451            backend: ComputeBackend::Cpu,
13452            ..Default::default()
13453        })
13454        .unwrap();
13455
13456        let result = proc.convert(&src, &mut dst, None);
13457        assert!(
13458            result.is_ok(),
13459            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13460            result.err()
13461        );
13462
13463        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13464        let floats = map.as_slice();
13465        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13466        for (i, &v) in floats.iter().enumerate() {
13467            assert!(
13468                v.is_finite() && (0.0..=1.0).contains(&v),
13469                "output[{i}]={v} is not finite or not in [0,1]"
13470            );
13471        }
13472        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13473        let non_zero = floats.iter().any(|&v| v > 0.01);
13474        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13475    }
13476
13477    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13478    ///
13479    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13480    #[test]
13481    // Linux-only `egl_display` field makes `..Default::default()` needless
13482    // on macOS only; see `convert_f16_forced_cpu_correct`.
13483    #[allow(clippy::needless_update)]
13484    fn convert_nv12_to_planar_rgb_f16_cpu() {
13485        const W: usize = 16;
13486        const H: usize = 16;
13487
13488        let src = TensorDyn::image(
13489            W,
13490            H,
13491            PixelFormat::Nv12,
13492            DType::U8,
13493            Some(TensorMemory::Mem),
13494            edgefirst_tensor::CpuAccess::ReadWrite,
13495        )
13496        .unwrap();
13497        {
13498            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13499            map.as_mut_slice().fill(128);
13500        }
13501
13502        let mut dst = TensorDyn::image(
13503            W,
13504            H,
13505            PixelFormat::PlanarRgb,
13506            DType::F16,
13507            Some(TensorMemory::Mem),
13508            edgefirst_tensor::CpuAccess::ReadWrite,
13509        )
13510        .unwrap();
13511
13512        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13513            backend: ComputeBackend::Cpu,
13514            ..Default::default()
13515        })
13516        .unwrap();
13517
13518        let result = proc.convert(&src, &mut dst, None);
13519        assert!(
13520            result.is_ok(),
13521            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13522            result.err()
13523        );
13524
13525        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13526        let halfs = map.as_slice();
13527        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13528        for (i, h) in halfs.iter().enumerate() {
13529            let v = h.to_f32();
13530            assert!(
13531                v.is_finite() && (0.0..=1.0).contains(&v),
13532                "output[{i}]={v} is not finite or not in [0,1]"
13533            );
13534        }
13535        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13536        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13537    }
13538
13539    // =========================================================================
13540    // GAP-5: create_image F32 + DMA must return NotSupported
13541    // =========================================================================
13542
13543    fn access_test_src(w: usize, h: usize) -> TensorDyn {
13544        use edgefirst_tensor::TensorMapTrait;
13545        let t = TensorDyn::image(
13546            w,
13547            h,
13548            PixelFormat::Rgba,
13549            DType::U8,
13550            Some(TensorMemory::Mem),
13551            edgefirst_tensor::CpuAccess::ReadWrite,
13552        )
13553        .unwrap();
13554        for (i, b) in t
13555            .as_u8()
13556            .unwrap()
13557            .map_mut()
13558            .unwrap()
13559            .as_mut_slice()
13560            .iter_mut()
13561            .enumerate()
13562        {
13563            *b = (i % 251) as u8;
13564        }
13565        t
13566    }
13567
13568    /// With only the CPU backend, `create_image` declares `ReadWrite` for the
13569    /// destinations whose CPU convert reads back what it wrote (the `I8`
13570    /// bias, the BGRA swizzle), and the convert into them succeeds.
13571    #[test]
13572    fn create_image_adds_readwrite_for_read_modify_write_passes() {
13573        use edgefirst_tensor::CpuAccess;
13574        let _lock = acquire_env_lock();
13575        let _guard = EnvGuard::snapshot(&[
13576            "EDGEFIRST_FORCE_BACKEND",
13577            "EDGEFIRST_DISABLE_GL",
13578            "EDGEFIRST_DISABLE_G2D",
13579            "EDGEFIRST_DISABLE_CPU",
13580        ]);
13581        unsafe {
13582            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
13583            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
13584            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
13585            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
13586        }
13587        let mut proc = ImageProcessor::new().unwrap();
13588        let src = access_test_src(128, 96);
13589        for (format, dtype) in [
13590            (PixelFormat::Rgb, DType::I8),
13591            (PixelFormat::Rgba, DType::I8),
13592            (PixelFormat::Bgra, DType::U8),
13593        ] {
13594            for requested in [CpuAccess::None, CpuAccess::Write] {
13595                let mut dst = proc
13596                    .create_image(64, 48, format, dtype, None, requested)
13597                    .unwrap();
13598                assert_eq!(
13599                    dst.cpu_access(),
13600                    CpuAccess::ReadWrite,
13601                    "{format:?}/{dtype:?} requested {requested:?}"
13602                );
13603                proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
13604                    panic!("CPU convert into {format:?}/{dtype:?} {requested:?}: {e}")
13605                });
13606            }
13607        }
13608    }
13609
13610    /// The CPU convert refuses up front, asking for `ReadWrite`, a
13611    /// `Write`-only destination it would read back (the `I8` bias, the BGRA
13612    /// swizzle); a BGRA-to-BGRA copy has no swizzle and needs only `Write`.
13613    #[test]
13614    fn cpu_convert_refuses_a_write_only_destination_it_reads_back() {
13615        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13616        let mut cpu = CPUProcessor::new();
13617        let src = access_test_src(32, 24);
13618        for (format, dtype) in [
13619            (PixelFormat::Rgb, DType::I8),
13620            (PixelFormat::Bgra, DType::U8),
13621        ] {
13622            let mut dst = TensorDyn::image(
13623                32,
13624                24,
13625                format,
13626                dtype,
13627                Some(TensorMemory::Mem),
13628                CpuAccess::Write,
13629            )
13630            .unwrap();
13631            match cpu.convert(&src, &mut dst, None) {
13632                Err(Error::Tensor(TensorError::CpuAccessDenied {
13633                    declared: CpuAccess::Write,
13634                    requested: CpuAccess::ReadWrite,
13635                })) => {}
13636                other => panic!("{format:?}/{dtype:?}: {other:?}"),
13637            }
13638        }
13639        let bgra_src = TensorDyn::image(
13640            32,
13641            24,
13642            PixelFormat::Bgra,
13643            DType::U8,
13644            Some(TensorMemory::Mem),
13645            CpuAccess::ReadWrite,
13646        )
13647        .unwrap();
13648        let mut dst = TensorDyn::image(
13649            32,
13650            24,
13651            PixelFormat::Bgra,
13652            DType::U8,
13653            Some(TensorMemory::Mem),
13654            CpuAccess::Write,
13655        )
13656        .unwrap();
13657        cpu.convert(&bgra_src, &mut dst, None).unwrap();
13658    }
13659
13660    /// The prediction `create_image` widens with, per backend mix: the CPU
13661    /// and G2D post-passes when either may run, nothing when the GPU renders
13662    /// the destination, `Write` for a host-memory readback.
13663    #[test]
13664    fn predicted_cpu_access_unions_the_backends_that_may_run() {
13665        use edgefirst_tensor::CpuAccess::{self, None as No, Read, ReadWrite, Write};
13666        let p = predicted_cpu_access;
13667        let takes = |a: CpuAccess| Some(Some(a));
13668        let declines = Some(None);
13669        // A forced backend is the only one that runs.
13670        assert_eq!(
13671            p(
13672                Some(ForcedBackend::G2d),
13673                None,
13674                takes(ReadWrite),
13675                takes(Write)
13676            ),
13677            ReadWrite
13678        );
13679        assert_eq!(
13680            p(Some(ForcedBackend::G2d), None, declines, takes(Write)),
13681            No
13682        );
13683        assert_eq!(
13684            p(
13685                Some(ForcedBackend::OpenGl),
13686                takes(Write),
13687                None,
13688                takes(ReadWrite)
13689            ),
13690            Write
13691        );
13692        assert_eq!(
13693            p(Some(ForcedBackend::Cpu), takes(No), None, takes(Write)),
13694            Write
13695        );
13696        // A GL render with no CPU access ends the chain.
13697        assert_eq!(p(None, takes(No), takes(ReadWrite), takes(ReadWrite)), No);
13698        // A host-memory readback leaves the fallbacks predicted.
13699        assert_eq!(p(None, takes(Write), None, takes(ReadWrite)), ReadWrite);
13700        // G2D may decline at run time, so the CPU stays predicted after it.
13701        assert_eq!(p(None, declines, takes(No), takes(Write)), Write);
13702        assert_eq!(p(None, declines, takes(ReadWrite), None), ReadWrite);
13703        assert_eq!(p(None, None, None, takes(Read)), Read);
13704        assert_eq!(p(None, None, None, None), No);
13705    }
13706
13707    /// The GL prediction follows the float classifier: a float destination
13708    /// GL cannot render (host memory, an unsupported format) is predicted
13709    /// for the CPU fallback.
13710    #[cfg(all(target_os = "linux", feature = "opengl"))]
13711    #[test]
13712    fn float_destinations_gl_declines_are_predicted_for_the_cpu() {
13713        use edgefirst_tensor::CpuAccess;
13714        let Ok(processor) = ImageProcessor::new() else {
13715            eprintln!("WARNING: SKIPPED: float prediction - no image processor");
13716            return;
13717        };
13718        let Some(gl) = processor.opengl.as_ref() else {
13719            eprintln!("WARNING: SKIPPED: float prediction - no OpenGL backend");
13720            return;
13721        };
13722        assert_eq!(
13723            gl.dst_cpu_access(TensorMemory::Mem, PixelFormat::PlanarRgb, DType::F16),
13724            None
13725        );
13726        assert_eq!(
13727            gl.dst_cpu_access(TensorMemory::DmaBuf, PixelFormat::Grey, DType::F32),
13728            None
13729        );
13730        let image = processor
13731            .create_image(
13732                64,
13733                64,
13734                PixelFormat::Rgb,
13735                DType::F32,
13736                Some(TensorMemory::Mem),
13737                CpuAccess::None,
13738            )
13739            .unwrap();
13740        assert!(image.cpu_access().writes());
13741    }
13742
13743    /// When a GPU backend declined and the CPU fallback is refused for
13744    /// access, the error carries the decline; otherwise it is the CPU's.
13745    #[test]
13746    fn a_refused_cpu_fallback_reports_the_gpu_decline() {
13747        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13748        let denied = || {
13749            Error::Tensor(TensorError::CpuAccessDenied {
13750                declared: CpuAccess::None,
13751                requested: CpuAccess::Write,
13752            })
13753        };
13754        let declined = || {
13755            vec![(
13756                "opengl",
13757                Error::NotSupported("unsupported source format".into()),
13758            )]
13759        };
13760        let e = with_gpu_declines(denied(), declined());
13761        assert!(
13762            matches!(
13763                &e,
13764                Error::CpuFallbackDenied {
13765                    denied: TensorError::CpuAccessDenied { .. },
13766                    ..
13767                }
13768            ),
13769            "{e:?}"
13770        );
13771        let msg = e.to_string();
13772        assert!(msg.contains("unsupported source format"), "{msg}");
13773        assert!(msg.contains("CPU access denied"), "{msg}");
13774        assert!(e.cpu_access_denied().is_some());
13775        assert!(matches!(
13776            with_gpu_declines(denied(), Vec::new()),
13777            Error::Tensor(TensorError::CpuAccessDenied { .. })
13778        ));
13779        assert!(matches!(
13780            with_gpu_declines(Error::NoConverter, declined()),
13781            Error::NoConverter
13782        ));
13783    }
13784
13785    /// With only the CPU backend, `create_image` widens a `Read` request to
13786    /// `ReadWrite` and a `None` request to `Write`, so the processor's own
13787    /// convert can fill the image.
13788    #[test]
13789    fn create_image_adds_write_when_the_cpu_backend_fills_it() {
13790        use edgefirst_tensor::CpuAccess;
13791        let _lock = acquire_env_lock();
13792        let _guard = EnvGuard::snapshot(&[
13793            "EDGEFIRST_FORCE_BACKEND",
13794            "EDGEFIRST_DISABLE_GL",
13795            "EDGEFIRST_DISABLE_G2D",
13796            "EDGEFIRST_DISABLE_CPU",
13797        ]);
13798        unsafe {
13799            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
13800            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
13801            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
13802            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
13803        }
13804        let mut proc = ImageProcessor::new().unwrap();
13805        let src = access_test_src(128, 96);
13806        for (requested, declared) in [
13807            (CpuAccess::Read, CpuAccess::ReadWrite),
13808            (CpuAccess::None, CpuAccess::Write),
13809            (CpuAccess::Write, CpuAccess::Write),
13810            (CpuAccess::ReadWrite, CpuAccess::ReadWrite),
13811        ] {
13812            let mut dst = proc
13813                .create_image(64, 48, PixelFormat::Rgb, DType::U8, None, requested)
13814                .unwrap();
13815            assert_eq!(dst.cpu_access(), declared, "requested {requested:?}");
13816            proc.convert(&src, &mut dst, None)
13817                .unwrap_or_else(|e| panic!("CPU convert into a {requested:?} request: {e}"));
13818        }
13819    }
13820
13821    /// A CPU convert refuses up front a caller destination declared without
13822    /// writes, and a source declared without reads, naming both accesses.
13823    #[test]
13824    fn cpu_convert_refuses_tensors_outside_their_declaration() {
13825        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13826        let mut cpu = CPUProcessor::new();
13827        let src = access_test_src(32, 24);
13828        for declared in [CpuAccess::Read, CpuAccess::None] {
13829            let mut dst = TensorDyn::image(
13830                32,
13831                24,
13832                PixelFormat::Rgb,
13833                DType::U8,
13834                Some(TensorMemory::Mem),
13835                declared,
13836            )
13837            .unwrap();
13838            match cpu.convert(&src, &mut dst, None) {
13839                Err(Error::Tensor(TensorError::CpuAccessDenied {
13840                    declared: d,
13841                    requested: CpuAccess::Write,
13842                })) => assert_eq!(d, declared),
13843                other => panic!("convert into a {declared:?} dst: {other:?}"),
13844            }
13845        }
13846        let write_only = TensorDyn::image(
13847            32,
13848            24,
13849            PixelFormat::Rgba,
13850            DType::U8,
13851            Some(TensorMemory::Mem),
13852            CpuAccess::Write,
13853        )
13854        .unwrap();
13855        let mut dst = TensorDyn::image(
13856            32,
13857            24,
13858            PixelFormat::Rgb,
13859            DType::U8,
13860            Some(TensorMemory::Mem),
13861            CpuAccess::ReadWrite,
13862        )
13863        .unwrap();
13864        let err = cpu.convert(&write_only, &mut dst, None).unwrap_err();
13865        assert!(
13866            matches!(
13867                err,
13868                Error::Tensor(TensorError::CpuAccessDenied {
13869                    declared: CpuAccess::Write,
13870                    requested: CpuAccess::Read,
13871                })
13872            ),
13873            "{err:?}"
13874        );
13875        assert!(err
13876            .to_string()
13877            .contains("declare CpuAccess::ReadWrite (the default)"));
13878    }
13879
13880    /// The CPU mask draw blends into its destination, so a `Write`-only
13881    /// destination is refused before anything is drawn.
13882    #[test]
13883    fn cpu_draw_requires_readwrite_on_the_destination() {
13884        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13885        let mut cpu = CPUProcessor::new();
13886        let mut dst = TensorDyn::image(
13887            32,
13888            24,
13889            PixelFormat::Rgba,
13890            DType::U8,
13891            Some(TensorMemory::Mem),
13892            CpuAccess::Write,
13893        )
13894        .unwrap();
13895        let err = cpu
13896            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
13897            .unwrap_err();
13898        assert!(
13899            matches!(
13900                err,
13901                Error::Tensor(TensorError::CpuAccessDenied {
13902                    declared: CpuAccess::Write,
13903                    requested: CpuAccess::ReadWrite,
13904                })
13905            ),
13906            "{err:?}"
13907        );
13908    }
13909
13910    /// `create_image_desc` without a compression request is exactly
13911    /// `create_image` (the processor's memory negotiation applies); with
13912    /// `Compression::Any` off-Android it resolves linear and counts the
13913    /// fallback in the processor-visible mirror.
13914    #[test]
13915    fn create_image_desc_negotiates_and_counts_fallbacks() {
13916        use edgefirst_tensor::{CpuAccess, ImageDesc};
13917        let proc = ImageProcessor::new().unwrap();
13918
13919        let desc =
13920            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
13921        let plain = proc.create_image_desc(&desc).unwrap();
13922        let classic = proc
13923            .create_image(
13924                64,
13925                64,
13926                PixelFormat::Rgba,
13927                DType::U8,
13928                None,
13929                CpuAccess::ReadWrite,
13930            )
13931            .unwrap();
13932        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
13933        assert_eq!(plain.compression(), None);
13934
13935        #[cfg(not(target_os = "android"))]
13936        {
13937            use edgefirst_tensor::Compression;
13938            let before = proc.compression_fallback_count();
13939            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
13940                .with_access(CpuAccess::None)
13941                .with_compression(Compression::Any);
13942            let t = proc.create_image_desc(&desc).unwrap();
13943            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
13944            assert!(
13945                proc.compression_fallback_count() > before,
13946                "Any resolving linear must count"
13947            );
13948        }
13949    }
13950
13951    /// A contiguous request through `create_image_desc` is a CMA DMA-BUF,
13952    /// or an error where there is no CMA heap; never a PBO or host memory.
13953    #[test]
13954    #[cfg(target_os = "linux")]
13955    fn create_image_desc_contiguous_is_cma_dma_or_an_error() {
13956        use edgefirst_tensor::{Contiguity, ImageDesc, TensorMemory};
13957        let proc = ImageProcessor::new().unwrap();
13958        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_contiguous(true);
13959        match proc.create_image_desc(&desc) {
13960            Ok(t) => {
13961                assert_eq!(t.memory(), TensorMemory::DmaBuf);
13962                assert_eq!(t.contiguity(), Contiguity::Contiguous);
13963            }
13964            Err(e) => {
13965                assert!(e.to_string().contains("linux,cma"), "{e}");
13966                crate::test_support::report_skip(&format!(
13967                    "{} - no CMA heap, the contiguous DMA-BUF arm did not run: {e}",
13968                    function!()
13969                ));
13970            }
13971        }
13972    }
13973
13974    /// There is no DRM FourCC for F32 images, so `create_image` with
13975    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
13976    #[test]
13977    #[cfg(target_os = "linux")]
13978    fn create_image_f32_dma_rejected() {
13979        let proc = ImageProcessor::new().unwrap();
13980        let result = proc.create_image(
13981            64,
13982            64,
13983            PixelFormat::Rgb,
13984            DType::F32,
13985            Some(TensorMemory::DmaBuf),
13986            edgefirst_tensor::CpuAccess::ReadWrite,
13987        );
13988        assert!(
13989            result.is_err(),
13990            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
13991        );
13992    }
13993
13994    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
13995    /// the returned `TensorDyn`.
13996    ///
13997    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
13998    /// unavailable we skip the DMA call but still verify the storage contract
13999    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
14000    /// constructed the same way the function body does it — and we confirm via
14001    /// code-read that the new parameter is unconditionally stored.
14002    #[test]
14003    #[cfg(target_os = "linux")]
14004    fn import_image_carries_colorimetry() {
14005        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
14006
14007        let expected = Colorimetry::default()
14008            .with_encoding(ColorEncoding::Bt709)
14009            .with_range(ColorRange::Limited);
14010
14011        if !is_dma_available() {
14012            // DMA unavailable on this host: exercise the storage path via
14013            // TensorDyn directly (mirrors what import_image does internally).
14014            let mut t = TensorDyn::image(
14015                8,
14016                8,
14017                PixelFormat::Rgba,
14018                DType::U8,
14019                Some(TensorMemory::Mem),
14020                edgefirst_tensor::CpuAccess::ReadWrite,
14021            )
14022            .expect("alloc");
14023            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
14024            t.set_colorimetry(Some(expected));
14025            assert_eq!(
14026                t.colorimetry(),
14027                Some(expected),
14028                "set_colorimetry must round-trip"
14029            );
14030            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
14031            return;
14032        }
14033
14034        // DMA is available: allocate a real DMA tensor, extract its fd, and
14035        // call import_image with an explicit Colorimetry.
14036        use edgefirst_tensor::{PlaneDescriptor, Tensor};
14037
14038        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
14039        let dma_tensor = Tensor::<u8>::new(
14040            &[rgba_bytes],
14041            Some(TensorMemory::DmaBuf),
14042            Some("import_test"),
14043        )
14044        .expect("dma alloc");
14045        let pd =
14046            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
14047
14048        let proc = ImageProcessor::new().expect("ImageProcessor");
14049        let result = proc.import_image(
14050            pd,
14051            None,
14052            64,
14053            64,
14054            PixelFormat::Rgba,
14055            DType::U8,
14056            Some(expected),
14057        );
14058        let tensor = result.expect("import_image must succeed on DMA fd");
14059        assert_eq!(
14060            tensor.colorimetry(),
14061            Some(expected),
14062            "import_image must store the supplied colorimetry on the returned TensorDyn"
14063        );
14064    }
14065}