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#[cfg(any(
328    test,
329    all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
330))]
331use edgefirst_tensor::Tensor;
332#[doc(inline)]
333pub use edgefirst_tensor::{
334    fit_code, interpolation_code, letterbox_placement_code, normalization_code, ContentRegion,
335    ContentRegionSource, Fit, FitMode, Interpolation, LetterboxPlacement, Normalization,
336    NormalizationPreset, PadSpace, PreprocessSpec, Region,
337};
338use edgefirst_tensor::{
339    DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
340};
341use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
342use enum_dispatch::enum_dispatch;
343pub use error::{Error, Result};
344#[cfg(target_os = "linux")]
345pub use g2d::G2DProcessor;
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(unix)]
349pub type CompletionFence = std::os::fd::OwnedFd;
350/// Handle returned by `convert_with_fence`: a sync_file fd on Unix, an
351/// event handle on Windows that is set when the GPU work completes.
352#[cfg(windows)]
353pub type CompletionFence = std::os::windows::io::OwnedHandle;
354#[cfg(all(
355    any(
356        target_os = "linux",
357        target_os = "macos",
358        target_os = "ios",
359        target_os = "android",
360        target_os = "windows"
361    ),
362    feature = "opengl"
363))]
364pub use opengl_headless::EglDisplayKind;
365#[cfg(all(
366    any(
367        target_os = "linux",
368        target_os = "macos",
369        target_os = "ios",
370        target_os = "android",
371        target_os = "windows"
372    ),
373    feature = "opengl"
374))]
375pub use opengl_headless::GLProcessorThreaded;
376#[cfg(all(
377    any(
378        target_os = "linux",
379        target_os = "macos",
380        target_os = "ios",
381        target_os = "android",
382        target_os = "windows"
383    ),
384    feature = "opengl"
385))]
386pub use opengl_headless::Int8InterpolationMode;
387#[cfg(target_os = "linux")]
388#[cfg(feature = "opengl")]
389pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
390// EGLImage cache counter snapshots (diagnostics): see
391// `GLProcessorThreaded::egl_cache_stats` and the steady-state import gate in
392// `crates/image/ARCHITECTURE.md § image.convert.gl.egl_import`.
393#[cfg(all(
394    any(
395        target_os = "linux",
396        target_os = "macos",
397        target_os = "ios",
398        target_os = "android",
399        target_os = "windows"
400    ),
401    feature = "opengl"
402))]
403pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
404use std::{fmt::Display, time::Instant};
405
406mod colorimetry;
407mod cpu;
408mod error;
409mod g2d;
410#[path = "gl/mod.rs"]
411mod opengl_headless;
412mod options;
413pub use options::ConvertOptions;
414#[cfg(test)]
415mod test_support;
416mod tiling;
417pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
418
419// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
420// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
421
422// The image option vocabularies are declared through
423// `edgefirst_tensor::ef_vocabulary!`: the discriminant written here is the
424// code every surface uses (`code()`, the `EF_ROTATION_*` / `EF_FLIP_*` /
425// `EF_COLOR_MODE_*` / `EF_FIT_*` C enumerators, and the Python enum values).
426// The C and Python enums assert against `code()` at compile time.
427edgefirst_tensor::ef_vocabulary! {
428    /// A quarter-turn rotation applied during [`ImageProcessorTrait::convert`].
429    #[derive(Default)]
430    pub enum Rotation {
431        #[default]
432        None = 0, "none", NONE,
433        Clockwise90 = 1, "clockwise90", CLOCKWISE90,
434        Rotate180 = 2, "rotate180", ROTATE180,
435        CounterClockwise90 = 3, "counter_clockwise90", COUNTER_CLOCKWISE90,
436    }
437    #[doc(hidden)]
438    pub mod rotation_code;
439}
440
441impl Rotation {
442    /// Creates a Rotation enum from an angle in degrees. The angle must be a
443    /// multiple of 90.
444    ///
445    /// # Panics
446    /// Panics if the angle is not a multiple of 90.
447    ///
448    /// # Examples
449    /// ```rust
450    /// # use edgefirst_image::Rotation;
451    /// let rotation = Rotation::from_degrees_clockwise(270);
452    /// assert_eq!(rotation, Rotation::CounterClockwise90);
453    /// ```
454    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
455        match angle.rem_euclid(360) {
456            0 => Rotation::None,
457            90 => Rotation::Clockwise90,
458            180 => Rotation::Rotate180,
459            270 => Rotation::CounterClockwise90,
460            _ => panic!("rotation angle is not a multiple of 90"),
461        }
462    }
463}
464
465edgefirst_tensor::ef_vocabulary! {
466    /// A mirror applied during [`ImageProcessorTrait::convert`].
467    #[derive(Default)]
468    pub enum Flip {
469        #[default]
470        None = 0, "none", NONE,
471        /// Mirror top to bottom.
472        Vertical = 1, "vertical", VERTICAL,
473        /// Mirror left to right.
474        Horizontal = 2, "horizontal", HORIZONTAL,
475    }
476    #[doc(hidden)]
477    pub mod flip_code;
478}
479
480edgefirst_tensor::ef_vocabulary! {
481    /// Controls how the color palette index is chosen for each detected object.
482    #[derive(Default)]
483    pub enum ColorMode {
484        /// Color is chosen by object class label (`det.label`). Default.
485        ///
486        /// Preserves backward compatibility and is correct for semantic
487        /// segmentation where colors carry class meaning.
488        #[default]
489        Class = 0, "class", CLASS,
490        /// Color is chosen by instance order (loop index, zero-based).
491        ///
492        /// Each detected object gets a unique color regardless of class,
493        /// useful for instance segmentation.
494        Instance = 1, "instance", INSTANCE,
495        /// Color is chosen by track ID (future use; currently behaves like
496        /// [`Instance`](Self::Instance)).
497        Track = 2, "track", TRACK,
498    }
499    #[doc(hidden)]
500    pub mod color_mode_code;
501}
502
503impl ColorMode {
504    /// Return the palette index for a detection given its loop index and label.
505    #[inline]
506    pub fn index(self, idx: usize, label: usize) -> usize {
507        match self {
508            ColorMode::Class => label,
509            ColorMode::Instance | ColorMode::Track => idx,
510        }
511    }
512}
513
514/// Controls the resolution and coordinate frame of masks produced by
515/// [`ImageProcessor::materialize_masks`].
516///
517/// - [`Proto`](Self::Proto) returns per-detection tiles at proto-plane
518///   resolution (e.g. 48×32 u8 for a typical COCO bbox on a 160×160 proto
519///   plane). This is the historical behavior of `materialize_masks` and the
520///   fastest path because no upsample runs inside HAL. Mask values are
521///   continuous sigmoid output quantized to `uint8 [0, 255]`.
522/// - [`Scaled`](Self::Scaled) returns per-detection tiles at caller-specified
523///   pixel resolution by upsampling the full proto plane once and cropping by
524///   bbox after sigmoid. The upsample uses bilinear interpolation with
525///   edge-clamp sampling — semantically equivalent to Ultralytics'
526///   `process_masks_retina` reference. When a `content_region` is also passed
527///   to [`materialize_masks`], the inverse letterbox transform is applied during
528///   the upsample so mask pixels land in original-content coordinates
529///   (drop-in for overlay on the original image). Mask values are binary
530///   `uint8 {0, 255}` after thresholding sigmoid > 0.5 — interchangeable
531///   with `Proto` output via the same `> 127` test.
532///
533/// [`materialize_masks`]: ImageProcessor::materialize_masks
534#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
535pub enum MaskResolution {
536    /// Per-detection tile at proto-plane resolution (default).
537    #[default]
538    Proto,
539    /// Per-detection tile at `(width, height)` pixel resolution in the
540    /// coordinate frame determined by the `content_region` parameter of
541    /// [`ImageProcessor::materialize_masks`].
542    Scaled {
543        /// Target pixel width of the output coordinate frame.
544        width: u32,
545        /// Target pixel height of the output coordinate frame.
546        height: u32,
547    },
548}
549
550/// Options for mask overlay rendering.
551///
552/// Controls how segmentation masks are composited onto the destination image:
553/// - `background`: when set, the background image is drawn first and masks
554///   are composited over it (result written to `dst`). When `None`, `dst` is
555///   cleared to `0x00000000` (fully transparent) before masks are drawn.
556///   **`dst` is always fully overwritten — its prior contents are never
557///   preserved.** Callers who used to pre-load an image into `dst` before
558///   calling `draw_decoded_masks` / `draw_proto_masks` must now supply that
559///   image via `background` instead (behaviour changed in v0.16.4).
560/// - `opacity`: scales the alpha of rendered mask and box colors. `1.0`
561///   (default) preserves the class color's alpha unchanged; `0.5` makes
562///   masks and boxes twice as transparent.
563/// - `color_mode`: controls whether colors are assigned by class label,
564///   instance index, or track ID. Defaults to [`ColorMode::Class`].
565#[derive(Debug, Clone, Copy)]
566pub struct MaskOverlay<'a> {
567    /// Compositing source image. Must have the same dimensions and pixel
568    /// format as `dst`. When `Some`, the output is `background + masks`.
569    /// When `None`, `dst` is cleared to `0x00000000` before masks are drawn.
570    pub background: Option<&'a TensorDyn>,
571    pub opacity: f32,
572    /// The image content within the model input, as normalized
573    /// `[xmin, ymin, xmax, ymax]` ([`ContentRegion::normalized`]); the rest is
574    /// letterbox padding.
575    ///
576    /// When set, bounding boxes and mask coordinates from the decoder (which
577    /// are in model-input normalized space) are mapped back to the original
578    /// image coordinate space before rendering.
579    ///
580    /// Use [`with_convert_options`](Self::with_convert_options) to compute this
581    /// from the options of the model input [`convert`](crate::ImageProcessorTrait::convert) call.
582    pub content_region: Option<[f32; 4]>,
583    pub color_mode: ColorMode,
584}
585
586impl Default for MaskOverlay<'_> {
587    fn default() -> Self {
588        Self {
589            background: None,
590            opacity: 1.0,
591            content_region: None,
592            color_mode: ColorMode::Class,
593        }
594    }
595}
596
597impl<'a> MaskOverlay<'a> {
598    pub fn new() -> Self {
599        Self::default()
600    }
601
602    /// Set the compositing source image.
603    ///
604    /// `bg` must have the same dimensions and pixel format as the `dst` passed
605    /// to [`draw_decoded_masks`](crate::ImageProcessorTrait::draw_decoded_masks) /
606    /// [`draw_proto_masks`](crate::ImageProcessorTrait::draw_proto_masks).
607    /// The output will be `bg + masks`. Without a background, `dst` is cleared
608    /// to `0x00000000`.
609    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
610        self.background = Some(bg);
611        self
612    }
613
614    pub fn with_opacity(mut self, opacity: f32) -> Self {
615        self.opacity = opacity.clamp(0.0, 1.0);
616        self
617    }
618
619    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
620        self.color_mode = mode;
621        self
622    }
623
624    /// Set the content region from the [`ConvertOptions`] used when
625    /// preparing the model input, so that bounding boxes and masks are
626    /// mapped back to the original image when rendering.
627    ///
628    /// Pass the same options that were given to
629    /// [`convert`](crate::ImageProcessorTrait::convert) along with the source
630    /// and model input dimensions. Has no effect without a letterbox fit, or
631    /// when the geometry does not resolve.
632    pub fn with_convert_options(
633        mut self,
634        options: &ConvertOptions,
635        src_w: usize,
636        src_h: usize,
637        model_w: usize,
638        model_h: usize,
639    ) -> Self {
640        if let Ok(region) = options.content_region(src_w, src_h, model_w, model_h) {
641            self.content_region = region.letterboxed();
642        }
643        self
644    }
645}
646
647/// Copy a [`Segmentation`], replacing its bounds with `bbox`.
648///
649/// `TensorDyn` is deliberately not `Clone` (it may own an fd), so the mask
650/// bytes are copied explicitly. This is only reached on the letterbox path
651/// where the bounds must be rewritten to match an un-letterboxed detect box.
652fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
653    use edgefirst_tensor::TensorMapTrait as _;
654    let src = s
655        .segmentation
656        .as_typed::<u8>()
657        .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
658    let map = src.map_read()?;
659    // `Some(Mem)`, NOT `None`. `None` means auto-select, which prefers DMA on
660    // Linux and IOSurface on macOS -- so this would take a dma_heap ioctl and
661    // hold an fd PER DETECTION PER FRAME for a mask of a few hundred bytes,
662    // and turn every downstream map_read() in the render loop into a
663    // DMA_BUF_IOCTL_SYNC instead of a no-op. A mask copy is CPU-only scratch.
664    // `mask_to_tensor` reaches Mem the same way, via from_arrayview3.
665    let dst = edgefirst_tensor::Tensor::<u8>::new(
666        s.segmentation.shape(),
667        Some(edgefirst_tensor::TensorMemory::Mem),
668        None,
669    )
670    .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
671    {
672        let mut dmap = dst.map_mut()?;
673        dmap.as_mut_slice().copy_from_slice(map.as_slice());
674    }
675    Ok(Segmentation {
676        xmin: bbox[0],
677        ymin: bbox[1],
678        xmax: bbox[2],
679        ymax: bbox[3],
680        segmentation: dst.into(),
681    })
682}
683
684/// Apply the inverse letterbox transform to a bounding box.
685///
686/// `lb` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model input
687/// that contains actual image content ([`MaskOverlay::content_region`]). Converts model-input-normalized
688/// coords to output-image-normalized coords, clamped to `[0.0, 1.0]`, and
689/// canonicalises the bbox (ensures xmin <= xmax).
690///
691/// Thin wrapper over [`edgefirst_tensor::unletter_norm`]: the single home for
692/// the inverse-letterbox math sits beside the type it transforms, so this mask
693/// path and `edgefirst_decoder::tiling`'s detection lift share one
694/// implementation.
695#[inline]
696fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
697    DetectBox {
698        bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
699        ..bbox
700    }
701}
702
703/// Source-side convert geometry: which sub-rectangle of the source to sample
704/// (`source`) and how to fit it into the destination (`fit`). Destination
705/// *placement* is the destination itself — a tensor, or a [`Region`] view /
706/// `batch` tile of one — not a field here.
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
708pub struct Crop {
709    /// Sub-rectangle of the source to sample. `None` samples the whole source.
710    pub source: Option<Region>,
711    /// How the source is fit into the destination shape.
712    pub fit: Fit,
713}
714
715impl Crop {
716    /// A no-op crop: whole source, stretched to fill the whole destination.
717    pub fn new() -> Self {
718        Self::default()
719    }
720
721    /// Alias for [`Crop::new`] — whole source, stretch to fill.
722    pub fn no_crop() -> Self {
723        Self::default()
724    }
725
726    /// Letterbox fit: preserve the source aspect ratio, padding the remainder
727    /// with `pad` (RGBA).
728    pub fn letterbox(pad: [u8; 4]) -> Self {
729        Self {
730            source: None,
731            fit: Fit::Letterbox { pad },
732        }
733    }
734
735    /// Sample only `source` of the input (builder).
736    pub fn with_source(mut self, source: Option<Region>) -> Self {
737        self.source = source;
738        self
739    }
740
741    /// Set the fit mode (builder).
742    pub fn with_fit(mut self, fit: Fit) -> Self {
743        self.fit = fit;
744        self
745    }
746
747    /// Resolve to the effective backend geometry for a `src_w`×`src_h` source
748    /// and `dst_w`×`dst_h` destination: the source sampling rect, the
749    /// destination placement rect (`None` = whole destination), and the pad
750    /// colour. **The single place letterbox placement is computed** — every
751    /// backend consumes the resolved rects rather than re-deriving them.
752    pub(crate) fn resolve(
753        &self,
754        src_w: usize,
755        src_h: usize,
756        dst_w: usize,
757        dst_h: usize,
758        placement: LetterboxPlacement,
759    ) -> Result<ResolvedCrop, Error> {
760        let src_rect = self.source.map(region_to_rect);
761        // The letterbox aspect uses the *effective* source content — the source
762        // crop when set, else the full source.
763        let (sw, sh) = match self.source {
764            Some(r) => (r.width, r.height),
765            None => (src_w, src_h),
766        };
767        let resolved = match self.fit {
768            Fit::Stretch => ResolvedCrop {
769                src_rect,
770                dst_rect: None,
771                dst_color: None,
772            },
773            Fit::Letterbox { pad } => ResolvedCrop {
774                src_rect,
775                dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h, placement)),
776                dst_color: Some(pad),
777            },
778        };
779        resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
780        Ok(resolved)
781    }
782
783    /// Validate against `TensorDyn` source and destination dimensions.
784    pub fn check_crop_dyn(
785        &self,
786        src: &edgefirst_tensor::TensorDyn,
787        dst: &edgefirst_tensor::TensorDyn,
788    ) -> Result<(), Error> {
789        self.resolve(
790            src.width().unwrap_or(0),
791            src.height().unwrap_or(0),
792            dst.width().unwrap_or(0),
793            dst.height().unwrap_or(0),
794            LetterboxPlacement::Center,
795        )
796        .map(|_| ())
797    }
798}
799
800/// Resolved crop geometry consumed by the backends. Produced by
801/// [`Crop::resolve`]; the backends read these fields directly (the same shape
802/// the public `Crop` carried before destination placement moved to the view).
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
804pub(crate) struct ResolvedCrop {
805    pub(crate) src_rect: Option<Rect>,
806    pub(crate) dst_rect: Option<Rect>,
807    pub(crate) dst_color: Option<[u8; 4]>,
808}
809
810impl ResolvedCrop {
811    /// A no-op resolved crop (whole source → whole destination, no pad).
812    #[allow(dead_code)] // used by unit tests and the batch render paths
813    pub(crate) fn no_crop() -> Self {
814        Self::default()
815    }
816
817    /// Whether a convert from a `src`-sized source to a `dst`-sized
818    /// destination with this crop changes the size of the sampled region. A
819    /// quarter-turn rotation swaps the source region's axes.
820    #[allow(dead_code)] // unused when no resampling backend is compiled in
821    pub(crate) fn resizes(
822        &self,
823        (src_w, src_h): (usize, usize),
824        (dst_w, dst_h): (usize, usize),
825        rotation: Rotation,
826    ) -> bool {
827        let (sw, sh) = self
828            .src_rect
829            .map_or((src_w, src_h), |r| (r.width, r.height));
830        let (dw, dh) = self
831            .dst_rect
832            .map_or((dst_w, dst_h), |r| (r.width, r.height));
833        let (sw, sh) = match rotation {
834            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (sh, sw),
835            Rotation::None | Rotation::Rotate180 => (sw, sh),
836        };
837        (sw, sh) != (dw, dh)
838    }
839
840    /// Validate the resolved rects against explicit dimensions.
841    pub(crate) fn check_crop_dims(
842        &self,
843        src_w: usize,
844        src_h: usize,
845        dst_w: usize,
846        dst_h: usize,
847    ) -> Result<(), Error> {
848        let src_ok = self
849            .src_rect
850            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
851        let dst_ok = self
852            .dst_rect
853            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
854        match (src_ok, dst_ok) {
855            (true, true) => Ok(()),
856            (true, false) => Err(Error::CropInvalid(format!(
857                "Dest crop invalid: {:?}",
858                self.dst_rect
859            ))),
860            (false, true) => Err(Error::CropInvalid(format!(
861                "Src crop invalid: {:?}",
862                self.src_rect
863            ))),
864            (false, false) => Err(Error::CropInvalid(format!(
865                "Dest and Src crop invalid: {:?} {:?}",
866                self.dst_rect, self.src_rect
867            ))),
868        }
869    }
870}
871
872/// Convert a pixel [`Region`] to the internal [`Rect`] placement type.
873fn region_to_rect(r: Region) -> Rect {
874    Rect {
875        left: r.x,
876        top: r.y,
877        width: r.width,
878        height: r.height,
879    }
880}
881
882/// [`edgefirst_tensor::letterbox_rect`] as the backends' [`Rect`].
883fn letterbox_rect(
884    sw: usize,
885    sh: usize,
886    dw: usize,
887    dh: usize,
888    placement: LetterboxPlacement,
889) -> Rect {
890    let r = edgefirst_tensor::letterbox_rect(sw, sh, dw, dh, placement);
891    Rect::new(r.x, r.y, r.width, r.height)
892}
893
894/// Internal placement rectangle (top-left + size). The **public** pixel
895/// sub-region type is [`Region`] (re-exported from `edgefirst-tensor`);
896/// `Rect` is the crate-private resolved-placement representation used by the
897/// CPU/G2D/GL backends after [`Crop::resolve`].
898#[derive(Debug, Clone, Copy, PartialEq, Eq)]
899pub(crate) struct Rect {
900    pub left: usize,
901    pub top: usize,
902    pub width: usize,
903    pub height: usize,
904}
905
906impl Rect {
907    // Creates a new Rect with the specified left, top, width, and height.
908    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
909        Self {
910            left,
911            top,
912            width,
913            height,
914        }
915    }
916}
917
918#[enum_dispatch(ImageProcessor)]
919pub trait ImageProcessorTrait {
920    /// Convert `src` into `dst`'s format and size, applying the crop first,
921    /// then the flip, then the rotation.
922    ///
923    /// The destination is normally an RGB-family colour — `Grey`, `Rgb`,
924    /// `Rgba`/`Bgra`, packed `HWC` or planar `CHW`. That is what the
925    /// accelerated paths target and what models expect. The CPU backend also
926    /// accepts a handful of YUV destinations (`Yuyv`→`Yuyv`, `Yuyv`→`Nv16`,
927    /// and the `Vyuy` equivalents); no GPU or G2D path does, so those pairs
928    /// always land on the CPU.
929    ///
930    /// # Arguments
931    ///
932    /// * `src` - The source image to convert from.
933    /// * `dst` - The destination image. Its shape and [`PixelFormat`] define
934    ///   the output; pass a [`view`](edgefirst_tensor::TensorDyn::view) or
935    ///   [`batch`](edgefirst_tensor::TensorDyn::batch) of a larger tensor to
936    ///   render into one tile of a batch.
937    /// * `options` - Rotation, flip, source crop, fit (stretch or letterbox),
938    ///   normalization and pad ([`ConvertOptions`]); `None` is the default
939    ///   convert. Destination placement lives on `dst`, not here.
940    ///
941    /// # Errors
942    ///
943    /// - [`Error::CropInvalid`] if the source crop falls outside `src`.
944    /// - [`Error::NotAnImage`] if either tensor carries no `PixelFormat`.
945    /// - [`Error::InvalidOptions`] if the options cannot apply to `dst`.
946    /// - [`Error::NoConverter`] if no backend accepts the format pair — the
947    ///   dispatch tries OpenGL, then G2D, then CPU, so this means all three
948    ///   declined.
949    /// - [`Error::Tensor`] with [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied)
950    ///   if a CPU pass needs access a tensor's declaration does not cover
951    ///   (source `Read`; destination `Write`, or `ReadWrite` for a view or a
952    ///   read-back pass).
953    /// - [`Error::CpuFallbackDenied`] if GL or G2D declined the convert and
954    ///   the CPU fallback was refused for access. [`Error::cpu_access_denied`]
955    ///   finds the refusal in either form.
956    ///
957    /// Backend-specific failures (EGL import, G2D blit, allocation) surface as
958    /// their own variants. A backend *declining* a pair is not an error while
959    /// another remains in the chain; it silently costs the zero-copy path, and
960    /// [`ImageProcessor::convert_fallback_count`] counts those.
961    fn convert(
962        &mut self,
963        src: &TensorDyn,
964        dst: &mut TensorDyn,
965        options: Option<ConvertOptions>,
966    ) -> Result<()>;
967
968    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
969    ///
970    /// Supports two segmentation modes based on the mask channel count:
971    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
972    ///   `segmentation` and `detect` are zipped.
973    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
974    ///   all classes; only the first element is used.
975    ///
976    /// # Format requirements
977    ///
978    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
979    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
980    /// - G2D backend: only produces the base frame (empty detections);
981    ///   returns `NotImplemented` when any detection or segmentation is
982    ///   supplied.
983    ///
984    /// # Output contract
985    ///
986    /// This function always fully writes `dst` — it never relies on the
987    /// caller having pre-cleared the destination. The four cases are:
988    ///
989    /// | detections | background | output                              |
990    /// |------------|------------|-------------------------------------|
991    /// | none       | none       | dst cleared to `0x00000000`         |
992    /// | none       | set        | dst ← background                    |
993    /// | set        | none       | masks drawn over cleared dst        |
994    /// | set        | set        | masks drawn over background         |
995    ///
996    /// Each backend implements this with its native primitives: G2D uses
997    /// `g2d_clear` / `g2d_blit`, OpenGL uses `glClear` / DMA-BUF GPU blit
998    /// plus the mask program, and CPU uses direct buffer fill / memcpy as
999    /// the terminal fallback. CPU-memcpy of DMA buffers is avoided on the
1000    /// accelerated paths.
1001    ///
1002    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
1003    ///
1004    /// `overlay` controls compositing: `background` is the compositing source
1005    /// (must match `dst` in size and format); `opacity` scales mask and box
1006    /// alpha. Box outlines are [`set_box_line_width`](Self::set_box_line_width)
1007    /// pixels thick and use the [`set_class_colors`](Self::set_class_colors)
1008    /// palette, alpha included.
1009    ///
1010    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
1011    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
1012    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
1013    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
1014    /// `Bgra` destination with no background is unaffected.
1015    ///
1016    /// # Buffer aliasing
1017    ///
1018    /// `dst` and `overlay.background` must reference **distinct underlying
1019    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
1020    /// dispatching to any backend — the GL path would otherwise read and
1021    /// write the same texture in a single draw, which is undefined behaviour
1022    /// on most drivers. Aliasing is detected via
1023    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
1024    /// catches both shared-allocation clones and separate imports over the
1025    /// same dmabuf fd.
1026    ///
1027    /// # Migration from v0.16.3 and earlier
1028    ///
1029    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1030    /// detections. That invariant no longer holds — `dst` is always fully
1031    /// written. Callers who pre-loaded an image into `dst` before calling this
1032    /// function must now pass that image via `overlay.background` instead.
1033    fn draw_decoded_masks(
1034        &mut self,
1035        dst: &mut TensorDyn,
1036        detect: &[DetectBox],
1037        segmentation: &[Segmentation],
1038        overlay: MaskOverlay<'_>,
1039    ) -> Result<()>;
1040
1041    /// Draw masks from proto data onto image (fused decode+draw).
1042    ///
1043    /// For YOLO segmentation models, this avoids materializing intermediate
1044    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1045    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1046    /// at the output resolution using bilinear sampling.
1047    ///
1048    /// `detect` and `proto_data.mask_coefficients` must have the same length
1049    /// (enforced by zip — excess entries are silently ignored). An empty
1050    /// `detect` slice is valid and produces the base frame — cleared or
1051    /// background-blitted — via the selected backend's native primitive.
1052    ///
1053    /// # Format requirements and output contract
1054    ///
1055    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1056    /// the "always fully writes dst" guarantee across all four
1057    /// detection/background combinations.
1058    ///
1059    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1060    fn draw_proto_masks(
1061        &mut self,
1062        dst: &mut TensorDyn,
1063        detect: &[DetectBox],
1064        proto_data: &ProtoData,
1065        overlay: MaskOverlay<'_>,
1066    ) -> Result<()>;
1067
1068    /// Sets the RGBA colors used for rendering segmentation masks and
1069    /// bounding boxes. Up to 20 colors can be set; entries past the slice
1070    /// keep their previous value.
1071    ///
1072    /// The alpha channel is the coverage of both masks and box outlines:
1073    /// each is blended over the destination with `alpha / 255` scaled by
1074    /// [`MaskOverlay::opacity`], so `255` draws opaque boxes and lower
1075    /// values draw translucent ones. The destination's own alpha channel is
1076    /// left as the base layer set it. The default palette's alpha is `0.7`.
1077    ///
1078    /// On [`ImageProcessor`] with no forced backend this reaches every
1079    /// available backend rather than the first that accepts, because a
1080    /// later draw may land on any of them. A forced backend receives the
1081    /// colors alone.
1082    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1083
1084    /// Sets the thickness, in destination pixels, of the bounding-box
1085    /// outlines drawn by [`draw_decoded_masks`](Self::draw_decoded_masks)
1086    /// and [`draw_proto_masks`](Self::draw_proto_masks). The default is
1087    /// [`DEFAULT_BOX_LINE_WIDTH`] (3 px).
1088    ///
1089    /// The outline is drawn outside the box edges, so the box interior is
1090    /// never covered. It is clipped at the image border.
1091    ///
1092    /// Reaches every available backend, as
1093    /// [`set_class_colors`](Self::set_class_colors) does.
1094    ///
1095    /// # Errors
1096    ///
1097    /// [`Error::InvalidOptions`] when `width` is 0, and
1098    /// [`Error::NotImplemented`] from a backend that does not draw (G2D).
1099    fn set_box_line_width(&mut self, width: u32) -> Result<()>;
1100
1101    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1102    ///
1103    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1104    /// into one batched destination by looping
1105    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1106    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1107    /// convert into sibling views of one buffer shares a single EGLImage import
1108    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1109    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1110    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1111    /// until `flush` returns.
1112    ///
1113    /// The default implementation is eager — it simply calls
1114    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1115    /// fast path remain correct (each call completes synchronously and `flush`
1116    /// is a no-op).
1117    fn convert_deferred(
1118        &mut self,
1119        src: &TensorDyn,
1120        dst: &mut TensorDyn,
1121        options: Option<ConvertOptions>,
1122    ) -> Result<()> {
1123        self.convert(src, dst, options)
1124    }
1125
1126    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1127    /// since the last flush, issuing a single GPU synchronization.
1128    ///
1129    /// After this returns, every deferred destination is finished and safe to
1130    /// read back or `cuda_map`. Backends with no deferred path (the default)
1131    /// return `Ok(())` immediately, since their converts already completed.
1132    fn flush(&mut self) -> Result<()> {
1133        Ok(())
1134    }
1135}
1136
1137/// Configuration for [`ImageProcessor`] construction.
1138///
1139/// Use with [`ImageProcessor::with_config`] to override the default EGL
1140/// display auto-detection and backend selection. The default configuration
1141/// preserves the existing auto-detection behaviour.
1142#[derive(Debug, Clone, Default)]
1143pub struct ImageProcessorConfig {
1144    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1145    ///
1146    /// When `None`, the processor probes displays in priority order: GBM,
1147    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1148    /// which displays are available on the current system.
1149    ///
1150    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1151    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1152    /// respectively; a `Some` value logs a debug note and is otherwise
1153    /// ignored).
1154    #[cfg(all(
1155        any(
1156            target_os = "linux",
1157            target_os = "macos",
1158            target_os = "ios",
1159            target_os = "android",
1160            target_os = "windows"
1161        ),
1162        feature = "opengl"
1163    ))]
1164    pub egl_display: Option<EglDisplayKind>,
1165
1166    /// Preferred compute backend.
1167    ///
1168    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1169    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1170    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1171    /// `EDGEFIRST_DISABLE_*` environment variables.
1172    ///
1173    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1174    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1175    /// - [`ComputeBackend::Cpu`]: init CPU only
1176    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1177    pub backend: ComputeBackend,
1178
1179    /// Colorimetry/performance trade-off for `convert()` (see
1180    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1181    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1182    /// overrides this setting when present.
1183    pub colorimetry: ColorimetryMode,
1184
1185    /// How `convert()` resamples when the source (or source crop) and the
1186    /// destination differ in size (see [`Interpolation`]), for converts whose
1187    /// options do not choose their own ([`ConvertOptions::interpolation`]).
1188    /// Defaults to [`Interpolation::Bilinear`]. The `EDGEFIRST_INTERPOLATION`
1189    /// environment variable (`nearest` | `bilinear`), read when the processor
1190    /// is created, overrides this setting and every per-convert choice.
1191    pub interpolation: Interpolation,
1192
1193    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1194    ///
1195    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1196    /// 16 otherwise. Precedence is:
1197    ///
1198    /// ```text
1199    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1200    /// ```
1201    ///
1202    /// Config beats the environment deliberately, matching
1203    /// [`backend`](Self::backend): an embedder that has measured its own
1204    /// pool must not be silently overridden by a stray environment variable
1205    /// in the deployment. `Some(0)` is rejected and falls through to the
1206    /// environment, then the default, exactly as an unparseable env value
1207    /// does.
1208    ///
1209    /// # This is a buffer *count*, not a size
1210    ///
1211    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1212    /// cannot be freed while an entry names it (that is what makes the cache
1213    /// correct as well as fast; see the "EGL image cache inside HAL" section
1214    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1215    /// how big the caller's buffers are:
1216    ///
1217    /// ```text
1218    /// worst-case pinned bytes                                (upper bound)
1219    ///     = capacity x 3 caches x frame size x live ImageProcessors
1220    ///
1221    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1222    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1223    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1224    /// ```
1225    ///
1226    /// A ceiling rather than an exact total: the source and NV R8 caches
1227    /// often hold two imports of the *same* buffer, and two entries naming
1228    /// one buffer pin it once. The per-cache figure is the exact one.
1229    ///
1230    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1231    /// destination, and the NV R8 source cache) — not per process and not
1232    /// per library. Two `ImageProcessor`s in one process have six
1233    /// independent caches and six independent capacities. A fourth cache,
1234    /// the chroma-plane views of hardware-filtered NV resizes, has the same
1235    /// capacity but imports only buffers the NV R8 cache also holds, so it
1236    /// adds entries without pinning more memory.
1237    ///
1238    /// Whether the worst case is ever approached is up to the caller. A
1239    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1240    /// sizes — settles at its pool size and never nears the bound. A stream
1241    /// that allocates a fresh buffer per frame holds the last `capacity` of
1242    /// them outright, which is the difference between "16 is free" and "16
1243    /// is 597 MB".
1244    ///
1245    /// Size it from measurement rather than guesswork:
1246    /// [`CacheStats::peak_entries`] reports the working set actually
1247    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1248    /// costing you re-imports (it should be zero in a steady state).
1249    ///
1250    /// Ignored when the OpenGL backend is not in use.
1251    pub egl_cache_capacity: Option<usize>,
1252}
1253
1254/// How `convert()` trades colorimetric exactness against speed on platforms
1255/// where the exact path is expensive.
1256///
1257/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1258/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1259/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1260/// but applies the driver's fixed BT.601-limited matrix regardless of the
1261/// source's tagged colorimetry. Platforms where the exact path is already
1262/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1263/// both modes.
1264///
1265/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1266/// precedence over the config field), or per-source by forcing a path with
1267/// `EDGEFIRST_NV_CONVERT_PATH`.
1268#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1269pub enum ColorimetryMode {
1270    /// Prefer the fastest path whose output is correct-enough video RGB
1271    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1272    /// sampler even when the source is not BT.601-limited.
1273    #[default]
1274    Fast,
1275    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1276    /// when it matches the source's resolved (encoding, range) exactly.
1277    Exact,
1278}
1279
1280/// `mode` if the backends run it, else [`Interpolation::supported`] with a
1281/// warning logged once per mode: bicubic and area describe reference
1282/// pipelines this crate does not implement.
1283pub(crate) fn runnable_interpolation(mode: Interpolation) -> Interpolation {
1284    let run = mode.supported();
1285    if run != mode && warn_first_fallback(mode) {
1286        log::warn!(
1287            "{} interpolation is not implemented; using {}",
1288            mode.as_str(),
1289            run.as_str()
1290        );
1291    }
1292    run
1293}
1294
1295/// Unsupported interpolations already warned about, one bit per code.
1296static INTERPOLATION_FALLBACKS_WARNED: std::sync::atomic::AtomicU32 =
1297    std::sync::atomic::AtomicU32::new(0);
1298
1299/// Whether this is the first fallback from `mode` in the process.
1300fn warn_first_fallback(mode: Interpolation) -> bool {
1301    let bit = 1u32 << (mode.code() % 32);
1302    INTERPOLATION_FALLBACKS_WARNED.fetch_or(bit, std::sync::atomic::Ordering::Relaxed) & bit == 0
1303}
1304
1305/// The interpolation selected by `EDGEFIRST_INTERPOLATION`, if it is set to
1306/// a recognised value. An unrecognised value is logged and ignored.
1307pub(crate) fn interpolation_from_env() -> Option<Interpolation> {
1308    let value = std::env::var("EDGEFIRST_INTERPOLATION").ok()?;
1309    let value = value.trim().to_ascii_lowercase();
1310    if value.is_empty() {
1311        return None;
1312    }
1313    let mode = Interpolation::from_str_code(&value);
1314    if mode.is_none() {
1315        log::warn!(
1316            "EDGEFIRST_INTERPOLATION={value:?} is not recognised \
1317             (expected nearest or bilinear); ignoring it"
1318        );
1319    }
1320    mode
1321}
1322
1323edgefirst_tensor::ef_vocabulary! {
1324    /// Compute backend selection for [`ImageProcessor`].
1325    ///
1326    /// Use with [`ImageProcessorConfig::backend`] to select which backend the
1327    /// processor should prefer. When a specific backend is selected, the
1328    /// processor initializes that backend plus CPU as a fallback. When `Auto`
1329    /// is used, the existing environment-variable-driven selection applies.
1330    ///
1331    /// The code is the one the C `EF_COMPUTE_BACKEND_*` enumerators use.
1332    #[derive(Default)]
1333    pub enum ComputeBackend {
1334        /// Auto-detect based on available hardware and environment variables.
1335        #[default]
1336        Auto = 0, "auto", AUTO,
1337        /// CPU-only processing (no hardware acceleration).
1338        Cpu = 1, "cpu", CPU,
1339        /// Prefer G2D hardware blitter (+ CPU fallback).
1340        G2d = 2, "g2d", G2D,
1341        /// Prefer OpenGL ES (+ CPU fallback).
1342        OpenGl = 3, "opengl", OPENGL,
1343    }
1344    #[doc(hidden)]
1345    pub mod compute_backend_code;
1346}
1347
1348/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1349/// or [`ImageProcessorConfig::backend`].
1350///
1351/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1352/// selected backend — no fallback chain is used.
1353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1354pub(crate) enum ForcedBackend {
1355    Cpu,
1356    G2d,
1357    OpenGl,
1358}
1359
1360/// The CPU fallback's error, carrying the GPU decline reasons when the CPU
1361/// was refused for access: the decline is what the caller has to fix.
1362/// `declined` pairs each declining backend's name with its error, formatted
1363/// only here so a frame the fallback serves pays for no message.
1364fn with_gpu_declines(e: Error, declined: Vec<(&'static str, Error)>) -> Error {
1365    match e {
1366        Error::Tensor(denied @ edgefirst_tensor::Error::CpuAccessDenied { .. })
1367            if !declined.is_empty() =>
1368        {
1369            let declined: Vec<String> = declined
1370                .iter()
1371                .map(|(backend, e)| format!("{backend}: {e}"))
1372                .collect();
1373            Error::CpuFallbackDenied {
1374                declined: declined.join("; "),
1375                denied,
1376            }
1377        }
1378        other => other,
1379    }
1380}
1381
1382/// The CPU access an [`ImageProcessor`]'s own convert may need on a
1383/// destination, from what each present backend needs when it takes it
1384/// (`Some`) or declines it (`None`).
1385///
1386/// A forced backend is the only one that runs. Otherwise a GL render that
1387/// needs no CPU access ends the chain, while G2D and the CPU fallback may
1388/// each still run, so their needs are all included.
1389fn predicted_cpu_access(
1390    forced: Option<ForcedBackend>,
1391    gl: Option<Option<edgefirst_tensor::CpuAccess>>,
1392    g2d: Option<Option<edgefirst_tensor::CpuAccess>>,
1393    cpu: Option<Option<edgefirst_tensor::CpuAccess>>,
1394) -> edgefirst_tensor::CpuAccess {
1395    use edgefirst_tensor::CpuAccess;
1396    let only = |b: Option<Option<CpuAccess>>| b.flatten().unwrap_or(CpuAccess::None);
1397    match forced {
1398        Some(ForcedBackend::Cpu) => return only(cpu),
1399        Some(ForcedBackend::G2d) => return only(g2d),
1400        Some(ForcedBackend::OpenGl) => return only(gl),
1401        None => {}
1402    }
1403    let gl = gl.flatten();
1404    if gl == Some(CpuAccess::None) {
1405        return CpuAccess::None;
1406    }
1407    [gl, g2d.flatten(), cpu.flatten()]
1408        .into_iter()
1409        .flatten()
1410        .fold(CpuAccess::None, CpuAccess::union)
1411}
1412
1413/// Reports which float-color-buffer extensions the GPU backend detected.
1414/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1415/// are independent.
1416///
1417/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1418/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1419/// both flags are typically `true`; on Vivante GC7000UL both are forced
1420/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1421/// exposes both via PBO; the flags match the GPU report.
1422///
1423/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1424/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1425/// extension probe but is not actionable — ANGLE's
1426/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1427/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1428/// path.
1429///
1430/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1431/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1432/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1433///
1434/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1435/// an error due to float capability — it falls back to CPU when the GPU
1436/// path is unavailable.
1437#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1438pub struct RenderDtypeSupport {
1439    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1440    ///
1441    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1442    /// this flag is informational only — no F32 IOSurface path exists.
1443    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1444    /// for every F32 layout.
1445    pub f32: bool,
1446    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1447    ///
1448    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1449    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1450    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1451    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1452    /// every F16 layout.
1453    pub f16: bool,
1454}
1455
1456/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1457///
1458/// Only F16 and F32 are eligible, and only when the corresponding flag in
1459/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1460/// handled by the existing `dtype.size() == 1` PBO gate.
1461///
1462/// The same question `create_image` asks before trying a zero-copy float
1463/// destination, which is why that gate calls this rather than restating it:
1464/// a float image is only worth allocating on the GPU where the display can
1465/// render into that dtype at all.
1466///
1467/// Compiled on every platform with a GL backend -- the name is historical
1468/// (it began as the float *PBO* gate) and both callers are live everywhere
1469/// the `opengl` feature builds a backend.
1470#[cfg(all(
1471    any(
1472        target_os = "linux",
1473        target_os = "windows",
1474        target_os = "macos",
1475        target_os = "ios",
1476        target_os = "android"
1477    ),
1478    feature = "opengl"
1479))]
1480pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1481    match dtype {
1482        DType::F16 => support.f16,
1483        DType::F32 => support.f32,
1484        _ => false,
1485    }
1486}
1487
1488/// Image converter that uses available hardware acceleration or CPU as a
1489/// fallback.
1490#[derive(Debug)]
1491pub struct ImageProcessor {
1492    /// CPU-based image converter as a fallback. This is only None if the
1493    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1494    pub cpu: Option<CPUProcessor>,
1495
1496    #[cfg(target_os = "linux")]
1497    /// G2D-based image converter for Linux systems. This is only available if
1498    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1499    /// is available.
1500    pub g2d: Option<G2DProcessor>,
1501    #[cfg(target_os = "linux")]
1502    #[cfg(feature = "opengl")]
1503    /// OpenGL-based image converter for Linux systems. This is only available
1504    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1505    /// ES is available.
1506    pub opengl: Option<GLProcessorThreaded>,
1507    #[cfg(any(
1508        target_os = "macos",
1509        target_os = "ios",
1510        target_os = "android",
1511        target_os = "windows"
1512    ))]
1513    #[cfg(feature = "opengl")]
1514    /// OpenGL-based image converter — the same unified
1515    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1516    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1517    /// (available when ANGLE's libEGL.dylib can be loaded — see
1518    /// README.md § macOS GPU Acceleration); Android runs it via the
1519    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1520    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1521    /// can be loaded — see README.md § Windows GPU Acceleration).
1522    pub opengl: Option<GLProcessorThreaded>,
1523
1524    /// When set, only the specified backend is used — no fallback chain.
1525    pub(crate) forced_backend: Option<ForcedBackend>,
1526
1527    /// Converts where the GL backend declined and the auto chain fell
1528    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1529    /// sees these frames), unlike the per-feed counters in
1530    /// `GLProcessorThreaded::convert_stats`. Read via
1531    /// [`convert_fallback_count`](Self::convert_fallback_count).
1532    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1533
1534    /// The interpolation a convert without its own uses, as the backends run it.
1535    pub(crate) interpolation: Interpolation,
1536
1537    /// `EDGEFIRST_INTERPOLATION`, read once at construction; it pins every convert.
1538    pub(crate) env_interpolation: Option<Interpolation>,
1539}
1540
1541unsafe impl Send for ImageProcessor {}
1542unsafe impl Sync for ImageProcessor {}
1543
1544impl ImageProcessor {
1545    /// Creates a new `ImageProcessor` instance, initializing available
1546    /// hardware converters based on the system capabilities and environment
1547    /// variables.
1548    ///
1549    /// # Examples
1550    /// ```rust,no_run
1551    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1552    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1553    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1554    /// # fn main() -> Result<(), edgefirst_image::Error> {
1555    /// let image = std::fs::read("zidane.jpg")?;
1556    /// // The codec emits the source's native format (a colour JPEG decodes to
1557    /// // NV12) and configures the destination tensor during the decode.
1558    /// let info = peek_info(&image).expect("peek");
1559    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1560    ///                                    Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
1561    /// let mut decoder = ImageDecoder::new();
1562    /// src.load_image(&mut decoder, &image).expect("decode");
1563    /// let mut converter = ImageProcessor::new()?;
1564    /// let mut dst =
1565    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1566    /// converter.convert(&src.into(), &mut dst, None)?;
1567    /// # Ok(())
1568    /// # }
1569    /// ```
1570    pub fn new() -> Result<Self> {
1571        Self::with_config(ImageProcessorConfig::default())
1572    }
1573
1574    /// Number of converts where the auto chain's GL backend declined and the
1575    /// frame fell through toward G2D/CPU — each one silently lost any
1576    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1577    /// asserts this stays flat across its steady-state loop; pair with
1578    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1579    /// reach GL were fed. Always 0 under a forced backend (no chain).
1580    pub fn convert_fallback_count(&self) -> u64 {
1581        self.convert_fallbacks
1582            .load(std::sync::atomic::Ordering::Relaxed)
1583    }
1584
1585    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1586    /// requests since process start that resolved to a linear layout
1587    /// (process-wide mirror of
1588    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1589    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1590    /// so pipeline telemetry reads from one place). A pipeline that
1591    /// expects compressed convert destinations asserts this stays flat
1592    /// after warmup.
1593    pub fn compression_fallback_count(&self) -> u64 {
1594        edgefirst_tensor::compression_fallback_count()
1595    }
1596
1597    /// Convert and return a native fence that signals when the GPU work
1598    /// completes, instead of blocking the CPU — the GL→NPU handoff
1599    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1600    /// is a manual-reset event set when the GPU work completes; wait on it
1601    /// with `WaitForSingleObject`.
1602    ///
1603    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1604    /// the fence to the consumer (e.g.
1605    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1606    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1607    /// `Ok(None)`: the convert completed with the normal blocking contract
1608    /// (no native fence on this platform, or a non-GL backend handled the
1609    /// frame) — the destination is already safe. Semantics are otherwise
1610    /// identical to [`convert`](ImageProcessorTrait::convert), including
1611    /// the GL→G2D→CPU fallback chain.
1612    ///
1613    /// On Windows, **for a texture destination**, the event and the
1614    /// destination's own `Tensor::gpu_completion` name the same fence value:
1615    /// one flush and one signal cover the convert, and the value recorded on
1616    /// `dst` is the one this event waits for, so a consumer may use either.
1617    /// A destination that is not a texture records nothing, and the event
1618    /// then carries a value of its own that only it can be waited on for.
1619    pub fn convert_with_fence(
1620        &mut self,
1621        src: &TensorDyn,
1622        dst: &mut TensorDyn,
1623        options: Option<ConvertOptions>,
1624    ) -> Result<Option<CompletionFence>> {
1625        let options = self.resolve_interpolation(options.unwrap_or_default());
1626        #[cfg(any(
1627            target_os = "linux",
1628            target_os = "macos",
1629            target_os = "ios",
1630            target_os = "android",
1631            target_os = "windows"
1632        ))]
1633        #[cfg(feature = "opengl")]
1634        {
1635            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1636            if self.forced_backend.is_none() || gl_forced {
1637                if let Some(opengl) = self.opengl.as_mut() {
1638                    match opengl.convert_with_fence(src, dst, Some(options)) {
1639                        Ok(fd) => return Ok(fd),
1640                        Err(e) if gl_forced => return Err(e),
1641                        Err(e) => {
1642                            // Not counted here: the blocking chain below
1643                            // re-runs the dispatcher, whose decline arm
1644                            // counts the fallback exactly once.
1645                            log::debug!(
1646                                "convert_with_fence: opengl declined, \
1647                                 falling back to the blocking chain: {e}"
1648                            );
1649                        }
1650                    }
1651                } else if gl_forced {
1652                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1653                }
1654            }
1655        }
1656        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1657        // completion == return, so no fence is needed.
1658        self.convert(src, dst, Some(options))?;
1659        Ok(None)
1660    }
1661
1662    /// Report which float dtypes the GPU can render to.
1663    ///
1664    /// Probes `GL_EXT_color_buffer_half_float` and
1665    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1666    /// and caches the result. Call this once at startup to decide whether
1667    /// to request F16 or F32 destination tensors; [`create_image`] uses
1668    /// the result internally to auto-select float PBO when supported.
1669    ///
1670    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1671    /// OpenGL backend is active or the `opengl` feature is disabled.
1672    ///
1673    /// [`create_image`]: Self::create_image
1674    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1675        #[cfg(all(
1676            any(
1677                target_os = "macos",
1678                target_os = "ios",
1679                target_os = "android",
1680                target_os = "windows"
1681            ),
1682            feature = "opengl"
1683        ))]
1684        if let Some(gl) = self.opengl.as_ref() {
1685            return gl.supported_render_dtypes();
1686        }
1687        #[cfg(all(target_os = "linux", feature = "opengl"))]
1688        if let Some(gl) = self.opengl.as_ref() {
1689            return gl.supported_render_dtypes();
1690        }
1691        RenderDtypeSupport {
1692            f32: false,
1693            f16: false,
1694        }
1695    }
1696
1697    /// Creates a new `ImageProcessor` with the given configuration.
1698    ///
1699    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1700    /// environment variables are ignored and the processor initializes the
1701    /// requested backend plus CPU as a fallback.
1702    ///
1703    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1704    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1705    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1706        let interpolation = config.interpolation;
1707        let mut processor = Self::with_config_backends(config)?;
1708        processor.env_interpolation = interpolation_from_env().map(runnable_interpolation);
1709        processor.set_interpolation(interpolation)?;
1710        Ok(processor)
1711    }
1712
1713    /// The backend selection half of [`Self::with_config`].
1714    #[allow(unused_variables)]
1715    fn with_config_backends(config: ImageProcessorConfig) -> Result<Self> {
1716        // ── Config-driven backend selection ──────────────────────────
1717        // When the caller explicitly requests a backend via the config,
1718        // skip all environment variable logic.
1719        match config.backend {
1720            ComputeBackend::Cpu => {
1721                log::info!("ComputeBackend::Cpu — CPU only");
1722                return Ok(Self {
1723                    cpu: Some(CPUProcessor::new()),
1724                    #[cfg(target_os = "linux")]
1725                    g2d: None,
1726                    #[cfg(target_os = "linux")]
1727                    #[cfg(feature = "opengl")]
1728                    opengl: None,
1729                    #[cfg(any(
1730                        target_os = "macos",
1731                        target_os = "ios",
1732                        target_os = "android",
1733                        target_os = "windows"
1734                    ))]
1735                    #[cfg(feature = "opengl")]
1736                    opengl: None,
1737                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1738                    interpolation: Interpolation::Bilinear,
1739                    env_interpolation: None,
1740                    forced_backend: None,
1741                });
1742            }
1743            ComputeBackend::G2d => {
1744                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1745                #[cfg(target_os = "linux")]
1746                {
1747                    let g2d = match G2DProcessor::new() {
1748                        Ok(g) => Some(g),
1749                        Err(e) => {
1750                            log::warn!("G2D requested but failed to initialize: {e:?}");
1751                            None
1752                        }
1753                    };
1754                    return Ok(Self {
1755                        cpu: Some(CPUProcessor::new()),
1756                        g2d,
1757                        #[cfg(feature = "opengl")]
1758                        opengl: None,
1759                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1760                        interpolation: Interpolation::Bilinear,
1761                        env_interpolation: None,
1762                        forced_backend: None,
1763                    });
1764                }
1765                #[cfg(not(target_os = "linux"))]
1766                {
1767                    log::warn!("G2D requested but not available on this platform, using CPU");
1768                    return Ok(Self {
1769                        cpu: Some(CPUProcessor::new()),
1770                        #[cfg(any(
1771                            target_os = "macos",
1772                            target_os = "ios",
1773                            target_os = "android",
1774                            target_os = "windows"
1775                        ))]
1776                        #[cfg(feature = "opengl")]
1777                        opengl: None,
1778                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1779                        interpolation: Interpolation::Bilinear,
1780                        env_interpolation: None,
1781                        forced_backend: None,
1782                    });
1783                }
1784            }
1785            ComputeBackend::OpenGl => {
1786                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1787                #[cfg(target_os = "linux")]
1788                {
1789                    #[cfg(feature = "opengl")]
1790                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1791                        config.egl_display,
1792                        config.egl_cache_capacity,
1793                    ) {
1794                        Ok(gl) => Some(gl),
1795                        Err(e) => {
1796                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1797                            None
1798                        }
1799                    };
1800                    return Ok(Self {
1801                        cpu: Some(CPUProcessor::new()),
1802                        g2d: None,
1803                        #[cfg(feature = "opengl")]
1804                        opengl,
1805                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1806                        interpolation: Interpolation::Bilinear,
1807                        env_interpolation: None,
1808                        forced_backend: None,
1809                    }
1810                    .apply_colorimetry_mode(config.colorimetry));
1811                }
1812                #[cfg(any(target_os = "macos", target_os = "ios"))]
1813                {
1814                    #[cfg(feature = "opengl")]
1815                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1816                        config.egl_display,
1817                        config.egl_cache_capacity,
1818                    ) {
1819                        Ok(gl) => Some(gl),
1820                        Err(e) => {
1821                            log::warn!(
1822                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1823                                 (install ANGLE via `brew install startergo/angle/angle` \
1824                                 and re-sign the dylibs — see README.md § macOS GPU \
1825                                 Acceleration). Falling back to CPU."
1826                            );
1827                            None
1828                        }
1829                    };
1830                    return Ok(Self {
1831                        cpu: Some(CPUProcessor::new()),
1832                        #[cfg(feature = "opengl")]
1833                        opengl,
1834                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1835                        interpolation: Interpolation::Bilinear,
1836                        env_interpolation: None,
1837                        forced_backend: None,
1838                    }
1839                    .apply_colorimetry_mode(config.colorimetry));
1840                }
1841                #[cfg(target_os = "android")]
1842                {
1843                    #[cfg(feature = "opengl")]
1844                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1845                        config.egl_display,
1846                        config.egl_cache_capacity,
1847                    ) {
1848                        Ok(gl) => Some(gl),
1849                        Err(e) => {
1850                            log::warn!(
1851                                "OpenGL requested but native EGL init failed: {e:?}. \
1852                                 Falling back to CPU."
1853                            );
1854                            None
1855                        }
1856                    };
1857                    return Ok(Self {
1858                        cpu: Some(CPUProcessor::new()),
1859                        #[cfg(feature = "opengl")]
1860                        opengl,
1861                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1862                        interpolation: Interpolation::Bilinear,
1863                        env_interpolation: None,
1864                        forced_backend: None,
1865                    }
1866                    .apply_colorimetry_mode(config.colorimetry));
1867                }
1868                #[cfg(target_os = "windows")]
1869                {
1870                    #[cfg(feature = "opengl")]
1871                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1872                        config.egl_display,
1873                        config.egl_cache_capacity,
1874                    ) {
1875                        Ok(gl) => Some(gl),
1876                        Err(e) => {
1877                            log::warn!(
1878                                "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1879                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1880                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1881                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1882                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1883                            );
1884                            None
1885                        }
1886                    };
1887                    return Ok(Self {
1888                        cpu: Some(CPUProcessor::new()),
1889                        #[cfg(feature = "opengl")]
1890                        opengl,
1891                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1892                        interpolation: Interpolation::Bilinear,
1893                        env_interpolation: None,
1894                        forced_backend: None,
1895                    }
1896                    .apply_colorimetry_mode(config.colorimetry));
1897                }
1898                #[cfg(not(any(
1899                    target_os = "linux",
1900                    target_os = "macos",
1901                    target_os = "ios",
1902                    target_os = "android",
1903                    target_os = "windows"
1904                )))]
1905                {
1906                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1907                    return Ok(Self {
1908                        cpu: Some(CPUProcessor::new()),
1909                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1910                        interpolation: Interpolation::Bilinear,
1911                        env_interpolation: None,
1912                        forced_backend: None,
1913                    });
1914                }
1915            }
1916            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1917        }
1918
1919        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1920        // When set, only the requested backend is initialised and no
1921        // fallback chain is used. Accepted values (case-insensitive):
1922        //   "cpu", "g2d", "opengl"
1923        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1924            let val_lower = val.to_lowercase();
1925            let forced = match val_lower.as_str() {
1926                "cpu" => ForcedBackend::Cpu,
1927                "g2d" => ForcedBackend::G2d,
1928                "opengl" => ForcedBackend::OpenGl,
1929                other => {
1930                    return Err(Error::ForcedBackendUnavailable(format!(
1931                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1932                    )));
1933                }
1934            };
1935
1936            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1937
1938            return match forced {
1939                ForcedBackend::Cpu => Ok(Self {
1940                    cpu: Some(CPUProcessor::new()),
1941                    #[cfg(target_os = "linux")]
1942                    g2d: None,
1943                    #[cfg(target_os = "linux")]
1944                    #[cfg(feature = "opengl")]
1945                    opengl: None,
1946                    #[cfg(any(
1947                        target_os = "macos",
1948                        target_os = "ios",
1949                        target_os = "android",
1950                        target_os = "windows"
1951                    ))]
1952                    #[cfg(feature = "opengl")]
1953                    opengl: None,
1954                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1955                    interpolation: Interpolation::Bilinear,
1956                    env_interpolation: None,
1957                    forced_backend: Some(ForcedBackend::Cpu),
1958                }),
1959                ForcedBackend::G2d => {
1960                    #[cfg(target_os = "linux")]
1961                    {
1962                        let g2d = G2DProcessor::new().map_err(|e| {
1963                            Error::ForcedBackendUnavailable(format!(
1964                                "g2d forced but failed to initialize: {e:?}"
1965                            ))
1966                        })?;
1967                        Ok(Self {
1968                            cpu: None,
1969                            g2d: Some(g2d),
1970                            #[cfg(feature = "opengl")]
1971                            opengl: None,
1972                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1973                            interpolation: Interpolation::Bilinear,
1974                            env_interpolation: None,
1975                            forced_backend: Some(ForcedBackend::G2d),
1976                        })
1977                    }
1978                    #[cfg(not(target_os = "linux"))]
1979                    {
1980                        Err(Error::ForcedBackendUnavailable(
1981                            "g2d backend is only available on Linux".into(),
1982                        ))
1983                    }
1984                }
1985                ForcedBackend::OpenGl => {
1986                    #[cfg(target_os = "linux")]
1987                    #[cfg(feature = "opengl")]
1988                    {
1989                        let opengl = GLProcessorThreaded::with_cache_capacity(
1990                            config.egl_display,
1991                            config.egl_cache_capacity,
1992                        )
1993                        .map_err(|e| {
1994                            Error::ForcedBackendUnavailable(format!(
1995                                "opengl forced but failed to initialize: {e:?}"
1996                            ))
1997                        })?;
1998                        Ok(Self {
1999                            cpu: None,
2000                            g2d: None,
2001                            opengl: Some(opengl),
2002                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2003                            interpolation: Interpolation::Bilinear,
2004                            env_interpolation: None,
2005                            forced_backend: Some(ForcedBackend::OpenGl),
2006                        }
2007                        .apply_colorimetry_mode(config.colorimetry))
2008                    }
2009                    #[cfg(any(target_os = "macos", target_os = "ios"))]
2010                    #[cfg(feature = "opengl")]
2011                    {
2012                        let opengl = GLProcessorThreaded::with_cache_capacity(
2013                            config.egl_display,
2014                            config.egl_cache_capacity,
2015                        )
2016                        .map_err(|e| {
2017                            Error::ForcedBackendUnavailable(format!(
2018                                "opengl forced on macOS but ANGLE init failed: {e:?}"
2019                            ))
2020                        })?;
2021                        Ok(Self {
2022                            cpu: None,
2023                            opengl: Some(opengl),
2024                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2025                            interpolation: Interpolation::Bilinear,
2026                            env_interpolation: None,
2027                            forced_backend: Some(ForcedBackend::OpenGl),
2028                        }
2029                        .apply_colorimetry_mode(config.colorimetry))
2030                    }
2031                    #[cfg(target_os = "android")]
2032                    #[cfg(feature = "opengl")]
2033                    {
2034                        let opengl = GLProcessorThreaded::with_cache_capacity(
2035                            config.egl_display,
2036                            config.egl_cache_capacity,
2037                        )
2038                        .map_err(|e| {
2039                            Error::ForcedBackendUnavailable(format!(
2040                                "opengl forced but native EGL init failed: {e:?}"
2041                            ))
2042                        })?;
2043                        Ok(Self {
2044                            cpu: None,
2045                            opengl: Some(opengl),
2046                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2047                            interpolation: Interpolation::Bilinear,
2048                            env_interpolation: None,
2049                            forced_backend: Some(ForcedBackend::OpenGl),
2050                        }
2051                        .apply_colorimetry_mode(config.colorimetry))
2052                    }
2053                    #[cfg(target_os = "windows")]
2054                    #[cfg(feature = "opengl")]
2055                    {
2056                        let opengl = GLProcessorThreaded::with_cache_capacity(
2057                            config.egl_display,
2058                            config.egl_cache_capacity,
2059                        )
2060                        .map_err(|e| {
2061                            Error::ForcedBackendUnavailable(format!(
2062                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
2063                            ))
2064                        })?;
2065                        Ok(Self {
2066                            cpu: None,
2067                            opengl: Some(opengl),
2068                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2069                            interpolation: Interpolation::Bilinear,
2070                            env_interpolation: None,
2071                            forced_backend: Some(ForcedBackend::OpenGl),
2072                        }
2073                        .apply_colorimetry_mode(config.colorimetry))
2074                    }
2075                    #[cfg(not(all(
2076                        any(
2077                            target_os = "linux",
2078                            target_os = "macos",
2079                            target_os = "ios",
2080                            target_os = "android",
2081                            target_os = "windows"
2082                        ),
2083                        feature = "opengl"
2084                    )))]
2085                    {
2086                        Err(Error::ForcedBackendUnavailable(
2087                            "opengl backend requires Linux, macOS, Android or Windows with the \
2088                             'opengl' feature enabled"
2089                                .into(),
2090                        ))
2091                    }
2092                }
2093            };
2094        }
2095
2096        // ── Existing DISABLE logic (unchanged) ──────────────────────
2097        #[cfg(target_os = "linux")]
2098        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
2099            .map(|x| x != "0" && x.to_lowercase() != "false")
2100            .unwrap_or(false)
2101        {
2102            log::debug!("EDGEFIRST_DISABLE_G2D is set");
2103            None
2104        } else {
2105            match G2DProcessor::new() {
2106                Ok(g2d_converter) => Some(g2d_converter),
2107                Err(err) => {
2108                    log::warn!("Failed to initialize G2D converter: {err:?}");
2109                    None
2110                }
2111            }
2112        };
2113
2114        #[cfg(target_os = "linux")]
2115        #[cfg(feature = "opengl")]
2116        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2117            .map(|x| x != "0" && x.to_lowercase() != "false")
2118            .unwrap_or(false)
2119        {
2120            log::debug!("EDGEFIRST_DISABLE_GL is set");
2121            None
2122        } else {
2123            match GLProcessorThreaded::with_cache_capacity(
2124                config.egl_display,
2125                config.egl_cache_capacity,
2126            ) {
2127                Ok(gl_converter) => Some(gl_converter),
2128                Err(err) => {
2129                    log::warn!("Failed to initialize GL converter: {err:?}");
2130                    None
2131                }
2132            }
2133        };
2134
2135        #[cfg(any(
2136            target_os = "macos",
2137            target_os = "ios",
2138            target_os = "android",
2139            target_os = "windows"
2140        ))]
2141        #[cfg(feature = "opengl")]
2142        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2143            .map(|x| x != "0" && x.to_lowercase() != "false")
2144            .unwrap_or(false)
2145        {
2146            log::debug!("EDGEFIRST_DISABLE_GL is set");
2147            None
2148        } else {
2149            match GLProcessorThreaded::with_cache_capacity(
2150                config.egl_display,
2151                config.egl_cache_capacity,
2152            ) {
2153                Ok(gl_converter) => Some(gl_converter),
2154                Err(err) => {
2155                    log::debug!(
2156                        "GL backend unavailable: {err:?} \
2157                         (CPU fallback will be used)"
2158                    );
2159                    None
2160                }
2161            }
2162        };
2163
2164        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
2165            .map(|x| x != "0" && x.to_lowercase() != "false")
2166            .unwrap_or(false)
2167        {
2168            log::debug!("EDGEFIRST_DISABLE_CPU is set");
2169            None
2170        } else {
2171            Some(CPUProcessor::new())
2172        };
2173        Ok(Self {
2174            cpu,
2175            #[cfg(target_os = "linux")]
2176            g2d,
2177            #[cfg(target_os = "linux")]
2178            #[cfg(feature = "opengl")]
2179            opengl,
2180            #[cfg(any(
2181                target_os = "macos",
2182                target_os = "ios",
2183                target_os = "android",
2184                target_os = "windows"
2185            ))]
2186            #[cfg(feature = "opengl")]
2187            opengl,
2188            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2189            interpolation: Interpolation::Bilinear,
2190            env_interpolation: None,
2191            forced_backend: None,
2192        }
2193        .apply_colorimetry_mode(config.colorimetry))
2194    }
2195
2196    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
2197    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
2198    /// plumbing for [`ImageProcessorConfig::colorimetry`].
2199    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
2200        #[cfg(all(
2201            any(
2202                target_os = "linux",
2203                target_os = "macos",
2204                target_os = "ios",
2205                target_os = "android",
2206                target_os = "windows"
2207            ),
2208            feature = "opengl"
2209        ))]
2210        {
2211            let mut me = self;
2212            if let Err(e) = me.set_colorimetry_mode(_mode) {
2213                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2214            }
2215            me
2216        }
2217        #[cfg(not(all(
2218            any(
2219                target_os = "linux",
2220                target_os = "macos",
2221                target_os = "ios",
2222                target_os = "android",
2223                target_os = "windows"
2224            ),
2225            feature = "opengl"
2226        )))]
2227        {
2228            let _ = _mode;
2229            self
2230        }
2231    }
2232
2233    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2234    /// on the OpenGL backend. No-op if OpenGL is not available. The
2235    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2236    /// it is set, this call logs and keeps the env-selected mode.
2237    #[cfg(all(
2238        any(
2239            target_os = "linux",
2240            target_os = "macos",
2241            target_os = "ios",
2242            target_os = "android",
2243            target_os = "windows"
2244        ),
2245        feature = "opengl"
2246    ))]
2247    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2248        if let Some(ref mut gl) = self.opengl {
2249            gl.set_colorimetry_mode(mode)?;
2250        }
2251        Ok(())
2252    }
2253
2254    /// Sets how `convert()` resamples when it resizes (see [`Interpolation`])
2255    /// on every backend this processor holds, for converts whose options do
2256    /// not choose their own ([`ConvertOptions::interpolation`]). Bicubic and
2257    /// area run as [`Interpolation::supported`], with a warning logged once
2258    /// per mode. The `EDGEFIRST_INTERPOLATION` environment variable, read
2259    /// when the processor is created, takes precedence: when it is set, this
2260    /// call keeps the env-selected mode.
2261    pub fn set_interpolation(&mut self, mode: Interpolation) -> Result<()> {
2262        let mode = match self.env_interpolation {
2263            Some(env) => {
2264                if env != mode.supported() {
2265                    log::debug!(
2266                        "EDGEFIRST_INTERPOLATION pins Interpolation::{env:?}; \
2267                         ignoring Interpolation::{mode:?}"
2268                    );
2269                }
2270                env
2271            }
2272            None => runnable_interpolation(mode),
2273        };
2274        if mode == self.interpolation {
2275            return Ok(());
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        self.interpolation = mode;
2298        Ok(())
2299    }
2300
2301    /// The interpolation the processor uses for converts that do not choose
2302    /// their own, as the backends run it.
2303    pub fn interpolation(&self) -> Interpolation {
2304        self.interpolation
2305    }
2306
2307    /// `options` with the interpolation this convert runs: the
2308    /// `EDGEFIRST_INTERPOLATION` pin, else the options' own (as
2309    /// [`runnable_interpolation`] maps it), else the processor's.
2310    fn resolve_interpolation(&self, options: ConvertOptions) -> ConvertOptions {
2311        let mode = match (self.env_interpolation, options.get_interpolation()) {
2312            (Some(env), _) => env,
2313            (None, Some(mode)) => runnable_interpolation(mode),
2314            (None, None) => self.interpolation,
2315        };
2316        options.with_interpolation(Some(mode))
2317    }
2318
2319    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2320    /// backend. No-op if OpenGL is not available.
2321    #[cfg(all(
2322        any(
2323            target_os = "linux",
2324            target_os = "macos",
2325            target_os = "ios",
2326            target_os = "android",
2327            target_os = "windows"
2328        ),
2329        feature = "opengl"
2330    ))]
2331    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2332        if let Some(ref mut gl) = self.opengl {
2333            gl.set_int8_interpolation_mode(mode)?;
2334        }
2335        Ok(())
2336    }
2337
2338    /// The declaration [`create_image`](Self::create_image) allocates a
2339    /// `memory` destination with: the caller's `access` plus whatever this
2340    /// processor's own convert may need on the CPU (see
2341    /// [`predicted_cpu_access`]). `views` marks a destination written through
2342    /// views, where any CPU write is partial and so needs `ReadWrite`.
2343    fn producer_access(
2344        &self,
2345        access: edgefirst_tensor::CpuAccess,
2346        format: PixelFormat,
2347        dtype: DType,
2348        memory: TensorMemory,
2349        views: bool,
2350    ) -> edgefirst_tensor::CpuAccess {
2351        use edgefirst_tensor::CpuAccess;
2352        #[cfg(all(
2353            any(
2354                target_os = "linux",
2355                target_os = "macos",
2356                target_os = "ios",
2357                target_os = "android",
2358                target_os = "windows"
2359            ),
2360            feature = "opengl"
2361        ))]
2362        let gl = self
2363            .opengl
2364            .as_ref()
2365            .map(|gl| gl.dst_cpu_access(memory, format, dtype));
2366        #[cfg(not(all(
2367            any(
2368                target_os = "linux",
2369                target_os = "macos",
2370                target_os = "ios",
2371                target_os = "android",
2372                target_os = "windows"
2373            ),
2374            feature = "opengl"
2375        )))]
2376        let gl = None;
2377        #[cfg(target_os = "linux")]
2378        let g2d = self
2379            .g2d
2380            .as_ref()
2381            .map(|_| g2d::dst_cpu_access(memory, format, dtype));
2382        #[cfg(not(target_os = "linux"))]
2383        let g2d = None;
2384        let cpu = self
2385            .cpu
2386            .as_ref()
2387            .map(|_| Some(cpu::convert_dst_access(None, format, dtype)));
2388        let needed = predicted_cpu_access(self.forced_backend, gl, g2d, cpu);
2389        let needed = if views && needed.writes() {
2390            CpuAccess::ReadWrite
2391        } else {
2392            needed
2393        };
2394        access.union(needed)
2395    }
2396
2397    /// Allocate an image tensor from a declarative
2398    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2399    /// full-featured variant of [`create_image`](Self::create_image).
2400    ///
2401    /// Without a compression or contiguous request this is exactly
2402    /// `create_image` (the processor's memory negotiation applies). With
2403    /// one, the allocation rides the tensor desc path un-negotiated: the
2404    /// layout decision belongs to the platform allocator, and the request's
2405    /// guards and fallback counting live there (see
2406    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)). A
2407    /// contiguous request is a CMA DMA-BUF at the same 64-byte GPU pitch
2408    /// `create_image` uses, or an error; it never falls back to a PBO or
2409    /// host memory. Its CPU access gets the same widening as `create_image`;
2410    /// a compression request's `CpuAccess::None` does not.
2411    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2412        // Tile compression requires `CpuAccess::None`, so it gets no widening.
2413        if desc.compression().is_some() {
2414            return Ok(TensorDyn::image_desc(desc)?);
2415        }
2416        if !desc.contiguous() {
2417            return self.create_image(
2418                desc.width(),
2419                desc.height(),
2420                desc.format(),
2421                desc.dtype(),
2422                desc.memory(),
2423                desc.access(),
2424            );
2425        }
2426        let access = self.producer_access(
2427            desc.access(),
2428            desc.format(),
2429            desc.dtype(),
2430            TensorMemory::DmaBuf,
2431            false,
2432        );
2433        Ok(TensorDyn::image_desc(&desc.clone().with_access(access))?)
2434    }
2435
2436    /// Create a [`TensorDyn`] image with the best available memory backend.
2437    ///
2438    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2439    ///
2440    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2441    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2442    /// memory backing (including PBO for GPU zero-copy) which direct
2443    /// allocation cannot achieve.
2444    ///
2445    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2446    /// because optimal allocation requires knowledge of the active compute
2447    /// backends (e.g. the GL context handle for PBO allocation). Individual
2448    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2449    /// cross-backend visibility.
2450    ///
2451    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2452    /// [`supported_render_dtypes`] reports the GPU supports that type,
2453    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2454    /// F16 only). If GPU float support is absent the allocation falls through
2455    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2456    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2457    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2458    ///
2459    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2460    /// [`convert`]: ImageProcessorTrait::convert
2461    ///
2462    /// # Arguments
2463    ///
2464    /// * `width` - Image width in pixels
2465    /// * `height` - Image height in pixels
2466    /// * `format` - Pixel format
2467    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2468    /// * `memory` - Optional memory type override; when `None`, the best
2469    ///   available backend is selected automatically.
2470    /// * `access` - Declares the CPU access you plan on (`ReadWrite` is the
2471    ///   natural choice). Use [`CpuAccess::None`](edgefirst_tensor::CpuAccess)
2472    ///   for a destination that only hardware reads (an NPU input).
2473    ///   Hardware access is always implied. What this processor's own
2474    ///   convert may do to the image as a destination on the CPU is added
2475    ///   to the declaration so that convert succeeds: `Write` when it may
2476    ///   fill the whole destination on the CPU (the CPU backend, host
2477    ///   memory, a format or float dtype the GPU path does not render),
2478    ///   `ReadWrite` when a pass reads back what was written or writes only
2479    ///   part of the buffer (an `I8` destination through the CPU or G2D, a
2480    ///   `Bgra` destination through the CPU, a G2D `Rgb` letterbox border).
2481    ///   The returned tensor's [`cpu_access`](edgefirst_tensor::TensorDyn::cpu_access)
2482    ///   may therefore be wider than `access`. Any other CPU map beyond the
2483    ///   declaration fails with
2484    ///   [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied).
2485    ///   The widening covers the destination role of convert only: a
2486    ///   tensor later used as a convert source needs `Read` declared by the
2487    ///   caller, and a CPU mask draw blends into its destination and needs
2488    ///   `ReadWrite`, so declare it when the CPU may draw. A destination
2489    ///   written through views (a batch) needs `ReadWrite` for a CPU convert,
2490    ///   which [`alloc_tile_batch`](Self::alloc_tile_batch) predicts. A
2491    ///   destination left without CPU access (`None` on a GPU-rendered
2492    ///   format) is written only by the GPU, so a convert into it needs the
2493    ///   GPU to accept the source as well; when it declines, the CPU fallback
2494    ///   is refused with [`Error::CpuFallbackDenied`] carrying the GPU's
2495    ///   reason. A PBO destination is always `ReadWrite`: the GL readback
2496    ///   maps it on the CPU itself, and a narrower declaration would buy no
2497    ///   cheaper mapping.
2498    ///
2499    /// # Returns
2500    ///
2501    /// A [`TensorDyn`] backed by the highest-performance memory type
2502    /// available on this system.
2503    ///
2504    /// # Examples
2505    ///
2506    /// ```rust,no_run
2507    /// # use edgefirst_image::ImageProcessor;
2508    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2509    /// # fn main() -> Result<(), edgefirst_image::Error> {
2510    /// let processor = ImageProcessor::new()?;
2511    ///
2512    /// // Model input the NPU reads directly — no CPU mapping planned.
2513    /// let input = processor.create_image(
2514    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2515    /// )?;
2516    /// # let _ = input;
2517    /// # Ok(())
2518    /// # }
2519    /// ```
2520    ///
2521    /// A display canvas the GPU renders and a Wayland compositor shows
2522    /// (linux-dmabuf), with a CPU-written overlay. Neither is read by the
2523    /// CPU, so on Linux both come from uncached CMA where available: the
2524    /// compositor's per-frame mapping of the canvas then costs no cache
2525    /// maintenance.
2526    ///
2527    /// ```rust,no_run
2528    /// # use edgefirst_image::{ImageProcessor, ImageProcessorTrait, MaskOverlay};
2529    /// # use edgefirst_tensor::{CpuAccess, DType, DetectBox, PixelFormat, TensorDyn, TensorMemory, TensorMapTrait, TensorTrait};
2530    /// # fn main() -> Result<(), edgefirst_image::Error> {
2531    /// # let camera: TensorDyn = unimplemented!();
2532    /// # let boxes: Vec<DetectBox> = Vec::new();
2533    /// let mut processor = ImageProcessor::new()?;
2534    /// let dma = Some(TensorMemory::DmaBuf);
2535    /// let frame = processor.create_image(1920, 1080, PixelFormat::Rgba, DType::U8, dma, CpuAccess::None)?;
2536    /// let mut canvas = processor.create_image(1920, 1080, PixelFormat::Rgba, DType::U8, dma, CpuAccess::None)?;
2537    ///
2538    /// // Translucent orange boxes, 2 px wide.
2539    /// processor.set_class_colors(&[[255, 96, 0, 160]])?;
2540    /// processor.set_box_line_width(2)?;
2541    ///
2542    /// let mut frame = frame;
2543    /// processor.convert(&camera, &mut frame, None)?;
2544    /// processor.draw_decoded_masks(&mut canvas, &boxes, &[], MaskOverlay::new().with_background(&frame))?;
2545    ///
2546    /// // Hand the canvas to the compositor: its fd, pitch and format.
2547    /// let fd = canvas.clone_fd()?;
2548    /// let stride = canvas.effective_row_stride();
2549    /// # let _ = (fd, stride);
2550    ///
2551    /// // A label strip the CPU renders: written whole, never read back.
2552    /// let overlay = processor.create_image(640, 64, PixelFormat::Rgba, DType::U8, dma, CpuAccess::Write)?;
2553    /// overlay.as_u8().unwrap().map_for_write()?.as_mut_slice().fill(0);
2554    /// # Ok(())
2555    /// # }
2556    /// ```
2557    ///
2558    /// The returned image's [`cpu_access`](TensorDyn::cpu_access) may be
2559    /// wider than `access` when this processor's own convert may need the
2560    /// CPU on it (a CPU-only processor, or a format the GPU does not
2561    /// render); a wider declaration selects cached memory.
2562    ///
2563    /// # Pitch alignment for DMA-backed allocations
2564    ///
2565    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2566    /// specifically) require every row pitch to be a multiple of
2567    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2568    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2569    /// silently padded so the row stride satisfies that requirement.
2570    ///
2571    /// **The user-requested `width` is preserved** — `tensor.width()`
2572    /// returns the same value you passed in. The padding is carried by
2573    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2574    /// GL backend reads when importing the buffer as an EGLImage.
2575    /// Callers that compute byte offsets from the tensor must use the
2576    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2577    /// full `stride × height` bytes.
2578    ///
2579    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2580    /// exactly `width × bpp × height` bytes with no padding. PBO and
2581    /// Mem fallbacks never pad — they don't go through EGLImage import.
2582    ///
2583    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2584    /// that external callers (GStreamer plugins, video pipelines) can
2585    /// use to size their own DMA-BUFs for GL compatibility.
2586    ///
2587    /// # Errors
2588    ///
2589    /// Returns an error if all allocation strategies fail.
2590    pub fn create_image(
2591        &self,
2592        width: usize,
2593        height: usize,
2594        format: PixelFormat,
2595        dtype: DType,
2596        memory: Option<TensorMemory>,
2597        access: edgefirst_tensor::CpuAccess,
2598    ) -> Result<TensorDyn> {
2599        self.create_image_with(width, height, format, dtype, memory, access, false)
2600    }
2601
2602    /// A DMA image for [`create_image_with`](Self::create_image_with), its
2603    /// packed rows padded to the GPU pitch alignment when that exceeds the
2604    /// natural pitch.
2605    ///
2606    /// Semi-planar and planar formats keep their natural layout: they are
2607    /// imported from camera capture via `from_fd` far more often than
2608    /// allocated here, and `Tensor::image_with_stride` rejects them.
2609    #[cfg(target_os = "linux")]
2610    fn create_dma_image(
2611        &self,
2612        width: usize,
2613        height: usize,
2614        format: PixelFormat,
2615        dtype: DType,
2616        access: edgefirst_tensor::CpuAccess,
2617        views: bool,
2618    ) -> Result<TensorDyn> {
2619        let dma_access = self.producer_access(access, format, dtype, TensorMemory::DmaBuf, views);
2620        let natural =
2621            primary_plane_bpp(format, dtype.size()).and_then(|bpp| width.checked_mul(bpp));
2622        let padded = natural
2623            .and_then(align_pitch_bytes_to_gpu_alignment)
2624            .filter(|&stride| {
2625                format.layout() == edgefirst_tensor::PixelLayout::Packed
2626                    && natural.is_some_and(|n| stride > n)
2627            });
2628        let memory = Some(edgefirst_tensor::TensorMemory::DmaBuf);
2629        match padded {
2630            Some(stride) => {
2631                log::debug!(
2632                    "create_image: padding row stride for {format:?} {width}x{height} \
2633                     from natural pitch to {stride} bytes for GPU alignment"
2634                );
2635                Ok(TensorDyn::image_with_stride(
2636                    width, height, format, dtype, stride, memory, dma_access,
2637                )?)
2638            }
2639            None => Ok(TensorDyn::image(
2640                width, height, format, dtype, memory, dma_access,
2641            )?),
2642        }
2643    }
2644
2645    /// A u8/i8 PBO image for [`create_image_with`](Self::create_image_with),
2646    /// or `None` when the dtype is wider or there is no PBO to be had. A PBO
2647    /// stays `ReadWrite`: the GL readback maps it itself.
2648    #[cfg(any(target_os = "linux", target_os = "windows"))]
2649    #[cfg(feature = "opengl")]
2650    fn create_byte_pbo_image(
2651        &self,
2652        width: usize,
2653        height: usize,
2654        format: PixelFormat,
2655        dtype: DType,
2656    ) -> Option<TensorDyn> {
2657        if dtype.size() != 1 {
2658            return None;
2659        }
2660        let gl = self.opengl.as_ref()?;
2661        let t = gl
2662            .create_pbo_image(width, height, format)
2663            .map_err(|e| log::debug!("PBO image creation failed, falling back to Mem: {e:?}"))
2664            .ok()?;
2665        if dtype != DType::I8 {
2666            return Some(TensorDyn::from(t));
2667        }
2668        // The int8 shader emulates i8 output via XOR 0x80 on the same u8 buffer.
2669        // Invariant: PBO tensors never have chroma (create_pbo_image → Tensor::wrap sets it None).
2670        debug_assert!(
2671            t.chroma().is_none(),
2672            "PBO i8 transmute requires chroma == None"
2673        );
2674        // SAFETY: Tensor<u8> and Tensor<i8> are layout-identical (same element
2675        // size, no T-dependent drop glue); same rationale as
2676        // gl::processor::tensor_i8_as_u8_mut.
2677        let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2678        Some(TensorDyn::from(t_i8))
2679    }
2680
2681    /// [`create_image`](Self::create_image), for a destination written
2682    /// through views when `views` is set (see `producer_access`).
2683    #[allow(clippy::too_many_arguments)]
2684    pub(crate) fn create_image_with(
2685        &self,
2686        width: usize,
2687        height: usize,
2688        format: PixelFormat,
2689        dtype: DType,
2690        memory: Option<TensorMemory>,
2691        access: edgefirst_tensor::CpuAccess,
2692        views: bool,
2693    ) -> Result<TensorDyn> {
2694        #[cfg(target_os = "linux")]
2695        let try_dma = || self.create_dma_image(width, height, format, dtype, access, views);
2696
2697        // If an explicit memory type is requested, honour it directly.
2698        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2699        // other memory types take the user-requested width verbatim.
2700        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2701        // which selects the IOSurface allocation path (FourCC-formatted)
2702        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2703        match memory {
2704            #[cfg(target_os = "linux")]
2705            Some(TensorMemory::DmaBuf) => {
2706                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2707                if dtype == DType::F32 {
2708                    return Err(Error::NotSupported(
2709                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2710                         use TensorMemory::Pbo for F32"
2711                            .to_string(),
2712                    ));
2713                }
2714                return try_dma();
2715            }
2716            Some(mem) => {
2717                return Ok(TensorDyn::image(
2718                    width,
2719                    height,
2720                    format,
2721                    dtype,
2722                    Some(mem),
2723                    self.producer_access(access, format, dtype, mem, views),
2724                )?);
2725            }
2726            None => {}
2727        }
2728
2729        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2730        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2731        // on Windows) for zero-copy import. Formats without a zero-copy
2732        // mapping now ERROR under explicit Dma (the explicit-Dma
2733        // contract), so auto-select catches that error here and falls
2734        // back to host storage — loudly, via the debug log below.
2735        #[cfg(any(
2736            target_os = "macos",
2737            target_os = "ios",
2738            target_os = "android",
2739            target_os = "windows"
2740        ))]
2741        #[cfg(feature = "opengl")]
2742        if let Some(gl) = self.opengl.as_ref() {
2743            let _ = gl;
2744            // Float textures only where the display can render into them;
2745            // otherwise the float PBO step below applies. The same question
2746            // that step asks, asked through the same helper so the two
2747            // cannot answer differently.
2748            let support = self.supported_render_dtypes();
2749            let dtype_ok =
2750                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2751            if dtype_ok {
2752                match TensorDyn::image(
2753                    width,
2754                    height,
2755                    format,
2756                    dtype,
2757                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2758                    self.producer_access(access, format, dtype, TensorMemory::DmaBuf, views),
2759                ) {
2760                    Ok(img) => return Ok(img),
2761                    Err(e) => {
2762                        // Falling back to a non-zero-copy destination is a real
2763                        // perf cliff — never do it silently (on-device triage
2764                        // starts from this line).
2765                        log::debug!(
2766                            "create_image: zero-copy Dma allocation declined \
2767                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2768                        );
2769                    }
2770                }
2771            }
2772        }
2773
2774        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2775        // as the preferred transfer path (PBO is better than DMA in that case).
2776        #[cfg(target_os = "linux")]
2777        {
2778            #[cfg(feature = "opengl")]
2779            let gl_uses_pbo = self
2780                .opengl
2781                .as_ref()
2782                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2783            #[cfg(not(feature = "opengl"))]
2784            let gl_uses_pbo = false;
2785
2786            if !gl_uses_pbo {
2787                if let Ok(img) = try_dma() {
2788                    return Ok(img);
2789                }
2790            }
2791        }
2792
2793        #[cfg(any(target_os = "linux", target_os = "windows"))]
2794        #[cfg(feature = "opengl")]
2795        if let Some(img) = self.create_byte_pbo_image(width, height, format, dtype) {
2796            return Ok(img);
2797        }
2798
2799        // Try float PBO when the GPU backend reports support for this dtype.
2800        // Falls through to Mem on error (same policy as u8 PBO above).
2801        #[cfg(any(target_os = "linux", target_os = "windows"))]
2802        #[cfg(feature = "opengl")]
2803        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2804            if let Some(gl) = &self.opengl {
2805                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2806                    Ok(t) => return Ok(t),
2807                    Err(e) => {
2808                        log::debug!(
2809                            "Float PBO image creation failed for {dtype:?}, \
2810                             falling back to Mem: {e:?}"
2811                        );
2812                    }
2813                }
2814            }
2815        }
2816
2817        // Fallback to Mem
2818        Ok(TensorDyn::image(
2819            width,
2820            height,
2821            format,
2822            dtype,
2823            Some(edgefirst_tensor::TensorMemory::Mem),
2824            self.producer_access(access, format, dtype, TensorMemory::Mem, views),
2825        )?)
2826    }
2827
2828    /// Import an external DMA-BUF image.
2829    ///
2830    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2831    /// consumes the descriptors and takes ownership of those fds (whether
2832    /// the call succeeds or fails).
2833    ///
2834    /// The caller must ensure the DMA-BUF allocation is large enough for the
2835    /// specified width, height, format, and any stride/offset on the plane
2836    /// descriptors. No buffer-size validation is performed; an undersized
2837    /// buffer may cause GPU faults or EGL import failure.
2838    ///
2839    /// # Arguments
2840    ///
2841    /// * `image` - Plane descriptor for the primary (or only) plane
2842    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2843    ///   (required for multiplane NV12)
2844    /// * `width` - Image width in pixels
2845    /// * `height` - Image height in pixels
2846    /// * `format` - Pixel format of the buffer
2847    /// * `dtype` - Element data type (e.g. `DType::U8`)
2848    ///
2849    /// # Returns
2850    ///
2851    /// A `TensorDyn` configured as an image.
2852    ///
2853    /// # Errors
2854    ///
2855    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2856    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2857    ///   DMA-backed
2858    /// * [`Error::InvalidShape`] if NV12 height is odd
2859    ///
2860    /// # Platform
2861    ///
2862    /// Linux only.
2863    ///
2864    /// # Examples
2865    ///
2866    /// ```rust,ignore
2867    /// use edgefirst_tensor::PlaneDescriptor;
2868    ///
2869    /// // Single-plane RGBA
2870    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2871    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2872    ///
2873    /// // Multi-plane NV12 with stride
2874    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2875    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2876    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2877    ///                             PixelFormat::Nv12, DType::U8, None)?;
2878    /// ```
2879    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2880    // a params struct would obscure more than it clarifies here.
2881    #[allow(clippy::too_many_arguments)]
2882    #[cfg(target_os = "linux")]
2883    pub fn import_image(
2884        &self,
2885        image: edgefirst_tensor::PlaneDescriptor,
2886        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2887        width: usize,
2888        height: usize,
2889        format: PixelFormat,
2890        dtype: DType,
2891        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2892    ) -> Result<TensorDyn> {
2893        use edgefirst_tensor::{Tensor, TensorMemory};
2894
2895        // Capture stride/offset from descriptors before consuming them
2896        let image_stride = image.stride();
2897        let image_offset = image.offset();
2898        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2899        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2900
2901        if let Some(chroma_pd) = chroma {
2902            // ── Multiplane path ──────────────────────────────────────
2903            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2904            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2905            // tensor with the wrong element type.
2906            if dtype != DType::U8 && dtype != DType::I8 {
2907                return Err(Error::NotSupported(format!(
2908                    "multiplane import only supports U8/I8, got {dtype:?}"
2909                )));
2910            }
2911            if format.layout() != PixelLayout::SemiPlanar {
2912                return Err(Error::NotSupported(format!(
2913                    "import_image with chroma requires a semi-planar format, got {format:?}"
2914                )));
2915            }
2916
2917            let chroma_h = match format {
2918                PixelFormat::Nv12 => {
2919                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2920                    height.div_ceil(2)
2921                }
2922                // NV16 multiplane will be supported in a future release;
2923                // the GL backend currently only handles NV12 plane1 attributes.
2924                PixelFormat::Nv16 => {
2925                    return Err(Error::NotSupported(
2926                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2927                    ))
2928                }
2929                _ => {
2930                    return Err(Error::NotSupported(format!(
2931                        "unsupported semi-planar format: {format:?}"
2932                    )))
2933                }
2934            };
2935
2936            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2937            if luma.memory() != TensorMemory::DmaBuf {
2938                return Err(Error::NotSupported(format!(
2939                    "luma fd must be DMA-backed, got {:?}",
2940                    luma.memory()
2941                )));
2942            }
2943
2944            let mut chroma_tensor =
2945                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2946            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2947                return Err(Error::NotSupported(format!(
2948                    "chroma fd must be DMA-backed, got {:?}",
2949                    chroma_tensor.memory()
2950                )));
2951            }
2952
2953            // Geometry on the chroma plane must be set before from_planes
2954            // consumes it: the combined handle answers chroma() from the
2955            // library, and there is no chroma_mut() on the dynamic lens.
2956            if let Some(s) = chroma_stride {
2957                if s < width {
2958                    return Err(Error::InvalidShape(format!(
2959                        "chroma stride {s} < minimum {width} for {format:?}"
2960                    )));
2961                }
2962                chroma_tensor.set_row_stride_unchecked(s);
2963            }
2964            if let Some(o) = chroma_offset {
2965                chroma_tensor.set_plane_offset(o);
2966            }
2967
2968            // from_planes creates the combined tensor with format set,
2969            // preserving luma's row_stride (currently None since luma was raw).
2970            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2971
2972            // Apply stride/offset to the combined tensor (luma plane)
2973            if let Some(s) = image_stride {
2974                tensor.set_row_stride(s)?;
2975            }
2976            if let Some(o) = image_offset {
2977                tensor.set_plane_offset(o);
2978            }
2979
2980            if dtype == DType::I8 {
2981                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2982                // the struct contains only type-erased storage (OwnedFd, shape, name),
2983                // no inline T values. This assertion catches layout drift at compile time.
2984                const {
2985                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2986                    assert!(
2987                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2988                    );
2989                }
2990                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2991                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2992                dyn_tensor.set_colorimetry(colorimetry);
2993                return Ok(dyn_tensor);
2994            }
2995            let mut dyn_tensor = TensorDyn::from(tensor);
2996            dyn_tensor.set_colorimetry(colorimetry);
2997            Ok(dyn_tensor)
2998        } else {
2999            // ── Single-plane path ────────────────────────────────────
3000            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
3001            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
3002            // hand-rolled match erroneously rejected NV24).
3003            let shape = format.allocation_shape(width, height).ok_or_else(|| {
3004                Error::NotSupported(format!(
3005                    "unsupported pixel format for import_image: {format:?}"
3006                ))
3007            })?;
3008            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
3009            if tensor.memory() != TensorMemory::DmaBuf {
3010                return Err(Error::NotSupported(format!(
3011                    "import_image requires DMA-backed fd, got {:?}",
3012                    tensor.memory()
3013                )));
3014            }
3015            let mut tensor = tensor.with_format(format)?;
3016            if let Some(s) = image_stride {
3017                tensor.set_row_stride(s)?;
3018            }
3019            if let Some(o) = image_offset {
3020                tensor.set_plane_offset(o);
3021            }
3022            tensor.set_colorimetry(colorimetry);
3023            Ok(tensor)
3024        }
3025    }
3026
3027    /// Decode model outputs and draw segmentation masks onto `dst`.
3028    ///
3029    /// The processor decodes via the provided
3030    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
3031    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
3032    ///
3033    /// Returns the detected bounding boxes.
3034    ///
3035    /// **Requires the `decode` feature**, which is off by default because it
3036    /// is the only thing in this crate that needs `edgefirst-decoder`. This
3037    /// method sequences a decode and a draw; both halves are available
3038    /// unconditionally and are equally fused, so without the feature write:
3039    ///
3040    /// ```ignore
3041    /// let mut boxes = Vec::new();
3042    /// match decoder.decode_proto(outputs, &mut boxes)? {
3043    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
3044    ///     None => {
3045    ///         let mut masks = Vec::new();
3046    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
3047    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
3048    ///     }
3049    /// }
3050    /// ```
3051    #[cfg(feature = "decode")]
3052    pub fn draw_masks(
3053        &mut self,
3054        decoder: &edgefirst_decoder::Decoder,
3055        outputs: &[&TensorDyn],
3056        dst: &mut TensorDyn,
3057        overlay: MaskOverlay<'_>,
3058    ) -> Result<Vec<DetectBox>> {
3059        let mut output_boxes = Vec::with_capacity(100);
3060
3061        // Try proto path first (fused rendering without materializing masks)
3062        let proto_result = decoder
3063            .decode_proto(outputs, &mut output_boxes)
3064            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
3065
3066        if let Some(proto_data) = proto_result {
3067            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
3068        } else {
3069            // Detection-only or unsupported model: full decode + render
3070            let mut output_masks = Vec::with_capacity(100);
3071            decoder
3072                .decode(outputs, &mut output_boxes, &mut output_masks)
3073                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
3074            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
3075        }
3076        Ok(output_boxes)
3077    }
3078
3079    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
3080    ///
3081    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
3082    /// maintaining object identities across frames. The tracker runs after
3083    /// NMS but before mask extraction.
3084    ///
3085    /// Returns detected boxes and track info.
3086    #[cfg(feature = "tracker")]
3087    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
3088        &mut self,
3089        decoder: &edgefirst_decoder::Decoder,
3090        tracker: &mut TR,
3091        timestamp: u64,
3092        outputs: &[&TensorDyn],
3093        dst: &mut TensorDyn,
3094        overlay: MaskOverlay<'_>,
3095    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
3096        let mut output_boxes = Vec::with_capacity(100);
3097        let mut output_tracks = Vec::new();
3098
3099        let proto_result = decoder
3100            .decode_proto_tracked(
3101                tracker,
3102                timestamp,
3103                outputs,
3104                &mut output_boxes,
3105                &mut output_tracks,
3106            )
3107            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
3108
3109        if let Some(proto_data) = proto_result {
3110            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
3111        } else {
3112            // Note: decode_proto_tracked returns None for detection-only/ModelPack
3113            // models WITHOUT calling the tracker. The else branch below is the
3114            // first (and only) tracker call for those model types.
3115            let mut output_masks = Vec::with_capacity(100);
3116            decoder
3117                .decode_tracked(
3118                    tracker,
3119                    timestamp,
3120                    outputs,
3121                    &mut output_boxes,
3122                    &mut output_masks,
3123                    &mut output_tracks,
3124                )
3125                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
3126            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
3127        }
3128        Ok((output_boxes, output_tracks))
3129    }
3130
3131    /// Materialize per-instance segmentation masks from raw prototype data.
3132    ///
3133    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
3134    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
3135    /// Mask values are continuous sigmoid confidence outputs quantized to u8
3136    /// (0 = background, 255 = full confidence), NOT binary thresholded.
3137    ///
3138    /// The returned [`Vec<Segmentation>`] can be:
3139    /// - Inspected or exported for analytics, IoU computation, etc.
3140    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
3141    ///   GPU-interpolated rendering.
3142    ///
3143    /// # Performance Note
3144    ///
3145    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
3146    /// the HAL from using its internal fused optimization path. For render-only
3147    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
3148    /// the fastest path automatically (currently 1.6×–27× faster on tested
3149    /// platforms). Use this method when you need access to the intermediate masks.
3150    ///
3151    /// # Errors
3152    ///
3153    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
3154    pub fn materialize_masks(
3155        &mut self,
3156        detect: &[DetectBox],
3157        proto_data: &ProtoData,
3158        content_region: Option<[f32; 4]>,
3159        resolution: MaskResolution,
3160    ) -> Result<Vec<Segmentation>> {
3161        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
3162        match resolution {
3163            MaskResolution::Proto => {
3164                cpu.materialize_segmentations(detect, proto_data, content_region)
3165            }
3166            MaskResolution::Scaled { width, height } => cpu.materialize_scaled_segmentations(
3167                detect,
3168                proto_data,
3169                content_region,
3170                width,
3171                height,
3172            ),
3173        }
3174    }
3175}
3176
3177impl ImageProcessorTrait for ImageProcessor {
3178    /// Converts the source image to the destination image format and size. The
3179    /// image is cropped first, then flipped, then rotated
3180    ///
3181    /// Prefer hardware accelerators when available, falling back to CPU if
3182    /// necessary.
3183    fn convert(
3184        &mut self,
3185        src: &TensorDyn,
3186        dst: &mut TensorDyn,
3187        options: Option<ConvertOptions>,
3188    ) -> Result<()> {
3189        let options = self.resolve_interpolation(options.unwrap_or_default());
3190        let (rotation, flip) = (options.get_rotation(), options.get_flip());
3191        let start = Instant::now();
3192        let src_fmt = src.format();
3193        let dst_fmt = dst.format();
3194        let _span = tracing::trace_span!(
3195            "image.convert",
3196            ?src_fmt,
3197            ?dst_fmt,
3198            src_memory = ?src.memory(),
3199            dst_memory = ?dst.memory(),
3200            ?rotation,
3201            ?flip,
3202        )
3203        .entered();
3204        log::trace!(
3205            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
3206             rotation={rotation:?}, flip={flip:?}, backend={:?}",
3207            src.dtype(),
3208            src.memory(),
3209            dst.dtype(),
3210            dst.memory(),
3211            self.forced_backend,
3212        );
3213
3214        // ── Forced backend: no fallback chain ────────────────────────
3215        if let Some(forced) = self.forced_backend {
3216            return match forced {
3217                ForcedBackend::Cpu => {
3218                    if let Some(cpu) = self.cpu.as_mut() {
3219                        let r = cpu.convert(src, dst, Some(options));
3220                        log::trace!(
3221                            "convert: forced=cpu result={} ({:?})",
3222                            if r.is_ok() { "ok" } else { "err" },
3223                            start.elapsed()
3224                        );
3225                        return r;
3226                    }
3227                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3228                }
3229                ForcedBackend::G2d => {
3230                    #[cfg(target_os = "linux")]
3231                    if let Some(g2d) = self.g2d.as_mut() {
3232                        let r = g2d.convert(src, dst, Some(options));
3233                        log::trace!(
3234                            "convert: forced=g2d result={} ({:?})",
3235                            if r.is_ok() { "ok" } else { "err" },
3236                            start.elapsed()
3237                        );
3238                        return r;
3239                    }
3240                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3241                }
3242                ForcedBackend::OpenGl => {
3243                    #[cfg(any(
3244                        target_os = "linux",
3245                        target_os = "macos",
3246                        target_os = "ios",
3247                        target_os = "android",
3248                        target_os = "windows"
3249                    ))]
3250                    #[cfg(feature = "opengl")]
3251                    if let Some(opengl) = self.opengl.as_mut() {
3252                        let r = opengl.convert(src, dst, Some(options));
3253                        log::trace!(
3254                            "convert: forced=opengl result={} ({:?})",
3255                            if r.is_ok() { "ok" } else { "err" },
3256                            start.elapsed()
3257                        );
3258                        return r;
3259                    }
3260                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3261                }
3262            };
3263        }
3264
3265        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
3266        #[allow(unused_mut)]
3267        let mut declined: Vec<(&'static str, Error)> = Vec::new();
3268        #[cfg(any(
3269            target_os = "linux",
3270            target_os = "macos",
3271            target_os = "ios",
3272            target_os = "android",
3273            target_os = "windows"
3274        ))]
3275        #[cfg(feature = "opengl")]
3276        if let Some(opengl) = self.opengl.as_mut() {
3277            match opengl.convert(src, dst, Some(options)) {
3278                Ok(_) => {
3279                    log::trace!(
3280                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
3281                        start.elapsed()
3282                    );
3283                    return Ok(());
3284                }
3285                Err(e) => {
3286                    self.convert_fallbacks
3287                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3288                    log::debug!(
3289                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
3290                         falling back toward G2D/CPU: {e}",
3291                        src.memory(),
3292                        dst.memory(),
3293                    );
3294                    declined.push(("opengl", e));
3295                }
3296            }
3297        }
3298
3299        #[cfg(target_os = "linux")]
3300        if let Some(g2d) = self.g2d.as_mut() {
3301            // G2D is matrix-only (no range control, no BT.2020). For any
3302            // conversion with a YUV side, resolve that side's colorimetry and
3303            // skip G2D entirely when it cannot be expressed (full-range YUV or
3304            // BT.2020), letting the chain fall through to GL/CPU which honour
3305            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
3306            // uses the destination. RGB→RGB has no YUV side and is unaffected.
3307            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
3308            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
3309            let g2d_eligible = if src_is_yuv || dst_is_yuv {
3310                let cm = if src_is_yuv {
3311                    crate::colorimetry::effective_colorimetry(src)
3312                } else {
3313                    crate::colorimetry::effective_colorimetry(dst)
3314                };
3315                crate::g2d::g2d_can_handle(&cm, true)
3316            } else {
3317                true
3318            };
3319            if !g2d_eligible {
3320                log::trace!(
3321                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
3322                     (colorimetry not expressible: full-range/BT.2020)"
3323                );
3324            } else {
3325                match g2d.convert(src, dst, Some(options)) {
3326                    Ok(_) => {
3327                        log::trace!(
3328                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
3329                            start.elapsed()
3330                        );
3331                        return Ok(());
3332                    }
3333                    Err(e) => {
3334                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
3335                        declined.push(("g2d", e));
3336                    }
3337                }
3338            }
3339        }
3340
3341        if let Some(cpu) = self.cpu.as_mut() {
3342            match cpu.convert(src, dst, Some(options)) {
3343                Ok(_) => {
3344                    log::trace!(
3345                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
3346                        start.elapsed()
3347                    );
3348                    return Ok(());
3349                }
3350                Err(e) => {
3351                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
3352                    return Err(with_gpu_declines(e, declined));
3353                }
3354            }
3355        }
3356        Err(Error::NoConverter)
3357    }
3358
3359    fn convert_deferred(
3360        &mut self,
3361        src: &TensorDyn,
3362        dst: &mut TensorDyn,
3363        options: Option<ConvertOptions>,
3364    ) -> Result<()> {
3365        let options = self.resolve_interpolation(options.unwrap_or_default());
3366        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
3367        // no per-tile glFinish). Route to the GL backend's deferred path when GL
3368        // is forced or auto-selectable; on a GL decline fall back to an eager
3369        // convert (the auto chain), which is correct everywhere — it completes
3370        // synchronously and `flush` stays a no-op for non-GL backends.
3371        #[cfg(any(
3372            target_os = "linux",
3373            target_os = "macos",
3374            target_os = "ios",
3375            target_os = "android",
3376            target_os = "windows"
3377        ))]
3378        #[cfg(feature = "opengl")]
3379        {
3380            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
3381            if gl_forced || self.forced_backend.is_none() {
3382                if let Some(opengl) = self.opengl.as_mut() {
3383                    match opengl.convert_deferred(src, dst, Some(options)) {
3384                        Ok(()) => return Ok(()),
3385                        Err(e) => {
3386                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
3387                            // A forced-GL caller gets the GL error, matching
3388                            // `convert`'s no-fallback forced-backend contract.
3389                            if gl_forced {
3390                                return Err(e);
3391                            }
3392                        }
3393                    }
3394                }
3395            }
3396        }
3397        self.convert(src, dst, Some(options))
3398    }
3399
3400    fn flush(&mut self) -> Result<()> {
3401        let _span = tracing::trace_span!("image.flush").entered();
3402        // Only the OpenGL backend defers; flushing it issues the single GPU
3403        // sync. CPU/G2D converts already completed, so there is nothing to flush.
3404        #[cfg(any(
3405            target_os = "linux",
3406            target_os = "macos",
3407            target_os = "ios",
3408            target_os = "android",
3409            target_os = "windows"
3410        ))]
3411        #[cfg(feature = "opengl")]
3412        if let Some(opengl) = self.opengl.as_mut() {
3413            return opengl.flush();
3414        }
3415        Ok(())
3416    }
3417
3418    fn draw_decoded_masks(
3419        &mut self,
3420        dst: &mut TensorDyn,
3421        detect: &[DetectBox],
3422        segmentation: &[Segmentation],
3423        overlay: MaskOverlay<'_>,
3424    ) -> Result<()> {
3425        let _span = tracing::trace_span!(
3426            "image.draw_decoded_masks",
3427            n_detections = detect.len(),
3428            n_segmentations = segmentation.len(),
3429        )
3430        .entered();
3431        let start = Instant::now();
3432
3433        if let Some(bg) = overlay.background {
3434            if bg.aliases(dst) {
3435                return Err(Error::AliasedBuffers(
3436                    "background must not reference the same buffer as dst".to_string(),
3437                ));
3438            }
3439        }
3440
3441        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3442        // a letterbox was applied to prepare the model input.
3443        let lb_boxes: Vec<DetectBox>;
3444        let lb_segs: Vec<Segmentation>;
3445        let (detect, segmentation) = if let Some(lb) = overlay.content_region {
3446            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3447            // Keep segmentation bboxes in sync with the transformed detect boxes
3448            // when we have a 1:1 correspondence (instance segmentation).
3449            if segmentation.len() == lb_boxes.len() {
3450                // Only the four bounds change; the mask bytes are copied
3451                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3452                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3453                // pins the allocation to Mem so the ALLOCATOR cost matches too
3454                // (see there). Rendering from borrowed masks plus a separate
3455                // bounds list would avoid the copy entirely.
3456                lb_segs = segmentation
3457                    .iter()
3458                    .zip(lb_boxes.iter())
3459                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3460                    .collect::<Result<Vec<_>>>()?;
3461                (lb_boxes.as_slice(), lb_segs.as_slice())
3462            } else {
3463                // No 1:1 correspondence, so the segmentation bounds are left
3464                // as they are -- borrow rather than the copy `to_vec()` used
3465                // to make.
3466                (lb_boxes.as_slice(), segmentation)
3467            }
3468        } else {
3469            (detect, segmentation)
3470        };
3471        #[cfg(target_os = "linux")]
3472        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3473
3474        // ── Forced backend: no fallback chain ────────────────────────
3475        if let Some(forced) = self.forced_backend {
3476            return match forced {
3477                ForcedBackend::Cpu => {
3478                    if let Some(cpu) = self.cpu.as_mut() {
3479                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3480                    }
3481                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3482                }
3483                ForcedBackend::G2d => {
3484                    // G2D can only produce empty frames (clear / bg blit).
3485                    // For populated frames it has no rasterizer — fail loudly.
3486                    #[cfg(target_os = "linux")]
3487                    if let Some(g2d) = self.g2d.as_mut() {
3488                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3489                    }
3490                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3491                }
3492                ForcedBackend::OpenGl => {
3493                    // GL handles background natively via GPU blit, and now
3494                    // actively clears when there is no background.
3495                    #[cfg(any(
3496                        target_os = "linux",
3497                        target_os = "macos",
3498                        target_os = "ios",
3499                        target_os = "android",
3500                        target_os = "windows"
3501                    ))]
3502                    #[cfg(feature = "opengl")]
3503                    if let Some(opengl) = self.opengl.as_mut() {
3504                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3505                    }
3506                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3507                }
3508            };
3509        }
3510
3511        // ── Auto dispatch ──────────────────────────────────────────
3512        // Empty frames prefer G2D when available — a single g2d_clear or
3513        // g2d_blit is the cheapest HW path to produce the correct output
3514        // and avoids spinning up the GL pipeline every zero-detection
3515        // frame in a triple-buffered display loop.
3516        #[cfg(target_os = "linux")]
3517        if is_empty_frame {
3518            if let Some(g2d) = self.g2d.as_mut() {
3519                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3520                    Ok(_) => {
3521                        log::trace!(
3522                            "draw_decoded_masks empty frame via g2d in {:?}",
3523                            start.elapsed()
3524                        );
3525                        return Ok(());
3526                    }
3527                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3528                }
3529            }
3530        }
3531
3532        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3533        // Both backends now own their own base-layer handling (bg blit
3534        // or clear), so we hand the overlay through untouched.
3535        #[cfg(any(
3536            target_os = "linux",
3537            target_os = "macos",
3538            target_os = "ios",
3539            target_os = "android",
3540            target_os = "windows"
3541        ))]
3542        #[cfg(feature = "opengl")]
3543        if let Some(opengl) = self.opengl.as_mut() {
3544            log::trace!(
3545                "draw_decoded_masks started with opengl in {:?}",
3546                start.elapsed()
3547            );
3548            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3549                Ok(_) => {
3550                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3551                    return Ok(());
3552                }
3553                Err(e) => {
3554                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3555                }
3556            }
3557        }
3558
3559        log::trace!(
3560            "draw_decoded_masks started with cpu in {:?}",
3561            start.elapsed()
3562        );
3563        if let Some(cpu) = self.cpu.as_mut() {
3564            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3565                Ok(_) => {
3566                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3567                    return Ok(());
3568                }
3569                Err(e) => {
3570                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3571                    return Err(e);
3572                }
3573            }
3574        }
3575        Err(Error::NoConverter)
3576    }
3577
3578    fn draw_proto_masks(
3579        &mut self,
3580        dst: &mut TensorDyn,
3581        detect: &[DetectBox],
3582        proto_data: &ProtoData,
3583        overlay: MaskOverlay<'_>,
3584    ) -> Result<()> {
3585        let start = Instant::now();
3586
3587        if let Some(bg) = overlay.background {
3588            if bg.aliases(dst) {
3589                return Err(Error::AliasedBuffers(
3590                    "background must not reference the same buffer as dst".to_string(),
3591                ));
3592            }
3593        }
3594
3595        // Un-letterbox detect boxes for rendering when a letterbox was applied
3596        // to prepare the model input.  The original `detect` coords are still
3597        // passed to `materialize_segmentations` (which needs model-space coords
3598        // to correctly crop the proto tensor) alongside `overlay.content_region` so
3599        // it can emit `Segmentation` structs in output-image space.
3600        let lb_boxes: Vec<DetectBox>;
3601        let render_detect = if let Some(lb) = overlay.content_region {
3602            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3603            lb_boxes.as_slice()
3604        } else {
3605            detect
3606        };
3607        #[cfg(target_os = "linux")]
3608        let is_empty_frame = detect.is_empty();
3609
3610        // ── Forced backend: no fallback chain ────────────────────────
3611        if let Some(forced) = self.forced_backend {
3612            return match forced {
3613                ForcedBackend::Cpu => {
3614                    if let Some(cpu) = self.cpu.as_mut() {
3615                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3616                    }
3617                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3618                }
3619                ForcedBackend::G2d => {
3620                    #[cfg(target_os = "linux")]
3621                    if let Some(g2d) = self.g2d.as_mut() {
3622                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3623                    }
3624                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3625                }
3626                ForcedBackend::OpenGl => {
3627                    #[cfg(any(
3628                        target_os = "linux",
3629                        target_os = "macos",
3630                        target_os = "ios",
3631                        target_os = "android",
3632                        target_os = "windows"
3633                    ))]
3634                    #[cfg(feature = "opengl")]
3635                    if let Some(opengl) = self.opengl.as_mut() {
3636                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3637                    }
3638                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3639                }
3640            };
3641        }
3642
3643        // ── Auto dispatch ──────────────────────────────────────────
3644        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3645        #[cfg(target_os = "linux")]
3646        if is_empty_frame {
3647            if let Some(g2d) = self.g2d.as_mut() {
3648                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3649                    Ok(_) => {
3650                        log::trace!(
3651                            "draw_proto_masks empty frame via g2d in {:?}",
3652                            start.elapsed()
3653                        );
3654                        return Ok(());
3655                    }
3656                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3657                }
3658            }
3659        }
3660
3661        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3662        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3663        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3664        // GL owns its own bg-blit / glClear — we pass the overlay through.
3665        //
3666        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3667        // needs `&mut`. The CPU borrow is scoped to its block so the
3668        // subsequent GL borrow is free to take over `self`.
3669        #[cfg(any(
3670            target_os = "linux",
3671            target_os = "macos",
3672            target_os = "ios",
3673            target_os = "android",
3674            target_os = "windows"
3675        ))]
3676        #[cfg(feature = "opengl")]
3677        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3678            let segmentation = match self.cpu.as_mut() {
3679                Some(cpu) => {
3680                    log::trace!(
3681                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3682                        start.elapsed()
3683                    );
3684                    cpu.materialize_segmentations(detect, proto_data, overlay.content_region)?
3685                }
3686                None => unreachable!("cpu presence checked above"),
3687            };
3688            if let Some(opengl) = self.opengl.as_mut() {
3689                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3690                    Ok(_) => {
3691                        log::trace!(
3692                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3693                            start.elapsed()
3694                        );
3695                        return Ok(());
3696                    }
3697                    Err(e) => {
3698                        log::trace!(
3699                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3700                        );
3701                    }
3702                }
3703            }
3704        }
3705
3706        let Some(cpu) = self.cpu.as_mut() else {
3707            return Err(Error::Internal(
3708                "draw_proto_masks requires CPU backend for fallback path".into(),
3709            ));
3710        };
3711        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3712        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3713    }
3714
3715    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3716        let start = Instant::now();
3717
3718        // ── Forced backend: no fallback chain ────────────────────────
3719        if let Some(forced) = self.forced_backend {
3720            return match forced {
3721                ForcedBackend::Cpu => {
3722                    if let Some(cpu) = self.cpu.as_mut() {
3723                        return cpu.set_class_colors(colors);
3724                    }
3725                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3726                }
3727                ForcedBackend::G2d => Err(Error::NotSupported(
3728                    "g2d does not support set_class_colors".into(),
3729                )),
3730                ForcedBackend::OpenGl => {
3731                    #[cfg(any(
3732                        target_os = "linux",
3733                        target_os = "macos",
3734                        target_os = "ios",
3735                        target_os = "android",
3736                        target_os = "windows"
3737                    ))]
3738                    #[cfg(feature = "opengl")]
3739                    if let Some(opengl) = self.opengl.as_mut() {
3740                        return opengl.set_class_colors(colors);
3741                    }
3742                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3743                }
3744            };
3745        }
3746
3747        // skip G2D as it doesn't support rendering to image
3748
3749        // Every available backend, not the first that accepts. A palette is
3750        // state a later draw reads, and which backend that draw lands on is
3751        // not known here: GL declines a `view()` destination, an unsupported
3752        // format or an import failure, and the CPU backend takes over. A
3753        // backend that never received the colours would render
3754        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3755        let mut backends = 0usize;
3756        let mut first_err = None;
3757        #[cfg(any(
3758            target_os = "linux",
3759            target_os = "macos",
3760            target_os = "ios",
3761            target_os = "android",
3762            target_os = "windows"
3763        ))]
3764        #[cfg(feature = "opengl")]
3765        if let Some(opengl) = self.opengl.as_mut() {
3766            backends += 1;
3767            match opengl.set_class_colors(colors) {
3768                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3769                Err(e) => {
3770                    log::trace!("colors didn't set with opengl: {e:?}");
3771                    first_err.get_or_insert(e);
3772                }
3773            }
3774        }
3775        if let Some(cpu) = self.cpu.as_mut() {
3776            backends += 1;
3777            match cpu.set_class_colors(colors) {
3778                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3779                Err(e) => {
3780                    log::trace!("colors didn't set with cpu: {e:?}");
3781                    first_err.get_or_insert(e);
3782                }
3783            }
3784        }
3785        // The first error, so a GL failure is not hidden by the CPU backend
3786        // accepting afterwards; the colours the other backends took are kept
3787        // either way, since a partial palette beats a stale one.
3788        match first_err {
3789            Some(e) => Err(e),
3790            None if backends > 0 => Ok(()),
3791            None => Err(Error::NoConverter),
3792        }
3793    }
3794
3795    fn set_box_line_width(&mut self, width: u32) -> Result<()> {
3796        check_box_line_width(width)?;
3797
3798        if let Some(forced) = self.forced_backend {
3799            return match forced {
3800                ForcedBackend::Cpu => {
3801                    if let Some(cpu) = self.cpu.as_mut() {
3802                        return cpu.set_box_line_width(width);
3803                    }
3804                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3805                }
3806                ForcedBackend::G2d => Err(Error::NotSupported(
3807                    "g2d does not support set_box_line_width".into(),
3808                )),
3809                ForcedBackend::OpenGl => {
3810                    #[cfg(any(
3811                        target_os = "linux",
3812                        target_os = "macos",
3813                        target_os = "ios",
3814                        target_os = "android",
3815                        target_os = "windows"
3816                    ))]
3817                    #[cfg(feature = "opengl")]
3818                    if let Some(opengl) = self.opengl.as_mut() {
3819                        return opengl.set_box_line_width(width);
3820                    }
3821                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3822                }
3823            };
3824        }
3825
3826        // Every drawing backend, for the reason `set_class_colors` gives.
3827        let mut backends = 0usize;
3828        let mut first_err = None;
3829        #[cfg(any(
3830            target_os = "linux",
3831            target_os = "macos",
3832            target_os = "ios",
3833            target_os = "android",
3834            target_os = "windows"
3835        ))]
3836        #[cfg(feature = "opengl")]
3837        if let Some(opengl) = self.opengl.as_mut() {
3838            backends += 1;
3839            if let Err(e) = opengl.set_box_line_width(width) {
3840                log::trace!("box line width didn't set with opengl: {e:?}");
3841                first_err.get_or_insert(e);
3842            }
3843        }
3844        if let Some(cpu) = self.cpu.as_mut() {
3845            backends += 1;
3846            if let Err(e) = cpu.set_box_line_width(width) {
3847                log::trace!("box line width didn't set with cpu: {e:?}");
3848                first_err.get_or_insert(e);
3849            }
3850        }
3851        match first_err {
3852            Some(e) => Err(e),
3853            None if backends > 0 => Ok(()),
3854            None => Err(Error::NoConverter),
3855        }
3856    }
3857}
3858
3859// ---------------------------------------------------------------------------
3860// Image loading / saving helpers
3861// ---------------------------------------------------------------------------
3862
3863/// Test-only convenience helper that peeks the image header, allocates a
3864/// tensor sized to the image (honoring DMA pitch padding on Linux when
3865/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3866/// the removed public `load_image` API for test sites; production callers
3867/// should use the explicit peek → allocate → decode pattern directly.
3868#[cfg(all(test, feature = "codec"))]
3869pub(crate) fn load_image_test_helper(
3870    image: &[u8],
3871    format: Option<PixelFormat>,
3872    memory: Option<TensorMemory>,
3873) -> Result<TensorDyn> {
3874    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3875
3876    // Peek the source header to get its NATIVE format and dimensions. The
3877    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3878    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3879    let info = peek_info(image)?;
3880    let native_fmt = info.format;
3881    let w = info.width;
3882    let h = info.height;
3883
3884    let mut decoder = ImageDecoder::new();
3885
3886    // Decode into a native-format tensor. The decoder sets the tensor's
3887    // dims+format, so we allocate it sized to the native layout.
3888    #[cfg(target_os = "linux")]
3889    let native_src = {
3890        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3891            let mut dma = Tensor::<u8>::image_with_stride(
3892                w,
3893                h,
3894                native_fmt,
3895                aligned_pitch,
3896                Some(TensorMemory::DmaBuf),
3897                edgefirst_tensor::CpuAccess::ReadWrite,
3898            )?;
3899            dma.load_image(&mut decoder, image)?;
3900            TensorDyn::from(dma)
3901        } else {
3902            let mut img = Tensor::<u8>::image(
3903                w,
3904                h,
3905                native_fmt,
3906                memory,
3907                edgefirst_tensor::CpuAccess::ReadWrite,
3908            )?;
3909            img.load_image(&mut decoder, image)?;
3910            TensorDyn::from(img)
3911        }
3912    };
3913    #[cfg(not(target_os = "linux"))]
3914    let native_src = {
3915        let mut img = Tensor::<u8>::image(
3916            w,
3917            h,
3918            native_fmt,
3919            memory,
3920            edgefirst_tensor::CpuAccess::ReadWrite,
3921        )?;
3922        img.load_image(&mut decoder, image)?;
3923        TensorDyn::from(img)
3924    };
3925
3926    // If the caller requested a different format, convert into it (same
3927    // dims) using a headless CPU-backed processor so the helper works
3928    // without GPU/G2D hardware.
3929    match format {
3930        Some(f) if f != native_fmt => {
3931            let mut dst = TensorDyn::image(
3932                w,
3933                h,
3934                f,
3935                DType::U8,
3936                memory,
3937                edgefirst_tensor::CpuAccess::ReadWrite,
3938            )?;
3939            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3940            // carries extra GL/G2D options so `..Default::default()` is needed,
3941            // but on macOS `backend` is the only field, making the update
3942            // redundant (clippy::needless_update). Allow it for cross-platform
3943            // parity — the alternative (field reassign) trips
3944            // clippy::field_reassign_with_default on Linux instead.
3945            #[allow(clippy::needless_update)]
3946            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3947                backend: ComputeBackend::Cpu,
3948                ..Default::default()
3949            })?;
3950            proc.convert(&native_src, &mut dst, None)?;
3951            Ok(dst)
3952        }
3953        _ => Ok(native_src),
3954    }
3955}
3956
3957/// Save a [`TensorDyn`] image as a JPEG file.
3958///
3959/// Only packed RGB and RGBA formats are supported.
3960pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3961    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3962        "save_jpeg requires u8 tensor".to_string(),
3963    ))?;
3964    let fmt = t.format().ok_or(Error::NotAnImage)?;
3965    if fmt.layout() != PixelLayout::Packed {
3966        return Err(Error::NotImplemented(
3967            "Saving planar images is not supported".to_string(),
3968        ));
3969    }
3970
3971    let colour = match fmt {
3972        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3973        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3974        _ => {
3975            return Err(Error::NotImplemented(
3976                "Unsupported image format for saving".to_string(),
3977            ));
3978        }
3979    };
3980
3981    let w = t.width().ok_or(Error::NotAnImage)?;
3982    let h = t.height().ok_or(Error::NotAnImage)?;
3983    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3984    let tensor_map = t.map_read()?;
3985
3986    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3987
3988    Ok(())
3989}
3990
3991pub(crate) struct FunctionTimer<T: Display> {
3992    name: T,
3993    start: std::time::Instant,
3994}
3995
3996impl<T: Display> FunctionTimer<T> {
3997    pub fn new(name: T) -> Self {
3998        Self {
3999            name,
4000            start: std::time::Instant::now(),
4001        }
4002    }
4003}
4004
4005impl<T: Display> Drop for FunctionTimer<T> {
4006    fn drop(&mut self) {
4007        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
4008    }
4009}
4010
4011const DEFAULT_COLORS: [[f32; 4]; 20] = [
4012    [0., 1., 0., 0.7],
4013    [1., 0.5568628, 0., 0.7],
4014    [0.25882353, 0.15294118, 0.13333333, 0.7],
4015    [0.8, 0.7647059, 0.78039216, 0.7],
4016    [0.3137255, 0.3137255, 0.3137255, 0.7],
4017    [0.1411765, 0.3098039, 0.1215686, 0.7],
4018    [1., 0.95686275, 0.5137255, 0.7],
4019    [0.3529412, 0.32156863, 0., 0.7],
4020    [0.4235294, 0.6235294, 0.6509804, 0.7],
4021    [0.5098039, 0.5098039, 0.7294118, 0.7],
4022    [0.00784314, 0.18823529, 0.29411765, 0.7],
4023    [0.0, 0.2706, 1.0, 0.7],
4024    [0.0, 0.0, 0.0, 0.7],
4025    [0.0, 0.5, 0.0, 0.7],
4026    [1.0, 0.0, 0.0, 0.7],
4027    [0.0, 0.0, 1.0, 0.7],
4028    [1.0, 0.5, 0.5, 0.7],
4029    [0.1333, 0.5451, 0.1333, 0.7],
4030    [0.1176, 0.4118, 0.8235, 0.7],
4031    [1., 1., 1., 0.7],
4032];
4033
4034const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
4035    let mut result = [[0; M]; N];
4036    let mut i = 0;
4037    while i < N {
4038        let mut j = 0;
4039        while j < M {
4040            result[i][j] = (a[i][j] * 255.0).round() as u8;
4041            j += 1;
4042        }
4043        i += 1;
4044    }
4045    result
4046}
4047
4048const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
4049
4050/// Default bounding-box outline thickness in pixels, used until
4051/// [`ImageProcessorTrait::set_box_line_width`] sets another.
4052pub const DEFAULT_BOX_LINE_WIDTH: u32 = 3;
4053
4054/// Validates a [`ImageProcessorTrait::set_box_line_width`] argument.
4055pub(crate) fn check_box_line_width(width: u32) -> Result<()> {
4056    if width == 0 {
4057        return Err(Error::InvalidOptions(
4058            "box line width must be at least 1 pixel".into(),
4059        ));
4060    }
4061    Ok(())
4062}
4063
4064#[cfg(test)]
4065#[cfg_attr(coverage_nightly, coverage(off))]
4066mod alignment_tests {
4067    use super::*;
4068
4069    #[test]
4070    fn align_width_rgba8_common_widths() {
4071        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
4072        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
4073        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
4074        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
4075        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
4076                                                              // crowd.png case from the imx95 investigation:
4077        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
4078        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
4079        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
4080        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
4081    }
4082
4083    #[test]
4084    fn align_width_rgb888_packed() {
4085        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
4086        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
4087        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
4088        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
4089        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
4090                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
4091        for w in [3004usize, 1281, 100, 17] {
4092            let padded = align_width_for_gpu_pitch(w, 3);
4093            assert!(padded >= w);
4094            assert_eq!((padded * 3) % 64, 0);
4095            assert_eq!((padded * 3) % 3, 0);
4096        }
4097    }
4098
4099    #[test]
4100    fn align_width_grey_u8() {
4101        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
4102        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
4103        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
4104        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
4105        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
4106    }
4107
4108    #[test]
4109    fn align_width_zero_inputs() {
4110        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
4111        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
4112    }
4113
4114    #[test]
4115    fn align_width_never_returns_smaller_than_input() {
4116        // Spot-check the "returned width >= input width" contract across a
4117        // range of values that would previously have hit `width * bpp`
4118        // overflow paths.
4119        for &bpp in &[1usize, 2, 3, 4, 8] {
4120            for &w in &[
4121                1usize,
4122                17,
4123                64,
4124                65,
4125                100,
4126                1280,
4127                1281,
4128                1920,
4129                3004,
4130                3072,
4131                3840,
4132                usize::MAX / 8,
4133                usize::MAX / 4,
4134                usize::MAX / 2,
4135                usize::MAX - 1,
4136                usize::MAX,
4137            ] {
4138                let aligned = align_width_for_gpu_pitch(w, bpp);
4139                assert!(
4140                    aligned >= w,
4141                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
4142                );
4143            }
4144        }
4145    }
4146
4147    #[test]
4148    fn align_width_overflow_returns_unaligned_not_smaller() {
4149        // For width values close to usize::MAX, padding up would wrap. The
4150        // function must return the original width rather than wrapping or
4151        // panicking. A pre-aligned width round-trips unchanged even at the
4152        // extreme.
4153        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
4154        assert_eq!(
4155            align_width_for_gpu_pitch(aligned_extreme, 4),
4156            aligned_extreme
4157        );
4158        // A misaligned extreme value cannot be rounded up — the function
4159        // returns the original.
4160        let misaligned_extreme = usize::MAX - 1;
4161        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
4162        assert!(
4163            result == misaligned_extreme || result >= misaligned_extreme,
4164            "extreme misaligned width must not be rounded down to {result}"
4165        );
4166    }
4167
4168    #[test]
4169    fn checked_lcm_basic_and_overflow() {
4170        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
4171        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
4172        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
4173        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
4174        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
4175        // Coprime values whose product exceeds usize::MAX must return None.
4176        assert_eq!(
4177            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
4178            None,
4179            "coprime extreme values must overflow detect, not panic"
4180        );
4181    }
4182
4183    #[test]
4184    fn primary_plane_bpp_known_formats() {
4185        // Packed formats use channels × elem_size.
4186        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
4187        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
4188        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
4189        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
4190        // Semi-planar (NV12) reports the luma plane's bpp.
4191        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
4192    }
4193}
4194
4195#[cfg(test)]
4196#[cfg_attr(coverage_nightly, coverage(off))]
4197#[allow(deprecated)]
4198mod image_tests {
4199    use super::*;
4200    #[cfg(target_os = "linux")]
4201    use crate::test_support::is_dma_available;
4202    use crate::{CPUProcessor, Rotation};
4203    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
4204    use image::buffer::ConvertBuffer;
4205
4206    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
4207    /// by going through the `TensorDyn` API.
4208    ///
4209    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
4210    /// round-trip so the caller can feed them to `compare_images` etc.
4211    fn convert_img(
4212        proc: &mut dyn ImageProcessorTrait,
4213        src: TensorDyn,
4214        dst: TensorDyn,
4215        rotation: Rotation,
4216        flip: Flip,
4217        crop: Crop,
4218    ) -> (Result<()>, TensorDyn, TensorDyn) {
4219        let src_fourcc = src.format().unwrap();
4220        let dst_fourcc = dst.format().unwrap();
4221        let src_dyn = src;
4222        let mut dst_dyn = dst;
4223        let result = proc.convert(
4224            &src_dyn,
4225            &mut dst_dyn,
4226            Some(
4227                ConvertOptions::new()
4228                    .rotation(rotation)
4229                    .flip(flip)
4230                    .crop(crop),
4231            ),
4232        );
4233        let src_back = {
4234            let mut __t = src_dyn.into_u8().unwrap();
4235            __t.set_format(src_fourcc).unwrap();
4236            TensorDyn::from(__t)
4237        };
4238        let dst_back = {
4239            let mut __t = dst_dyn.into_u8().unwrap();
4240            __t.set_format(dst_fourcc).unwrap();
4241            TensorDyn::from(__t)
4242        };
4243        (result, src_back, dst_back)
4244    }
4245
4246    #[ctor::ctor(unsafe)]
4247    fn init() {
4248        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
4249    }
4250
4251    macro_rules! function {
4252        () => {{
4253            fn f() {}
4254            fn type_name_of<T>(_: T) -> &'static str {
4255                std::any::type_name::<T>()
4256            }
4257            let name = type_name_of(f);
4258
4259            // Find and cut the rest of the path
4260            match &name[..name.len() - 3].rfind(':') {
4261                Some(pos) => &name[pos + 1..name.len() - 3],
4262                None => &name[..name.len() - 3],
4263            }
4264        }};
4265    }
4266
4267    /// Master oracle for the view/batch **destination** batch engine: render `N`
4268    /// tiles into row-bands of ONE tall destination and assert each band equals
4269    /// the same source converted standalone — proving correct band placement and
4270    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
4271    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
4272    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
4273    /// once at `flush()`; other backends fall back to an eager per-band convert
4274    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
4275    ///
4276    /// Identical source/tile size makes the convert an exact copy, so the
4277    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
4278    /// solid colors per tile make any sibling wipe a hard failure.
4279    #[test]
4280    fn batch_view_dst_tiles_match_standalone() {
4281        let mut proc = match ImageProcessor::new() {
4282            Ok(p) => p,
4283            Err(e) => {
4284                crate::test_support::report_skip(&format!(
4285                    "{} — ImageProcessor init failed ({e:?})",
4286                    function!()
4287                ));
4288                return;
4289            }
4290        };
4291        let n = 3usize;
4292        let (w, h) = (32usize, 24usize);
4293        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
4294        let make_src = |c: [u8; 4]| -> TensorDyn {
4295            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
4296            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4297        };
4298        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
4299        // runs (one parent import + per-tile glViewport); skip if unavailable.
4300        let Some(parent) = crate::test_support::dma_allocated(
4301            TensorDyn::image(
4302                w,
4303                n * h,
4304                PixelFormat::Rgba,
4305                DType::U8,
4306                Some(TensorMemory::DmaBuf),
4307                edgefirst_tensor::CpuAccess::ReadWrite,
4308            ),
4309            "tall DMA destination",
4310        ) else {
4311            return;
4312        };
4313
4314        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
4315        for (i, &c) in colors.iter().enumerate().take(n) {
4316            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
4317            proc.convert_deferred(&make_src(c), &mut tile, None)
4318                .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
4319        }
4320        proc.flush().unwrap();
4321
4322        for (i, &c) in colors.iter().enumerate().take(n) {
4323            // Standalone full-buffer convert of the same source = the oracle.
4324            let mut solo = TensorDyn::image(
4325                w,
4326                h,
4327                PixelFormat::Rgba,
4328                DType::U8,
4329                Some(TensorMemory::DmaBuf),
4330                edgefirst_tensor::CpuAccess::ReadWrite,
4331            )
4332            .unwrap();
4333            proc.convert(&make_src(c), &mut solo, None).unwrap();
4334
4335            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
4336            let band_bytes = band
4337                .as_typed::<u8>()
4338                .unwrap()
4339                .map()
4340                .unwrap()
4341                .as_slice()
4342                .to_vec();
4343            let solo_bytes = solo
4344                .as_typed::<u8>()
4345                .unwrap()
4346                .map()
4347                .unwrap()
4348                .as_slice()
4349                .to_vec();
4350            assert_eq!(
4351                band_bytes, solo_bytes,
4352                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
4353            );
4354            assert!(
4355                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
4356                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
4357            );
4358        }
4359    }
4360
4361    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
4362    /// at any origin has distinct content (catches crop-origin / band-placement
4363    /// bugs a solid color cannot).
4364    #[cfg(test)]
4365    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
4366        let mut bytes = vec![0u8; w * h * 4];
4367        for y in 0..h {
4368            for x in 0..w {
4369                let i = (y * w + x) * 4;
4370                bytes[i] = x as u8;
4371                bytes[i + 1] = y as u8;
4372                bytes[i + 2] = (x ^ y) as u8;
4373                bytes[i + 3] = 255;
4374            }
4375        }
4376        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4377    }
4378
4379    /// `set_box_line_width` validates before reaching any backend and the
4380    /// width reaches whichever backend draws: the default chain (GL where
4381    /// available, else CPU) and a forced CPU backend.
4382    #[test]
4383    fn set_box_line_width_reaches_the_drawing_backend() {
4384        use crate::test_support::{box_drawn_columns, box_expected_span, BOX_DIM};
4385        let configs = [
4386            ImageProcessorConfig::default(),
4387            ImageProcessorConfig {
4388                backend: ComputeBackend::Cpu,
4389                ..Default::default()
4390            },
4391        ];
4392        for config in configs {
4393            let backend = config.backend;
4394            let mut proc = ImageProcessor::with_config(config).unwrap();
4395            let err = proc.set_box_line_width(0).unwrap_err();
4396            assert!(
4397                matches!(err, Error::InvalidOptions(_)),
4398                "{backend:?}: {err:?}"
4399            );
4400            let flat =
4401                crate::test_support::draw_box_fixture(&mut proc, [200, 100, 50, 255], Some(4), 1.0);
4402            assert_eq!(
4403                box_drawn_columns(&flat, BOX_DIM / 2),
4404                box_expected_span(4),
4405                "{backend:?}"
4406            );
4407        }
4408    }
4409
4410    /// `tile_into` band == standalone crop-convert of the same source region,
4411    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
4412    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
4413    #[test]
4414    fn tile_into_cpu_distinct_content_parity() {
4415        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4416            backend: ComputeBackend::Cpu,
4417            ..Default::default()
4418        })
4419        .expect("the CPU backend is always available");
4420        let (fw, fh) = (96usize, 64usize);
4421        let src = gradient_frame(fw, fh);
4422        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
4423        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4424        assert_eq!(n, 6);
4425
4426        let mut parent = proc
4427            .alloc_tile_batch(
4428                n,
4429                &cfg,
4430                PixelFormat::Rgba,
4431                DType::U8,
4432                Some(TensorMemory::Mem),
4433                edgefirst_tensor::CpuAccess::ReadWrite,
4434            )
4435            .unwrap();
4436        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4437        assert_eq!(placements.len(), n);
4438
4439        for p in &placements {
4440            let source = Region::new(
4441                p.origin.0 as usize,
4442                p.origin.1 as usize,
4443                p.crop_size.0 as usize,
4444                p.crop_size.1 as usize,
4445            );
4446            let mut solo = TensorDyn::image(
4447                32,
4448                32,
4449                PixelFormat::Rgba,
4450                DType::U8,
4451                Some(TensorMemory::Mem),
4452                edgefirst_tensor::CpuAccess::ReadWrite,
4453            )
4454            .unwrap();
4455            proc.convert(
4456                &src,
4457                &mut solo,
4458                Some(
4459                    crate::ConvertOptions::new().crop(
4460                        Crop::default()
4461                            .with_source(Some(source))
4462                            .with_fit(Fit::Stretch),
4463                    ),
4464                ),
4465            )
4466            .unwrap();
4467            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4468            let band_bytes = band
4469                .as_typed::<u8>()
4470                .unwrap()
4471                .map()
4472                .unwrap()
4473                .as_slice()
4474                .to_vec();
4475            let solo_bytes = solo
4476                .as_typed::<u8>()
4477                .unwrap()
4478                .map()
4479                .unwrap()
4480                .as_slice()
4481                .to_vec();
4482            assert_eq!(
4483                band_bytes, solo_bytes,
4484                "tile {} band differs from standalone crop-convert",
4485                p.index
4486            );
4487        }
4488    }
4489
4490    /// Streaming `tile_one` into a single slot == the corresponding batched
4491    /// `tile_into` band (proves the two paths agree).
4492    #[test]
4493    fn tile_one_matches_tile_into_band() {
4494        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4495            backend: ComputeBackend::Cpu,
4496            ..Default::default()
4497        })
4498        .expect("the CPU backend is always available");
4499        let (fw, fh) = (96usize, 64usize);
4500        let src = gradient_frame(fw, fh);
4501        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4502        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4503
4504        let mut parent = proc
4505            .alloc_tile_batch(
4506                n,
4507                &cfg,
4508                PixelFormat::Rgba,
4509                DType::U8,
4510                Some(TensorMemory::Mem),
4511                edgefirst_tensor::CpuAccess::ReadWrite,
4512            )
4513            .unwrap();
4514        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4515
4516        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4517        for p in &plan {
4518            let mut slot = TensorDyn::image(
4519                32,
4520                32,
4521                PixelFormat::Rgba,
4522                DType::U8,
4523                Some(TensorMemory::Mem),
4524                edgefirst_tensor::CpuAccess::ReadWrite,
4525            )
4526            .unwrap();
4527            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4528            proc.flush().unwrap();
4529            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4530            let slot_bytes = slot
4531                .as_typed::<u8>()
4532                .unwrap()
4533                .map()
4534                .unwrap()
4535                .as_slice()
4536                .to_vec();
4537            let band_bytes = band
4538                .as_typed::<u8>()
4539                .unwrap()
4540                .map()
4541                .unwrap()
4542                .as_slice()
4543                .to_vec();
4544            assert_eq!(
4545                slot_bytes, band_bytes,
4546                "tile {} stream != batch band",
4547                p.index
4548            );
4549        }
4550    }
4551
4552    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4553    /// GPU render).
4554    /// `tile_into` through the auto backend with a DMA parent — exercises the
4555    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4556    /// unavailable). The source is sampled via `Crop::with_source`, never
4557    /// `src.view()`, so all tiles share one source import.
4558    #[test]
4559    fn tile_into_auto_dma_parity() {
4560        let mut proc = match ImageProcessor::new() {
4561            Ok(p) => p,
4562            Err(e) => {
4563                crate::test_support::report_skip(&format!(
4564                    "{} — ImageProcessor init failed ({e:?})",
4565                    function!()
4566                ));
4567                return;
4568            }
4569        };
4570        let (fw, fh) = (96usize, 64usize);
4571        let src = gradient_frame(fw, fh);
4572        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4573        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4574
4575        let Some(mut parent) = crate::test_support::dma_allocated(
4576            proc.alloc_tile_batch(
4577                n,
4578                &cfg,
4579                PixelFormat::Rgba,
4580                DType::U8,
4581                Some(TensorMemory::DmaBuf),
4582                edgefirst_tensor::CpuAccess::ReadWrite,
4583            ),
4584            "tall DMA parent",
4585        ) else {
4586            return;
4587        };
4588        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4589
4590        for p in &placements {
4591            let source = Region::new(
4592                p.origin.0 as usize,
4593                p.origin.1 as usize,
4594                p.crop_size.0 as usize,
4595                p.crop_size.1 as usize,
4596            );
4597            let mut solo = TensorDyn::image(
4598                32,
4599                32,
4600                PixelFormat::Rgba,
4601                DType::U8,
4602                Some(TensorMemory::DmaBuf),
4603                edgefirst_tensor::CpuAccess::ReadWrite,
4604            )
4605            .unwrap();
4606            proc.convert(
4607                &src,
4608                &mut solo,
4609                Some(
4610                    crate::ConvertOptions::new().crop(
4611                        Crop::default()
4612                            .with_source(Some(source))
4613                            .with_fit(Fit::Stretch),
4614                    ),
4615                ),
4616            )
4617            .unwrap();
4618            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4619            // Structural-similarity tolerance (the house GPU-parity bar) rather
4620            // than exact bytes: rendering a tile into a viewport band at a
4621            // non-zero offset vs. a standalone origin render diverges by sampling
4622            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4623            // the same tolerance class as the other cross-backend parity tests.
4624            compare_images(
4625                &band,
4626                &solo,
4627                0.98,
4628                &format!("{}_tile{}", function!(), p.index),
4629            );
4630        }
4631    }
4632
4633    /// `tile_into` rejects a destination too small to hold all tile bands.
4634    #[test]
4635    fn tile_into_undersized_dst_errors() {
4636        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4637            backend: ComputeBackend::Cpu,
4638            ..Default::default()
4639        })
4640        .expect("the CPU backend is always available");
4641        let (fw, fh) = (96usize, 64usize);
4642        let src = gradient_frame(fw, fh);
4643        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4644                                                               // Allocate a parent for only 2 bands, far short of 6.
4645        let mut small = TensorDyn::image(
4646            32,
4647            2 * 32,
4648            PixelFormat::Rgba,
4649            DType::U8,
4650            Some(TensorMemory::Mem),
4651            edgefirst_tensor::CpuAccess::ReadWrite,
4652        )
4653        .unwrap();
4654        let r = proc.tile_into(&src, &mut small, &cfg);
4655        assert!(
4656            matches!(r, Err(Error::InvalidShape(_))),
4657            "expected InvalidShape, got {r:?}"
4658        );
4659    }
4660
4661    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4662    #[test]
4663    fn tiling_alloc_rejects_invalid_config() {
4664        let proc = ImageProcessor::with_config(ImageProcessorConfig {
4665            backend: ComputeBackend::Cpu,
4666            ..Default::default()
4667        })
4668        .expect("the CPU backend is always available");
4669        let bad = TilingConfig::new(0, 640);
4670        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4671        assert!(proc
4672            .alloc_tile_batch(
4673                4,
4674                &bad,
4675                PixelFormat::Rgba,
4676                DType::U8,
4677                Some(TensorMemory::Mem),
4678                edgefirst_tensor::CpuAccess::ReadWrite,
4679            )
4680            .is_err());
4681    }
4682
4683    #[test]
4684    fn plan_tiles_metadata_4k() {
4685        let proc = ImageProcessor::with_config(ImageProcessorConfig {
4686            backend: ComputeBackend::Cpu,
4687            ..Default::default()
4688        })
4689        .expect("the CPU backend is always available");
4690        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4691        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4692        assert_eq!(plan.len(), 32);
4693        assert!(plan.iter().all(|p| p.count == 32));
4694        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4695        assert!(plan.iter().all(|p| p.content_region.is_none())); // stretch fit
4696        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4697        assert_eq!(plan[0].origin, (0.0, 0.0));
4698    }
4699
4700    #[test]
4701    fn test_invalid_crop() {
4702        let src = TensorDyn::image(
4703            100,
4704            100,
4705            PixelFormat::Rgb,
4706            DType::U8,
4707            None,
4708            edgefirst_tensor::CpuAccess::ReadWrite,
4709        )
4710        .unwrap();
4711        let dst = TensorDyn::image(
4712            100,
4713            100,
4714            PixelFormat::Rgb,
4715            DType::U8,
4716            None,
4717            edgefirst_tensor::CpuAccess::ReadWrite,
4718        )
4719        .unwrap();
4720
4721        // A source crop exceeding the source bounds is rejected.
4722        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4723        assert!(matches!(
4724            crop.check_crop_dyn(&src, &dst),
4725            Err(Error::CropInvalid(_))
4726        ));
4727
4728        // A source crop within bounds is valid.
4729        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4730        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4731
4732        // Letterbox is always valid — placement is computed within the dst.
4733        assert!(Crop::letterbox([0, 0, 0, 255])
4734            .check_crop_dyn(&src, &dst)
4735            .is_ok());
4736    }
4737
4738    #[test]
4739    fn test_invalid_tensor_format() -> Result<(), Error> {
4740        // 4D tensor cannot be set to a 3-channel pixel format
4741        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4742        let result = tensor.set_format(PixelFormat::Rgb);
4743        assert!(result.is_err(), "4D tensor should reject set_format");
4744
4745        // Tensor with wrong channel count for the format
4746        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4747        let result = tensor.set_format(PixelFormat::Rgb);
4748        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4749
4750        Ok(())
4751    }
4752
4753    #[test]
4754    fn test_invalid_image_file() -> Result<(), Error> {
4755        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4756        assert!(
4757            matches!(result, Err(Error::Codec(_))),
4758            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4759        );
4760        Ok(())
4761    }
4762
4763    #[test]
4764    fn test_invalid_jpeg_format() -> Result<(), Error> {
4765        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4766        // YUYV is not a valid decode target; peek_info fails before the magic-
4767        // bytes check, so the precise variant depends on which error fires first.
4768        assert!(
4769            matches!(result, Err(Error::Codec(_))),
4770            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4771        );
4772        Ok(())
4773    }
4774
4775    #[test]
4776    fn test_load_resize_save() {
4777        let file = edgefirst_bench::testdata::read("zidane.jpg");
4778        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4779        assert_eq!(img.width(), Some(1280));
4780        assert_eq!(img.height(), Some(720));
4781
4782        let dst = TensorDyn::image(
4783            640,
4784            360,
4785            PixelFormat::Rgba,
4786            DType::U8,
4787            None,
4788            edgefirst_tensor::CpuAccess::ReadWrite,
4789        )
4790        .unwrap();
4791        let mut converter = CPUProcessor::new();
4792        let (result, _img, dst) = convert_img(
4793            &mut converter,
4794            img,
4795            dst,
4796            Rotation::None,
4797            Flip::None,
4798            Crop::no_crop(),
4799        );
4800        result.unwrap();
4801        assert_eq!(dst.width(), Some(640));
4802        assert_eq!(dst.height(), Some(360));
4803
4804        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4805
4806        let file = std::fs::read("zidane_resized.jpg").unwrap();
4807        // With `format: None` the helper returns the source's native format.
4808        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4809        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4810        assert_eq!(img.width(), Some(640));
4811        assert_eq!(img.height(), Some(360));
4812        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4813    }
4814
4815    #[test]
4816    fn test_from_tensor_planar() -> Result<(), Error> {
4817        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4818        tensor
4819            .map()?
4820            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4821        let planar = {
4822            tensor
4823                .set_format(PixelFormat::PlanarRgb)
4824                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4825            TensorDyn::from(tensor)
4826        };
4827
4828        let rbga = load_bytes_to_tensor(
4829            1280,
4830            720,
4831            PixelFormat::Rgba,
4832            None,
4833            &edgefirst_bench::testdata::read("camera720p.rgba"),
4834        )?;
4835        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4836
4837        Ok(())
4838    }
4839
4840    #[test]
4841    fn test_from_tensor_invalid_format() {
4842        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4843        // Since there's no "TEST" pixel format, this validates graceful handling.
4844        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4845    }
4846
4847    #[test]
4848    #[should_panic(expected = "Failed to save planar RGB image")]
4849    fn test_save_planar() {
4850        let planar_img = load_bytes_to_tensor(
4851            1280,
4852            720,
4853            PixelFormat::PlanarRgb,
4854            None,
4855            &edgefirst_bench::testdata::read("camera720p.8bps"),
4856        )
4857        .unwrap();
4858
4859        let save_path = "/tmp/planar_rgb.jpg";
4860        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4861    }
4862
4863    #[test]
4864    #[should_panic(expected = "Failed to save YUYV image")]
4865    fn test_save_yuyv() {
4866        let planar_img = load_bytes_to_tensor(
4867            1280,
4868            720,
4869            PixelFormat::Yuyv,
4870            None,
4871            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4872        )
4873        .unwrap();
4874
4875        let save_path = "/tmp/yuyv.jpg";
4876        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4877    }
4878
4879    #[test]
4880    fn test_rotation_angle() {
4881        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4882        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4883        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4884        assert_eq!(
4885            Rotation::from_degrees_clockwise(270),
4886            Rotation::CounterClockwise90
4887        );
4888        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4889        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4890        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4891        assert_eq!(
4892            Rotation::from_degrees_clockwise(630),
4893            Rotation::CounterClockwise90
4894        );
4895    }
4896
4897    #[test]
4898    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4899    fn test_rotation_angle_panic() {
4900        Rotation::from_degrees_clockwise(361);
4901    }
4902
4903    #[test]
4904    fn test_disable_env_var() -> Result<(), Error> {
4905        // Acquire the env-var mutex for the entire test body so we never race
4906        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4907        let _lock = acquire_env_lock();
4908
4909        // Snapshot ALL env vars we might touch so the RAII guard restores them
4910        // on exit (even on panic), preventing env-var poisoning of other tests.
4911        let _guard = EnvGuard::snapshot(&[
4912            "EDGEFIRST_FORCE_BACKEND",
4913            "EDGEFIRST_DISABLE_GL",
4914            "EDGEFIRST_DISABLE_G2D",
4915            "EDGEFIRST_DISABLE_CPU",
4916        ]);
4917
4918        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4919        // so clear it for the duration of this test.
4920        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4921
4922        #[cfg(target_os = "linux")]
4923        {
4924            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4925            let converter = ImageProcessor::new()?;
4926            assert!(converter.g2d.is_none());
4927            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4928        }
4929
4930        #[cfg(any(target_os = "linux", target_os = "windows"))]
4931        #[cfg(feature = "opengl")]
4932        {
4933            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4934            let converter = ImageProcessor::new()?;
4935            assert!(converter.opengl.is_none());
4936            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4937        }
4938
4939        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4940        let converter = ImageProcessor::new()?;
4941        assert!(converter.cpu.is_none());
4942        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4943
4944        // Disable everything — convert must return NoConverter.
4945        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4946        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4947        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4948        let mut converter = ImageProcessor::new()?;
4949
4950        let src = TensorDyn::image(
4951            1280,
4952            720,
4953            PixelFormat::Rgba,
4954            DType::U8,
4955            None,
4956            edgefirst_tensor::CpuAccess::ReadWrite,
4957        )?;
4958        let dst = TensorDyn::image(
4959            640,
4960            360,
4961            PixelFormat::Rgba,
4962            DType::U8,
4963            None,
4964            edgefirst_tensor::CpuAccess::ReadWrite,
4965        )?;
4966        let (result, _src, _dst) = convert_img(
4967            &mut converter,
4968            src,
4969            dst,
4970            Rotation::None,
4971            Flip::None,
4972            Crop::no_crop(),
4973        );
4974        assert!(matches!(result, Err(Error::NoConverter)));
4975        // _guard restores all env vars on drop.
4976        Ok(())
4977    }
4978
4979    #[test]
4980    fn test_unsupported_conversion() {
4981        let src = TensorDyn::image(
4982            1280,
4983            720,
4984            PixelFormat::Nv12,
4985            DType::U8,
4986            None,
4987            edgefirst_tensor::CpuAccess::ReadWrite,
4988        )
4989        .unwrap();
4990        let dst = TensorDyn::image(
4991            640,
4992            360,
4993            PixelFormat::Nv12,
4994            DType::U8,
4995            None,
4996            edgefirst_tensor::CpuAccess::ReadWrite,
4997        )
4998        .unwrap();
4999        let mut converter = ImageProcessor::new().unwrap();
5000        let (result, _src, _dst) = convert_img(
5001            &mut converter,
5002            src,
5003            dst,
5004            Rotation::None,
5005            Flip::None,
5006            Crop::no_crop(),
5007        );
5008        log::debug!("result: {:?}", result);
5009        assert!(matches!(
5010            result,
5011            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
5012        ));
5013    }
5014
5015    #[test]
5016    fn test_load_grey() {
5017        // A single-component (greyscale) JPEG decodes to its native GREY
5018        // format, which has no even-dimension constraint, so the 1024×681
5019        // `grey.jpg` loads and converts to RGBA successfully.
5020        let grey_img = crate::load_image_test_helper(
5021            &edgefirst_bench::testdata::read("grey.jpg"),
5022            Some(PixelFormat::Rgba),
5023            None,
5024        )
5025        .unwrap();
5026        assert_eq!(grey_img.width(), Some(1024));
5027        assert_eq!(grey_img.height(), Some(681));
5028
5029        // `grey-rgb.jpg` holds the same grey content but is encoded as a
5030        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
5031        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
5032        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
5033        // decode succeeds and converts to RGBA at the true dimensions.
5034        let grey_but_rgb = crate::load_image_test_helper(
5035            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
5036            Some(PixelFormat::Rgba),
5037            None,
5038        )
5039        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
5040        assert_eq!(grey_but_rgb.width(), Some(1024));
5041        assert_eq!(grey_but_rgb.height(), Some(681));
5042    }
5043
5044    #[test]
5045    fn test_new_nv12() {
5046        let nv12 = TensorDyn::image(
5047            1280,
5048            720,
5049            PixelFormat::Nv12,
5050            DType::U8,
5051            None,
5052            edgefirst_tensor::CpuAccess::ReadWrite,
5053        )
5054        .unwrap();
5055        assert_eq!(nv12.height(), Some(720));
5056        assert_eq!(nv12.width(), Some(1280));
5057        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
5058        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
5059        assert_eq!(nv12.format().unwrap().channels(), 1);
5060        assert!(nv12.format().is_some_and(
5061            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
5062        ))
5063    }
5064
5065    #[test]
5066    #[cfg(any(target_os = "linux", target_os = "windows"))]
5067    fn test_new_image_converter() {
5068        let dst_width = 640;
5069        let dst_height = 360;
5070        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5071        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5072
5073        let mut converter = ImageProcessor::new().unwrap();
5074        let converter_dst = converter
5075            .create_image(
5076                dst_width,
5077                dst_height,
5078                PixelFormat::Rgba,
5079                DType::U8,
5080                None,
5081                edgefirst_tensor::CpuAccess::ReadWrite,
5082            )
5083            .unwrap();
5084        let (result, src, converter_dst) = convert_img(
5085            &mut converter,
5086            src,
5087            converter_dst,
5088            Rotation::None,
5089            Flip::None,
5090            Crop::no_crop(),
5091        );
5092        result.unwrap();
5093
5094        let cpu_dst = TensorDyn::image(
5095            dst_width,
5096            dst_height,
5097            PixelFormat::Rgba,
5098            DType::U8,
5099            None,
5100            edgefirst_tensor::CpuAccess::ReadWrite,
5101        )
5102        .unwrap();
5103        let mut cpu_converter = CPUProcessor::new();
5104        let (result, _src, cpu_dst) = convert_img(
5105            &mut cpu_converter,
5106            src,
5107            cpu_dst,
5108            Rotation::None,
5109            Flip::None,
5110            Crop::no_crop(),
5111        );
5112        result.unwrap();
5113
5114        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
5115    }
5116
5117    #[test]
5118    #[cfg(any(target_os = "linux", target_os = "windows"))]
5119    fn test_create_image_dtype_i8() {
5120        let mut converter = ImageProcessor::new().unwrap();
5121
5122        // I8 image should allocate successfully via create_image
5123        let dst = converter
5124            .create_image(
5125                320,
5126                240,
5127                PixelFormat::Rgb,
5128                DType::I8,
5129                None,
5130                edgefirst_tensor::CpuAccess::ReadWrite,
5131            )
5132            .unwrap();
5133        assert_eq!(dst.dtype(), DType::I8);
5134        assert!(dst.width() == Some(320));
5135        assert!(dst.height() == Some(240));
5136        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
5137
5138        // U8 for comparison
5139        let dst_u8 = converter
5140            .create_image(
5141                320,
5142                240,
5143                PixelFormat::Rgb,
5144                DType::U8,
5145                None,
5146                edgefirst_tensor::CpuAccess::ReadWrite,
5147            )
5148            .unwrap();
5149        assert_eq!(dst_u8.dtype(), DType::U8);
5150
5151        // Convert into I8 dst should succeed
5152        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5153        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5154        let mut dst_i8 = converter
5155            .create_image(
5156                320,
5157                240,
5158                PixelFormat::Rgb,
5159                DType::I8,
5160                None,
5161                edgefirst_tensor::CpuAccess::ReadWrite,
5162            )
5163            .unwrap();
5164        converter.convert(&src, &mut dst_i8, None).unwrap();
5165    }
5166
5167    #[test]
5168    #[cfg(target_os = "linux")]
5169    fn test_create_image_nv12_dma_non_aligned_width() {
5170        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
5171        // may legitimately carry a GPU-pitch-padded row stride — that is the
5172        // intended behaviour, not a bug. Verify the logical geometry is preserved
5173        // for any width and that a reported stride is a valid (>= logical)
5174        // padding, rather than asserting the absence of a stride.
5175        let converter = ImageProcessor::new().unwrap();
5176
5177        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
5178        let Some(img) = crate::test_support::dma_allocated(
5179            converter.create_image(
5180                100,
5181                64,
5182                PixelFormat::Nv12,
5183                DType::U8,
5184                Some(TensorMemory::DmaBuf),
5185                edgefirst_tensor::CpuAccess::ReadWrite,
5186            ),
5187            "create_image NV12 DMA non-aligned width",
5188        ) else {
5189            return;
5190        };
5191        assert_eq!(img.width(), Some(100));
5192        assert_eq!(img.height(), Some(64));
5193        assert_eq!(img.format(), Some(PixelFormat::Nv12));
5194        if let Some(stride) = img.row_stride() {
5195            assert!(
5196                stride >= 100,
5197                "NV12 row_stride {stride} must be >= the logical width (100)",
5198            );
5199        }
5200    }
5201
5202    #[test]
5203    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
5204              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
5205              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
5206    fn test_crop_skip() {
5207        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5208        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5209
5210        let mut converter = ImageProcessor::new().unwrap();
5211        let converter_dst = converter
5212            .create_image(
5213                1280,
5214                720,
5215                PixelFormat::Rgba,
5216                DType::U8,
5217                None,
5218                edgefirst_tensor::CpuAccess::ReadWrite,
5219            )
5220            .unwrap();
5221        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
5222        let (result, src, converter_dst) = convert_img(
5223            &mut converter,
5224            src,
5225            converter_dst,
5226            Rotation::None,
5227            Flip::None,
5228            crop,
5229        );
5230        result.unwrap();
5231
5232        let cpu_dst = TensorDyn::image(
5233            1280,
5234            720,
5235            PixelFormat::Rgba,
5236            DType::U8,
5237            None,
5238            edgefirst_tensor::CpuAccess::ReadWrite,
5239        )
5240        .unwrap();
5241        let mut cpu_converter = CPUProcessor::new();
5242        let (result, _src, cpu_dst) = convert_img(
5243            &mut cpu_converter,
5244            src,
5245            cpu_dst,
5246            Rotation::None,
5247            Flip::None,
5248            crop,
5249        );
5250        result.unwrap();
5251
5252        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
5253    }
5254
5255    #[test]
5256    fn test_invalid_pixel_format() {
5257        // PixelFormat::from_fourcc returns None for unknown formats,
5258        // so TensorDyn::image cannot be called with an invalid format.
5259        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
5260    }
5261
5262    // Helper function to check if G2D library is available (Linux/i.MX8 only)
5263    #[cfg(target_os = "linux")]
5264    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5265
5266    #[cfg(target_os = "linux")]
5267    fn is_g2d_available() -> bool {
5268        crate::test_support::g2d_required_or(
5269            *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok()),
5270        )
5271    }
5272
5273    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
5274    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
5275    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
5276    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
5277    // `gl_backend_available_canary`).
5278    #[cfg(any(
5279        target_os = "linux",
5280        target_os = "macos",
5281        target_os = "ios",
5282        target_os = "android",
5283        target_os = "windows"
5284    ))]
5285    #[cfg(feature = "opengl")]
5286    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5287
5288    #[cfg(any(
5289        target_os = "linux",
5290        target_os = "macos",
5291        target_os = "ios",
5292        target_os = "android",
5293        target_os = "windows"
5294    ))]
5295    #[cfg(feature = "opengl")]
5296    // Helper function to check if OpenGL is available
5297    fn is_opengl_available() -> bool {
5298        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
5299    }
5300
5301    /// CI canary: fails the lane when the GL backend cannot initialize.
5302    ///
5303    /// Every GL test in this suite self-skips when the backend is
5304    /// unavailable — correct for developer machines, but it means a broken
5305    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
5306    /// failure mode documented in the workflow) ships an untested GL
5307    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
5308    /// only by CI jobs that install a working GL stack; local runs without
5309    /// one pass trivially. On macOS it additionally requires
5310    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
5311    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
5312    #[test]
5313    #[cfg(feature = "opengl")]
5314    fn gl_backend_available_canary() {
5315        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
5316        if !require_gl {
5317            crate::test_support::report_skip(&format!(
5318                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
5319                function!()
5320            ));
5321            return;
5322        }
5323        #[cfg(target_os = "macos")]
5324        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
5325            crate::test_support::report_skip(&format!(
5326                "{} — ANGLE dlopen gate closed (coverage pass 1)",
5327                function!()
5328            ));
5329            return;
5330        }
5331        GLProcessorThreaded::new(None).expect(
5332            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
5333             check the ANGLE install/re-sign step and binary entitlements \
5334             (macOS) or the EGL stack (Linux)",
5335        );
5336    }
5337
5338    #[test]
5339    fn test_load_jpeg_with_exif() {
5340        use edgefirst_codec::peek_info;
5341
5342        // The migrated codec NEVER applies EXIF orientation: it decodes to the
5343        // source's native (un-rotated) dimensions and reports the rotation via
5344        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
5345        // (90° clockwise) over a 1280×720 frame.
5346        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
5347        let info = peek_info(&file).unwrap();
5348        assert_eq!(info.rotation_degrees, 90);
5349        assert!(!info.flip_horizontal);
5350
5351        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5352        // Native (un-rotated) dimensions — the decode does not rotate.
5353        assert_eq!(loaded.width(), Some(1280));
5354        assert_eq!(loaded.height(), Some(720));
5355
5356        // Applying the reported rotation downstream reproduces the upright
5357        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
5358        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5359        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5360
5361        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5362        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
5363
5364        let cpu_dst = TensorDyn::image(
5365            dst_width,
5366            dst_height,
5367            PixelFormat::Rgba,
5368            DType::U8,
5369            None,
5370            edgefirst_tensor::CpuAccess::ReadWrite,
5371        )
5372        .unwrap();
5373        let mut cpu_converter = CPUProcessor::new();
5374
5375        // Rotate the native-orientation `loaded` frame and the native `zidane`
5376        // frame by the same reported rotation; the results must agree.
5377        let loaded_rotated = TensorDyn::image(
5378            dst_width,
5379            dst_height,
5380            PixelFormat::Rgba,
5381            DType::U8,
5382            None,
5383            edgefirst_tensor::CpuAccess::ReadWrite,
5384        )
5385        .unwrap();
5386        let (r0, _loaded, loaded_rotated) = convert_img(
5387            &mut cpu_converter,
5388            loaded,
5389            loaded_rotated,
5390            rotation,
5391            Flip::None,
5392            Crop::no_crop(),
5393        );
5394        r0.unwrap();
5395
5396        let (result, _cpu_src, cpu_dst) = convert_img(
5397            &mut cpu_converter,
5398            cpu_src,
5399            cpu_dst,
5400            rotation,
5401            Flip::None,
5402            Crop::no_crop(),
5403        );
5404        result.unwrap();
5405
5406        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
5407    }
5408
5409    #[test]
5410    fn test_load_png_with_exif() {
5411        use edgefirst_codec::peek_info;
5412
5413        // PNGs also report EXIF orientation without applying it.
5414        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
5415        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
5416        let info = peek_info(&file).unwrap();
5417        assert_eq!(info.rotation_degrees, 180);
5418        assert!(!info.flip_horizontal);
5419
5420        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5421        // Native (un-rotated) dimensions — PNG decodes upright as authored.
5422        assert_eq!(loaded.height(), Some(720));
5423        assert_eq!(loaded.width(), Some(1280));
5424
5425        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
5426        // EXIF orientation. Because the codec no longer applies the rotation,
5427        // the decoded pixels match `zidane.jpg` directly (no convert needed).
5428        // Re-applying the reported rotation to both must still agree.
5429        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5430        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5431
5432        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5433        let cpu_dst = TensorDyn::image(
5434            1280,
5435            720,
5436            PixelFormat::Rgba,
5437            DType::U8,
5438            None,
5439            edgefirst_tensor::CpuAccess::ReadWrite,
5440        )
5441        .unwrap();
5442        let mut cpu_converter = CPUProcessor::new();
5443
5444        let (result, _cpu_src, cpu_dst) = convert_img(
5445            &mut cpu_converter,
5446            cpu_src,
5447            cpu_dst,
5448            rotation,
5449            Flip::None,
5450            Crop::no_crop(),
5451        );
5452        result.unwrap();
5453
5454        // Rotate the decoded PNG by the same reported angle so both frames are
5455        // in the same (180°-rotated) orientation before comparing.
5456        let loaded_rotated = TensorDyn::image(
5457            1280,
5458            720,
5459            PixelFormat::Rgba,
5460            DType::U8,
5461            None,
5462            edgefirst_tensor::CpuAccess::ReadWrite,
5463        )
5464        .unwrap();
5465        let (r0, _loaded, loaded_rotated) = convert_img(
5466            &mut cpu_converter,
5467            loaded,
5468            loaded_rotated,
5469            rotation,
5470            Flip::None,
5471            Crop::no_crop(),
5472        );
5473        r0.unwrap();
5474
5475        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5476        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5477        // subsampling) before the RGBA conversion, so the two paths differ by a
5478        // couple of percent versus the old direct-RGB JPEG decode.
5479        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5480    }
5481
5482    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5483    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5484    /// compiled without its JPEG feature). Used to exercise the decoder /
5485    /// pitch-padding paths for arbitrary dimensions without having to bundle
5486    /// a fixture file per test size.
5487    #[cfg(target_os = "linux")]
5488    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5489        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5490        for y in 0..height {
5491            for x in 0..width {
5492                bytes.push(((x + y) & 0xFF) as u8);
5493                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5494                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5495            }
5496        }
5497        let mut out = Vec::new();
5498        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5499        encoder
5500            .encode(
5501                &bytes,
5502                width as u16,
5503                height as u16,
5504                jpeg_encoder::ColorType::Rgb,
5505            )
5506            .expect("jpeg-encoder must succeed on trivial input");
5507        out
5508    }
5509
5510    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5511    /// via the pitch-padded DMA path and letterboxed through the GL
5512    /// backend must produce correct output. Before the Rgba/Bgra
5513    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5514    /// failed the pre-check and forced a CPU texture upload fallback;
5515    /// with the relaxation, EGL import succeeds at the driver level and
5516    /// the GL fast path runs. Output correctness is checked against a
5517    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5518    #[test]
5519    #[cfg(target_os = "linux")]
5520    #[cfg(feature = "opengl")]
5521    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5522        use crate::test_support::is_dma_available;
5523        if !is_dma_available() {
5524            crate::test_support::report_skip(
5525                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5526            );
5527            return;
5528        }
5529        // 375 is the canonical failure width from dataset loaders —
5530        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5531        // so the old pre-check rejected it; new code accepts it.
5532        let jpeg = make_rgb_jpeg(375, 333);
5533        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5534        assert_eq!(src_gl.width(), Some(375));
5535        // Row stride must still be pitch-padded (separate concern from width).
5536        let stride = src_gl.row_stride().unwrap();
5537        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5538
5539        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5540        let mut gl_proc = ImageProcessor::new().unwrap();
5541        let gl_dst = gl_proc
5542            .create_image(
5543                640,
5544                640,
5545                PixelFormat::Rgba,
5546                DType::U8,
5547                None,
5548                edgefirst_tensor::CpuAccess::ReadWrite,
5549            )
5550            .unwrap();
5551        let (r_gl, _src_gl, gl_dst) = convert_img(
5552            &mut gl_proc,
5553            src_gl,
5554            gl_dst,
5555            Rotation::None,
5556            Flip::None,
5557            Crop::no_crop(),
5558        );
5559        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5560
5561        // CPU reference via a fresh load so the two paths start from
5562        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5563        // CPU-only processor regardless of which backends the host has
5564        // available.
5565        let src_cpu =
5566            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5567                .unwrap();
5568        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5569            backend: ComputeBackend::Cpu,
5570            ..Default::default()
5571        })
5572        .unwrap();
5573        let cpu_dst = TensorDyn::image(
5574            640,
5575            640,
5576            PixelFormat::Rgba,
5577            DType::U8,
5578            Some(TensorMemory::Mem),
5579            edgefirst_tensor::CpuAccess::ReadWrite,
5580        )
5581        .unwrap();
5582        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5583            &mut cpu_proc,
5584            src_cpu,
5585            cpu_dst,
5586            Rotation::None,
5587            Flip::None,
5588            Crop::no_crop(),
5589        );
5590        r_cpu.unwrap();
5591
5592        // Structural similarity: the GL path may have gone through EGL
5593        // import OR fallen back to CPU texture upload — either way, the
5594        // output must match the CPU reference closely.
5595        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5596    }
5597
5598    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5599    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5600    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5601    /// to the padded value. The earlier pitch-padding commit fixed this in
5602    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5603    /// `effective_row_stride == 2000`.
5604    #[test]
5605    #[cfg(target_os = "linux")]
5606    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5607        use crate::test_support::is_dma_available;
5608        if !is_dma_available() {
5609            crate::test_support::report_skip(
5610                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5611            );
5612            return;
5613        }
5614        // Widths that force a non-64-aligned natural RGBA pitch. All three
5615        // are divisible by 4 so the EGL width-alignment pre-check passes.
5616        // The pitch-padding fix is what makes these importable at all.
5617        for &w in &[500u32, 612, 428] {
5618            let jpeg = make_rgb_jpeg(w, 333);
5619            let loaded =
5620                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5621            let natural = (w as usize) * 4;
5622            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5623            assert!(
5624                aligned > natural,
5625                "test sanity: width {w} should be unaligned"
5626            );
5627            let stride = loaded
5628                .row_stride()
5629                .expect("padded DMA path must set an explicit row_stride — regression if None");
5630            assert_eq!(
5631                stride, aligned,
5632                "width {w}: expected padded stride {aligned}, got {stride} \
5633                 (regression: pitch-padding branch skipped?)"
5634            );
5635            let eff = loaded.effective_row_stride().unwrap();
5636            assert_eq!(
5637                eff, aligned,
5638                "effective_row_stride must match stored stride"
5639            );
5640            assert_eq!(loaded.width(), Some(w as usize));
5641            assert_eq!(loaded.height(), Some(333));
5642        }
5643    }
5644
5645    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5646    /// must NOT route into the pitch-padded DMA path when the caller left
5647    /// the choice to the allocator (`None`) but DMA is unavailable on the
5648    /// host. The padded path requires `image_with_stride`, which always
5649    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5650    /// would convert a normally-working image load into a hard failure
5651    /// (since `Tensor::image(..., None)` would have fallen back to
5652    /// SHM/Mem).
5653    #[test]
5654    #[cfg(target_os = "linux")]
5655    fn test_padded_dma_pitch_for_respects_memory_choice() {
5656        use crate::test_support::is_dma_available;
5657        use edgefirst_tensor::TensorMemory;
5658
5659        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5660        // every case so any "no padding" answer is unambiguous.
5661        let unaligned_w = 500;
5662
5663        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5664        assert_eq!(
5665            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5666            None,
5667            "Mem must never trigger DMA padding"
5668        );
5669        assert_eq!(
5670            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5671            None,
5672            "Shm must never trigger DMA padding"
5673        );
5674
5675        // Caller explicitly asks for DMA: always pad if width needs it.
5676        // Even if the runtime can't actually allocate DMA, the caller
5677        // owns that decision and the resulting allocation error is
5678        // their problem, not ours.
5679        assert_eq!(
5680            crate::padded_dma_pitch_for(
5681                PixelFormat::Rgba,
5682                unaligned_w,
5683                &Some(TensorMemory::DmaBuf),
5684            ),
5685            Some(2048),
5686            "explicit Dma must pad regardless of runtime DMA availability"
5687        );
5688
5689        // Caller leaves it to the allocator: behaviour depends on
5690        // host-runtime DMA availability. This is the case the fix
5691        // guards against.
5692        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5693        if is_dma_available() {
5694            assert_eq!(
5695                none_result,
5696                Some(2048),
5697                "memory=None + DMA available → pad (will route through DMA)"
5698            );
5699        } else {
5700            assert_eq!(
5701                none_result, None,
5702                "memory=None + DMA unavailable → must NOT pad (would force \
5703                 image_with_stride into a DMA-only allocation that fails). \
5704                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5705            );
5706        }
5707    }
5708
5709    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5710    // deterministic ramp pattern so multiple tests can cross-check output
5711    // without bundling an extra fixture file.
5712    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5713        let mut bytes = Vec::with_capacity((width * height) as usize);
5714        for y in 0..height {
5715            for x in 0..width {
5716                bytes.push(((x + y) & 0xFF) as u8);
5717            }
5718        }
5719        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5720        let mut buf = Vec::new();
5721        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5722            .unwrap();
5723        buf
5724    }
5725
5726    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5727    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5728    /// alignment) must still load via the pitch-padded DMA path. Gated on
5729    /// DMA availability because `image_with_stride` is DMA-only.
5730    #[test]
5731    #[cfg(target_os = "linux")]
5732    fn test_load_png_grey_misaligned_width_dma() {
5733        use crate::test_support::is_dma_available;
5734        if !is_dma_available() {
5735            crate::test_support::report_skip(
5736                "test_load_png_grey_misaligned_width_dma — DMA not available",
5737            );
5738            return;
5739        }
5740        let png = make_grey_png(612, 388);
5741        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5742        assert_eq!(loaded.width(), Some(612));
5743        assert_eq!(loaded.height(), Some(388));
5744        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5745
5746        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5747        // must indirect through row_stride() rather than assume width.
5748        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5749        let stride = loaded.row_stride().unwrap_or(612);
5750        assert!(stride >= 612);
5751        let bytes: &[u8] = &map;
5752        for y in 0..388usize {
5753            for x in 0..612usize {
5754                let expected = ((x + y) & 0xFF) as u8;
5755                let got = bytes[y * stride + x];
5756                assert_eq!(
5757                    got, expected,
5758                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5759                );
5760            }
5761        }
5762    }
5763
5764    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5765    /// platform (no DMA permission requirement) and covers the
5766    /// decoder-native Luma → Grey no-conversion path.
5767    #[test]
5768    fn test_load_png_grey_mem() {
5769        use edgefirst_tensor::TensorMemory;
5770        let png = make_grey_png(612, 100);
5771        let loaded =
5772            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5773                .unwrap();
5774        assert_eq!(loaded.width(), Some(612));
5775        assert_eq!(loaded.height(), Some(100));
5776        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5777        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5778        let bytes: &[u8] = &map;
5779        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5780        assert_eq!(bytes.len(), 612 * 100);
5781        for y in 0..100 {
5782            for x in 0..612 {
5783                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5784            }
5785        }
5786    }
5787
5788    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5789    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5790    /// stay portable to host-side test environments.
5791    #[test]
5792    fn test_load_png_grey_to_rgb_mem() {
5793        use edgefirst_tensor::TensorMemory;
5794        let png = make_grey_png(620, 240);
5795        let loaded =
5796            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5797                .unwrap();
5798        assert_eq!(loaded.width(), Some(620));
5799        assert_eq!(loaded.height(), Some(240));
5800        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5801
5802        // Greyscale promoted to RGB replicates luma into each channel.
5803        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5804        let bytes: &[u8] = &map;
5805        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5806            let expected = ((x + y) & 0xFF) as u8;
5807            let off = (y * 620 + x) * 3;
5808            assert_eq!(bytes[off], expected, "R@{x},{y}");
5809            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5810            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5811        }
5812    }
5813
5814    #[test]
5815    #[cfg(target_os = "linux")]
5816    fn test_g2d_resize() {
5817        if !is_g2d_available() {
5818            crate::test_support::report_skip(
5819                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5820            );
5821            return;
5822        }
5823        if !is_dma_available() {
5824            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5825            return;
5826        }
5827
5828        let dst_width = 640;
5829        let dst_height = 360;
5830        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5831        let src = crate::load_image_test_helper(
5832            &file,
5833            Some(PixelFormat::Rgba),
5834            Some(TensorMemory::DmaBuf),
5835        )
5836        .unwrap();
5837
5838        let g2d_dst = TensorDyn::image(
5839            dst_width,
5840            dst_height,
5841            PixelFormat::Rgba,
5842            DType::U8,
5843            Some(TensorMemory::DmaBuf),
5844            edgefirst_tensor::CpuAccess::ReadWrite,
5845        )
5846        .unwrap();
5847        let mut g2d_converter = G2DProcessor::new().unwrap();
5848        let (result, src, g2d_dst) = convert_img(
5849            &mut g2d_converter,
5850            src,
5851            g2d_dst,
5852            Rotation::None,
5853            Flip::None,
5854            Crop::no_crop(),
5855        );
5856        result.unwrap();
5857
5858        let cpu_dst = TensorDyn::image(
5859            dst_width,
5860            dst_height,
5861            PixelFormat::Rgba,
5862            DType::U8,
5863            None,
5864            edgefirst_tensor::CpuAccess::ReadWrite,
5865        )
5866        .unwrap();
5867        let mut cpu_converter = CPUProcessor::new();
5868        let (result, _src, cpu_dst) = convert_img(
5869            &mut cpu_converter,
5870            src,
5871            cpu_dst,
5872            Rotation::None,
5873            Flip::None,
5874            Crop::no_crop(),
5875        );
5876        result.unwrap();
5877
5878        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5879        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5880        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5881        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5882        // structural gap not exercised by these limited-range fixtures.
5883        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5884    }
5885
5886    #[test]
5887    #[cfg(any(
5888        target_os = "linux",
5889        target_os = "macos",
5890        target_os = "ios",
5891        target_os = "android",
5892        target_os = "windows"
5893    ))]
5894    #[cfg(feature = "opengl")]
5895    fn test_opengl_resize() {
5896        if !is_opengl_available() {
5897            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5898            return;
5899        }
5900
5901        let dst_width = 640;
5902        let dst_height = 360;
5903        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5904        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5905
5906        let cpu_dst = TensorDyn::image(
5907            dst_width,
5908            dst_height,
5909            PixelFormat::Rgba,
5910            DType::U8,
5911            None,
5912            edgefirst_tensor::CpuAccess::ReadWrite,
5913        )
5914        .unwrap();
5915        let mut cpu_converter = CPUProcessor::new();
5916        let (result, src, cpu_dst) = convert_img(
5917            &mut cpu_converter,
5918            src,
5919            cpu_dst,
5920            Rotation::None,
5921            Flip::None,
5922            Crop::no_crop(),
5923        );
5924        result.unwrap();
5925
5926        let mut src = src;
5927        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5928
5929        for _ in 0..5 {
5930            let gl_dst = TensorDyn::image(
5931                dst_width,
5932                dst_height,
5933                PixelFormat::Rgba,
5934                DType::U8,
5935                None,
5936                edgefirst_tensor::CpuAccess::ReadWrite,
5937            )
5938            .unwrap();
5939            let (result, src_back, gl_dst) = convert_img(
5940                &mut gl_converter,
5941                src,
5942                gl_dst,
5943                Rotation::None,
5944                Flip::None,
5945                Crop::no_crop(),
5946            );
5947            result.unwrap();
5948            src = src_back;
5949
5950            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5951        }
5952    }
5953
5954    #[test]
5955    #[cfg(any(
5956        target_os = "linux",
5957        target_os = "macos",
5958        target_os = "ios",
5959        target_os = "android",
5960        target_os = "windows"
5961    ))]
5962    #[cfg(feature = "opengl")]
5963    fn test_opengl_10_threads() {
5964        if !is_opengl_available() {
5965            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5966            return;
5967        }
5968
5969        let handles: Vec<_> = (0..10)
5970            .map(|i| {
5971                std::thread::Builder::new()
5972                    .name(format!("Thread {i}"))
5973                    .spawn(test_opengl_resize)
5974                    .unwrap()
5975            })
5976            .collect();
5977        handles.into_iter().for_each(|h| {
5978            if let Err(e) = h.join() {
5979                std::panic::resume_unwind(e)
5980            }
5981        });
5982    }
5983
5984    #[test]
5985    #[cfg(any(
5986        target_os = "linux",
5987        target_os = "macos",
5988        target_os = "ios",
5989        target_os = "android",
5990        target_os = "windows"
5991    ))]
5992    #[cfg(feature = "opengl")]
5993    fn test_opengl_grey() {
5994        if !is_opengl_available() {
5995            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5996            return;
5997        }
5998
5999        let img = crate::load_image_test_helper(
6000            &edgefirst_bench::testdata::read("grey.jpg"),
6001            Some(PixelFormat::Grey),
6002            None,
6003        )
6004        .unwrap();
6005
6006        let gl_dst = TensorDyn::image(
6007            640,
6008            640,
6009            PixelFormat::Grey,
6010            DType::U8,
6011            None,
6012            edgefirst_tensor::CpuAccess::ReadWrite,
6013        )
6014        .unwrap();
6015        let cpu_dst = TensorDyn::image(
6016            640,
6017            640,
6018            PixelFormat::Grey,
6019            DType::U8,
6020            None,
6021            edgefirst_tensor::CpuAccess::ReadWrite,
6022        )
6023        .unwrap();
6024
6025        let mut converter = CPUProcessor::new();
6026
6027        let (result, img, cpu_dst) = convert_img(
6028            &mut converter,
6029            img,
6030            cpu_dst,
6031            Rotation::None,
6032            Flip::None,
6033            Crop::no_crop(),
6034        );
6035        result.unwrap();
6036
6037        let mut gl = GLProcessorThreaded::new(None).unwrap();
6038        let (result, _img, gl_dst) = convert_img(
6039            &mut gl,
6040            img,
6041            gl_dst,
6042            Rotation::None,
6043            Flip::None,
6044            Crop::no_crop(),
6045        );
6046        result.unwrap();
6047
6048        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6049    }
6050
6051    #[test]
6052    #[cfg(target_os = "linux")]
6053    fn test_g2d_src_crop() {
6054        if !is_g2d_available() {
6055            crate::test_support::report_skip(
6056                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
6057            );
6058            return;
6059        }
6060        if !is_dma_available() {
6061            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6062            return;
6063        }
6064
6065        let dst_width = 640;
6066        let dst_height = 640;
6067        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6068        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6069
6070        let cpu_dst = TensorDyn::image(
6071            dst_width,
6072            dst_height,
6073            PixelFormat::Rgba,
6074            DType::U8,
6075            None,
6076            edgefirst_tensor::CpuAccess::ReadWrite,
6077        )
6078        .unwrap();
6079        let mut cpu_converter = CPUProcessor::new();
6080        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
6081        let (result, src, cpu_dst) = convert_img(
6082            &mut cpu_converter,
6083            src,
6084            cpu_dst,
6085            Rotation::None,
6086            Flip::None,
6087            crop,
6088        );
6089        result.unwrap();
6090
6091        let g2d_dst = TensorDyn::image(
6092            dst_width,
6093            dst_height,
6094            PixelFormat::Rgba,
6095            DType::U8,
6096            None,
6097            edgefirst_tensor::CpuAccess::ReadWrite,
6098        )
6099        .unwrap();
6100        let mut g2d_converter = G2DProcessor::new().unwrap();
6101        let (result, _src, g2d_dst) = convert_img(
6102            &mut g2d_converter,
6103            src,
6104            g2d_dst,
6105            Rotation::None,
6106            Flip::None,
6107            crop,
6108        );
6109        result.unwrap();
6110
6111        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6112        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6113        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6114        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6115        // structural gap not exercised by these limited-range fixtures.
6116        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6117    }
6118
6119    #[test]
6120    #[cfg(target_os = "linux")]
6121    fn test_g2d_dst_crop() {
6122        if !is_g2d_available() {
6123            crate::test_support::report_skip(
6124                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
6125            );
6126            return;
6127        }
6128        if !is_dma_available() {
6129            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6130            return;
6131        }
6132
6133        let dst_width = 640;
6134        let dst_height = 640;
6135        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6136        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6137
6138        let cpu_dst = TensorDyn::image(
6139            dst_width,
6140            dst_height,
6141            PixelFormat::Rgba,
6142            DType::U8,
6143            None,
6144            edgefirst_tensor::CpuAccess::ReadWrite,
6145        )
6146        .unwrap();
6147        let mut cpu_converter = CPUProcessor::new();
6148        let crop = Crop::new();
6149        let (result, src, cpu_dst) = convert_img(
6150            &mut cpu_converter,
6151            src,
6152            cpu_dst,
6153            Rotation::None,
6154            Flip::None,
6155            crop,
6156        );
6157        result.unwrap();
6158
6159        let g2d_dst = TensorDyn::image(
6160            dst_width,
6161            dst_height,
6162            PixelFormat::Rgba,
6163            DType::U8,
6164            None,
6165            edgefirst_tensor::CpuAccess::ReadWrite,
6166        )
6167        .unwrap();
6168        let mut g2d_converter = G2DProcessor::new().unwrap();
6169        let (result, _src, g2d_dst) = convert_img(
6170            &mut g2d_converter,
6171            src,
6172            g2d_dst,
6173            Rotation::None,
6174            Flip::None,
6175            crop,
6176        );
6177        result.unwrap();
6178
6179        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6180        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6181        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6182        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6183        // structural gap not exercised by these limited-range fixtures.
6184        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6185    }
6186
6187    #[test]
6188    #[cfg(target_os = "linux")]
6189    fn test_g2d_all_rgba() {
6190        if !is_g2d_available() {
6191            crate::test_support::report_skip(
6192                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
6193            );
6194            return;
6195        }
6196        if !is_dma_available() {
6197            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6198            return;
6199        }
6200
6201        let dst_width = 640;
6202        let dst_height = 640;
6203        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6204        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6205        let src_dyn = src;
6206
6207        let mut cpu_dst = TensorDyn::image(
6208            dst_width,
6209            dst_height,
6210            PixelFormat::Rgba,
6211            DType::U8,
6212            None,
6213            edgefirst_tensor::CpuAccess::ReadWrite,
6214        )
6215        .unwrap();
6216        let mut cpu_converter = CPUProcessor::new();
6217        let mut g2d_dst = TensorDyn::image(
6218            dst_width,
6219            dst_height,
6220            PixelFormat::Rgba,
6221            DType::U8,
6222            None,
6223            edgefirst_tensor::CpuAccess::ReadWrite,
6224        )
6225        .unwrap();
6226        let mut g2d_converter = G2DProcessor::new().unwrap();
6227
6228        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
6229
6230        for rot in [
6231            Rotation::None,
6232            Rotation::Clockwise90,
6233            Rotation::Rotate180,
6234            Rotation::CounterClockwise90,
6235        ] {
6236            cpu_dst
6237                .as_typed::<u8>()
6238                .unwrap()
6239                .map()
6240                .unwrap()
6241                .as_mut_slice()
6242                .fill(114);
6243            g2d_dst
6244                .as_typed::<u8>()
6245                .unwrap()
6246                .map()
6247                .unwrap()
6248                .as_mut_slice()
6249                .fill(114);
6250            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
6251                let mut cpu_dst_dyn = cpu_dst;
6252                cpu_converter
6253                    .convert(
6254                        &src_dyn,
6255                        &mut cpu_dst_dyn,
6256                        Some(crate::ConvertOptions::new().crop(crop)),
6257                    )
6258                    .unwrap();
6259                cpu_dst = {
6260                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
6261                    __t.set_format(PixelFormat::Rgba).unwrap();
6262                    TensorDyn::from(__t)
6263                };
6264
6265                let mut g2d_dst_dyn = g2d_dst;
6266                g2d_converter
6267                    .convert(
6268                        &src_dyn,
6269                        &mut g2d_dst_dyn,
6270                        Some(crate::ConvertOptions::new().crop(crop)),
6271                    )
6272                    .unwrap();
6273                g2d_dst = {
6274                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
6275                    __t.set_format(PixelFormat::Rgba).unwrap();
6276                    TensorDyn::from(__t)
6277                };
6278
6279                compare_images(
6280                    &g2d_dst,
6281                    &cpu_dst,
6282                    0.98,
6283                    &format!("{} {:?} {:?}", function!(), rot, flip),
6284                );
6285            }
6286        }
6287    }
6288
6289    #[test]
6290    #[cfg(any(
6291        target_os = "linux",
6292        target_os = "macos",
6293        target_os = "ios",
6294        target_os = "android",
6295        target_os = "windows"
6296    ))]
6297    #[cfg(feature = "opengl")]
6298    fn test_opengl_src_crop() {
6299        if !is_opengl_available() {
6300            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6301            return;
6302        }
6303
6304        let dst_width = 640;
6305        let dst_height = 360;
6306        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6307        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6308        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
6309
6310        let cpu_dst = TensorDyn::image(
6311            dst_width,
6312            dst_height,
6313            PixelFormat::Rgba,
6314            DType::U8,
6315            None,
6316            edgefirst_tensor::CpuAccess::ReadWrite,
6317        )
6318        .unwrap();
6319        let mut cpu_converter = CPUProcessor::new();
6320        let (result, src, cpu_dst) = convert_img(
6321            &mut cpu_converter,
6322            src,
6323            cpu_dst,
6324            Rotation::None,
6325            Flip::None,
6326            crop,
6327        );
6328        result.unwrap();
6329
6330        let gl_dst = TensorDyn::image(
6331            dst_width,
6332            dst_height,
6333            PixelFormat::Rgba,
6334            DType::U8,
6335            None,
6336            edgefirst_tensor::CpuAccess::ReadWrite,
6337        )
6338        .unwrap();
6339        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6340        let (result, _src, gl_dst) = convert_img(
6341            &mut gl_converter,
6342            src,
6343            gl_dst,
6344            Rotation::None,
6345            Flip::None,
6346            crop,
6347        );
6348        result.unwrap();
6349
6350        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6351    }
6352
6353    #[test]
6354    #[cfg(any(
6355        target_os = "linux",
6356        target_os = "macos",
6357        target_os = "ios",
6358        target_os = "android",
6359        target_os = "windows"
6360    ))]
6361    #[cfg(feature = "opengl")]
6362    fn test_opengl_dst_crop() {
6363        if !is_opengl_available() {
6364            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6365            return;
6366        }
6367
6368        let dst_width = 640;
6369        let dst_height = 640;
6370        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6371        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6372
6373        let cpu_dst = TensorDyn::image(
6374            dst_width,
6375            dst_height,
6376            PixelFormat::Rgba,
6377            DType::U8,
6378            None,
6379            edgefirst_tensor::CpuAccess::ReadWrite,
6380        )
6381        .unwrap();
6382        let mut cpu_converter = CPUProcessor::new();
6383        let crop = Crop::new();
6384        let (result, src, cpu_dst) = convert_img(
6385            &mut cpu_converter,
6386            src,
6387            cpu_dst,
6388            Rotation::None,
6389            Flip::None,
6390            crop,
6391        );
6392        result.unwrap();
6393
6394        let gl_dst = TensorDyn::image(
6395            dst_width,
6396            dst_height,
6397            PixelFormat::Rgba,
6398            DType::U8,
6399            None,
6400            edgefirst_tensor::CpuAccess::ReadWrite,
6401        )
6402        .unwrap();
6403        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6404        let (result, _src, gl_dst) = convert_img(
6405            &mut gl_converter,
6406            src,
6407            gl_dst,
6408            Rotation::None,
6409            Flip::None,
6410            crop,
6411        );
6412        result.unwrap();
6413
6414        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6415    }
6416    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
6417    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
6418    /// padded allocation still gets the pattern at the right columns.
6419    #[cfg(all(target_os = "windows", feature = "opengl"))]
6420    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
6421        let t = TensorDyn::image(
6422            w,
6423            h,
6424            PixelFormat::Rgba,
6425            DType::U8,
6426            Some(TensorMemory::Mem),
6427            edgefirst_tensor::CpuAccess::ReadWrite,
6428        )
6429        .unwrap();
6430        fill_synthetic_rgba(&t);
6431        t
6432    }
6433
6434    /// Every pixel of `actual` equals `expected`, compared through the
6435    /// tensors' own row compaction so a padded texture and a tight `Mem`
6436    /// image compare as images. `compare_images` scores similarity and
6437    /// accepts anything at a 0.0 threshold; this is the exact check.
6438    #[cfg(all(target_os = "windows", feature = "opengl"))]
6439    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
6440        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
6441        let flat = |t: &TensorDyn| {
6442            let typed = t.as_typed::<u8>().expect("u8 image");
6443            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6444            typed.copy_to_flat(&mut out).expect("compact the rows");
6445            out
6446        };
6447        let (a, e) = (flat(actual), flat(expected));
6448        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
6449        assert_eq!(
6450            mismatches,
6451            0,
6452            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
6453            a.len(),
6454            &a[..16.min(a.len())],
6455            &e[..16.min(e.len())]
6456        );
6457    }
6458
6459    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
6460    /// a texture included, at its own row stride.
6461    #[cfg(all(target_os = "windows", feature = "opengl"))]
6462    fn fill_synthetic_rgba(t: &TensorDyn) {
6463        let w = t.width().unwrap();
6464        let h = t.height().unwrap();
6465        let bound = t.as_typed::<u8>().unwrap();
6466        let stride = bound.effective_row_stride().unwrap_or(w * 4);
6467        let mut m = bound.map().unwrap();
6468        let px = m.as_mut_slice();
6469        for y in 0..h {
6470            for x in 0..w {
6471                let o = y * stride + x * 4;
6472                px[o] = x as u8;
6473                px[o + 1] = y as u8;
6474                px[o + 2] = (x + y) as u8;
6475                px[o + 3] = 255;
6476            }
6477        }
6478    }
6479
6480    /// Element-wise comparison of two float images of the same format and
6481    /// dtype.
6482    ///
6483    /// Both sides are read through `copy_to_flat`, the tensor's own row
6484    /// compaction, for the reason `compare_images` does: a Windows texture
6485    /// destination pads its rows, so reading the head of the mapping as a
6486    /// tight image would read padding as values. F16 values are widened with
6487    /// `half::f16::to_f64` before the comparison.
6488    #[cfg(all(target_os = "windows", feature = "opengl"))]
6489    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6490        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6491        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6492        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6493        let values = |t: &TensorDyn| -> Vec<f64> {
6494            let elems: usize = t.shape().iter().product();
6495            match t.dtype() {
6496                DType::F32 => {
6497                    let typed = t.as_typed::<f32>().expect("F32 image");
6498                    let mut bytes = vec![0u8; elems * 4];
6499                    typed
6500                        .copy_to_flat(&mut bytes)
6501                        .expect("compact the padded rows");
6502                    bytes
6503                        .as_chunks::<4>()
6504                        .0
6505                        .iter()
6506                        .map(|c| f32::from_le_bytes(*c) as f64)
6507                        .collect()
6508                }
6509                DType::F16 => {
6510                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6511                    let mut bytes = vec![0u8; elems * 2];
6512                    typed
6513                        .copy_to_flat(&mut bytes)
6514                        .expect("compact the padded rows");
6515                    bytes
6516                        .as_chunks::<2>()
6517                        .0
6518                        .iter()
6519                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6520                        .collect()
6521                }
6522                other => panic!("{what}: {other:?} is not a float dtype"),
6523            }
6524        };
6525        let (av, bv) = (values(a), values(b));
6526        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6527        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6528            assert!(
6529                (x - y).abs() <= tol,
6530                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6531                (x - y).abs()
6532            );
6533        }
6534    }
6535
6536    /// The float PBO readback has to land each destination row at the
6537    /// destination's own pitch, exactly as the u8 PBO readback does.
6538    ///
6539    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6540    /// 3H)` RGBA16F texels, one plane row per surface row, for
6541    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6542    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6543    /// destination declared. A pool tensor narrowed with `configure_image`
6544    /// that keeps the pool's pitch is exactly that destination, so every row
6545    /// after the first landed short.
6546    ///
6547    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6548    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6549    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6550    /// not divide, so those rows are read tight and spread afterwards. The
6551    /// tight stride is the control: it must still take the read it always
6552    /// did.
6553    ///
6554    /// The oracle is the same convert into a tight PBO of the same geometry.
6555    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6556    /// tensor's own row compaction, so it compares logical elements and not
6557    /// mapping offsets.
6558    #[test]
6559    #[cfg(all(target_os = "windows", feature = "opengl"))]
6560    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6561        if !is_opengl_available() {
6562            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6563            return;
6564        }
6565        const W: usize = 64;
6566        const H: usize = 32;
6567        const POOL_W: usize = 96;
6568
6569        let mut gpu = ImageProcessor::new().unwrap();
6570        let support = gpu.supported_render_dtypes();
6571        let src = synthetic_rgba(W, H);
6572
6573        // (format, dtype, supported, the pitches to declare)
6574        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6575            (
6576                PixelFormat::PlanarRgb,
6577                DType::F16,
6578                support.f16,
6579                // Tight plane row, the pool's pitch (a whole texel count),
6580                // and a 4-byte pad that no 8-byte texel divides.
6581                &[W * 2, POOL_W * 2, W * 2 + 4],
6582            ),
6583            (
6584                PixelFormat::Rgb,
6585                DType::F32,
6586                support.f32,
6587                &[W * 3 * 4, POOL_W * 3 * 4],
6588            ),
6589        ];
6590
6591        for (fmt, dtype, supported, strides) in cases {
6592            if !supported {
6593                crate::test_support::report_skip(&format!(
6594                    "{} - {fmt:?}/{dtype:?} not reported",
6595                    function!()
6596                ));
6597                continue;
6598            }
6599            let Some(gl) = gpu.opengl.as_ref() else {
6600                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6601                return;
6602            };
6603            // The oracle: a tight PBO of the target geometry, same route.
6604            let mut reference = gl
6605                .create_pbo_image_dtype(W, H, fmt, dtype)
6606                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight reference PBO: {e:?}"));
6607            assert_eq!(
6608                reference.memory(),
6609                TensorMemory::Pbo,
6610                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6611            );
6612            gpu.convert(&src, &mut reference, None)
6613                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6614
6615            for &stride in strides {
6616                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6617                // Allocate at the pool width, then narrow the geometry and
6618                // declare the pitch, as the u8 stride tests do.
6619                let mut padded = gpu
6620                    .opengl
6621                    .as_ref()
6622                    .expect("GL processor")
6623                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6624                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6625                padded
6626                    .configure_image(W, H, fmt)
6627                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6628                padded
6629                    .set_row_stride(stride)
6630                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6631                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6632
6633                let before = gpu.convert_fallback_count();
6634                gpu.convert(&src, &mut padded, None)
6635                    .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6636                // A CPU fallback writes the destination with the stride-aware
6637                // CPU writers and would pass without the GL readback running.
6638                assert_eq!(
6639                    gpu.convert_fallback_count(),
6640                    before,
6641                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6642                );
6643
6644                let tol = if dtype == DType::F16 {
6645                    1.0 / 1024.0
6646                } else {
6647                    1e-6
6648                };
6649                compare_float_images(&padded, &reference, tol, &label);
6650            }
6651        }
6652    }
6653
6654    /// Every float destination layout the GL backend reports it can render
6655    /// must render zero-copy into the texture and match the CPU converter
6656    /// element for element: F16 and F32 planar (three and four planes),
6657    /// interleaved float RGB, and float RGBA.
6658    ///
6659    /// The destination memory is requested explicitly so each layout is
6660    /// exercised as a texture whatever `create_image`'s own allocation
6661    /// policy would pick for it; the policy itself is covered by
6662    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6663    #[test]
6664    #[cfg(all(target_os = "windows", feature = "opengl"))]
6665    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6666        if !is_opengl_available() {
6667            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6668            return;
6669        }
6670        if !crate::test_support::is_gpu_buffer_available() {
6671            crate::test_support::report_skip(&format!(
6672                "{} - no zero-copy GPU buffers",
6673                function!()
6674            ));
6675            return;
6676        }
6677        let mut gpu = ImageProcessor::new().unwrap();
6678        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6679            backend: ComputeBackend::Cpu,
6680            ..Default::default()
6681        })
6682        .unwrap();
6683        let support = gpu.supported_render_dtypes();
6684        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6685        let src = synthetic_rgba(640, 480);
6686        for (fmt, dtype, need) in [
6687            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6688            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6689            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6690            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6691            (PixelFormat::Rgb, DType::F16, support.f16),
6692            (PixelFormat::Rgb, DType::F32, support.f32),
6693            (PixelFormat::Rgba, DType::F16, support.f16),
6694            (PixelFormat::Rgba, DType::F32, support.f32),
6695        ] {
6696            if !need {
6697                crate::test_support::report_skip(&format!(
6698                    "{} - {fmt:?}/{dtype:?} not reported",
6699                    function!()
6700                ));
6701                continue;
6702            }
6703            let mut dst_gpu = gpu
6704                .create_image(
6705                    640,
6706                    480,
6707                    fmt,
6708                    dtype,
6709                    Some(TensorMemory::DmaBuf),
6710                    edgefirst_tensor::CpuAccess::ReadWrite,
6711                )
6712                .unwrap();
6713            assert_eq!(
6714                dst_gpu.memory(),
6715                TensorMemory::DmaBuf,
6716                "{fmt:?}/{dtype:?} must be a texture"
6717            );
6718            let mut dst_cpu = TensorDyn::image(
6719                640,
6720                480,
6721                fmt,
6722                dtype,
6723                Some(TensorMemory::Mem),
6724                edgefirst_tensor::CpuAccess::ReadWrite,
6725            )
6726            .unwrap();
6727            let before = gpu.convert_fallback_count();
6728            gpu.convert(&src, &mut dst_gpu, None).unwrap();
6729            cpu.convert(&src, &mut dst_cpu, None).unwrap();
6730            assert_eq!(
6731                gpu.convert_fallback_count(),
6732                before,
6733                "{fmt:?}/{dtype:?} fell back to CPU"
6734            );
6735            let tol = if dtype == DType::F16 {
6736                1.0 / 1024.0
6737            } else {
6738                1e-6
6739            };
6740            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6741        }
6742    }
6743
6744    // The tests below wait on a Windows completion fence directly, so they
6745    // need the raw wait call — no crate in this workspace wraps it.
6746    #[cfg(target_os = "windows")]
6747    #[link(name = "kernel32")]
6748    extern "system" {
6749        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6750    }
6751
6752    /// A batch of deferred converts records one distinct fence value per
6753    /// tile and submits once, at `flush`.
6754    ///
6755    /// The values are what a GPU consumer waits on, so they have to stay
6756    /// per-destination and strictly increasing; the submission is what
6757    /// batching exists to spare, so there must be exactly one of it. The
6758    /// destinations are compared against the same convert done standalone,
6759    /// which is what proves the deferred submission did not lose a tile.
6760    #[cfg(all(target_os = "windows", feature = "opengl"))]
6761    #[test]
6762    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6763        use edgefirst_tensor::CpuAccess;
6764
6765        if !crate::test_support::is_gpu_buffer_available() {
6766            crate::test_support::report_skip(&format!(
6767                "{} - no zero-copy GPU buffers",
6768                function!()
6769            ));
6770            return;
6771        }
6772        let mut p = match ImageProcessor::new() {
6773            Ok(p) => p,
6774            Err(e) => {
6775                crate::test_support::report_skip(&format!(
6776                    "{} - ImageProcessor init failed ({e:?})",
6777                    function!()
6778                ));
6779                return;
6780            }
6781        };
6782        let (w, h, tiles) = (640usize, 480usize, 4usize);
6783        let src = synthetic_rgba(w, h);
6784        let mut dsts: Vec<TensorDyn> = (0..tiles)
6785            .map(|_| {
6786                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6787                    .unwrap()
6788            })
6789            .collect();
6790        if dsts[0].memory() != TensorMemory::DmaBuf {
6791            crate::test_support::report_skip(&format!(
6792                "{} - destinations are not textures",
6793                function!()
6794            ));
6795            return;
6796        }
6797        for dst in &mut dsts {
6798            p.convert_deferred(&src, dst, None).unwrap();
6799        }
6800        let values: Vec<u64> = dsts
6801            .iter()
6802            .map(|d| {
6803                d.gpu_completion()
6804                    .unwrap()
6805                    .expect("each deferred convert records its own completion")
6806                    .value
6807            })
6808            .collect();
6809        for pair in values.windows(2) {
6810            assert!(
6811                pair[1] > pair[0],
6812                "the batch allocated {values:?}, which is not one increasing value per tile"
6813            );
6814        }
6815        p.flush().unwrap();
6816        // The one submission the batch owed has happened, so every recorded
6817        // value is reachable.
6818        let dev = edgefirst_tensor::d3d11::device().unwrap();
6819        let last = *values.last().unwrap();
6820        let ev = dev.event_for(last).unwrap();
6821        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6822        // this call.
6823        assert_eq!(
6824            unsafe {
6825                WaitForSingleObject(
6826                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6827                    5000,
6828                )
6829            },
6830            0,
6831            "the batch's newest value {last} never arrived after flush"
6832        );
6833
6834        let mut solo = p
6835            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6836            .unwrap();
6837        p.convert(&src, &mut solo, None).unwrap();
6838        // Read through the tensor's own compaction: a Windows texture's
6839        // staging rows are pitch-padded, so a flat read of the mapping would
6840        // compare padding as pixels.
6841        let flat = |img: &TensorDyn| -> Vec<u8> {
6842            let t = img.as_typed::<u8>().expect("u8 image");
6843            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6844            t.copy_to_flat(&mut out).expect("compact the padded rows");
6845            out
6846        };
6847        let want = flat(&solo);
6848        for (i, dst) in dsts.iter().enumerate() {
6849            assert_eq!(
6850                flat(dst),
6851                want,
6852                "tile {i} does not match a standalone convert"
6853            );
6854        }
6855    }
6856
6857    /// A processor dropped with a batch still open submits what that batch
6858    /// recorded.
6859    ///
6860    /// The values are already published on destinations that outlive the
6861    /// processor, so a consumer may be waiting on one; a deferred signal that
6862    /// is never submitted is a wait nothing can satisfy. `flush` is what
6863    /// normally pays it, and the whole point here is that the caller never
6864    /// calls it.
6865    #[cfg(all(target_os = "windows", feature = "opengl"))]
6866    #[test]
6867    fn windows_dropping_a_processor_submits_an_open_batch() {
6868        use edgefirst_tensor::CpuAccess;
6869
6870        if !crate::test_support::is_gpu_buffer_available() {
6871            crate::test_support::report_skip(&format!(
6872                "{} - no zero-copy GPU buffers",
6873                function!()
6874            ));
6875            return;
6876        }
6877        let src = synthetic_rgba(320, 240);
6878        let recorded = {
6879            let mut p = match ImageProcessor::new() {
6880                Ok(p) => p,
6881                Err(e) => {
6882                    crate::test_support::report_skip(&format!(
6883                        "{} - init failed ({e:?})",
6884                        function!()
6885                    ));
6886                    return;
6887                }
6888            };
6889            let mut dst = p
6890                .create_image(
6891                    320,
6892                    240,
6893                    PixelFormat::Rgba,
6894                    DType::U8,
6895                    None,
6896                    CpuAccess::Read,
6897                )
6898                .unwrap();
6899            if dst.memory() != TensorMemory::DmaBuf {
6900                crate::test_support::report_skip(&format!(
6901                    "{} - destination is not a texture",
6902                    function!()
6903                ));
6904                return;
6905            }
6906            p.convert_deferred(&src, &mut dst, None).unwrap();
6907            // No `flush`: the drop below is what has to settle the batch.
6908            dst.gpu_completion()
6909                .unwrap()
6910                .expect("the deferred convert recorded a completion")
6911                .value
6912        };
6913
6914        let dev = edgefirst_tensor::d3d11::device().unwrap();
6915        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6916        while dev.completed_value() < recorded {
6917            assert!(
6918                std::time::Instant::now() < deadline,
6919                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6920                dev.completed_value()
6921            );
6922            std::thread::yield_now();
6923        }
6924    }
6925
6926    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6927    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6928    /// `convert_with_fence` is available on every platform: on Windows it
6929    /// returns a manual-reset event set when the GPU work completes.
6930    #[cfg(all(target_os = "windows", feature = "opengl"))]
6931    #[test]
6932    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6933        use edgefirst_tensor::CpuAccess;
6934        use std::os::windows::io::AsRawHandle;
6935
6936        if !crate::test_support::is_gpu_buffer_available() {
6937            crate::test_support::report_skip(&format!(
6938                "{} - no zero-copy GPU buffers",
6939                function!()
6940            ));
6941            return;
6942        }
6943        let mut p = ImageProcessor::new().unwrap();
6944        let src = synthetic_rgba(320, 240);
6945        let mut dst = p
6946            .create_image(
6947                320,
6948                240,
6949                PixelFormat::Rgba,
6950                DType::U8,
6951                None,
6952                CpuAccess::Read,
6953            )
6954            .unwrap();
6955        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6956        // answers host memory and there is no fence to wait on; the lanes
6957        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6958        if dst.memory() != TensorMemory::DmaBuf
6959            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6960        {
6961            crate::test_support::report_skip(&format!(
6962                "{} - create_image gave {:?}, not a texture (no GL backend)",
6963                function!(),
6964                dst.memory()
6965            ));
6966            return;
6967        }
6968        assert_eq!(
6969            dst.memory(),
6970            TensorMemory::DmaBuf,
6971            "create_image(None) must prefer a texture on Windows"
6972        );
6973        // Read before the wait: the recorded value must be the one the fence
6974        // names, not an older point the destination happened to carry.
6975        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6976        let fence = p
6977            .convert_with_fence(&src, &mut dst, None)
6978            .unwrap()
6979            .expect("Windows exports a fence");
6980        let recorded = dst
6981            .gpu_completion()
6982            .unwrap()
6983            .expect("the convert recorded a completion")
6984            .value;
6985        assert!(
6986            before.is_none_or(|b| recorded > b),
6987            "the fenced convert recorded a newer value than {before:?}"
6988        );
6989        // One flush and one signal for the whole call: the value the
6990        // destination carries is the newest the device has allocated, so
6991        // nothing was signalled after `record_completion`. Two signals (the
6992        // shape this replaced) would leave `last_signalled` one past it.
6993        assert_eq!(
6994            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6995            recorded,
6996            "a fenced convert must signal exactly once"
6997        );
6998        // SAFETY: `fence` owns a live Win32 event handle returned by
6999        // `convert_with_fence`; WaitForSingleObject only reads the handle,
7000        // which stays valid for the duration of this call because `fence`
7001        // is not dropped until after it.
7002        assert_eq!(
7003            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
7004            0,
7005            "fence was not set within 5 seconds"
7006        );
7007        // The event and the destination's own completion name the same
7008        // value, so a consumer may wait on either: the event fired, and the
7009        // device's completed value has reached the value `dst` reports.
7010        assert!(
7011            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
7012            "the event fired, so the recorded value {recorded} is complete"
7013        );
7014        assert_eq!(
7015            dst.gpu_completion().unwrap().map(|c| c.value),
7016            Some(recorded),
7017            "the destination still reports the value the event waited on"
7018        );
7019        let mut expected = TensorDyn::image(
7020            320,
7021            240,
7022            PixelFormat::Rgba,
7023            DType::U8,
7024            Some(TensorMemory::Mem),
7025            CpuAccess::ReadWrite,
7026        )
7027        .unwrap();
7028        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7029            backend: ComputeBackend::Cpu,
7030            ..Default::default()
7031        })
7032        .unwrap();
7033        cpu.convert(&src, &mut expected, None).unwrap();
7034        compare_images(&dst, &expected, 0.0, "fenced convert");
7035    }
7036
7037    /// A Windows GL test's destination must be a texture; without a GL backend
7038    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
7039    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
7040    /// texture-first claim a hard assertion.
7041    #[cfg(all(target_os = "windows", feature = "opengl"))]
7042    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
7043        if t.memory() == TensorMemory::DmaBuf {
7044            return true;
7045        }
7046        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
7047            panic!(
7048                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
7049                t.memory()
7050            );
7051        }
7052        crate::test_support::report_skip(&format!(
7053            "{test} - create_image gave {:?}, not a texture (no GL backend)",
7054            t.memory()
7055        ));
7056        false
7057    }
7058
7059    /// A draw into a texture destination records a completion on it the way
7060    /// a convert does, so a device consumer can wait on the drawn frame. The
7061    /// destination is `CpuAccess::Read`: only the GL backend can write it,
7062    /// so a CPU fallback fails the draw outright instead of leaving the
7063    /// completion empty.
7064    #[cfg(all(target_os = "windows", feature = "opengl"))]
7065    #[test]
7066    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
7067        use edgefirst_tensor::{CpuAccess, Segmentation};
7068        use ndarray::Array3;
7069
7070        if !crate::test_support::is_gpu_buffer_available() {
7071            crate::test_support::report_skip(&format!(
7072                "{} - no zero-copy GPU buffers",
7073                function!()
7074            ));
7075            return;
7076        }
7077        let mut p = ImageProcessor::new().unwrap();
7078        let mut dst = p
7079            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7080            .unwrap();
7081        if !texture_or_skip(&dst, function!()) {
7082            return;
7083        }
7084        assert!(
7085            dst.gpu_completion().unwrap().is_none(),
7086            "nothing has written this texture yet"
7087        );
7088
7089        let detect = DetectBox {
7090            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7091            score: 0.99,
7092            label: 0,
7093        };
7094        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7095        let seg = Segmentation {
7096            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7097                .unwrap()
7098                .into(),
7099            xmin: 0.25,
7100            ymin: 0.25,
7101            xmax: 0.75,
7102            ymax: 0.75,
7103        };
7104        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
7105            .expect("draw into a read-only texture");
7106
7107        let completion = dst
7108            .gpu_completion()
7109            .unwrap()
7110            .expect("the draw recorded no completion on dst");
7111        assert!(completion.value > 0, "completion value must be non-zero");
7112
7113        // The drawn frame is read back through the texture's staging copy;
7114        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
7115        // needs only a read mapping, which the read-only texture allows.
7116        // The corner is cleared and the box centre carries the mask colour.
7117        let mut flat = vec![0u8; 64 * 64 * 4];
7118        dst.as_typed::<u8>()
7119            .unwrap()
7120            .copy_to_flat(&mut flat)
7121            .expect("read the drawn texture");
7122        let px = |x: usize, y: usize| -> [u8; 4] {
7123            let o = (y * 64 + x) * 4;
7124            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
7125        };
7126        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
7127        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
7128    }
7129
7130    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
7131    /// completion on a texture destination too.
7132    #[cfg(all(target_os = "windows", feature = "opengl"))]
7133    #[test]
7134    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
7135        use edgefirst_tensor::{CpuAccess, Tensor};
7136
7137        if !crate::test_support::is_gpu_buffer_available() {
7138            crate::test_support::report_skip(&format!(
7139                "{} - no zero-copy GPU buffers",
7140                function!()
7141            ));
7142            return;
7143        }
7144        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
7145        // coverage pass); that is the same skip the texture guard below gives,
7146        // unless the lane set HAL_TEST_REQUIRE_GL=1.
7147        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
7148            Ok(p) => p,
7149            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
7150                crate::test_support::report_skip(&format!(
7151                    "{} - no OpenGL backend: {e}",
7152                    function!()
7153                ));
7154                return;
7155            }
7156            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
7157        };
7158        let mut dst = p
7159            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7160            .unwrap();
7161        if !texture_or_skip(&dst, function!()) {
7162            return;
7163        }
7164
7165        let detect = DetectBox {
7166            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7167            score: 0.99,
7168            label: 0,
7169        };
7170        // One prototype channel of ones and a positive coefficient: every
7171        // pixel of the box is foreground.
7172        let proto = ProtoData {
7173            mask_coefficients: TensorDyn::F32(
7174                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
7175            ),
7176            protos: TensorDyn::F32(
7177                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
7178            ),
7179            layout: ProtoLayout::Nhwc,
7180        };
7181        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
7182            .expect("forced-GL proto draw into a read-only texture");
7183
7184        let completion = dst
7185            .gpu_completion()
7186            .unwrap()
7187            .expect("the proto draw recorded no completion on dst");
7188        assert!(completion.value > 0, "completion value must be non-zero");
7189    }
7190
7191    /// An empty draw with a host-memory background onto a texture
7192    /// destination leaves the background in the texture, rows in order.
7193    /// The gradient pattern makes a missing or vertically flipped base
7194    /// layer differ from the expected image.
7195    #[cfg(all(target_os = "windows", feature = "opengl"))]
7196    #[test]
7197    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
7198        use edgefirst_tensor::CpuAccess;
7199
7200        if !crate::test_support::is_gpu_buffer_available() {
7201            crate::test_support::report_skip(&format!(
7202                "{} - no zero-copy GPU buffers",
7203                function!()
7204            ));
7205            return;
7206        }
7207        let mut p = ImageProcessor::new().unwrap();
7208        let bg = synthetic_rgba(64, 48);
7209        let mut dst = p
7210            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7211            .unwrap();
7212        if !texture_or_skip(&dst, function!()) {
7213            return;
7214        }
7215        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7216            .expect("empty draw with a host background onto a texture");
7217        assert_pixels_identical(&dst, &bg, "host background onto a texture");
7218        assert!(dst.gpu_completion().unwrap().is_some());
7219    }
7220
7221    /// The same draw with the background in a texture of its own: the
7222    /// base layer is sampled from the background texture directly.
7223    #[cfg(all(target_os = "windows", feature = "opengl"))]
7224    #[test]
7225    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
7226        use edgefirst_tensor::CpuAccess;
7227
7228        if !crate::test_support::is_gpu_buffer_available() {
7229            crate::test_support::report_skip(&format!(
7230                "{} - no zero-copy GPU buffers",
7231                function!()
7232            ));
7233            return;
7234        }
7235        let mut p = ImageProcessor::new().unwrap();
7236        let expected = synthetic_rgba(64, 48);
7237        let bg = p
7238            .create_image(
7239                64,
7240                48,
7241                PixelFormat::Rgba,
7242                DType::U8,
7243                None,
7244                CpuAccess::ReadWrite,
7245            )
7246            .unwrap();
7247        if !texture_or_skip(&bg, function!()) {
7248            return;
7249        }
7250        fill_synthetic_rgba(&bg);
7251        let mut dst = p
7252            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7253            .unwrap();
7254        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
7255        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7256            .expect("empty draw with a texture background onto a texture");
7257        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
7258        assert!(dst.gpu_completion().unwrap().is_some());
7259    }
7260
7261    /// A draw into a `view()` of a texture fills that band and leaves its
7262    /// sibling alone. The GL draw declines a sub-region destination
7263    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
7264    /// the parent, takes it; the zero-copy arm would otherwise have cleared
7265    /// the parent's first rows instead of the view's.
7266    #[cfg(all(target_os = "windows", feature = "opengl"))]
7267    #[test]
7268    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
7269        use edgefirst_tensor::CpuAccess;
7270
7271        if !crate::test_support::is_gpu_buffer_available() {
7272            crate::test_support::report_skip(&format!(
7273                "{} - no zero-copy GPU buffers",
7274                function!()
7275            ));
7276            return;
7277        }
7278        let (w, h) = (32usize, 64usize);
7279        let mut p = ImageProcessor::new().unwrap();
7280        let parent = p
7281            .create_image(
7282                w,
7283                h,
7284                PixelFormat::Rgba,
7285                DType::U8,
7286                None,
7287                CpuAccess::ReadWrite,
7288            )
7289            .unwrap();
7290        if !texture_or_skip(&parent, function!()) {
7291            return;
7292        }
7293        fill_synthetic_rgba(&parent);
7294
7295        // The lower band only. An empty draw with no background clears its
7296        // destination, so a write at the parent's origin would show up as
7297        // cleared pixels in the upper band.
7298        let mut band = parent
7299            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7300            .unwrap();
7301        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7302            .expect("empty draw into a texture view");
7303        drop(band);
7304
7305        let mut flat = vec![0u8; w * h * 4];
7306        parent
7307            .as_typed::<u8>()
7308            .unwrap()
7309            .copy_to_flat(&mut flat)
7310            .expect("read the parent texture");
7311        for y in 0..h {
7312            for x in 0..w {
7313                let o = (y * w + x) * 4;
7314                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
7315                let want = if y < h / 2 {
7316                    [x as u8, y as u8, (x + y) as u8, 255]
7317                } else {
7318                    [0, 0, 0, 0]
7319                };
7320                assert_eq!(got, want, "pixel ({x},{y})");
7321            }
7322        }
7323
7324        // The GL decline is what sent the draw above to the CPU: with GL
7325        // forced there is no CPU arm to fall through to, so it surfaces.
7326        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
7327        let mut band = parent
7328            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7329            .unwrap();
7330        let err = forced
7331            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7332            .expect_err("forced GL must decline a view destination");
7333        assert!(
7334            matches!(err, Error::NotSupported(_)),
7335            "expected NotSupported, got {err:?}"
7336        );
7337    }
7338
7339    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
7340    /// a draw GL declines still renders the caller's palette. The view
7341    /// destination is the reachable decline: `check_draw_dst_placement`
7342    /// refuses it and the CPU backend draws the mask.
7343    #[cfg(all(target_os = "windows", feature = "opengl"))]
7344    #[test]
7345    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
7346        use edgefirst_tensor::{CpuAccess, Segmentation};
7347        use ndarray::Array3;
7348
7349        if !crate::test_support::is_gpu_buffer_available() {
7350            crate::test_support::report_skip(&format!(
7351                "{} - no zero-copy GPU buffers",
7352                function!()
7353            ));
7354            return;
7355        }
7356        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
7357        assert_ne!(
7358            CUSTOM[..3],
7359            DEFAULT_COLORS_U8[0][..3],
7360            "the custom colour must differ from the default palette's, or \
7361             this test cannot tell them apart"
7362        );
7363
7364        let (w, h) = (32usize, 64usize);
7365        let mut p = ImageProcessor::new().unwrap();
7366        let parent = p
7367            .create_image(
7368                w,
7369                h,
7370                PixelFormat::Rgba,
7371                DType::U8,
7372                None,
7373                CpuAccess::ReadWrite,
7374            )
7375            .unwrap();
7376        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7377        // answers host memory and nothing declines the draw; the test needs
7378        // the texture-backed parent so the view is the reachable decline. The
7379        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
7380        if parent.memory() != TensorMemory::DmaBuf
7381            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7382        {
7383            crate::test_support::report_skip(&format!(
7384                "{} - create_image gave {:?}, not a texture (no GL backend)",
7385                function!(),
7386                parent.memory()
7387            ));
7388            return;
7389        }
7390        p.set_class_colors(&[CUSTOM]).unwrap();
7391
7392        let detect = DetectBox {
7393            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7394            score: 0.99,
7395            label: 0,
7396        };
7397        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7398        let seg = Segmentation {
7399            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7400                .unwrap()
7401                .into(),
7402            xmin: 0.25,
7403            ymin: 0.25,
7404            xmax: 0.75,
7405            ymax: 0.75,
7406        };
7407        let mut band = parent
7408            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7409            .unwrap();
7410        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
7411            .expect("draw into a texture view");
7412        drop(band);
7413
7414        let mut flat = vec![0u8; w * h * 4];
7415        parent
7416            .as_typed::<u8>()
7417            .unwrap()
7418            .copy_to_flat(&mut flat)
7419            .expect("read the parent texture");
7420        // The band's centre, inside the box. The mask blend writes RGB only;
7421        // alpha is whatever the base layer left, which is the clear.
7422        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
7423        assert_eq!(
7424            &flat[o..o + 3],
7425            &CUSTOM[..3],
7426            "the CPU backend drew the default palette, so it never received \
7427             set_class_colors"
7428        );
7429    }
7430
7431    /// Two independently-created `ImageProcessor`s, each converting into its
7432    /// own texture destination in lockstep, must not bleed GL state into
7433    /// each other across 200 iterations (the destination created by
7434    /// `create_image(None)` is the texture arm this task adds).
7435    #[cfg(all(target_os = "windows", feature = "opengl"))]
7436    #[test]
7437    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
7438        use edgefirst_tensor::CpuAccess;
7439
7440        if !crate::test_support::is_gpu_buffer_available() {
7441            crate::test_support::report_skip(&format!(
7442                "{} - no zero-copy GPU buffers",
7443                function!()
7444            ));
7445            return;
7446        }
7447        let mut a = ImageProcessor::new().unwrap();
7448        let mut b = ImageProcessor::new().unwrap();
7449        let src_a = synthetic_rgba(512, 512);
7450        let src_b = synthetic_rgba(256, 256);
7451        let mut dst_a = a
7452            .create_image(
7453                512,
7454                512,
7455                PixelFormat::Rgba,
7456                DType::U8,
7457                None,
7458                CpuAccess::Read,
7459            )
7460            .unwrap();
7461        let mut dst_b = b
7462            .create_image(
7463                256,
7464                256,
7465                PixelFormat::Rgba,
7466                DType::U8,
7467                None,
7468                CpuAccess::Read,
7469            )
7470            .unwrap();
7471        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7472            backend: ComputeBackend::Cpu,
7473            ..Default::default()
7474        })
7475        .unwrap();
7476        let mut ref_a = TensorDyn::image(
7477            512,
7478            512,
7479            PixelFormat::Rgba,
7480            DType::U8,
7481            Some(TensorMemory::Mem),
7482            CpuAccess::ReadWrite,
7483        )
7484        .unwrap();
7485        let mut ref_b = TensorDyn::image(
7486            256,
7487            256,
7488            PixelFormat::Rgba,
7489            DType::U8,
7490            Some(TensorMemory::Mem),
7491            CpuAccess::ReadWrite,
7492        )
7493        .unwrap();
7494        cpu.convert(&src_a, &mut ref_a, None).unwrap();
7495        cpu.convert(&src_b, &mut ref_b, None).unwrap();
7496        for _ in 0..200 {
7497            a.convert(&src_a, &mut dst_a, None).unwrap();
7498            b.convert(&src_b, &mut dst_b, None).unwrap();
7499            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7500            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7501        }
7502    }
7503
7504    #[test]
7505    #[cfg(any(
7506        target_os = "linux",
7507        target_os = "macos",
7508        target_os = "ios",
7509        target_os = "android",
7510        target_os = "windows"
7511    ))]
7512    #[cfg(feature = "opengl")]
7513    fn test_opengl_all_rgba() {
7514        if !is_opengl_available() {
7515            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7516            return;
7517        }
7518
7519        let dst_width = 640;
7520        let dst_height = 640;
7521        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7522
7523        let mut cpu_converter = CPUProcessor::new();
7524
7525        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7526
7527        // Every backing this host can allocate: Shm is Unix-only, and the
7528        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7529        // Windows yet).
7530        let mut mem = vec![None, Some(TensorMemory::Mem)];
7531        if edgefirst_tensor::is_shm_available() {
7532            mem.push(Some(TensorMemory::Shm));
7533        }
7534        if crate::test_support::is_gpu_buffer_available() {
7535            mem.push(Some(TensorMemory::DmaBuf));
7536        } else {
7537            crate::test_support::report_skip(&format!(
7538                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7539                function!()
7540            ));
7541        }
7542        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7543        for m in mem {
7544            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7545            let src_dyn = src;
7546
7547            for rot in [
7548                Rotation::None,
7549                Rotation::Clockwise90,
7550                Rotation::Rotate180,
7551                Rotation::CounterClockwise90,
7552            ] {
7553                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7554                    let cpu_dst = TensorDyn::image(
7555                        dst_width,
7556                        dst_height,
7557                        PixelFormat::Rgba,
7558                        DType::U8,
7559                        m,
7560                        edgefirst_tensor::CpuAccess::ReadWrite,
7561                    )
7562                    .unwrap();
7563                    let gl_dst = TensorDyn::image(
7564                        dst_width,
7565                        dst_height,
7566                        PixelFormat::Rgba,
7567                        DType::U8,
7568                        m,
7569                        edgefirst_tensor::CpuAccess::ReadWrite,
7570                    )
7571                    .unwrap();
7572                    cpu_dst
7573                        .as_typed::<u8>()
7574                        .unwrap()
7575                        .map()
7576                        .unwrap()
7577                        .as_mut_slice()
7578                        .fill(114);
7579                    gl_dst
7580                        .as_typed::<u8>()
7581                        .unwrap()
7582                        .map()
7583                        .unwrap()
7584                        .as_mut_slice()
7585                        .fill(114);
7586
7587                    let mut cpu_dst_dyn = cpu_dst;
7588                    cpu_converter
7589                        .convert(
7590                            &src_dyn,
7591                            &mut cpu_dst_dyn,
7592                            Some(crate::ConvertOptions::new().crop(crop)),
7593                        )
7594                        .unwrap();
7595                    let cpu_dst = {
7596                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7597                        __t.set_format(PixelFormat::Rgba).unwrap();
7598                        TensorDyn::from(__t)
7599                    };
7600
7601                    let mut gl_dst_dyn = gl_dst;
7602                    gl_converter
7603                        .convert(
7604                            &src_dyn,
7605                            &mut gl_dst_dyn,
7606                            Some(crate::ConvertOptions::new().crop(crop)),
7607                        )
7608                        .map_err(|e| {
7609                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7610                            e
7611                        })
7612                        .unwrap();
7613                    let gl_dst = {
7614                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7615                        __t.set_format(PixelFormat::Rgba).unwrap();
7616                        TensorDyn::from(__t)
7617                    };
7618
7619                    compare_images(
7620                        &gl_dst,
7621                        &cpu_dst,
7622                        0.98,
7623                        &format!("{} {:?} {:?}", function!(), rot, flip),
7624                    );
7625                }
7626            }
7627        }
7628    }
7629
7630    #[test]
7631    #[cfg(target_os = "linux")]
7632    fn test_cpu_rotate() {
7633        for rot in [
7634            Rotation::Clockwise90,
7635            Rotation::Rotate180,
7636            Rotation::CounterClockwise90,
7637        ] {
7638            test_cpu_rotate_(rot);
7639        }
7640    }
7641
7642    #[cfg(target_os = "linux")]
7643    fn test_cpu_rotate_(rot: Rotation) {
7644        // This test rotates the image 4 times and checks that the image was returned to
7645        // be the same Currently doesn't check if rotations actually rotated in
7646        // right direction
7647        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7648
7649        let unchanged_src =
7650            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7651        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7652
7653        let (dst_width, dst_height) = match rot {
7654            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7655            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7656                (src.height().unwrap(), src.width().unwrap())
7657            }
7658        };
7659
7660        let cpu_dst = TensorDyn::image(
7661            dst_width,
7662            dst_height,
7663            PixelFormat::Rgba,
7664            DType::U8,
7665            None,
7666            edgefirst_tensor::CpuAccess::ReadWrite,
7667        )
7668        .unwrap();
7669        let mut cpu_converter = CPUProcessor::new();
7670
7671        // After rotating 4 times, the image should be the same as the original
7672
7673        let (result, src, cpu_dst) = convert_img(
7674            &mut cpu_converter,
7675            src,
7676            cpu_dst,
7677            rot,
7678            Flip::None,
7679            Crop::no_crop(),
7680        );
7681        result.unwrap();
7682
7683        let (result, cpu_dst, src) = convert_img(
7684            &mut cpu_converter,
7685            cpu_dst,
7686            src,
7687            rot,
7688            Flip::None,
7689            Crop::no_crop(),
7690        );
7691        result.unwrap();
7692
7693        let (result, src, cpu_dst) = convert_img(
7694            &mut cpu_converter,
7695            src,
7696            cpu_dst,
7697            rot,
7698            Flip::None,
7699            Crop::no_crop(),
7700        );
7701        result.unwrap();
7702
7703        let (result, _cpu_dst, src) = convert_img(
7704            &mut cpu_converter,
7705            cpu_dst,
7706            src,
7707            rot,
7708            Flip::None,
7709            Crop::no_crop(),
7710        );
7711        result.unwrap();
7712
7713        compare_images(&src, &unchanged_src, 0.98, function!());
7714    }
7715
7716    #[test]
7717    #[cfg(any(
7718        target_os = "linux",
7719        target_os = "macos",
7720        target_os = "ios",
7721        target_os = "android",
7722        target_os = "windows"
7723    ))]
7724    #[cfg(feature = "opengl")]
7725    fn test_opengl_rotate() {
7726        if !is_opengl_available() {
7727            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7728            return;
7729        }
7730
7731        let size = (1280, 720);
7732        // Same backing list as `test_opengl_all_rgba`.
7733        let mut mem = vec![None, Some(TensorMemory::Mem)];
7734        if edgefirst_tensor::is_shm_available() {
7735            mem.push(Some(TensorMemory::Shm));
7736        }
7737        if crate::test_support::is_gpu_buffer_available() {
7738            mem.push(Some(TensorMemory::DmaBuf));
7739        } else {
7740            crate::test_support::report_skip(&format!(
7741                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7742                function!()
7743            ));
7744        }
7745        for m in mem {
7746            for rot in [
7747                Rotation::Clockwise90,
7748                Rotation::Rotate180,
7749                Rotation::CounterClockwise90,
7750            ] {
7751                test_opengl_rotate_(size, rot, m);
7752            }
7753        }
7754    }
7755
7756    #[cfg(any(
7757        target_os = "linux",
7758        target_os = "macos",
7759        target_os = "ios",
7760        target_os = "android",
7761        target_os = "windows"
7762    ))]
7763    #[cfg(feature = "opengl")]
7764    fn test_opengl_rotate_(
7765        size: (usize, usize),
7766        rot: Rotation,
7767        tensor_memory: Option<TensorMemory>,
7768    ) {
7769        let (dst_width, dst_height) = match rot {
7770            Rotation::None | Rotation::Rotate180 => size,
7771            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7772        };
7773
7774        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7775        let src =
7776            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7777
7778        let cpu_dst = TensorDyn::image(
7779            dst_width,
7780            dst_height,
7781            PixelFormat::Rgba,
7782            DType::U8,
7783            None,
7784            edgefirst_tensor::CpuAccess::ReadWrite,
7785        )
7786        .unwrap();
7787        let mut cpu_converter = CPUProcessor::new();
7788
7789        let (result, mut src, cpu_dst) = convert_img(
7790            &mut cpu_converter,
7791            src,
7792            cpu_dst,
7793            rot,
7794            Flip::None,
7795            Crop::no_crop(),
7796        );
7797        result.unwrap();
7798
7799        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7800
7801        for _ in 0..5 {
7802            let gl_dst = TensorDyn::image(
7803                dst_width,
7804                dst_height,
7805                PixelFormat::Rgba,
7806                DType::U8,
7807                tensor_memory,
7808                edgefirst_tensor::CpuAccess::ReadWrite,
7809            )
7810            .unwrap();
7811            let (result, src_back, gl_dst) = convert_img(
7812                &mut gl_converter,
7813                src,
7814                gl_dst,
7815                rot,
7816                Flip::None,
7817                Crop::no_crop(),
7818            );
7819            result.unwrap();
7820            src = src_back;
7821            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7822        }
7823    }
7824
7825    #[test]
7826    #[cfg(target_os = "linux")]
7827    fn test_g2d_rotate() {
7828        if !is_g2d_available() {
7829            crate::test_support::report_skip(
7830                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7831            );
7832            return;
7833        }
7834        if !is_dma_available() {
7835            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7836            return;
7837        }
7838
7839        let size = (1280, 720);
7840        for rot in [
7841            Rotation::Clockwise90,
7842            Rotation::Rotate180,
7843            Rotation::CounterClockwise90,
7844        ] {
7845            test_g2d_rotate_(size, rot);
7846        }
7847    }
7848
7849    #[cfg(target_os = "linux")]
7850    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7851        let (dst_width, dst_height) = match rot {
7852            Rotation::None | Rotation::Rotate180 => size,
7853            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7854        };
7855
7856        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7857        let src = crate::load_image_test_helper(
7858            &file,
7859            Some(PixelFormat::Rgba),
7860            Some(TensorMemory::DmaBuf),
7861        )
7862        .unwrap();
7863
7864        let cpu_dst = TensorDyn::image(
7865            dst_width,
7866            dst_height,
7867            PixelFormat::Rgba,
7868            DType::U8,
7869            None,
7870            edgefirst_tensor::CpuAccess::ReadWrite,
7871        )
7872        .unwrap();
7873        let mut cpu_converter = CPUProcessor::new();
7874
7875        let (result, src, cpu_dst) = convert_img(
7876            &mut cpu_converter,
7877            src,
7878            cpu_dst,
7879            rot,
7880            Flip::None,
7881            Crop::no_crop(),
7882        );
7883        result.unwrap();
7884
7885        let g2d_dst = TensorDyn::image(
7886            dst_width,
7887            dst_height,
7888            PixelFormat::Rgba,
7889            DType::U8,
7890            Some(TensorMemory::DmaBuf),
7891            edgefirst_tensor::CpuAccess::ReadWrite,
7892        )
7893        .unwrap();
7894        let mut g2d_converter = G2DProcessor::new().unwrap();
7895
7896        let (result, _src, g2d_dst) = convert_img(
7897            &mut g2d_converter,
7898            src,
7899            g2d_dst,
7900            rot,
7901            Flip::None,
7902            Crop::no_crop(),
7903        );
7904        result.unwrap();
7905
7906        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7907        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7908        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7909        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7910        // structural gap not exercised by these limited-range fixtures.
7911        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7912    }
7913
7914    #[test]
7915    fn test_rgba_to_yuyv_resize_cpu() {
7916        let src = load_bytes_to_tensor(
7917            1280,
7918            720,
7919            PixelFormat::Rgba,
7920            None,
7921            &edgefirst_bench::testdata::read("camera720p.rgba"),
7922        )
7923        .unwrap();
7924
7925        let (dst_width, dst_height) = (640, 360);
7926
7927        let dst = TensorDyn::image(
7928            dst_width,
7929            dst_height,
7930            PixelFormat::Yuyv,
7931            DType::U8,
7932            None,
7933            edgefirst_tensor::CpuAccess::ReadWrite,
7934        )
7935        .unwrap();
7936
7937        let dst_through_yuyv = TensorDyn::image(
7938            dst_width,
7939            dst_height,
7940            PixelFormat::Rgba,
7941            DType::U8,
7942            None,
7943            edgefirst_tensor::CpuAccess::ReadWrite,
7944        )
7945        .unwrap();
7946        let dst_direct = TensorDyn::image(
7947            dst_width,
7948            dst_height,
7949            PixelFormat::Rgba,
7950            DType::U8,
7951            None,
7952            edgefirst_tensor::CpuAccess::ReadWrite,
7953        )
7954        .unwrap();
7955
7956        let mut cpu_converter = CPUProcessor::new();
7957
7958        let (result, src, dst) = convert_img(
7959            &mut cpu_converter,
7960            src,
7961            dst,
7962            Rotation::None,
7963            Flip::None,
7964            Crop::no_crop(),
7965        );
7966        result.unwrap();
7967
7968        let (result, _dst, dst_through_yuyv) = convert_img(
7969            &mut cpu_converter,
7970            dst,
7971            dst_through_yuyv,
7972            Rotation::None,
7973            Flip::None,
7974            Crop::no_crop(),
7975        );
7976        result.unwrap();
7977
7978        let (result, _src, dst_direct) = convert_img(
7979            &mut cpu_converter,
7980            src,
7981            dst_direct,
7982            Rotation::None,
7983            Flip::None,
7984            Crop::no_crop(),
7985        );
7986        result.unwrap();
7987
7988        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7989    }
7990
7991    #[test]
7992    #[cfg(any(
7993        target_os = "linux",
7994        target_os = "macos",
7995        target_os = "ios",
7996        target_os = "android",
7997        target_os = "windows"
7998    ))]
7999    #[cfg(feature = "opengl")]
8000    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
8001    fn test_rgba_to_yuyv_resize_opengl() {
8002        if !is_opengl_available() {
8003            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8004            return;
8005        }
8006
8007        if !crate::test_support::is_gpu_buffer_available() {
8008            crate::test_support::report_skip(&format!(
8009                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8010                function!()
8011            ));
8012            return;
8013        }
8014
8015        let src = load_bytes_to_tensor(
8016            1280,
8017            720,
8018            PixelFormat::Rgba,
8019            None,
8020            &edgefirst_bench::testdata::read("camera720p.rgba"),
8021        )
8022        .unwrap();
8023
8024        let (dst_width, dst_height) = (640, 360);
8025
8026        let dst = TensorDyn::image(
8027            dst_width,
8028            dst_height,
8029            PixelFormat::Yuyv,
8030            DType::U8,
8031            Some(TensorMemory::DmaBuf),
8032            edgefirst_tensor::CpuAccess::ReadWrite,
8033        )
8034        .unwrap();
8035
8036        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8037
8038        let (result, src, dst) = convert_img(
8039            &mut gl_converter,
8040            src,
8041            dst,
8042            Rotation::None,
8043            Flip::None,
8044            Crop::letterbox([255, 255, 255, 255]),
8045        );
8046        result.unwrap();
8047
8048        std::fs::write(
8049            "rgba_to_yuyv_opengl.yuyv",
8050            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
8051        )
8052        .unwrap();
8053        let cpu_dst = TensorDyn::image(
8054            dst_width,
8055            dst_height,
8056            PixelFormat::Yuyv,
8057            DType::U8,
8058            Some(TensorMemory::DmaBuf),
8059            edgefirst_tensor::CpuAccess::ReadWrite,
8060        )
8061        .unwrap();
8062        let (result, _src, cpu_dst) = convert_img(
8063            &mut CPUProcessor::new(),
8064            src,
8065            cpu_dst,
8066            Rotation::None,
8067            Flip::None,
8068            Crop::no_crop(),
8069        );
8070        result.unwrap();
8071
8072        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
8073    }
8074
8075    #[test]
8076    #[cfg(target_os = "linux")]
8077    fn test_rgba_to_yuyv_resize_g2d() {
8078        if !is_g2d_available() {
8079            crate::test_support::report_skip(
8080                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
8081            );
8082            return;
8083        }
8084        if !is_dma_available() {
8085            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8086            return;
8087        }
8088
8089        let src = load_bytes_to_tensor(
8090            1280,
8091            720,
8092            PixelFormat::Rgba,
8093            Some(TensorMemory::DmaBuf),
8094            &edgefirst_bench::testdata::read("camera720p.rgba"),
8095        )
8096        .unwrap();
8097
8098        let (dst_width, dst_height) = (1280, 720);
8099
8100        let cpu_dst = TensorDyn::image(
8101            dst_width,
8102            dst_height,
8103            PixelFormat::Yuyv,
8104            DType::U8,
8105            Some(TensorMemory::DmaBuf),
8106            edgefirst_tensor::CpuAccess::ReadWrite,
8107        )
8108        .unwrap();
8109
8110        let g2d_dst = TensorDyn::image(
8111            dst_width,
8112            dst_height,
8113            PixelFormat::Yuyv,
8114            DType::U8,
8115            Some(TensorMemory::DmaBuf),
8116            edgefirst_tensor::CpuAccess::ReadWrite,
8117        )
8118        .unwrap();
8119
8120        let mut g2d_converter = G2DProcessor::new().unwrap();
8121        let crop = Crop::new();
8122
8123        g2d_dst
8124            .as_typed::<u8>()
8125            .unwrap()
8126            .map()
8127            .unwrap()
8128            .as_mut_slice()
8129            .fill(128);
8130        let (result, src, g2d_dst) = convert_img(
8131            &mut g2d_converter,
8132            src,
8133            g2d_dst,
8134            Rotation::None,
8135            Flip::None,
8136            crop,
8137        );
8138        result.unwrap();
8139
8140        let cpu_dst_img = cpu_dst;
8141        cpu_dst_img
8142            .as_typed::<u8>()
8143            .unwrap()
8144            .map()
8145            .unwrap()
8146            .as_mut_slice()
8147            .fill(128);
8148        let (result, _src, cpu_dst) = convert_img(
8149            &mut CPUProcessor::new(),
8150            src,
8151            cpu_dst_img,
8152            Rotation::None,
8153            Flip::None,
8154            crop,
8155        );
8156        result.unwrap();
8157
8158        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
8159    }
8160
8161    #[test]
8162    fn test_yuyv_to_rgba_cpu() {
8163        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
8164        let src = TensorDyn::image(
8165            1280,
8166            720,
8167            PixelFormat::Yuyv,
8168            DType::U8,
8169            None,
8170            edgefirst_tensor::CpuAccess::ReadWrite,
8171        )
8172        .unwrap();
8173        src.as_typed::<u8>()
8174            .unwrap()
8175            .map()
8176            .unwrap()
8177            .as_mut_slice()
8178            .copy_from_slice(&file);
8179
8180        let dst = TensorDyn::image(
8181            1280,
8182            720,
8183            PixelFormat::Rgba,
8184            DType::U8,
8185            None,
8186            edgefirst_tensor::CpuAccess::ReadWrite,
8187        )
8188        .unwrap();
8189        let mut cpu_converter = CPUProcessor::new();
8190
8191        let (result, _src, dst) = convert_img(
8192            &mut cpu_converter,
8193            src,
8194            dst,
8195            Rotation::None,
8196            Flip::None,
8197            Crop::no_crop(),
8198        );
8199        result.unwrap();
8200
8201        let target_image = TensorDyn::image(
8202            1280,
8203            720,
8204            PixelFormat::Rgba,
8205            DType::U8,
8206            None,
8207            edgefirst_tensor::CpuAccess::ReadWrite,
8208        )
8209        .unwrap();
8210        target_image
8211            .as_typed::<u8>()
8212            .unwrap()
8213            .map()
8214            .unwrap()
8215            .as_mut_slice()
8216            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8217
8218        // CPU path resolves the untagged 720p source to BT.709 limited (height
8219        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8220        compare_images(&dst, &target_image, 0.98, function!());
8221    }
8222
8223    #[test]
8224    fn test_yuyv_to_rgb_cpu() {
8225        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
8226        let src = TensorDyn::image(
8227            1280,
8228            720,
8229            PixelFormat::Yuyv,
8230            DType::U8,
8231            None,
8232            edgefirst_tensor::CpuAccess::ReadWrite,
8233        )
8234        .unwrap();
8235        src.as_typed::<u8>()
8236            .unwrap()
8237            .map()
8238            .unwrap()
8239            .as_mut_slice()
8240            .copy_from_slice(&file);
8241
8242        let dst = TensorDyn::image(
8243            1280,
8244            720,
8245            PixelFormat::Rgb,
8246            DType::U8,
8247            None,
8248            edgefirst_tensor::CpuAccess::ReadWrite,
8249        )
8250        .unwrap();
8251        let mut cpu_converter = CPUProcessor::new();
8252
8253        let (result, _src, dst) = convert_img(
8254            &mut cpu_converter,
8255            src,
8256            dst,
8257            Rotation::None,
8258            Flip::None,
8259            Crop::no_crop(),
8260        );
8261        result.unwrap();
8262
8263        let target_image = TensorDyn::image(
8264            1280,
8265            720,
8266            PixelFormat::Rgb,
8267            DType::U8,
8268            None,
8269            edgefirst_tensor::CpuAccess::ReadWrite,
8270        )
8271        .unwrap();
8272        target_image
8273            .as_typed::<u8>()
8274            .unwrap()
8275            .map()
8276            .unwrap()
8277            .as_mut_slice()
8278            .as_chunks_mut::<3>()
8279            .0
8280            .iter_mut()
8281            .zip(
8282                edgefirst_bench::testdata::read("camera720p.rgba")
8283                    .as_chunks::<4>()
8284                    .0,
8285            )
8286            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8287
8288        // CPU path resolves the untagged 720p source to BT.709 limited (height
8289        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8290        compare_images(&dst, &target_image, 0.98, function!());
8291    }
8292
8293    #[test]
8294    #[cfg(target_os = "linux")]
8295    fn test_yuyv_to_rgba_g2d() {
8296        if !is_g2d_available() {
8297            crate::test_support::report_skip(
8298                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
8299            );
8300            return;
8301        }
8302        if !is_dma_available() {
8303            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8304            return;
8305        }
8306
8307        let src = load_bytes_to_tensor(
8308            1280,
8309            720,
8310            PixelFormat::Yuyv,
8311            None,
8312            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8313        )
8314        .unwrap();
8315
8316        let dst = TensorDyn::image(
8317            1280,
8318            720,
8319            PixelFormat::Rgba,
8320            DType::U8,
8321            Some(TensorMemory::DmaBuf),
8322            edgefirst_tensor::CpuAccess::ReadWrite,
8323        )
8324        .unwrap();
8325        let mut g2d_converter = G2DProcessor::new().unwrap();
8326
8327        let (result, _src, dst) = convert_img(
8328            &mut g2d_converter,
8329            src,
8330            dst,
8331            Rotation::None,
8332            Flip::None,
8333            Crop::no_crop(),
8334        );
8335        result.unwrap();
8336
8337        let target_image = TensorDyn::image(
8338            1280,
8339            720,
8340            PixelFormat::Rgba,
8341            DType::U8,
8342            None,
8343            edgefirst_tensor::CpuAccess::ReadWrite,
8344        )
8345        .unwrap();
8346        target_image
8347            .as_typed::<u8>()
8348            .unwrap()
8349            .map()
8350            .unwrap()
8351            .as_mut_slice()
8352            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8353
8354        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8355        // so the matrix delta vs the reference that forced 0.95 has closed;
8356        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8357        compare_images(&dst, &target_image, 0.98, function!());
8358    }
8359
8360    #[test]
8361    #[cfg(any(
8362        target_os = "linux",
8363        target_os = "macos",
8364        target_os = "ios",
8365        target_os = "android",
8366        target_os = "windows"
8367    ))]
8368    #[cfg(feature = "opengl")]
8369    fn test_yuyv_to_rgba_opengl() {
8370        if !is_opengl_available() {
8371            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8372            return;
8373        }
8374        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
8375        // D3D11 texture on Windows; skips where no such backing exists.
8376        if !crate::test_support::is_gpu_buffer_available() {
8377            crate::test_support::report_skip(&format!(
8378                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8379                function!()
8380            ));
8381            return;
8382        }
8383
8384        let src = load_bytes_to_tensor(
8385            1280,
8386            720,
8387            PixelFormat::Yuyv,
8388            Some(TensorMemory::DmaBuf),
8389            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8390        )
8391        .unwrap();
8392
8393        let dst = TensorDyn::image(
8394            1280,
8395            720,
8396            PixelFormat::Rgba,
8397            DType::U8,
8398            Some(TensorMemory::DmaBuf),
8399            edgefirst_tensor::CpuAccess::ReadWrite,
8400        )
8401        .unwrap();
8402        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8403
8404        let (result, _src, dst) = convert_img(
8405            &mut gl_converter,
8406            src,
8407            dst,
8408            Rotation::None,
8409            Flip::None,
8410            Crop::no_crop(),
8411        );
8412        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8413        // Production ImageProcessor falls back to CPU; this oracle skips.
8414        if let Err(crate::Error::NotSupported(msg)) = &result {
8415            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
8416            return;
8417        }
8418        result.unwrap();
8419
8420        let target_image = TensorDyn::image(
8421            1280,
8422            720,
8423            PixelFormat::Rgba,
8424            DType::U8,
8425            None,
8426            edgefirst_tensor::CpuAccess::ReadWrite,
8427        )
8428        .unwrap();
8429        target_image
8430            .as_typed::<u8>()
8431            .unwrap()
8432            .map()
8433            .unwrap()
8434            .as_mut_slice()
8435            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8436
8437        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8438        // so the matrix delta vs the reference that forced 0.95 has closed;
8439        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8440        compare_images(&dst, &target_image, 0.98, function!());
8441    }
8442
8443    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8444    /// IOSurface backend end-to-end and compares against the same
8445    /// reference image. Skips silently if ANGLE isn't installed so the
8446    /// test suite still passes on CI hosts without the Homebrew tap.
8447    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8448    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8449    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8450    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8451    /// luma round-trips to R=G=B (identity GREY→RGB).
8452    #[test]
8453    #[cfg(target_os = "macos")]
8454    #[cfg(feature = "opengl")]
8455    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8456        let mut proc = match GLProcessorThreaded::new(None) {
8457            Ok(p) => p,
8458            Err(e) => {
8459                crate::test_support::report_skip(&format!(
8460                    "{} — GL engine init failed ({e:?})",
8461                    function!()
8462                ));
8463                return;
8464            }
8465        };
8466
8467        let (w, h) = (16usize, 16usize);
8468        let src = TensorDyn::image(
8469            w,
8470            h,
8471            PixelFormat::Grey,
8472            DType::U8,
8473            Some(TensorMemory::DmaBuf),
8474            edgefirst_tensor::CpuAccess::ReadWrite,
8475        )
8476        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8477        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8478        {
8479            let su8 = src.as_typed::<u8>().unwrap();
8480            let stride = src
8481                .as_typed::<u8>()
8482                .unwrap()
8483                .effective_row_stride()
8484                .unwrap();
8485            let mut m = su8.map().unwrap();
8486            let buf = m.as_mut_slice();
8487            for y in 0..h {
8488                for x in 0..w {
8489                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8490                }
8491            }
8492        }
8493
8494        let dst = TensorDyn::image(
8495            w,
8496            h,
8497            PixelFormat::Rgba,
8498            DType::U8,
8499            Some(TensorMemory::DmaBuf),
8500            edgefirst_tensor::CpuAccess::ReadWrite,
8501        )
8502        .unwrap();
8503        let (result, src_back, dst) = convert_img(
8504            &mut proc,
8505            src,
8506            dst,
8507            Rotation::None,
8508            Flip::None,
8509            Crop::no_crop(),
8510        );
8511        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8512
8513        let src_stride = src_back
8514            .as_typed::<u8>()
8515            .unwrap()
8516            .effective_row_stride()
8517            .unwrap();
8518        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8519        let sbytes = src_map.as_slice();
8520        let dst_stride = dst
8521            .as_typed::<u8>()
8522            .unwrap()
8523            .effective_row_stride()
8524            .unwrap();
8525        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8526        let dbytes = dst_map.as_slice();
8527        for y in 0..h {
8528            for x in 0..w {
8529                let yv = sbytes[y * src_stride + x] as i16;
8530                let p = y * dst_stride + x * 4;
8531                for c in 0..3 {
8532                    assert!(
8533                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8534                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8535                        dbytes[p + c]
8536                    );
8537                }
8538            }
8539        }
8540    }
8541
8542    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8543    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8544    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8545    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8546    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8547    #[test]
8548    #[cfg(target_os = "macos")]
8549    #[cfg(feature = "opengl")]
8550    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8551        let mut gpu = match GLProcessorThreaded::new(None) {
8552            Ok(p) => p,
8553            Err(e) => {
8554                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8555                return;
8556            }
8557        };
8558        let (w, h) = (64usize, 64usize);
8559        let Some(src) = crate::test_support::gpu_buffer_allocated(
8560            TensorDyn::image(
8561                w,
8562                h,
8563                PixelFormat::Nv12,
8564                DType::U8,
8565                Some(TensorMemory::DmaBuf),
8566                edgefirst_tensor::CpuAccess::ReadWrite,
8567            ),
8568            "NV12 IOSurface",
8569        ) else {
8570            return;
8571        };
8572        src.as_typed::<u8>()
8573            .unwrap()
8574            .map()
8575            .unwrap()
8576            .as_mut_slice()
8577            .fill(128); // Y=U=V=128
8578
8579        let Some(dst) = crate::test_support::gpu_buffer_allocated(
8580            TensorDyn::image(
8581                w,
8582                h,
8583                PixelFormat::PlanarRgb,
8584                DType::F16,
8585                Some(TensorMemory::DmaBuf),
8586                edgefirst_tensor::CpuAccess::ReadWrite,
8587            ),
8588            "F16 PlanarRgb IOSurface",
8589        ) else {
8590            return;
8591        };
8592        let mut dst = dst;
8593        // Call convert directly (the convert_img helper restores u8 only).
8594        if let Err(e) = ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, None) {
8595            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8596            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8597            // fail on a capability gap (the same policy as the F16 path tests).
8598            crate::test_support::report_skip(&format!(
8599                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8600                function!()
8601            ));
8602            return;
8603        }
8604        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8605        let map = dt.map().unwrap();
8606        let vals = map.as_slice();
8607        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8608        // mediump YUV math + half-float rounding.
8609        let mut checked = 0usize;
8610        for &v in vals.iter() {
8611            let f = f32::from(v);
8612            assert!(
8613                (0.40..=0.60).contains(&f),
8614                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8615            );
8616            checked += 1;
8617        }
8618        assert!(
8619            checked >= w * h * 3,
8620            "expected >= 3 planes of samples, got {checked}"
8621        );
8622    }
8623
8624    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8625    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8626    /// frame, filled at the preserved physical stride, and converted with a
8627    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8628    /// exactly the orchestrator's preprocess. Guards against the pooled
8629    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8630    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8631    #[test]
8632    #[cfg(target_os = "macos")]
8633    #[cfg(feature = "opengl")]
8634    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8635        let mut gpu = match GLProcessorThreaded::new(None) {
8636            Ok(p) => p,
8637            Err(e) => {
8638                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8639                return;
8640            }
8641        };
8642        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8643        let (fw, fh) = (96usize, 64usize);
8644        let (pool_w, pool_h) = (256usize, 768usize);
8645        let (model_w, model_h) = (128usize, 128usize);
8646
8647        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8648            TensorDyn::image(
8649                pool_w,
8650                pool_h,
8651                PixelFormat::Grey,
8652                DType::U8,
8653                Some(TensorMemory::DmaBuf),
8654                edgefirst_tensor::CpuAccess::ReadWrite,
8655            ),
8656            "R8 pool",
8657        ) else {
8658            return;
8659        };
8660        src.configure_image(fw, fh, PixelFormat::Nv12)
8661            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8662        let stride = src
8663            .as_typed::<u8>()
8664            .unwrap()
8665            .effective_row_stride()
8666            .unwrap();
8667        src.as_typed::<u8>()
8668            .unwrap()
8669            .map()
8670            .unwrap()
8671            .as_mut_slice()
8672            .fill(128); // neutral grey
8673
8674        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8675            TensorDyn::image(
8676                model_w,
8677                model_h,
8678                PixelFormat::PlanarRgb,
8679                DType::F16,
8680                Some(TensorMemory::DmaBuf),
8681                edgefirst_tensor::CpuAccess::ReadWrite,
8682            ),
8683            "F16 PlanarRgb dst",
8684        ) else {
8685            return;
8686        };
8687
8688        // Letterbox crop like the profiler: the backend computes the band.
8689        let _ = model_w;
8690        let crop = Crop::new()
8691            .with_source(Some(Region::new(0, 0, fw, fh)))
8692            .with_fit(Fit::Letterbox {
8693                pad: [0, 0, 0, 255],
8694            });
8695        if let Err(e) = ImageProcessorTrait::convert(
8696            &mut gpu,
8697            &src,
8698            &mut dst,
8699            Some(ConvertOptions::new().crop(crop)),
8700        ) {
8701            crate::test_support::report_skip(&format!(
8702                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8703                function!()
8704            ));
8705            return;
8706        }
8707        let _ = stride;
8708        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8709        // completed and produced finite values (no hang, no NaN garbage).
8710        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8711        let map = dt.map().unwrap();
8712        let any_half = map.as_slice().iter().any(|&v| {
8713            let f = f32::from(v);
8714            (0.40..=0.60).contains(&f)
8715        });
8716        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8717    }
8718
8719    /// Mirrors the orchestrator: the GL processor is created on one thread
8720    /// and `convert()` is called from a *different* thread (the profiler's
8721    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8722    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8723    /// fails loudly rather than hanging the whole test binary.
8724    #[test]
8725    #[cfg(target_os = "macos")]
8726    #[cfg(feature = "opengl")]
8727    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8728        use std::sync::mpsc;
8729        // Public ImageProcessor (Send) created HERE (the main test thread),
8730        // exactly like the orchestrator builds `config.processor` during setup.
8731        let mut proc = match ImageProcessor::new() {
8732            Ok(p) => p,
8733            Err(e) => {
8734                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8735                return;
8736            }
8737        };
8738        let (fw, fh) = (96usize, 64usize);
8739        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8740            TensorDyn::image(
8741                256,
8742                768,
8743                PixelFormat::Grey,
8744                DType::U8,
8745                Some(TensorMemory::DmaBuf),
8746                edgefirst_tensor::CpuAccess::ReadWrite,
8747            ),
8748            "NV12 R8 pool",
8749        ) else {
8750            return;
8751        };
8752        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8753        src.as_typed::<u8>()
8754            .unwrap()
8755            .map()
8756            .unwrap()
8757            .as_mut_slice()
8758            .fill(128);
8759        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8760            TensorDyn::image(
8761                128,
8762                128,
8763                PixelFormat::PlanarRgb,
8764                DType::F16,
8765                Some(TensorMemory::DmaBuf),
8766                edgefirst_tensor::CpuAccess::ReadWrite,
8767            ),
8768            "F16 PlanarRgb dst",
8769        ) else {
8770            return;
8771        };
8772        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8773
8774        // ...then MOVED to a worker thread where convert() runs — exactly the
8775        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8776        let (tx, rx) = mpsc::channel::<bool>();
8777        let worker = std::thread::spawn(move || {
8778            let _ = ImageProcessorTrait::convert(
8779                &mut proc,
8780                &src,
8781                &mut dst,
8782                Some(ConvertOptions::new().crop(crop)),
8783            );
8784            let _ = tx.send(true);
8785        });
8786        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8787            Ok(_) => { let _ = worker.join(); }
8788            Err(_) => panic!(
8789                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8790            ),
8791        }
8792    }
8793
8794    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8795    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8796    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8797    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8798    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8799    /// (no runaway) over many iterations.
8800    #[test]
8801    #[cfg(target_os = "macos")]
8802    #[cfg(feature = "opengl")]
8803    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8804        let mut gpu = match GLProcessorThreaded::new(None) {
8805            Ok(p) => p,
8806            Err(e) => {
8807                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8808                return;
8809            }
8810        };
8811        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8812        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8813        let (max_w, max_h) = (640usize, 640usize);
8814        let depth = 4usize;
8815        let mut srcs = Vec::new();
8816        let mut dsts = Vec::new();
8817        for _ in 0..depth {
8818            let Some(src) = crate::test_support::gpu_buffer_allocated(
8819                TensorDyn::image(
8820                    max_w,
8821                    max_h * 3,
8822                    PixelFormat::Grey,
8823                    DType::U8,
8824                    Some(TensorMemory::DmaBuf),
8825                    edgefirst_tensor::CpuAccess::ReadWrite,
8826                ),
8827                "R8 pool",
8828            ) else {
8829                return;
8830            };
8831            srcs.push(src);
8832            let Some(dst) = crate::test_support::gpu_buffer_allocated(
8833                TensorDyn::image(
8834                    640,
8835                    640,
8836                    PixelFormat::PlanarRgb,
8837                    DType::F16,
8838                    Some(TensorMemory::DmaBuf),
8839                    edgefirst_tensor::CpuAccess::ReadWrite,
8840                ),
8841                "F16 PlanarRgb dst",
8842            ) else {
8843                return;
8844            };
8845            dsts.push(dst);
8846        }
8847        // COCO-like assorted frame sizes (all ≤ max), cycled.
8848        let sizes = [
8849            (640, 480),
8850            (500, 375),
8851            (640, 427),
8852            (333, 500),
8853            (480, 640),
8854            (612, 612),
8855            (428, 640),
8856            (576, 432),
8857        ];
8858        let mut first_ms = 0f64;
8859        let mut last_ms = 0f64;
8860        let iters = 40usize;
8861        for i in 0..iters {
8862            let (fw, fh) = sizes[i % sizes.len()];
8863            let src = &mut srcs[i % depth];
8864            let dst = &mut dsts[i % depth];
8865            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8866            src.as_typed::<u8>()
8867                .unwrap()
8868                .map()
8869                .unwrap()
8870                .as_mut_slice()
8871                .fill(128);
8872            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8873            let t0 = std::time::Instant::now();
8874            ImageProcessorTrait::convert(
8875                &mut gpu,
8876                src,
8877                dst,
8878                Some(ConvertOptions::new().crop(crop)),
8879            )
8880            .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8881            let ms = t0.elapsed().as_secs_f64() * 1e3;
8882            if i == 2 {
8883                first_ms = ms;
8884            }
8885            if i == iters - 1 {
8886                last_ms = ms;
8887            }
8888        }
8889        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8890        assert!(
8891            last_ms < first_ms * 5.0 + 5.0,
8892            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8893        );
8894    }
8895
8896    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8897    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8898    /// IOSurface source and a Mem source from the same logical YUV pattern
8899    /// (each at its own row stride), converts the IOSurface on the GPU and the
8900    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8901    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8902    #[test]
8903    #[cfg(target_os = "macos")]
8904    #[cfg(feature = "opengl")]
8905    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8906        let mut gpu = match GLProcessorThreaded::new(None) {
8907            Ok(p) => p,
8908            Err(e) => {
8909                crate::test_support::report_skip(&format!(
8910                    "{} — GL engine init failed ({e:?})",
8911                    function!()
8912                ));
8913                return;
8914            }
8915        };
8916        let mut cpu = CPUProcessor::new();
8917
8918        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8919        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8920        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8921        // plane starts at `h * stride`; each chroma row advances
8922        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8923        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8924        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8925        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8926        //
8927        // Takes explicit w/h so the closure can be reused across multiple frame
8928        // sizes (even and odd) without capturing a fixed outer variable.
8929        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8930            for y in 0..h {
8931                for x in 0..w {
8932                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8933                }
8934            }
8935            let (cw, ch, uv_grid_rows) = match fmt {
8936                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8937                PixelFormat::Nv16 => (w / 2, h, 1usize),
8938                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8939            };
8940            let uv_plane = h * stride;
8941            for cy in 0..ch {
8942                for cx in 0..cw {
8943                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8944                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8945                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8946                }
8947            }
8948        };
8949
8950        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8951            for (w, h) in [
8952                (16usize, 16usize), // original even-dim case
8953                (15, 16),           // odd-W
8954                (16, 15),           // odd-H
8955            ] {
8956                let mem = TensorDyn::image(
8957                    w,
8958                    h,
8959                    fmt,
8960                    DType::U8,
8961                    None,
8962                    edgefirst_tensor::CpuAccess::ReadWrite,
8963                )
8964                .unwrap();
8965                let mem_stride = mem
8966                    .as_typed::<u8>()
8967                    .unwrap()
8968                    .effective_row_stride()
8969                    .unwrap();
8970                fill(
8971                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8972                    mem_stride,
8973                    fmt,
8974                    w,
8975                    h,
8976                );
8977                let cpu_dst = TensorDyn::image(
8978                    w,
8979                    h,
8980                    PixelFormat::Rgba,
8981                    DType::U8,
8982                    None,
8983                    edgefirst_tensor::CpuAccess::ReadWrite,
8984                )
8985                .unwrap();
8986                let (r, _s, cpu_dst) = convert_img(
8987                    &mut cpu,
8988                    mem,
8989                    cpu_dst,
8990                    Rotation::None,
8991                    Flip::None,
8992                    Crop::no_crop(),
8993                );
8994                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8995
8996                let ios = TensorDyn::image(
8997                    w,
8998                    h,
8999                    fmt,
9000                    DType::U8,
9001                    Some(TensorMemory::DmaBuf),
9002                    edgefirst_tensor::CpuAccess::ReadWrite,
9003                )
9004                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
9005                let ios_stride = ios
9006                    .as_typed::<u8>()
9007                    .unwrap()
9008                    .effective_row_stride()
9009                    .unwrap();
9010                fill(
9011                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9012                    ios_stride,
9013                    fmt,
9014                    w,
9015                    h,
9016                );
9017                let gpu_dst = TensorDyn::image(
9018                    w,
9019                    h,
9020                    PixelFormat::Rgba,
9021                    DType::U8,
9022                    Some(TensorMemory::DmaBuf),
9023                    edgefirst_tensor::CpuAccess::ReadWrite,
9024                )
9025                .unwrap();
9026                let (r, _s, gpu_dst) = convert_img(
9027                    &mut gpu,
9028                    ios,
9029                    gpu_dst,
9030                    Rotation::None,
9031                    Flip::None,
9032                    Crop::no_crop(),
9033                );
9034                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
9035
9036                let cs = cpu_dst
9037                    .as_typed::<u8>()
9038                    .unwrap()
9039                    .effective_row_stride()
9040                    .unwrap();
9041                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9042                let cb = cmap.as_slice();
9043                let gs = gpu_dst
9044                    .as_typed::<u8>()
9045                    .unwrap()
9046                    .effective_row_stride()
9047                    .unwrap();
9048                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9049                let gb = gmap.as_slice();
9050                let mut max_d = 0i16;
9051                for y in 0..h {
9052                    for x in 0..w {
9053                        for c in 0..3 {
9054                            let cv = cb[y * cs + x * 4 + c] as i16;
9055                            let gv = gb[y * gs + x * 4 + c] as i16;
9056                            max_d = max_d.max((cv - gv).abs());
9057                        }
9058                    }
9059                }
9060                assert!(
9061                    max_d <= 3,
9062                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
9063                );
9064            }
9065        }
9066    }
9067
9068    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
9069    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
9070    /// the frame's even width) is reconfigured to each NV frame, filled at the
9071    /// preserved physical stride, and converted on the GPU. This proves ANGLE
9072    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
9073    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
9074    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
9075    #[test]
9076    #[cfg(target_os = "macos")]
9077    #[cfg(feature = "opengl")]
9078    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
9079        let mut gpu = match GLProcessorThreaded::new(None) {
9080            Ok(p) => p,
9081            Err(e) => {
9082                crate::test_support::report_skip(&format!(
9083                    "{} — GL engine init failed ({e:?})",
9084                    function!()
9085                ));
9086                return;
9087            }
9088        };
9089        let mut cpu = CPUProcessor::new();
9090        // The pool is generously oversized (256-wide → bpr 256, well beyond any
9091        // frame's even width) and tall enough for NV24's 3·H at the test frames.
9092        let (pool_w, pool_h) = (256usize, 256usize);
9093
9094        // Canonical semi-planar fill: Y plane at the row stride, then the UV
9095        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
9096        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
9097        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
9098            for y in 0..h {
9099                for x in 0..w {
9100                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
9101                }
9102            }
9103            let (cw, ch, uv_grid_rows) = match fmt {
9104                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
9105                PixelFormat::Nv16 => (w / 2, h, 1usize),
9106                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
9107            };
9108            let uv_plane = h * stride;
9109            for cy in 0..ch {
9110                for cx in 0..cw {
9111                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
9112                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
9113                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
9114                }
9115            }
9116        };
9117
9118        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
9119            for (w, h) in [
9120                (40usize, 24usize), // original even-dim case
9121                (15, 16),           // odd-W
9122                (16, 15),           // odd-H
9123            ] {
9124                // `ew` is the minimum even extent; the pool stride must exceed it
9125                // to actually exercise the physical-stride shader decoupling.
9126                let ew = w.next_multiple_of(2);
9127
9128                // CPU reference from a tightly-packed Mem tensor at the frame size.
9129                let mem = TensorDyn::image(
9130                    w,
9131                    h,
9132                    fmt,
9133                    DType::U8,
9134                    None,
9135                    edgefirst_tensor::CpuAccess::ReadWrite,
9136                )
9137                .unwrap();
9138                let mem_stride = mem
9139                    .as_typed::<u8>()
9140                    .unwrap()
9141                    .effective_row_stride()
9142                    .unwrap();
9143                fill(
9144                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9145                    mem_stride,
9146                    fmt,
9147                    w,
9148                    h,
9149                );
9150                let cpu_dst = TensorDyn::image(
9151                    w,
9152                    h,
9153                    PixelFormat::Rgba,
9154                    DType::U8,
9155                    None,
9156                    edgefirst_tensor::CpuAccess::ReadWrite,
9157                )
9158                .unwrap();
9159                let (r, _s, cpu_dst) = convert_img(
9160                    &mut cpu,
9161                    mem,
9162                    cpu_dst,
9163                    Rotation::None,
9164                    Flip::None,
9165                    Crop::no_crop(),
9166                );
9167                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
9168
9169                // GPU source: a LARGER R8 pool surface, reconfigured down to the
9170                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
9171                // tensor's row stride; the fill writes the frame at that stride.
9172                let Some(mut ios) = crate::test_support::gpu_buffer_allocated(
9173                    TensorDyn::image(
9174                        pool_w,
9175                        pool_h,
9176                        PixelFormat::Grey,
9177                        DType::U8,
9178                        Some(TensorMemory::DmaBuf),
9179                        edgefirst_tensor::CpuAccess::ReadWrite,
9180                    ),
9181                    "R8 pool IOSurface",
9182                ) else {
9183                    return;
9184                };
9185                ios.configure_image(w, h, fmt)
9186                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
9187                let ios_stride = ios
9188                    .as_typed::<u8>()
9189                    .unwrap()
9190                    .effective_row_stride()
9191                    .unwrap();
9192                assert!(
9193                    ios_stride > ew,
9194                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
9195                     (test must exercise padding)"
9196                );
9197                fill(
9198                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9199                    ios_stride,
9200                    fmt,
9201                    w,
9202                    h,
9203                );
9204
9205                let gpu_dst = TensorDyn::image(
9206                    w,
9207                    h,
9208                    PixelFormat::Rgba,
9209                    DType::U8,
9210                    Some(TensorMemory::DmaBuf),
9211                    edgefirst_tensor::CpuAccess::ReadWrite,
9212                )
9213                .unwrap();
9214                let (r, _s, gpu_dst) = convert_img(
9215                    &mut gpu,
9216                    ios,
9217                    gpu_dst,
9218                    Rotation::None,
9219                    Flip::None,
9220                    Crop::no_crop(),
9221                );
9222                r.unwrap_or_else(|e| {
9223                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
9224                });
9225
9226                let cs = cpu_dst
9227                    .as_typed::<u8>()
9228                    .unwrap()
9229                    .effective_row_stride()
9230                    .unwrap();
9231                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9232                let cb = cmap.as_slice();
9233                let gs = gpu_dst
9234                    .as_typed::<u8>()
9235                    .unwrap()
9236                    .effective_row_stride()
9237                    .unwrap();
9238                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9239                let gb = gmap.as_slice();
9240                let mut max_d = 0i16;
9241                for y in 0..h {
9242                    for x in 0..w {
9243                        for c in 0..3 {
9244                            let cv = cb[y * cs + x * 4 + c] as i16;
9245                            let gv = gb[y * gs + x * 4 + c] as i16;
9246                            max_d = max_d.max((cv - gv).abs());
9247                        }
9248                    }
9249                }
9250                assert!(
9251                    max_d <= 3,
9252                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
9253                );
9254            }
9255        }
9256    }
9257
9258    #[test]
9259    #[cfg(target_os = "macos")]
9260    #[cfg(feature = "opengl")]
9261    fn test_yuyv_to_rgba_opengl_macos() {
9262        let mut proc = match GLProcessorThreaded::new(None) {
9263            Ok(p) => p,
9264            Err(e) => {
9265                crate::test_support::report_skip(&format!(
9266                    "{} — GL engine init failed ({e:?}). \
9267                     Install ANGLE via `brew install startergo/angle/angle` \
9268                     and re-sign per README.md § macOS GPU Acceleration to \
9269                     run this test.",
9270                    function!()
9271                ));
9272                return;
9273            }
9274        };
9275
9276        let src = load_bytes_to_tensor(
9277            1280,
9278            720,
9279            PixelFormat::Yuyv,
9280            Some(TensorMemory::DmaBuf),
9281            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9282        )
9283        .unwrap();
9284
9285        let dst = TensorDyn::image(
9286            1280,
9287            720,
9288            PixelFormat::Rgba,
9289            DType::U8,
9290            Some(TensorMemory::DmaBuf),
9291            edgefirst_tensor::CpuAccess::ReadWrite,
9292        )
9293        .unwrap();
9294
9295        let (result, _src, dst) = convert_img(
9296            &mut proc,
9297            src,
9298            dst,
9299            Rotation::None,
9300            Flip::None,
9301            Crop::no_crop(),
9302        );
9303        result.unwrap();
9304
9305        let target_image = TensorDyn::image(
9306            1280,
9307            720,
9308            PixelFormat::Rgba,
9309            DType::U8,
9310            None,
9311            edgefirst_tensor::CpuAccess::ReadWrite,
9312        )
9313        .unwrap();
9314        target_image
9315            .as_typed::<u8>()
9316            .unwrap()
9317            .map()
9318            .unwrap()
9319            .as_mut_slice()
9320            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9321
9322        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
9323        // camera720p source resolves to BT.709 limited (matching the BT.709
9324        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
9325        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
9326        compare_images(&dst, &target_image, 0.98, function!());
9327    }
9328
9329    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
9330    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
9331    /// both filled with a synthetic mid-grey pattern. Validates the
9332    /// shader math at the chroma-pairing boundary on small textures
9333    /// and exercises the IOSurface bytes-per-row alignment path at 4K
9334    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
9335    ///
9336    /// Resolutions below 32 pixels wide aren't tested because the
9337    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
9338    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
9339    /// which exercises a sampling pattern that's ANGLE-version
9340    /// dependent rather than HAL-correctness dependent.
9341    ///
9342    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
9343    /// pixel-exact correctness against a reference image at 720p) by
9344    /// ensuring the pipeline does not crash or produce gross errors at
9345    /// resolution extremes. Pixel-exact validation at 4K would require
9346    /// a 30 MB reference file we don't want to bundle.
9347    #[test]
9348    #[cfg(target_os = "macos")]
9349    #[cfg(feature = "opengl")]
9350    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
9351        let mut proc = match GLProcessorThreaded::new(None) {
9352            Ok(p) => p,
9353            Err(e) => {
9354                crate::test_support::report_skip(&format!(
9355                    "{} — GL engine init failed ({e:?})",
9356                    function!()
9357                ));
9358                return;
9359            }
9360        };
9361
9362        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
9363            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
9364            // chroma) → RGB grey at the output.
9365            let bytes_per_row = w * 2;
9366            let mut yuyv = vec![0u8; bytes_per_row * h];
9367            for chunk in yuyv.as_chunks_mut::<4>().0 {
9368                chunk[0] = 128; // Y0
9369                chunk[1] = 128; // U
9370                chunk[2] = 128; // Y1
9371                chunk[3] = 128; // V
9372            }
9373
9374            let src =
9375                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9376                    .unwrap();
9377
9378            let dst = TensorDyn::image(
9379                w,
9380                h,
9381                PixelFormat::Rgba,
9382                DType::U8,
9383                Some(TensorMemory::DmaBuf),
9384                edgefirst_tensor::CpuAccess::ReadWrite,
9385            )
9386            .unwrap();
9387
9388            let (result, _src, dst) = convert_img(
9389                &mut proc,
9390                src,
9391                dst,
9392                Rotation::None,
9393                Flip::None,
9394                Crop::no_crop(),
9395            );
9396            result.expect("GL convert should succeed at this resolution");
9397
9398            // The neutral-chroma input must produce a near-grey output;
9399            // BT.709 limited-range maps Y=128/UV=128 → roughly
9400            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
9401            // rounding.
9402            let dst_u8 = dst.as_typed::<u8>().unwrap();
9403            let dst_map = dst_u8.map().unwrap();
9404            let dst_bytes = dst_map.as_slice();
9405            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9406            for px in dst_bytes.as_chunks::<4>().0 {
9407                for (i, &c) in px[..3].iter().enumerate() {
9408                    assert!(
9409                        (120..=140).contains(&c),
9410                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9411                        function!(),
9412                    );
9413                }
9414                assert_eq!(px[3], 255, "alpha must be 1.0");
9415            }
9416        }
9417    }
9418
9419    /// Verify that two consecutive convert() calls on the same source
9420    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9421    /// added with the macOS GL backend hardening — without it, each
9422    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9423    ///
9424    /// This is a behaviour test rather than a perf test (the timing
9425    /// difference is 100-200µs which is too noisy to assert on); we
9426    /// check that the second call succeeds and produces a result
9427    /// identical to the first.
9428    #[test]
9429    #[cfg(target_os = "macos")]
9430    #[cfg(feature = "opengl")]
9431    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9432        let mut proc = match GLProcessorThreaded::new(None) {
9433            Ok(p) => p,
9434            Err(e) => {
9435                crate::test_support::report_skip(&format!(
9436                    "{} — GL engine init failed ({e:?})",
9437                    function!()
9438                ));
9439                return;
9440            }
9441        };
9442
9443        // Allocate one source + one destination, run convert twice.
9444        let mut yuyv = vec![0u8; 64 * 32 * 2];
9445        for chunk in yuyv.as_chunks_mut::<4>().0 {
9446            chunk[0] = 200;
9447            chunk[1] = 100;
9448            chunk[2] = 200;
9449            chunk[3] = 156;
9450        }
9451        let src =
9452            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9453                .unwrap();
9454        let dst = TensorDyn::image(
9455            64,
9456            32,
9457            PixelFormat::Rgba,
9458            DType::U8,
9459            Some(TensorMemory::DmaBuf),
9460            edgefirst_tensor::CpuAccess::ReadWrite,
9461        )
9462        .unwrap();
9463
9464        let (r1, src, dst) = convert_img(
9465            &mut proc,
9466            src,
9467            dst,
9468            Rotation::None,
9469            Flip::None,
9470            Crop::no_crop(),
9471        );
9472        r1.unwrap();
9473        let first: Vec<u8> = dst
9474            .as_typed::<u8>()
9475            .unwrap()
9476            .map()
9477            .unwrap()
9478            .as_slice()
9479            .to_vec();
9480
9481        let (r2, _src, dst) = convert_img(
9482            &mut proc,
9483            src,
9484            dst,
9485            Rotation::None,
9486            Flip::None,
9487            Crop::no_crop(),
9488        );
9489        r2.unwrap();
9490        let second: Vec<u8> = dst
9491            .as_typed::<u8>()
9492            .unwrap()
9493            .map()
9494            .unwrap()
9495            .as_slice()
9496            .to_vec();
9497
9498        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9499    }
9500
9501    /// Steady-state import gate (macOS half of the Linux
9502    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9503    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9504    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9505    /// stay flat while hits grow. Counter-based hardening of
9506    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9507    /// re-imports per frame passes the pixel-equality test but fails this.
9508    #[test]
9509    #[cfg(target_os = "macos")]
9510    #[cfg(feature = "opengl")]
9511    fn test_macos_gl_pbuffer_cache_steady_state() {
9512        let mut proc = match GLProcessorThreaded::new(None) {
9513            Ok(p) => p,
9514            Err(e) => {
9515                crate::test_support::report_skip(&format!(
9516                    "{} — GL engine init failed ({e:?})",
9517                    function!()
9518                ));
9519                return;
9520            }
9521        };
9522
9523        let (w, h) = (64usize, 32usize);
9524        const POOL: usize = 3;
9525        const FRAMES: usize = 100;
9526
9527        let yuyv = vec![128u8; w * h * 2];
9528        let pool: Vec<TensorDyn> = (0..POOL)
9529            .map(|_| {
9530                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9531                    .unwrap()
9532            })
9533            .collect();
9534        let mut dst = TensorDyn::image(
9535            w,
9536            h,
9537            PixelFormat::Rgba,
9538            DType::U8,
9539            Some(TensorMemory::DmaBuf),
9540            edgefirst_tensor::CpuAccess::ReadWrite,
9541        )
9542        .unwrap();
9543
9544        // Warmup: two passes over the pool import every surface once.
9545        for src in pool.iter().cycle().take(POOL * 2) {
9546            proc.convert(src, &mut dst, None).unwrap();
9547        }
9548        let warm = proc.egl_cache_stats().unwrap();
9549
9550        for src in pool.iter().cycle().take(FRAMES) {
9551            proc.convert(src, &mut dst, None).unwrap();
9552        }
9553        let steady = proc.egl_cache_stats().unwrap();
9554
9555        assert_eq!(
9556            warm.total_misses(),
9557            steady.total_misses(),
9558            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9559        );
9560        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits + s.nv_uv.hits;
9561        assert!(
9562            hits(&steady) - hits(&warm) >= FRAMES as u64,
9563            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9564            hits(&steady) - hits(&warm)
9565        );
9566    }
9567
9568    /// Backend assertion for the F16 zero-copy path: when the GL backend
9569    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9570    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9571    /// engine — proven by the engine's import-cache counters moving, not
9572    /// just by output correctness. This is the guard against the
9573    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9574    /// destination keeps every output-correctness test green while
9575    /// quietly running ~10× slower on CPU; only a backend observable
9576    /// catches it. Skips ONLY when GL itself is unavailable or the
9577    /// configuration lacks F16 — a convert error or a CPU-routed convert
9578    /// with the capability present is a FAILURE.
9579    #[test]
9580    #[cfg(target_os = "macos")]
9581    #[cfg(feature = "opengl")]
9582    fn test_macos_gl_f16_planar_is_gl_backed() {
9583        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9584        let Some(ref gl) = proc.opengl else {
9585            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9586            return;
9587        };
9588        if !gl.supported_render_dtypes().f16 {
9589            crate::test_support::report_skip(&format!(
9590                "{} — configuration lacks F16 color-buffer support",
9591                function!()
9592            ));
9593            return;
9594        }
9595        let stats_before = gl.egl_cache_stats().expect("cache stats");
9596
9597        let src = TensorDyn::image(
9598            1280,
9599            720,
9600            PixelFormat::Nv12,
9601            DType::U8,
9602            Some(TensorMemory::DmaBuf),
9603            edgefirst_tensor::CpuAccess::ReadWrite,
9604        )
9605        .unwrap();
9606        {
9607            let t = src.as_typed::<u8>().unwrap();
9608            let mut m = t.map().unwrap();
9609            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9610                *b = ((i * 31) % 211) as u8;
9611            }
9612        }
9613        let mut dst = TensorDyn::image(
9614            640,
9615            640,
9616            PixelFormat::PlanarRgb,
9617            DType::F16,
9618            Some(TensorMemory::DmaBuf),
9619            edgefirst_tensor::CpuAccess::ReadWrite,
9620        )
9621        .unwrap();
9622
9623        proc.convert(
9624            &src,
9625            &mut dst,
9626            Some(crate::ConvertOptions::new().crop(Crop::letterbox([114, 114, 114, 255]))),
9627        )
9628        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9629        let stats_after = proc
9630            .opengl
9631            .as_ref()
9632            .expect("GL backend present")
9633            .egl_cache_stats()
9634            .expect("cache stats");
9635        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9636        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9637        // convert that imported its source, failed mid-engine, and fell back
9638        // to CPU (1 miss) cannot satisfy this gate.
9639        assert!(
9640            stats_after.total_misses() >= stats_before.total_misses() + 2,
9641            "convert succeeded but the GL engine did not import both the \
9642             source and the F16 destination — the work did not (fully) run \
9643             on the GL backend (silent CPU fallback); misses before={} after={}",
9644            stats_before.total_misses(),
9645            stats_after.total_misses()
9646        );
9647    }
9648
9649    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9650    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9651    /// render). New on every platform with F16 render support — macOS
9652    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9653    /// backend's reference within the float-path tolerance.
9654    #[test]
9655    #[cfg(feature = "opengl")]
9656    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9657        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9658            backend: ComputeBackend::OpenGl,
9659            ..Default::default()
9660        }) {
9661            Ok(p) if p.opengl.is_some() => p,
9662            _ => {
9663                crate::test_support::report_skip(&format!(
9664                    "{} — GL backend unavailable",
9665                    function!()
9666                ));
9667                return;
9668            }
9669        };
9670        if !gl
9671            .opengl
9672            .as_ref()
9673            .map(|g| g.supported_render_dtypes().f16)
9674            .unwrap_or(false)
9675        {
9676            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9677            return;
9678        }
9679        let mem = if crate::test_support::is_gpu_buffer_available() {
9680            TensorMemory::DmaBuf
9681        } else {
9682            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9683            return;
9684        };
9685
9686        let src = TensorDyn::image(
9687            1280,
9688            720,
9689            PixelFormat::Nv12,
9690            DType::U8,
9691            Some(mem),
9692            edgefirst_tensor::CpuAccess::ReadWrite,
9693        )
9694        .unwrap();
9695        {
9696            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9697            // chroma with different kernels (nearest vs bilinear), which
9698            // legitimately diverges on per-texel chroma noise. Gradients
9699            // keep that kernel difference sub-LSB while still exercising
9700            // the full matrix math and the letterbox geometry.
9701            let t = src.as_typed::<u8>().unwrap();
9702            let mut m = t.map().unwrap();
9703            let buf = m.as_mut_slice();
9704            let (w, h) = (1280usize, 720usize);
9705            for y in 0..h {
9706                for x in 0..w {
9707                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9708                }
9709            }
9710            for y in 0..(h / 2) {
9711                for x in 0..(w / 2) {
9712                    let o = h * w + y * w + 2 * x;
9713                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9714                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9715                }
9716            }
9717        }
9718        let crop = Crop::letterbox([114, 114, 114, 255]);
9719        let mut gl_dst = TensorDyn::image(
9720            640,
9721            640,
9722            PixelFormat::PlanarRgb,
9723            DType::F16,
9724            Some(mem),
9725            edgefirst_tensor::CpuAccess::ReadWrite,
9726        )
9727        .unwrap();
9728        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9729        // silently falls back to CPU on a GL error, turning this oracle into
9730        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9731        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9732        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9733        // take the production CPU fallback; this oracle cannot run there.
9734        match gl.opengl.as_mut().expect("GL backend present").convert(
9735            &src,
9736            &mut gl_dst,
9737            Some(crate::ConvertOptions::new().crop(crop)),
9738        ) {
9739            Ok(()) => {}
9740            Err(crate::Error::NotSupported(msg)) => {
9741                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9742                return;
9743            }
9744            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9745        }
9746
9747        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9748            backend: ComputeBackend::Cpu,
9749            ..Default::default()
9750        })
9751        .unwrap();
9752        let mut cpu_dst = TensorDyn::image(
9753            640,
9754            640,
9755            PixelFormat::PlanarRgb,
9756            DType::F16,
9757            Some(TensorMemory::Mem),
9758            edgefirst_tensor::CpuAccess::ReadWrite,
9759        )
9760        .unwrap();
9761        cpu.convert(
9762            &src,
9763            &mut cpu_dst,
9764            Some(crate::ConvertOptions::new().crop(crop)),
9765        )
9766        .expect("CPU reference convert");
9767
9768        let g = gl_dst
9769            .as_typed::<half::f16>()
9770            .unwrap()
9771            .map()
9772            .unwrap()
9773            .as_slice()
9774            .to_vec();
9775        let c = cpu_dst
9776            .as_typed::<half::f16>()
9777            .unwrap()
9778            .map()
9779            .unwrap()
9780            .as_slice()
9781            .to_vec();
9782        assert_eq!(g.len(), c.len());
9783        let mut max_diff = 0.0f32;
9784        let mut max_at = 0usize;
9785        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9786            let d = (a.to_f32() - b.to_f32()).abs();
9787            if d > max_diff {
9788                max_diff = d;
9789                max_at = i;
9790            }
9791        }
9792        // Localize: plane (R/G/B), row, col of the worst element.
9793        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9794        let (row, col) = (rem / 640, rem % 640);
9795        eprintln!(
9796            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9797             gl={} cpu={}",
9798            g[max_at].to_f32(),
9799            c[max_at].to_f32()
9800        );
9801        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9802        // CPU's direct path: allow a few 8-bit steps of divergence.
9803        assert!(
9804            max_diff <= 4.0 / 255.0 + 1e-3,
9805            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9806        );
9807    }
9808
9809    /// Zero-copy source → heap destination through the GL engine,
9810    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9811    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9812    /// the macOS `glReadnPixels` failure: imported source, rendered,
9813    /// then errored at the heap readback). RGBA→BGRA so the convert is
9814    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9815    /// (Vivante's NV fast path legitimately diverges from the CPU
9816    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9817    /// readbacks).
9818    #[test]
9819    #[cfg(feature = "opengl")]
9820    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9821        let mut proc = match ImageProcessor::new() {
9822            Ok(p) if p.opengl.is_some() => p,
9823            _ => {
9824                crate::test_support::report_skip(&format!(
9825                    "{} — GL backend unavailable",
9826                    function!()
9827                ));
9828                return;
9829            }
9830        };
9831        if !crate::test_support::is_gpu_buffer_available() {
9832            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9833            return;
9834        }
9835
9836        let src = TensorDyn::image(
9837            1280,
9838            720,
9839            PixelFormat::Rgba,
9840            DType::U8,
9841            Some(TensorMemory::DmaBuf),
9842            edgefirst_tensor::CpuAccess::ReadWrite,
9843        )
9844        .unwrap();
9845        {
9846            let t = src.as_typed::<u8>().unwrap();
9847            let mut m = t.map().unwrap();
9848            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9849                *b = ((i * 31) % 211) as u8;
9850            }
9851        }
9852        let mut gl_dst = TensorDyn::image(
9853            1280,
9854            720,
9855            PixelFormat::Bgra,
9856            DType::U8,
9857            Some(TensorMemory::Mem),
9858            edgefirst_tensor::CpuAccess::ReadWrite,
9859        )
9860        .unwrap();
9861        proc.opengl
9862            .as_mut()
9863            .expect("GL backend present")
9864            .convert(&src, &mut gl_dst, None)
9865            .expect("zero-copy src → heap dst GL convert");
9866
9867        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9868            backend: ComputeBackend::Cpu,
9869            ..Default::default()
9870        })
9871        .unwrap();
9872        let mut cpu_dst = TensorDyn::image(
9873            1280,
9874            720,
9875            PixelFormat::Bgra,
9876            DType::U8,
9877            Some(TensorMemory::Mem),
9878            edgefirst_tensor::CpuAccess::ReadWrite,
9879        )
9880        .unwrap();
9881        cpu.convert(&src, &mut cpu_dst, None)
9882            .expect("CPU reference convert");
9883
9884        let g = gl_dst
9885            .as_typed::<u8>()
9886            .unwrap()
9887            .map()
9888            .unwrap()
9889            .as_slice()
9890            .to_vec();
9891        let c = cpu_dst
9892            .as_typed::<u8>()
9893            .unwrap()
9894            .map()
9895            .unwrap()
9896            .as_slice()
9897            .to_vec();
9898        assert_eq!(g.len(), c.len());
9899        let max_diff = g
9900            .iter()
9901            .zip(c.iter())
9902            .map(|(a, b)| a.abs_diff(*b))
9903            .max()
9904            .unwrap();
9905        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9906        // only rounding slack.
9907        assert!(
9908            max_diff <= 2,
9909            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9910        );
9911    }
9912
9913    #[test]
9914    #[cfg(target_os = "linux")]
9915    fn test_yuyv_to_rgb_g2d() {
9916        if !is_g2d_available() {
9917            crate::test_support::report_skip(
9918                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9919            );
9920            return;
9921        }
9922        if !is_dma_available() {
9923            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9924            return;
9925        }
9926
9927        let src = load_bytes_to_tensor(
9928            1280,
9929            720,
9930            PixelFormat::Yuyv,
9931            None,
9932            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9933        )
9934        .unwrap();
9935
9936        let g2d_dst = TensorDyn::image(
9937            1280,
9938            720,
9939            PixelFormat::Rgb,
9940            DType::U8,
9941            Some(TensorMemory::DmaBuf),
9942            edgefirst_tensor::CpuAccess::ReadWrite,
9943        )
9944        .unwrap();
9945        let mut g2d_converter = G2DProcessor::new().unwrap();
9946
9947        let (result, src, g2d_dst) = convert_img(
9948            &mut g2d_converter,
9949            src,
9950            g2d_dst,
9951            Rotation::None,
9952            Flip::None,
9953            Crop::no_crop(),
9954        );
9955        result.unwrap();
9956
9957        let cpu_dst = TensorDyn::image(
9958            1280,
9959            720,
9960            PixelFormat::Rgb,
9961            DType::U8,
9962            None,
9963            edgefirst_tensor::CpuAccess::ReadWrite,
9964        )
9965        .unwrap();
9966        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9967
9968        let (result, _src, cpu_dst) = convert_img(
9969            &mut cpu_converter,
9970            src,
9971            cpu_dst,
9972            Rotation::None,
9973            Flip::None,
9974            Crop::no_crop(),
9975        );
9976        result.unwrap();
9977
9978        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9979        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9980        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9981        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9982        // structural gap not exercised by these limited-range fixtures.
9983        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9984    }
9985
9986    #[test]
9987    #[cfg(target_os = "linux")]
9988    fn test_yuyv_to_yuyv_resize_g2d() {
9989        if !is_g2d_available() {
9990            crate::test_support::report_skip(
9991                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9992            );
9993            return;
9994        }
9995        if !is_dma_available() {
9996            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9997            return;
9998        }
9999
10000        let src = load_bytes_to_tensor(
10001            1280,
10002            720,
10003            PixelFormat::Yuyv,
10004            None,
10005            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10006        )
10007        .unwrap();
10008
10009        let g2d_dst = TensorDyn::image(
10010            600,
10011            400,
10012            PixelFormat::Yuyv,
10013            DType::U8,
10014            Some(TensorMemory::DmaBuf),
10015            edgefirst_tensor::CpuAccess::ReadWrite,
10016        )
10017        .unwrap();
10018        let mut g2d_converter = G2DProcessor::new().unwrap();
10019
10020        let (result, src, g2d_dst) = convert_img(
10021            &mut g2d_converter,
10022            src,
10023            g2d_dst,
10024            Rotation::None,
10025            Flip::None,
10026            Crop::no_crop(),
10027        );
10028        result.unwrap();
10029
10030        let cpu_dst = TensorDyn::image(
10031            600,
10032            400,
10033            PixelFormat::Yuyv,
10034            DType::U8,
10035            None,
10036            edgefirst_tensor::CpuAccess::ReadWrite,
10037        )
10038        .unwrap();
10039        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10040
10041        let (result, _src, cpu_dst) = convert_img(
10042            &mut cpu_converter,
10043            src,
10044            cpu_dst,
10045            Rotation::None,
10046            Flip::None,
10047            Crop::no_crop(),
10048        );
10049        result.unwrap();
10050
10051        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
10052        // diverges from the CPU reference by enough that the similarity score
10053        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
10054        // The threshold is held at 0.85 so the test guards against gross
10055        // regressions while tolerating the driver's inherent colorimetry error.
10056        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
10057        eprintln!(
10058            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
10059             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
10060        );
10061        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
10062    }
10063
10064    #[test]
10065    fn test_yuyv_to_rgba_resize_cpu() {
10066        let src = load_bytes_to_tensor(
10067            1280,
10068            720,
10069            PixelFormat::Yuyv,
10070            None,
10071            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10072        )
10073        .unwrap();
10074
10075        let (dst_width, dst_height) = (960, 540);
10076
10077        let dst = TensorDyn::image(
10078            dst_width,
10079            dst_height,
10080            PixelFormat::Rgba,
10081            DType::U8,
10082            None,
10083            edgefirst_tensor::CpuAccess::ReadWrite,
10084        )
10085        .unwrap();
10086        let mut cpu_converter = CPUProcessor::new();
10087
10088        let (result, _src, dst) = convert_img(
10089            &mut cpu_converter,
10090            src,
10091            dst,
10092            Rotation::None,
10093            Flip::None,
10094            Crop::no_crop(),
10095        );
10096        result.unwrap();
10097
10098        let dst_target = TensorDyn::image(
10099            dst_width,
10100            dst_height,
10101            PixelFormat::Rgba,
10102            DType::U8,
10103            None,
10104            edgefirst_tensor::CpuAccess::ReadWrite,
10105        )
10106        .unwrap();
10107        let src_target = load_bytes_to_tensor(
10108            1280,
10109            720,
10110            PixelFormat::Rgba,
10111            None,
10112            &edgefirst_bench::testdata::read("camera720p.rgba"),
10113        )
10114        .unwrap();
10115        let (result, _src_target, dst_target) = convert_img(
10116            &mut cpu_converter,
10117            src_target,
10118            dst_target,
10119            Rotation::None,
10120            Flip::None,
10121            Crop::no_crop(),
10122        );
10123        result.unwrap();
10124
10125        // CPU path resolves the untagged 720p source to BT.709 limited (height
10126        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10127        compare_images(&dst, &dst_target, 0.98, function!());
10128    }
10129
10130    #[test]
10131    #[cfg(target_os = "linux")]
10132    fn test_yuyv_to_rgba_crop_flip_g2d() {
10133        if !is_g2d_available() {
10134            crate::test_support::report_skip(
10135                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
10136            );
10137            return;
10138        }
10139        if !is_dma_available() {
10140            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10141            return;
10142        }
10143
10144        let src = load_bytes_to_tensor(
10145            1280,
10146            720,
10147            PixelFormat::Yuyv,
10148            Some(TensorMemory::DmaBuf),
10149            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10150        )
10151        .unwrap();
10152
10153        let (dst_width, dst_height) = (640, 640);
10154
10155        let dst_g2d = TensorDyn::image(
10156            dst_width,
10157            dst_height,
10158            PixelFormat::Rgba,
10159            DType::U8,
10160            Some(TensorMemory::DmaBuf),
10161            edgefirst_tensor::CpuAccess::ReadWrite,
10162        )
10163        .unwrap();
10164        let mut g2d_converter = G2DProcessor::new().unwrap();
10165        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
10166
10167        let (result, src, dst_g2d) = convert_img(
10168            &mut g2d_converter,
10169            src,
10170            dst_g2d,
10171            Rotation::None,
10172            Flip::Horizontal,
10173            crop,
10174        );
10175        result.unwrap();
10176
10177        let dst_cpu = TensorDyn::image(
10178            dst_width,
10179            dst_height,
10180            PixelFormat::Rgba,
10181            DType::U8,
10182            Some(TensorMemory::DmaBuf),
10183            edgefirst_tensor::CpuAccess::ReadWrite,
10184        )
10185        .unwrap();
10186        let mut cpu_converter = CPUProcessor::new();
10187
10188        let (result, _src, dst_cpu) = convert_img(
10189            &mut cpu_converter,
10190            src,
10191            dst_cpu,
10192            Rotation::None,
10193            Flip::Horizontal,
10194            crop,
10195        );
10196        result.unwrap();
10197        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10198        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10199        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10200        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10201        // structural gap not exercised by these limited-range fixtures.
10202        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
10203    }
10204
10205    #[test]
10206    #[cfg(any(
10207        target_os = "linux",
10208        target_os = "macos",
10209        target_os = "ios",
10210        target_os = "android",
10211        target_os = "windows"
10212    ))]
10213    #[cfg(feature = "opengl")]
10214    fn test_yuyv_to_rgba_crop_flip_opengl() {
10215        if !is_opengl_available() {
10216            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10217            return;
10218        }
10219
10220        if !crate::test_support::is_gpu_buffer_available() {
10221            crate::test_support::report_skip(&format!(
10222                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10223                function!()
10224            ));
10225            return;
10226        }
10227
10228        let src = load_bytes_to_tensor(
10229            1280,
10230            720,
10231            PixelFormat::Yuyv,
10232            Some(TensorMemory::DmaBuf),
10233            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10234        )
10235        .unwrap();
10236
10237        let (dst_width, dst_height) = (640, 640);
10238
10239        let dst_gl = TensorDyn::image(
10240            dst_width,
10241            dst_height,
10242            PixelFormat::Rgba,
10243            DType::U8,
10244            Some(TensorMemory::DmaBuf),
10245            edgefirst_tensor::CpuAccess::ReadWrite,
10246        )
10247        .unwrap();
10248        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10249        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
10250
10251        let (result, src, dst_gl) = convert_img(
10252            &mut gl_converter,
10253            src,
10254            dst_gl,
10255            Rotation::None,
10256            Flip::Horizontal,
10257            crop,
10258        );
10259        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
10260        if let Err(crate::Error::NotSupported(msg)) = &result {
10261            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
10262            return;
10263        }
10264        result.unwrap();
10265
10266        let dst_cpu = TensorDyn::image(
10267            dst_width,
10268            dst_height,
10269            PixelFormat::Rgba,
10270            DType::U8,
10271            Some(TensorMemory::DmaBuf),
10272            edgefirst_tensor::CpuAccess::ReadWrite,
10273        )
10274        .unwrap();
10275        let mut cpu_converter = CPUProcessor::new();
10276
10277        let (result, _src, dst_cpu) = convert_img(
10278            &mut cpu_converter,
10279            src,
10280            dst_cpu,
10281            Rotation::None,
10282            Flip::Horizontal,
10283            crop,
10284        );
10285        result.unwrap();
10286        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
10287        // YUV color-space/sample-range hints, so the matrix delta that forced
10288        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
10289        // on the GPU lanes).
10290        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
10291    }
10292
10293    #[test]
10294    fn test_vyuy_to_rgba_cpu() {
10295        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10296        let src = TensorDyn::image(
10297            1280,
10298            720,
10299            PixelFormat::Vyuy,
10300            DType::U8,
10301            None,
10302            edgefirst_tensor::CpuAccess::ReadWrite,
10303        )
10304        .unwrap();
10305        src.as_typed::<u8>()
10306            .unwrap()
10307            .map()
10308            .unwrap()
10309            .as_mut_slice()
10310            .copy_from_slice(&file);
10311
10312        let dst = TensorDyn::image(
10313            1280,
10314            720,
10315            PixelFormat::Rgba,
10316            DType::U8,
10317            None,
10318            edgefirst_tensor::CpuAccess::ReadWrite,
10319        )
10320        .unwrap();
10321        let mut cpu_converter = CPUProcessor::new();
10322
10323        let (result, _src, dst) = convert_img(
10324            &mut cpu_converter,
10325            src,
10326            dst,
10327            Rotation::None,
10328            Flip::None,
10329            Crop::no_crop(),
10330        );
10331        result.unwrap();
10332
10333        let target_image = TensorDyn::image(
10334            1280,
10335            720,
10336            PixelFormat::Rgba,
10337            DType::U8,
10338            None,
10339            edgefirst_tensor::CpuAccess::ReadWrite,
10340        )
10341        .unwrap();
10342        target_image
10343            .as_typed::<u8>()
10344            .unwrap()
10345            .map()
10346            .unwrap()
10347            .as_mut_slice()
10348            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10349
10350        // CPU path resolves the untagged 720p source to BT.709 limited (height
10351        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10352        compare_images(&dst, &target_image, 0.98, function!());
10353    }
10354
10355    #[test]
10356    fn test_vyuy_to_rgb_cpu() {
10357        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10358        let src = TensorDyn::image(
10359            1280,
10360            720,
10361            PixelFormat::Vyuy,
10362            DType::U8,
10363            None,
10364            edgefirst_tensor::CpuAccess::ReadWrite,
10365        )
10366        .unwrap();
10367        src.as_typed::<u8>()
10368            .unwrap()
10369            .map()
10370            .unwrap()
10371            .as_mut_slice()
10372            .copy_from_slice(&file);
10373
10374        let dst = TensorDyn::image(
10375            1280,
10376            720,
10377            PixelFormat::Rgb,
10378            DType::U8,
10379            None,
10380            edgefirst_tensor::CpuAccess::ReadWrite,
10381        )
10382        .unwrap();
10383        let mut cpu_converter = CPUProcessor::new();
10384
10385        let (result, _src, dst) = convert_img(
10386            &mut cpu_converter,
10387            src,
10388            dst,
10389            Rotation::None,
10390            Flip::None,
10391            Crop::no_crop(),
10392        );
10393        result.unwrap();
10394
10395        let target_image = TensorDyn::image(
10396            1280,
10397            720,
10398            PixelFormat::Rgb,
10399            DType::U8,
10400            None,
10401            edgefirst_tensor::CpuAccess::ReadWrite,
10402        )
10403        .unwrap();
10404        target_image
10405            .as_typed::<u8>()
10406            .unwrap()
10407            .map()
10408            .unwrap()
10409            .as_mut_slice()
10410            .as_chunks_mut::<3>()
10411            .0
10412            .iter_mut()
10413            .zip(
10414                edgefirst_bench::testdata::read("camera720p.rgba")
10415                    .as_chunks::<4>()
10416                    .0,
10417            )
10418            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10419
10420        // CPU path resolves the untagged 720p source to BT.709 limited (height
10421        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10422        compare_images(&dst, &target_image, 0.98, function!());
10423    }
10424
10425    #[test]
10426    #[cfg(target_os = "linux")]
10427    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10428    fn test_vyuy_to_rgba_g2d() {
10429        if !is_g2d_available() {
10430            crate::test_support::report_skip(
10431                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10432            );
10433            return;
10434        }
10435        if !is_dma_available() {
10436            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10437            return;
10438        }
10439
10440        let src = load_bytes_to_tensor(
10441            1280,
10442            720,
10443            PixelFormat::Vyuy,
10444            None,
10445            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10446        )
10447        .unwrap();
10448
10449        let dst = TensorDyn::image(
10450            1280,
10451            720,
10452            PixelFormat::Rgba,
10453            DType::U8,
10454            Some(TensorMemory::DmaBuf),
10455            edgefirst_tensor::CpuAccess::ReadWrite,
10456        )
10457        .unwrap();
10458        let mut g2d_converter = G2DProcessor::new().unwrap();
10459
10460        let (result, _src, dst) = convert_img(
10461            &mut g2d_converter,
10462            src,
10463            dst,
10464            Rotation::None,
10465            Flip::None,
10466            Crop::no_crop(),
10467        );
10468        match result {
10469            Err(Error::G2D(_)) => {
10470                crate::test_support::report_skip(
10471                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10472                );
10473                return;
10474            }
10475            r => r.unwrap(),
10476        }
10477
10478        let target_image = TensorDyn::image(
10479            1280,
10480            720,
10481            PixelFormat::Rgba,
10482            DType::U8,
10483            None,
10484            edgefirst_tensor::CpuAccess::ReadWrite,
10485        )
10486        .unwrap();
10487        target_image
10488            .as_typed::<u8>()
10489            .unwrap()
10490            .map()
10491            .unwrap()
10492            .as_mut_slice()
10493            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10494
10495        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10496        // so the matrix delta vs the reference that forced 0.95 has closed;
10497        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10498        compare_images(&dst, &target_image, 0.98, function!());
10499    }
10500
10501    #[test]
10502    #[cfg(target_os = "linux")]
10503    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10504    fn test_vyuy_to_rgb_g2d() {
10505        if !is_g2d_available() {
10506            crate::test_support::report_skip(
10507                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10508            );
10509            return;
10510        }
10511        if !is_dma_available() {
10512            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10513            return;
10514        }
10515
10516        let src = load_bytes_to_tensor(
10517            1280,
10518            720,
10519            PixelFormat::Vyuy,
10520            None,
10521            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10522        )
10523        .unwrap();
10524
10525        let g2d_dst = TensorDyn::image(
10526            1280,
10527            720,
10528            PixelFormat::Rgb,
10529            DType::U8,
10530            Some(TensorMemory::DmaBuf),
10531            edgefirst_tensor::CpuAccess::ReadWrite,
10532        )
10533        .unwrap();
10534        let mut g2d_converter = G2DProcessor::new().unwrap();
10535
10536        let (result, src, g2d_dst) = convert_img(
10537            &mut g2d_converter,
10538            src,
10539            g2d_dst,
10540            Rotation::None,
10541            Flip::None,
10542            Crop::no_crop(),
10543        );
10544        match result {
10545            Err(Error::G2D(_)) => {
10546                crate::test_support::report_skip(
10547                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10548                );
10549                return;
10550            }
10551            r => r.unwrap(),
10552        }
10553
10554        let cpu_dst = TensorDyn::image(
10555            1280,
10556            720,
10557            PixelFormat::Rgb,
10558            DType::U8,
10559            None,
10560            edgefirst_tensor::CpuAccess::ReadWrite,
10561        )
10562        .unwrap();
10563        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10564
10565        let (result, _src, cpu_dst) = convert_img(
10566            &mut cpu_converter,
10567            src,
10568            cpu_dst,
10569            Rotation::None,
10570            Flip::None,
10571            Crop::no_crop(),
10572        );
10573        result.unwrap();
10574
10575        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10576        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10577        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10578        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10579        // structural gap not exercised by these limited-range fixtures.
10580        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10581    }
10582
10583    #[test]
10584    #[cfg(any(
10585        target_os = "linux",
10586        target_os = "macos",
10587        target_os = "ios",
10588        target_os = "android",
10589        target_os = "windows"
10590    ))]
10591    #[cfg(feature = "opengl")]
10592    fn test_vyuy_to_rgba_opengl() {
10593        if !is_opengl_available() {
10594            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10595            return;
10596        }
10597        if !crate::test_support::is_gpu_buffer_available() {
10598            crate::test_support::report_skip(&format!(
10599                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10600                function!()
10601            ));
10602            return;
10603        }
10604
10605        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on macOS/iOS.
10606        let expect = if cfg!(target_os = "linux") {
10607            crate::test_support::Expect::Succeeds
10608        } else {
10609            crate::test_support::Expect::MayFail("no zero-copy VYUY mapping on this platform")
10610        };
10611        let Some(src) = crate::test_support::allocated(
10612            load_bytes_to_tensor(
10613                1280,
10614                720,
10615                PixelFormat::Vyuy,
10616                Some(TensorMemory::DmaBuf),
10617                &edgefirst_bench::testdata::read("camera720p.vyuy"),
10618            ),
10619            "zero-copy VYUY source",
10620            expect,
10621        ) else {
10622            return;
10623        };
10624
10625        let dst = TensorDyn::image(
10626            1280,
10627            720,
10628            PixelFormat::Rgba,
10629            DType::U8,
10630            Some(TensorMemory::DmaBuf),
10631            edgefirst_tensor::CpuAccess::ReadWrite,
10632        )
10633        .unwrap();
10634        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10635
10636        let (result, _src, dst) = convert_img(
10637            &mut gl_converter,
10638            src,
10639            dst,
10640            Rotation::None,
10641            Flip::None,
10642            Crop::no_crop(),
10643        );
10644        match result {
10645            Err(Error::NotSupported(_)) => {
10646                crate::test_support::report_skip(&format!(
10647                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10648                    function!()
10649                ));
10650                return;
10651            }
10652            r => r.unwrap(),
10653        }
10654
10655        let target_image = TensorDyn::image(
10656            1280,
10657            720,
10658            PixelFormat::Rgba,
10659            DType::U8,
10660            None,
10661            edgefirst_tensor::CpuAccess::ReadWrite,
10662        )
10663        .unwrap();
10664        target_image
10665            .as_typed::<u8>()
10666            .unwrap()
10667            .map()
10668            .unwrap()
10669            .as_mut_slice()
10670            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10671
10672        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10673        // so the matrix delta vs the reference that forced 0.95 has closed;
10674        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10675        compare_images(&dst, &target_image, 0.98, function!());
10676    }
10677
10678    #[test]
10679    fn test_nv12_to_rgba_cpu() {
10680        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10681        let src = TensorDyn::image(
10682            1280,
10683            720,
10684            PixelFormat::Nv12,
10685            DType::U8,
10686            None,
10687            edgefirst_tensor::CpuAccess::ReadWrite,
10688        )
10689        .unwrap();
10690        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10691            .copy_from_slice(&file);
10692
10693        let dst = TensorDyn::image(
10694            1280,
10695            720,
10696            PixelFormat::Rgba,
10697            DType::U8,
10698            None,
10699            edgefirst_tensor::CpuAccess::ReadWrite,
10700        )
10701        .unwrap();
10702        let mut cpu_converter = CPUProcessor::new();
10703
10704        let (result, _src, dst) = convert_img(
10705            &mut cpu_converter,
10706            src,
10707            dst,
10708            Rotation::None,
10709            Flip::None,
10710            Crop::no_crop(),
10711        );
10712        result.unwrap();
10713
10714        let target_image = crate::load_image_test_helper(
10715            &edgefirst_bench::testdata::read("zidane.jpg"),
10716            Some(PixelFormat::Rgba),
10717            None,
10718        )
10719        .unwrap();
10720
10721        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10722        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10723        // decode), so it differs slightly from the RGBA derived from the
10724        // separate `zidane.nv12` fixture.
10725        compare_images(&dst, &target_image, 0.95, function!());
10726    }
10727
10728    #[test]
10729    fn test_nv12_odd_height_to_rgb_cpu() {
10730        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10731        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10732        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10733        // must convert to a uniform grey RGB, exercising the odd-height
10734        // chroma-row count and the logical-height derivation in convert.
10735        // (Odd *width* is rounded to an even buffer at allocation, so it is
10736        // covered by the decode integration tests rather than here.)
10737        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10738        // DMA on i.MX, which would leave the dst's row padding unconverted and
10739        // break the flat byte scan below).
10740        let mut src = TensorDyn::image(
10741            8,
10742            5,
10743            PixelFormat::Nv12,
10744            DType::U8,
10745            Some(TensorMemory::Mem),
10746            edgefirst_tensor::CpuAccess::ReadWrite,
10747        )
10748        .unwrap();
10749        assert_eq!(src.shape(), &[8, 8]);
10750        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10751        src.as_typed::<u8>()
10752            .unwrap()
10753            .map()
10754            .unwrap()
10755            .as_mut_slice()
10756            .fill(128);
10757        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10758        // identity this test asserts). Without a tag, the colorimetry heuristic
10759        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10760        src.set_colorimetry(Some(
10761            edgefirst_tensor::Colorimetry::default()
10762                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10763                .with_range(edgefirst_tensor::ColorRange::Full),
10764        ));
10765
10766        let dst = TensorDyn::image(
10767            8,
10768            5,
10769            PixelFormat::Rgb,
10770            DType::U8,
10771            Some(TensorMemory::Mem),
10772            edgefirst_tensor::CpuAccess::ReadWrite,
10773        )
10774        .unwrap();
10775        let mut cpu_converter = CPUProcessor::new();
10776        let (result, _src, dst) = convert_img(
10777            &mut cpu_converter,
10778            src,
10779            dst,
10780            Rotation::None,
10781            Flip::None,
10782            Crop::no_crop(),
10783        );
10784        result.unwrap();
10785
10786        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10787        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10788        for (i, &b) in map.as_slice().iter().enumerate() {
10789            assert!(
10790                (b as i16 - 128).abs() <= 2,
10791                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10792            );
10793        }
10794    }
10795
10796    #[test]
10797    fn test_nv24_to_rgb_cpu() {
10798        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10799        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10800        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10801        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10802        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10803        let mut src = TensorDyn::image(
10804            8,
10805            4,
10806            PixelFormat::Nv24,
10807            DType::U8,
10808            Some(TensorMemory::Mem),
10809            edgefirst_tensor::CpuAccess::ReadWrite,
10810        )
10811        .unwrap();
10812        assert_eq!(src.shape(), &[12, 8]);
10813        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10814        src.as_typed::<u8>()
10815            .unwrap()
10816            .map()
10817            .unwrap()
10818            .as_mut_slice()
10819            .fill(128);
10820        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10821        // the heuristic picks limited range and Y=128 expands to ~131.
10822        src.set_colorimetry(Some(
10823            edgefirst_tensor::Colorimetry::default()
10824                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10825                .with_range(edgefirst_tensor::ColorRange::Full),
10826        ));
10827
10828        let dst = TensorDyn::image(
10829            8,
10830            4,
10831            PixelFormat::Rgb,
10832            DType::U8,
10833            Some(TensorMemory::Mem),
10834            edgefirst_tensor::CpuAccess::ReadWrite,
10835        )
10836        .unwrap();
10837        let mut cpu_converter = CPUProcessor::new();
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        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10849        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10850        for (i, &b) in map.as_slice().iter().enumerate() {
10851            assert!(
10852                (b as i16 - 128).abs() <= 2,
10853                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10854            );
10855        }
10856    }
10857
10858    #[test]
10859    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10860        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10861        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10862        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10863        // distinct RGB triples, proving the CPU path honours the source's tagged
10864        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10865        // falls through to this CPU path; QA F9.)
10866        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10867        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10868            let mut src = TensorDyn::image(
10869                8,
10870                4,
10871                PixelFormat::Nv12,
10872                DType::U8,
10873                Some(TensorMemory::Mem),
10874                edgefirst_tensor::CpuAccess::ReadWrite,
10875            )
10876            .unwrap();
10877            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10878            assert_eq!(src.shape(), &[6, 8]);
10879            {
10880                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10881                let buf = map.as_mut_slice();
10882                buf[..32].fill(120); // Y
10883                for px in buf[32..].as_chunks_mut::<2>().0 {
10884                    px[0] = 180; // U / Cb
10885                    px[1] = 64; // V / Cr
10886                }
10887            }
10888            // Range held constant (Limited) across all three so only the encoding
10889            // matrix varies between runs.
10890            src.set_colorimetry(Some(
10891                edgefirst_tensor::Colorimetry::default()
10892                    .with_encoding(enc)
10893                    .with_range(edgefirst_tensor::ColorRange::Limited),
10894            ));
10895            let dst = TensorDyn::image(
10896                8,
10897                4,
10898                PixelFormat::Rgb,
10899                DType::U8,
10900                Some(TensorMemory::Mem),
10901                edgefirst_tensor::CpuAccess::ReadWrite,
10902            )
10903            .unwrap();
10904            let mut cpu = CPUProcessor::new();
10905            let (result, _src, dst) = convert_img(
10906                &mut cpu,
10907                src,
10908                dst,
10909                Rotation::None,
10910                Flip::None,
10911                Crop::no_crop(),
10912            );
10913            result.unwrap();
10914            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10915            let s = map.as_slice();
10916            [s[0], s[1], s[2]]
10917        }
10918
10919        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10920        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10921        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10922
10923        assert_ne!(
10924            bt2020, bt601,
10925            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10926        );
10927        assert_ne!(
10928            bt2020, bt709,
10929            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10930        );
10931        assert_ne!(
10932            bt601, bt709,
10933            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10934        );
10935    }
10936
10937    #[test]
10938    fn test_nv12_to_rgb_cpu() {
10939        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10940        let src = TensorDyn::image(
10941            1280,
10942            720,
10943            PixelFormat::Nv12,
10944            DType::U8,
10945            None,
10946            edgefirst_tensor::CpuAccess::ReadWrite,
10947        )
10948        .unwrap();
10949        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10950            .copy_from_slice(&file);
10951
10952        let dst = TensorDyn::image(
10953            1280,
10954            720,
10955            PixelFormat::Rgb,
10956            DType::U8,
10957            None,
10958            edgefirst_tensor::CpuAccess::ReadWrite,
10959        )
10960        .unwrap();
10961        let mut cpu_converter = CPUProcessor::new();
10962
10963        let (result, _src, dst) = convert_img(
10964            &mut cpu_converter,
10965            src,
10966            dst,
10967            Rotation::None,
10968            Flip::None,
10969            Crop::no_crop(),
10970        );
10971        result.unwrap();
10972
10973        let target_image = crate::load_image_test_helper(
10974            &edgefirst_bench::testdata::read("zidane.jpg"),
10975            Some(PixelFormat::Rgb),
10976            None,
10977        )
10978        .unwrap();
10979
10980        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10981        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10982        // decode), so it differs slightly from the RGB derived from the
10983        // separate `zidane.nv12` fixture.
10984        compare_images(&dst, &target_image, 0.95, function!());
10985    }
10986
10987    #[test]
10988    fn test_nv12_to_grey_cpu() {
10989        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10990        let src = TensorDyn::image(
10991            1280,
10992            720,
10993            PixelFormat::Nv12,
10994            DType::U8,
10995            None,
10996            edgefirst_tensor::CpuAccess::ReadWrite,
10997        )
10998        .unwrap();
10999        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
11000            .copy_from_slice(&file);
11001
11002        let dst = TensorDyn::image(
11003            1280,
11004            720,
11005            PixelFormat::Grey,
11006            DType::U8,
11007            None,
11008            edgefirst_tensor::CpuAccess::ReadWrite,
11009        )
11010        .unwrap();
11011        let mut cpu_converter = CPUProcessor::new();
11012
11013        let (result, _src, dst) = convert_img(
11014            &mut cpu_converter,
11015            src,
11016            dst,
11017            Rotation::None,
11018            Flip::None,
11019            Crop::no_crop(),
11020        );
11021        result.unwrap();
11022
11023        let target_image = crate::load_image_test_helper(
11024            &edgefirst_bench::testdata::read("zidane.jpg"),
11025            Some(PixelFormat::Grey),
11026            None,
11027        )
11028        .unwrap();
11029
11030        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
11031        // the colour JPEG decoded to native NV12 and then converted to GREY
11032        // (was a direct JPEG → GREY decode), so it differs slightly from the
11033        // grey derived from the `zidane.nv12` fixture.
11034        compare_images(&dst, &target_image, 0.95, function!());
11035    }
11036
11037    #[test]
11038    fn test_nv12_to_yuyv_cpu() {
11039        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
11040        let src = TensorDyn::image(
11041            1280,
11042            720,
11043            PixelFormat::Nv12,
11044            DType::U8,
11045            None,
11046            edgefirst_tensor::CpuAccess::ReadWrite,
11047        )
11048        .unwrap();
11049        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
11050            .copy_from_slice(&file);
11051
11052        let dst = TensorDyn::image(
11053            1280,
11054            720,
11055            PixelFormat::Yuyv,
11056            DType::U8,
11057            None,
11058            edgefirst_tensor::CpuAccess::ReadWrite,
11059        )
11060        .unwrap();
11061        let mut cpu_converter = CPUProcessor::new();
11062
11063        let (result, _src, dst) = convert_img(
11064            &mut cpu_converter,
11065            src,
11066            dst,
11067            Rotation::None,
11068            Flip::None,
11069            Crop::no_crop(),
11070        );
11071        result.unwrap();
11072
11073        let target_image = crate::load_image_test_helper(
11074            &edgefirst_bench::testdata::read("zidane.jpg"),
11075            Some(PixelFormat::Rgb),
11076            None,
11077        )
11078        .unwrap();
11079
11080        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
11081        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
11082        // decode), so it differs slightly from the YUYV-sourced frame derived
11083        // from the separate `zidane.nv12` fixture.
11084        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
11085    }
11086
11087    #[test]
11088    fn test_cpu_resize_nv16() {
11089        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11090        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11091
11092        let cpu_nv16_dst = TensorDyn::image(
11093            640,
11094            640,
11095            PixelFormat::Nv16,
11096            DType::U8,
11097            None,
11098            edgefirst_tensor::CpuAccess::ReadWrite,
11099        )
11100        .unwrap();
11101        let cpu_rgb_dst = TensorDyn::image(
11102            640,
11103            640,
11104            PixelFormat::Rgb,
11105            DType::U8,
11106            None,
11107            edgefirst_tensor::CpuAccess::ReadWrite,
11108        )
11109        .unwrap();
11110        let mut cpu_converter = CPUProcessor::new();
11111        let crop = Crop::letterbox([255, 128, 0, 255]);
11112
11113        let (result, src, cpu_nv16_dst) = convert_img(
11114            &mut cpu_converter,
11115            src,
11116            cpu_nv16_dst,
11117            Rotation::None,
11118            Flip::None,
11119            crop,
11120        );
11121        result.unwrap();
11122
11123        let (result, _src, cpu_rgb_dst) = convert_img(
11124            &mut cpu_converter,
11125            src,
11126            cpu_rgb_dst,
11127            Rotation::None,
11128            Flip::None,
11129            crop,
11130        );
11131        result.unwrap();
11132        compare_images_convert_to_rgb(
11133            &cpu_nv16_dst,
11134            &cpu_rgb_dst,
11135            yuv_threshold(0.99),
11136            function!(),
11137        );
11138    }
11139
11140    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
11141    /// conversion.
11142    ///
11143    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
11144    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
11145    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
11146    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
11147    /// lower-precision integer approximation with a documented error of up to
11148    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
11149    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
11150    /// 0.99 on an A53, while every `rdm` core passes.
11151    ///
11152    /// No CI runner is A53-class, so this only reproduces on target.
11153    ///
11154    /// Relaxing per-core keeps the assertion tight where the precise path
11155    /// runs, instead of loosening it everywhere to accommodate one core.
11156    fn yuv_threshold(balanced: f64) -> f64 {
11157        #[cfg(target_arch = "aarch64")]
11158        {
11159            if !std::arch::is_aarch64_feature_detected!("rdm") {
11160                return balanced - 0.01;
11161            }
11162        }
11163        balanced
11164    }
11165
11166    fn load_bytes_to_tensor(
11167        width: usize,
11168        height: usize,
11169        format: PixelFormat,
11170        memory: Option<TensorMemory>,
11171        bytes: &[u8],
11172    ) -> Result<TensorDyn, Error> {
11173        let src = TensorDyn::image(
11174            width,
11175            height,
11176            format,
11177            DType::U8,
11178            memory,
11179            edgefirst_tensor::CpuAccess::ReadWrite,
11180        )?;
11181        src.as_typed::<u8>()
11182            .unwrap()
11183            .map()?
11184            .as_mut_slice()
11185            .copy_from_slice(bytes);
11186        Ok(src)
11187    }
11188
11189    // DEDUP: this function is also defined verbatim in
11190    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
11191    // must be kept in sync. Cross-module sharing would require either a
11192    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
11193    // separate test-utils crate — both are disproportionate for a single
11194    // helper. If the implementation ever diverges, extract to a shared
11195    // `test_helpers` module in this crate.
11196    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
11197        assert_eq!(img1.height(), img2.height(), "Heights differ");
11198        assert_eq!(img1.width(), img2.width(), "Widths differ");
11199        assert_eq!(
11200            img1.format().unwrap(),
11201            img2.format().unwrap(),
11202            "PixelFormat differ"
11203        );
11204        assert!(
11205            matches!(
11206                img1.format().unwrap(),
11207                PixelFormat::Rgb
11208                    | PixelFormat::Rgba
11209                    | PixelFormat::Grey
11210                    | PixelFormat::PlanarRgb
11211                    | PixelFormat::PlanarRgba
11212            ),
11213            "format must be Rgb or Rgba for comparison"
11214        );
11215
11216        // A pitch-padded destination -- the D3D11 staging texture behind a
11217        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
11218        // -- spaces its rows at `effective_row_stride()`, so reading the head
11219        // of the mapping as a tight image would read padding as pixels.
11220        // `copy_to_flat` is the tensor's own compaction and degenerates to one
11221        // memcpy when the tensor is tight.
11222        fn flat(img: &TensorDyn) -> Vec<u8> {
11223            let t = img.as_typed::<u8>().expect("u8 image");
11224            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
11225            t.copy_to_flat(&mut out).expect("compact the padded rows");
11226            out
11227        }
11228
11229        let image1 = match img1.format().unwrap() {
11230            PixelFormat::Rgb => image::RgbImage::from_vec(
11231                img1.width().unwrap() as u32,
11232                img1.height().unwrap() as u32,
11233                flat(img1),
11234            )
11235            .unwrap(),
11236            PixelFormat::Rgba => image::RgbaImage::from_vec(
11237                img1.width().unwrap() as u32,
11238                img1.height().unwrap() as u32,
11239                flat(img1),
11240            )
11241            .unwrap()
11242            .convert(),
11243            PixelFormat::Grey => image::GrayImage::from_vec(
11244                img1.width().unwrap() as u32,
11245                img1.height().unwrap() as u32,
11246                flat(img1),
11247            )
11248            .unwrap()
11249            .convert(),
11250            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11251                img1.width().unwrap() as u32,
11252                (img1.height().unwrap() * img1.format().unwrap().channels()) as u32,
11253                flat(img1),
11254            )
11255            .unwrap()
11256            .convert(),
11257            _ => return,
11258        };
11259
11260        let image2 = match img2.format().unwrap() {
11261            PixelFormat::Rgb => image::RgbImage::from_vec(
11262                img2.width().unwrap() as u32,
11263                img2.height().unwrap() as u32,
11264                flat(img2),
11265            )
11266            .unwrap(),
11267            PixelFormat::Rgba => image::RgbaImage::from_vec(
11268                img2.width().unwrap() as u32,
11269                img2.height().unwrap() as u32,
11270                flat(img2),
11271            )
11272            .unwrap()
11273            .convert(),
11274            PixelFormat::Grey => image::GrayImage::from_vec(
11275                img2.width().unwrap() as u32,
11276                img2.height().unwrap() as u32,
11277                flat(img2),
11278            )
11279            .unwrap()
11280            .convert(),
11281            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11282                img2.width().unwrap() as u32,
11283                (img2.height().unwrap() * img2.format().unwrap().channels()) as u32,
11284                flat(img2),
11285            )
11286            .unwrap()
11287            .convert(),
11288            _ => return,
11289        };
11290
11291        let similarity = image_compare::rgb_similarity_structure(
11292            &image_compare::Algorithm::RootMeanSquared,
11293            &image1,
11294            &image2,
11295        )
11296        .expect("Image Comparison failed");
11297        if similarity.score < threshold {
11298            // image1.save(format!("{name}_1.png"));
11299            // image2.save(format!("{name}_2.png"));
11300            similarity
11301                .image
11302                .to_color_map()
11303                .save(format!("{name}.png"))
11304                .unwrap();
11305            panic!(
11306                "{name}: converted image and target image have similarity score too low: {} < {}",
11307                similarity.score, threshold
11308            )
11309        }
11310    }
11311
11312    fn compare_images_convert_to_rgb(
11313        img1: &TensorDyn,
11314        img2: &TensorDyn,
11315        threshold: f64,
11316        name: &str,
11317    ) {
11318        assert_eq!(img1.height(), img2.height(), "Heights differ");
11319        assert_eq!(img1.width(), img2.width(), "Widths differ");
11320
11321        let mut img_rgb1 = TensorDyn::image(
11322            img1.width().unwrap(),
11323            img1.height().unwrap(),
11324            PixelFormat::Rgb,
11325            DType::U8,
11326            Some(TensorMemory::Mem),
11327            edgefirst_tensor::CpuAccess::ReadWrite,
11328        )
11329        .unwrap();
11330        let mut img_rgb2 = TensorDyn::image(
11331            img1.width().unwrap(),
11332            img1.height().unwrap(),
11333            PixelFormat::Rgb,
11334            DType::U8,
11335            Some(TensorMemory::Mem),
11336            edgefirst_tensor::CpuAccess::ReadWrite,
11337        )
11338        .unwrap();
11339        let mut __cv = CPUProcessor::default();
11340        let r1 = __cv.convert(img1, &mut img_rgb1, None);
11341        let r2 = __cv.convert(img2, &mut img_rgb2, None);
11342        if r1.is_err() || r2.is_err() {
11343            // Fallback: compare raw bytes as greyscale strip
11344            let w = img1.width().unwrap() as u32;
11345            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11346            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11347            let h1 = (data1.len() as u32) / w;
11348            let h2 = (data2.len() as u32) / w;
11349            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
11350            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
11351            let similarity = image_compare::gray_similarity_structure(
11352                &image_compare::Algorithm::RootMeanSquared,
11353                &g1,
11354                &g2,
11355            )
11356            .expect("Image Comparison failed");
11357            if similarity.score < threshold {
11358                panic!(
11359                    "{name}: converted image and target image have similarity score too low: {} < {}",
11360                    similarity.score, threshold
11361                )
11362            }
11363            return;
11364        }
11365
11366        let image1 = image::RgbImage::from_vec(
11367            img_rgb1.width().unwrap() as u32,
11368            img_rgb1.height().unwrap() as u32,
11369            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11370        )
11371        .unwrap();
11372
11373        let image2 = image::RgbImage::from_vec(
11374            img_rgb2.width().unwrap() as u32,
11375            img_rgb2.height().unwrap() as u32,
11376            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11377        )
11378        .unwrap();
11379
11380        let similarity = image_compare::rgb_similarity_structure(
11381            &image_compare::Algorithm::RootMeanSquared,
11382            &image1,
11383            &image2,
11384        )
11385        .expect("Image Comparison failed");
11386        if similarity.score < threshold {
11387            // image1.save(format!("{name}_1.png"));
11388            // image2.save(format!("{name}_2.png"));
11389            similarity
11390                .image
11391                .to_color_map()
11392                .save(format!("{name}.png"))
11393                .unwrap();
11394            panic!(
11395                "{name}: converted image and target image have similarity score too low: {} < {}",
11396                similarity.score, threshold
11397            )
11398        }
11399    }
11400
11401    // =========================================================================
11402    // PixelFormat::Nv12 Format Tests
11403    // =========================================================================
11404
11405    #[test]
11406    fn test_nv12_image_creation() {
11407        let width = 640;
11408        let height = 480;
11409        let img = TensorDyn::image(
11410            width,
11411            height,
11412            PixelFormat::Nv12,
11413            DType::U8,
11414            None,
11415            edgefirst_tensor::CpuAccess::ReadWrite,
11416        )
11417        .unwrap();
11418
11419        assert_eq!(img.width(), Some(width));
11420        assert_eq!(img.height(), Some(height));
11421        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11422        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11423        assert_eq!(
11424            img.as_typed::<u8>().unwrap().shape(),
11425            &[height * 3 / 2, width]
11426        );
11427    }
11428
11429    #[test]
11430    fn test_nv12_channels() {
11431        let img = TensorDyn::image(
11432            640,
11433            480,
11434            PixelFormat::Nv12,
11435            DType::U8,
11436            None,
11437            edgefirst_tensor::CpuAccess::ReadWrite,
11438        )
11439        .unwrap();
11440        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11441        assert_eq!(img.format().unwrap().channels(), 1);
11442    }
11443
11444    // =========================================================================
11445    // Tensor Format Metadata Tests
11446    // =========================================================================
11447
11448    #[test]
11449    fn test_tensor_set_format_planar() {
11450        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11451        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11452        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11453        assert_eq!(tensor.width(), Some(640));
11454        assert_eq!(tensor.height(), Some(480));
11455    }
11456
11457    #[test]
11458    fn test_tensor_set_format_interleaved() {
11459        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11460        tensor.set_format(PixelFormat::Rgba).unwrap();
11461        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11462        assert_eq!(tensor.width(), Some(640));
11463        assert_eq!(tensor.height(), Some(480));
11464    }
11465
11466    #[test]
11467    fn test_tensordyn_image_rgb() {
11468        let img = TensorDyn::image(
11469            640,
11470            480,
11471            PixelFormat::Rgb,
11472            DType::U8,
11473            None,
11474            edgefirst_tensor::CpuAccess::ReadWrite,
11475        )
11476        .unwrap();
11477        assert_eq!(img.width(), Some(640));
11478        assert_eq!(img.height(), Some(480));
11479        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11480    }
11481
11482    #[test]
11483    fn test_tensordyn_image_planar_rgb() {
11484        let img = TensorDyn::image(
11485            640,
11486            480,
11487            PixelFormat::PlanarRgb,
11488            DType::U8,
11489            None,
11490            edgefirst_tensor::CpuAccess::ReadWrite,
11491        )
11492        .unwrap();
11493        assert_eq!(img.width(), Some(640));
11494        assert_eq!(img.height(), Some(480));
11495        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11496    }
11497
11498    #[test]
11499    fn test_rgb_int8_format() {
11500        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11501        let img = TensorDyn::image(
11502            1280,
11503            720,
11504            PixelFormat::Rgb,
11505            DType::I8,
11506            Some(TensorMemory::Mem),
11507            edgefirst_tensor::CpuAccess::ReadWrite,
11508        )
11509        .unwrap();
11510        assert_eq!(img.width(), Some(1280));
11511        assert_eq!(img.height(), Some(720));
11512        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11513        assert_eq!(img.dtype(), DType::I8);
11514    }
11515
11516    #[test]
11517    fn test_planar_rgb_int8_format() {
11518        let img = TensorDyn::image(
11519            1280,
11520            720,
11521            PixelFormat::PlanarRgb,
11522            DType::I8,
11523            Some(TensorMemory::Mem),
11524            edgefirst_tensor::CpuAccess::ReadWrite,
11525        )
11526        .unwrap();
11527        assert_eq!(img.width(), Some(1280));
11528        assert_eq!(img.height(), Some(720));
11529        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11530        assert_eq!(img.dtype(), DType::I8);
11531    }
11532
11533    #[test]
11534    fn test_rgb_from_tensor() {
11535        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11536        tensor.set_format(PixelFormat::Rgb).unwrap();
11537        let img = TensorDyn::from(tensor);
11538        assert_eq!(img.width(), Some(1280));
11539        assert_eq!(img.height(), Some(720));
11540        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11541    }
11542
11543    #[test]
11544    fn test_planar_rgb_from_tensor() {
11545        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11546        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11547        let img = TensorDyn::from(tensor);
11548        assert_eq!(img.width(), Some(1280));
11549        assert_eq!(img.height(), Some(720));
11550        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11551    }
11552
11553    #[test]
11554    fn test_dtype_determines_int8() {
11555        // DType::I8 indicates int8 data
11556        let u8_img = TensorDyn::image(
11557            64,
11558            64,
11559            PixelFormat::Rgb,
11560            DType::U8,
11561            None,
11562            edgefirst_tensor::CpuAccess::ReadWrite,
11563        )
11564        .unwrap();
11565        let i8_img = TensorDyn::image(
11566            64,
11567            64,
11568            PixelFormat::Rgb,
11569            DType::I8,
11570            None,
11571            edgefirst_tensor::CpuAccess::ReadWrite,
11572        )
11573        .unwrap();
11574        assert_eq!(u8_img.dtype(), DType::U8);
11575        assert_eq!(i8_img.dtype(), DType::I8);
11576    }
11577
11578    #[test]
11579    fn test_pixel_layout_packed_vs_planar() {
11580        // Packed vs planar layout classification
11581        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11582        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11583        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11584        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11585    }
11586
11587    /// Integration test that exercises the PBO-to-PBO convert path.
11588    /// A PBO cannot hand out a host address that outlives its map guard, and
11589    /// the error has to say why rather than reading as unfinished work.
11590    ///
11591    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11592    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11593    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11594    /// would mean holding the buffer mapped across GL work, which is the
11595    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11596    /// answer, but that is a buffer-allocation feature, not wiring.
11597    #[cfg(target_os = "linux")]
11598    #[cfg(feature = "opengl")]
11599    #[test]
11600    fn pbo_reports_why_it_cannot_pin() {
11601        use edgefirst_tensor::{Error, TensorTrait};
11602
11603        let converter = ImageProcessor::new().unwrap();
11604        let is_pbo = converter
11605            .opengl
11606            .as_ref()
11607            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11608        if !is_pbo {
11609            crate::test_support::report_skip(
11610                "pbo_reports_why_it_cannot_pin - transfer backend is not PBO",
11611            );
11612            return;
11613        }
11614
11615        let t = converter
11616            .create_image(
11617                64,
11618                64,
11619                PixelFormat::Rgba,
11620                DType::U8,
11621                None,
11622                edgefirst_tensor::CpuAccess::ReadWrite,
11623            )
11624            .unwrap();
11625        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11626        let t = t.as_typed::<u8>().unwrap();
11627
11628        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11629            Err(Error::NotImplemented(m)) => {
11630                assert!(
11631                    m.contains("glMapBufferRange"),
11632                    "must name the constraint: {m}"
11633                );
11634                assert!(m.contains("map()"), "must point at the supported API: {m}");
11635            }
11636            other => panic!("expected an explained NotImplemented, got {other:?}"),
11637        }
11638        // Sync is unavailable for the same reason, and must not silently succeed.
11639        assert!(matches!(
11640            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11641            Err(Error::NotImplemented(_))
11642        ));
11643
11644        // The supported path still works, which is what makes the refusal safe.
11645        let g = t.map().expect("map must work where pin cannot");
11646        assert!(!g.as_slice().is_empty());
11647    }
11648
11649    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11650    /// then converts between them. Skipped when GL is unavailable or the
11651    /// backend is not PBO (e.g. DMA-buf systems).
11652    #[cfg(target_os = "linux")]
11653    #[cfg(feature = "opengl")]
11654    #[test]
11655    fn test_convert_pbo_to_pbo() {
11656        let mut converter = ImageProcessor::new().unwrap();
11657
11658        // Skip if GL is not available or backend is not PBO
11659        let is_pbo = converter
11660            .opengl
11661            .as_ref()
11662            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11663        if !is_pbo {
11664            crate::test_support::report_skip(
11665                "test_convert_pbo_to_pbo - transfer backend is not PBO",
11666            );
11667            return;
11668        }
11669
11670        let src_w = 640;
11671        let src_h = 480;
11672        let dst_w = 320;
11673        let dst_h = 240;
11674
11675        // Create PBO-backed source image
11676        let pbo_src = converter
11677            .create_image(
11678                src_w,
11679                src_h,
11680                PixelFormat::Rgba,
11681                DType::U8,
11682                None,
11683                edgefirst_tensor::CpuAccess::ReadWrite,
11684            )
11685            .unwrap();
11686        assert_eq!(
11687            pbo_src.as_typed::<u8>().unwrap().memory(),
11688            TensorMemory::Pbo,
11689            "create_image should produce a PBO tensor"
11690        );
11691
11692        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11693        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11694        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11695
11696        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11697        let mem_src = TensorDyn::image(
11698            src_w,
11699            src_h,
11700            PixelFormat::Rgba,
11701            DType::U8,
11702            Some(TensorMemory::Mem),
11703            edgefirst_tensor::CpuAccess::ReadWrite,
11704        )
11705        .unwrap();
11706        let (result, _jpeg_src, mem_src) = convert_img(
11707            &mut CPUProcessor::new(),
11708            jpeg_src,
11709            mem_src,
11710            Rotation::None,
11711            Flip::None,
11712            Crop::no_crop(),
11713        );
11714        result.unwrap();
11715
11716        // Copy pixel data into the PBO source by mapping it
11717        {
11718            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11719            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11720            pbo_map.copy_from_slice(&src_data);
11721        }
11722
11723        // Create PBO-backed destination image
11724        let pbo_dst = converter
11725            .create_image(
11726                dst_w,
11727                dst_h,
11728                PixelFormat::Rgba,
11729                DType::U8,
11730                None,
11731                edgefirst_tensor::CpuAccess::ReadWrite,
11732            )
11733            .unwrap();
11734        assert_eq!(
11735            pbo_dst.as_typed::<u8>().unwrap().memory(),
11736            TensorMemory::Pbo
11737        );
11738
11739        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11740        let mut pbo_dst = pbo_dst;
11741        let result = converter.convert(&pbo_src, &mut pbo_dst, None);
11742        result.unwrap();
11743
11744        // Verify: compare with CPU-only conversion of the same input
11745        let cpu_dst = TensorDyn::image(
11746            dst_w,
11747            dst_h,
11748            PixelFormat::Rgba,
11749            DType::U8,
11750            Some(TensorMemory::Mem),
11751            edgefirst_tensor::CpuAccess::ReadWrite,
11752        )
11753        .unwrap();
11754        let (result, _mem_src, cpu_dst) = convert_img(
11755            &mut CPUProcessor::new(),
11756            mem_src,
11757            cpu_dst,
11758            Rotation::None,
11759            Flip::None,
11760            Crop::no_crop(),
11761        );
11762        result.unwrap();
11763
11764        let pbo_dst_img = {
11765            let mut __t = pbo_dst.into_u8().unwrap();
11766            __t.set_format(PixelFormat::Rgba).unwrap();
11767            TensorDyn::from(__t)
11768        };
11769        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11770        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11771    }
11772
11773    #[test]
11774    fn test_image_bgra() {
11775        let img = TensorDyn::image(
11776            640,
11777            480,
11778            PixelFormat::Bgra,
11779            DType::U8,
11780            Some(edgefirst_tensor::TensorMemory::Mem),
11781            edgefirst_tensor::CpuAccess::ReadWrite,
11782        )
11783        .unwrap();
11784        assert_eq!(img.width(), Some(640));
11785        assert_eq!(img.height(), Some(480));
11786        assert_eq!(img.format().unwrap().channels(), 4);
11787        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11788    }
11789
11790    // ========================================================================
11791    // Tests for EDGEFIRST_FORCE_BACKEND env var
11792    // ========================================================================
11793
11794    #[test]
11795    fn test_force_backend_cpu() {
11796        let _lock = acquire_env_lock();
11797        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11798        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11799        let converter = ImageProcessor::new().unwrap();
11800        assert!(converter.cpu.is_some());
11801        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11802    }
11803
11804    #[test]
11805    fn test_force_backend_invalid() {
11806        let _lock = acquire_env_lock();
11807        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11808        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11809        let result = ImageProcessor::new();
11810        assert!(
11811            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11812            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11813        );
11814    }
11815
11816    #[test]
11817    fn test_force_backend_unset() {
11818        let _lock = acquire_env_lock();
11819        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11820        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11821        let converter = ImageProcessor::new().unwrap();
11822        assert!(converter.forced_backend.is_none());
11823    }
11824
11825    // ========================================================================
11826    // Tests for hybrid mask path error handling
11827    // ========================================================================
11828
11829    #[test]
11830    fn test_draw_proto_masks_no_cpu_returns_error() {
11831        // Serialize against all other env-var-mutating tests.
11832        let _lock = acquire_env_lock();
11833        let _guard = EnvGuard::snapshot(&[
11834            "EDGEFIRST_FORCE_BACKEND",
11835            "EDGEFIRST_DISABLE_GL",
11836            "EDGEFIRST_DISABLE_G2D",
11837            "EDGEFIRST_DISABLE_CPU",
11838        ]);
11839
11840        // Disable all backends so cpu.is_none() after construction.
11841        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11842        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11843        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11844
11845        let mut converter = ImageProcessor::new().unwrap();
11846        assert!(converter.cpu.is_none(), "CPU should be disabled");
11847
11848        let dst = TensorDyn::image(
11849            640,
11850            480,
11851            PixelFormat::Rgba,
11852            DType::U8,
11853            Some(TensorMemory::Mem),
11854            edgefirst_tensor::CpuAccess::ReadWrite,
11855        )
11856        .unwrap();
11857        let mut dst_dyn = dst;
11858        let det = [DetectBox {
11859            bbox: edgefirst_tensor::BoundingBox {
11860                xmin: 0.1,
11861                ymin: 0.1,
11862                xmax: 0.5,
11863                ymax: 0.5,
11864            },
11865            score: 0.9,
11866            label: 0,
11867        }];
11868        let proto_data = {
11869            use edgefirst_tensor::{Tensor, TensorDyn};
11870            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11871            let protos_t =
11872                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11873            ProtoData {
11874                mask_coefficients: TensorDyn::F32(coeff_t),
11875                protos: TensorDyn::F32(protos_t),
11876                layout: ProtoLayout::Nhwc,
11877            }
11878        };
11879        let result =
11880            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11881        assert!(
11882            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11883            "draw_proto_masks without CPU should return Internal error: {result:?}"
11884        );
11885    }
11886
11887    #[test]
11888    fn test_draw_proto_masks_cpu_fallback_works() {
11889        // Force CPU-only backend to ensure the CPU fallback path executes.
11890        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11891        let _lock = acquire_env_lock();
11892        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11893        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11894        let mut converter = ImageProcessor::new().unwrap();
11895        assert!(converter.cpu.is_some());
11896
11897        let dst = TensorDyn::image(
11898            64,
11899            64,
11900            PixelFormat::Rgba,
11901            DType::U8,
11902            Some(TensorMemory::Mem),
11903            edgefirst_tensor::CpuAccess::ReadWrite,
11904        )
11905        .unwrap();
11906        let mut dst_dyn = dst;
11907        let det = [DetectBox {
11908            bbox: edgefirst_tensor::BoundingBox {
11909                xmin: 0.1,
11910                ymin: 0.1,
11911                xmax: 0.5,
11912                ymax: 0.5,
11913            },
11914            score: 0.9,
11915            label: 0,
11916        }];
11917        let proto_data = {
11918            use edgefirst_tensor::{Tensor, TensorDyn};
11919            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11920            let protos_t =
11921                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11922            ProtoData {
11923                mask_coefficients: TensorDyn::F32(coeff_t),
11924                protos: TensorDyn::F32(protos_t),
11925                layout: ProtoLayout::Nhwc,
11926            }
11927        };
11928        let result =
11929            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11930        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11931    }
11932
11933    // ============================================================
11934    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11935    // verified tests. Exercises each backend against the full
11936    // output-contract matrix:
11937    //
11938    //   | detections | background | expected dst             |
11939    //   |------------|------------|--------------------------|
11940    //   | empty      | none       | fully cleared (0x00)     |
11941    //   | empty      | set        | fully equal to bg        |
11942    //   | set        | none       | cleared outside box +    |
11943    //   |            |            | mask-coloured inside     |
11944    //   | set        | set        | bg outside box + mask    |
11945    //   |            |            | blended inside           |
11946    //
11947    // Every test pre-fills dst with a non-zero "dirty" pattern so
11948    // that any silent `return Ok(())` leaks the pattern into the
11949    // asserted output and fails loudly.
11950    // ============================================================
11951
11952    // =========================================================================
11953    // Env-var serialisation helpers
11954    //
11955    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11956    // ENV_MUTEX for their full duration.  This single mutex serialises
11957    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11958    // test_force_backend_*, with_force_backend, and with_env — preventing
11959    // any two of them from racing in a parallel `cargo test` run.
11960    // =========================================================================
11961
11962    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11963    /// kept alive for the entire duration of the test body.
11964    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11965        use std::sync::{Mutex, OnceLock};
11966        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11967        ENV_MUTEX
11968            .get_or_init(|| Mutex::new(()))
11969            .lock()
11970            .unwrap_or_else(|e| e.into_inner())
11971    }
11972
11973    /// RAII guard that snapshots a set of env vars on construction and
11974    /// restores them on `Drop`, even if the test panics.
11975    struct EnvGuard {
11976        vars: Vec<(&'static str, Option<String>)>,
11977    }
11978
11979    impl EnvGuard {
11980        /// Snapshot the current values of `names`.  Call this while holding
11981        /// the env lock (the lock is not taken here — that is the caller's
11982        /// responsibility so the lock scope can be wider than the guard).
11983        fn snapshot(names: &[&'static str]) -> Self {
11984            Self {
11985                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11986            }
11987        }
11988    }
11989
11990    impl Drop for EnvGuard {
11991        fn drop(&mut self) {
11992            for (k, v) in &self.vars {
11993                match v {
11994                    Some(s) => unsafe { std::env::set_var(k, s) },
11995                    None => unsafe { std::env::remove_var(k) },
11996                }
11997            }
11998        }
11999    }
12000
12001    /// `EDGEFIRST_INTERPOLATION` accepts `nearest` and `bilinear` in any
12002    /// case and ignores anything else.
12003    #[test]
12004    fn test_interpolation_env_parse() {
12005        let _lock = acquire_env_lock();
12006        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12007        for (value, want) in [
12008            (Some("nearest"), Some(Interpolation::Nearest)),
12009            (Some(" BILINEAR "), Some(Interpolation::Bilinear)),
12010            (Some("lanczos"), None),
12011            (Some(""), None),
12012            (None, None),
12013        ] {
12014            match value {
12015                Some(v) => unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", v) },
12016                None => unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") },
12017            }
12018            assert_eq!(
12019                interpolation_from_env(),
12020                want,
12021                "EDGEFIRST_INTERPOLATION={value:?}"
12022            );
12023        }
12024    }
12025
12026    /// `ImageProcessorConfig::interpolation` reaches the CPU backend, and
12027    /// `EDGEFIRST_INTERPOLATION` overrides it.
12028    #[test]
12029    fn test_interpolation_config_and_env_reach_cpu() {
12030        let _lock = acquire_env_lock();
12031        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12032        let mut checker = Vec::with_capacity(8 * 8 * 4);
12033        for y in 0..8 {
12034            for x in 0..8 {
12035                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
12036                checker.extend_from_slice(&[v, v, v, 255]);
12037            }
12038        }
12039        let resize = |processor: &mut dyn ImageProcessorTrait| {
12040            let mut src = TensorDyn::image(
12041                8,
12042                8,
12043                PixelFormat::Rgba,
12044                DType::U8,
12045                Some(TensorMemory::Mem),
12046                edgefirst_tensor::CpuAccess::ReadWrite,
12047            )
12048            .unwrap();
12049            src.as_u8_mut()
12050                .unwrap()
12051                .map_mut()
12052                .unwrap()
12053                .as_mut_slice()
12054                .copy_from_slice(&checker);
12055            let mut dst = TensorDyn::image(
12056                12,
12057                12,
12058                PixelFormat::Rgba,
12059                DType::U8,
12060                Some(TensorMemory::Mem),
12061                edgefirst_tensor::CpuAccess::ReadWrite,
12062            )
12063            .unwrap();
12064            processor.convert(&src, &mut dst, None).unwrap();
12065            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12066        };
12067        let nearest = resize(&mut CPUProcessor::new_nearest());
12068        let bilinear = resize(&mut CPUProcessor::new());
12069        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
12070        let config = |interpolation| ImageProcessorConfig {
12071            backend: ComputeBackend::Cpu,
12072            interpolation,
12073            ..Default::default()
12074        };
12075
12076        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12077        let mut p = ImageProcessor::with_config(config(Interpolation::Nearest)).unwrap();
12078        assert_eq!(resize(&mut p), nearest, "config Nearest");
12079        p.set_interpolation(Interpolation::Bilinear).unwrap();
12080        assert_eq!(resize(&mut p), bilinear, "set_interpolation(Bilinear)");
12081
12082        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "nearest") };
12083        let mut p = ImageProcessor::with_config(config(Interpolation::Bilinear)).unwrap();
12084        assert_eq!(
12085            resize(&mut p),
12086            nearest,
12087            "EDGEFIRST_INTERPOLATION pins Nearest"
12088        );
12089        p.set_interpolation(Interpolation::Bilinear).unwrap();
12090        assert_eq!(
12091            resize(&mut p),
12092            nearest,
12093            "the env pin outlives set_interpolation"
12094        );
12095    }
12096
12097    /// A per-convert interpolation overrides the processor's for that
12098    /// convert only, so two option sets on one processor do not interfere;
12099    /// `EDGEFIRST_INTERPOLATION` pins both.
12100    #[test]
12101    fn test_per_convert_interpolation_overrides_processor() {
12102        let _lock = acquire_env_lock();
12103        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12104        let mut checker = Vec::with_capacity(8 * 8 * 4);
12105        for y in 0..8 {
12106            for x in 0..8 {
12107                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
12108                checker.extend_from_slice(&[v, v, v, 255]);
12109            }
12110        }
12111        let resize = |processor: &mut dyn ImageProcessorTrait, options: Option<ConvertOptions>| {
12112            let mut src = TensorDyn::image(
12113                8,
12114                8,
12115                PixelFormat::Rgba,
12116                DType::U8,
12117                Some(TensorMemory::Mem),
12118                edgefirst_tensor::CpuAccess::ReadWrite,
12119            )
12120            .unwrap();
12121            src.as_u8_mut()
12122                .unwrap()
12123                .map_mut()
12124                .unwrap()
12125                .as_mut_slice()
12126                .copy_from_slice(&checker);
12127            let mut dst = TensorDyn::image(
12128                12,
12129                12,
12130                PixelFormat::Rgba,
12131                DType::U8,
12132                Some(TensorMemory::Mem),
12133                edgefirst_tensor::CpuAccess::ReadWrite,
12134            )
12135            .unwrap();
12136            processor.convert(&src, &mut dst, options).unwrap();
12137            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12138        };
12139        let nearest = resize(&mut CPUProcessor::new_nearest(), None);
12140        let bilinear = resize(&mut CPUProcessor::new(), None);
12141        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
12142        let cpu_only = || {
12143            ImageProcessor::with_config(ImageProcessorConfig {
12144                backend: ComputeBackend::Cpu,
12145                ..Default::default()
12146            })
12147            .unwrap()
12148        };
12149        let model_a = ConvertOptions::new().interpolation(Interpolation::Nearest);
12150        let model_b = ConvertOptions::new().interpolation(Interpolation::Bicubic);
12151
12152        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12153        let mut p = cpu_only();
12154        assert_eq!(p.interpolation(), Interpolation::Bilinear);
12155        for _ in 0..2 {
12156            assert_eq!(resize(&mut p, Some(model_a)), nearest, "model A: nearest");
12157            assert_eq!(resize(&mut p, Some(model_b)), bilinear, "model B: bicubic");
12158            assert_eq!(resize(&mut p, None), bilinear, "processor setting kept");
12159        }
12160        p.set_interpolation(Interpolation::Nearest).unwrap();
12161        assert_eq!(resize(&mut p, Some(model_b)), bilinear, "options win");
12162        assert_eq!(resize(&mut p, None), nearest, "processor fallback");
12163
12164        let mut spec = PreprocessSpec::new();
12165        spec.interpolation = Interpolation::Nearest;
12166        let from_spec = ConvertOptions::from_spec(&spec).unwrap();
12167        assert_eq!(from_spec.get_interpolation(), Some(Interpolation::Nearest));
12168        let mut p = cpu_only();
12169        let stretch = from_spec.crop(Crop::default());
12170        assert_eq!(
12171            resize(&mut p, Some(stretch)),
12172            nearest,
12173            "from_spec carries it"
12174        );
12175
12176        let cpu = p.cpu.as_mut().unwrap();
12177        cpu.set_interpolation(Interpolation::Nearest);
12178        p.set_interpolation(Interpolation::Bilinear).unwrap();
12179        assert_eq!(
12180            resize(p.cpu.as_mut().unwrap(), None),
12181            nearest,
12182            "an unchanged mode does not reach the backends"
12183        );
12184
12185        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "bilinear") };
12186        let mut p = cpu_only();
12187        assert_eq!(resize(&mut p, Some(model_a)), bilinear, "the env pin wins");
12188    }
12189
12190    /// The threaded GL worker honours a per-convert interpolation and keeps
12191    /// the processor's for converts without one.
12192    #[test]
12193    #[cfg(feature = "opengl")]
12194    fn test_per_convert_interpolation_reaches_gl_worker() {
12195        let _lock = acquire_env_lock();
12196        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12197        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12198        let mut p = match ImageProcessor::with_config(ImageProcessorConfig {
12199            backend: ComputeBackend::OpenGl,
12200            ..Default::default()
12201        }) {
12202            Ok(p) if p.opengl.is_some() => p,
12203            _ => {
12204                crate::test_support::report_skip(&format!(
12205                    "{} — GL backend unavailable",
12206                    function!()
12207                ));
12208                return;
12209            }
12210        };
12211        let (sw, sh, dw, dh) = (40usize, 30usize, 56usize, 42usize);
12212        let mut rgba = Vec::with_capacity(sw * sh * 4);
12213        for y in 0..sh {
12214            for x in 0..sw {
12215                let v = if (x + y) % 2 == 0 { 16u8 } else { 235 };
12216                rgba.extend_from_slice(&[v, 128, 255 - v, 255]);
12217            }
12218        }
12219        let mut src = TensorDyn::image(
12220            sw,
12221            sh,
12222            PixelFormat::Rgba,
12223            DType::U8,
12224            Some(TensorMemory::Mem),
12225            edgefirst_tensor::CpuAccess::ReadWrite,
12226        )
12227        .unwrap();
12228        src.as_u8_mut()
12229            .unwrap()
12230            .map_mut()
12231            .unwrap()
12232            .as_mut_slice()
12233            .copy_from_slice(&rgba);
12234        let gl_convert = |p: &mut ImageProcessor, options: Option<ConvertOptions>| {
12235            let mut dst = TensorDyn::image(
12236                dw,
12237                dh,
12238                PixelFormat::Rgba,
12239                DType::U8,
12240                Some(TensorMemory::Mem),
12241                edgefirst_tensor::CpuAccess::ReadWrite,
12242            )
12243            .unwrap();
12244            let options = p.resolve_interpolation(options.unwrap_or_default());
12245            p.opengl
12246                .as_mut()
12247                .unwrap()
12248                .convert(&src, &mut dst, Some(options))
12249                .unwrap();
12250            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12251        };
12252        let nearest = ConvertOptions::new().interpolation(Interpolation::Nearest);
12253        let per_convert = gl_convert(&mut p, Some(nearest));
12254        let processor_bilinear = gl_convert(&mut p, None);
12255        p.set_interpolation(Interpolation::Nearest).unwrap();
12256        let processor_nearest = gl_convert(&mut p, None);
12257        assert_ne!(processor_bilinear, processor_nearest);
12258        assert_eq!(per_convert, processor_nearest, "per-convert Nearest");
12259        let bilinear = ConvertOptions::new().interpolation(Interpolation::Bilinear);
12260        assert_eq!(gl_convert(&mut p, Some(bilinear)), processor_bilinear);
12261    }
12262
12263    /// Each unsupported interpolation warns at most once per process.
12264    #[test]
12265    fn test_interpolation_fallback_warns_once() {
12266        runnable_interpolation(Interpolation::Area);
12267        assert!(!warn_first_fallback(Interpolation::Area));
12268        assert_eq!(
12269            runnable_interpolation(Interpolation::Area),
12270            Interpolation::Bilinear
12271        );
12272        assert!(!warn_first_fallback(Interpolation::Area));
12273    }
12274
12275    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
12276    /// removed), restoring the prior value afterward. Tests are env-
12277    /// serialized via the process-wide `ENV_MUTEX`.
12278    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
12279        let _lock = acquire_env_lock();
12280        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12281        match value {
12282            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
12283            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
12284        }
12285        body()
12286    }
12287
12288    /// Allocate an RGBA image tensor and pre-fill every byte with a
12289    /// distinctive non-zero pattern. Any test that relies on the old
12290    /// "dst is already cleared" assumption will see this pattern leak
12291    /// through to the output and fail.
12292    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
12293        let dst = TensorDyn::image(
12294            w,
12295            h,
12296            PixelFormat::Rgba,
12297            DType::U8,
12298            mem,
12299            edgefirst_tensor::CpuAccess::ReadWrite,
12300        )
12301        .unwrap();
12302        {
12303            use edgefirst_tensor::TensorMapTrait;
12304            let u8t = dst.as_typed::<u8>().unwrap();
12305            let mut map = u8t.map().unwrap();
12306            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
12307                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
12308            }
12309        }
12310        dst
12311    }
12312
12313    /// Allocate an RGBA background filled with a constant colour.
12314    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
12315        let bg = TensorDyn::image(
12316            w,
12317            h,
12318            PixelFormat::Rgba,
12319            DType::U8,
12320            mem,
12321            edgefirst_tensor::CpuAccess::ReadWrite,
12322        )
12323        .unwrap();
12324        {
12325            use edgefirst_tensor::TensorMapTrait;
12326            let u8t = bg.as_typed::<u8>().unwrap();
12327            let mut map = u8t.map().unwrap();
12328            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12329                chunk.copy_from_slice(&rgba);
12330            }
12331        }
12332        bg
12333    }
12334
12335    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
12336        use edgefirst_tensor::TensorMapTrait;
12337        let w = dst.width().unwrap();
12338        let off = (y * w + x) * 4;
12339        let u8t = dst.as_typed::<u8>().unwrap();
12340        let map = u8t.map().unwrap();
12341        let s = map.as_slice();
12342        [s[off], s[off + 1], s[off + 2], s[off + 3]]
12343    }
12344
12345    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
12346        use edgefirst_tensor::TensorMapTrait;
12347        let u8t = dst.as_typed::<u8>().unwrap();
12348        let map = u8t.map().unwrap();
12349        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
12350            assert_eq!(
12351                chunk, &expected,
12352                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
12353            );
12354        }
12355    }
12356
12357    /// Scenario 1: empty detections, empty segmentation, no background
12358    /// → dst must be fully cleared to 0x00000000.
12359    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
12360        let mut dst = make_dirty_dst(64, 64, None);
12361        processor
12362            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12363            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
12364        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
12365
12366        let mut dst = make_dirty_dst(64, 64, None);
12367        let proto = {
12368            use edgefirst_tensor::{Tensor, TensorDyn};
12369            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12370            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12371            let protos_t =
12372                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12373            ProtoData {
12374                mask_coefficients: TensorDyn::F32(coeff_t),
12375                protos: TensorDyn::F32(protos_t),
12376                layout: ProtoLayout::Nhwc,
12377            }
12378        };
12379        processor
12380            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
12381            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
12382        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
12383    }
12384
12385    /// Scenario 2: empty detections, empty segmentation, background set
12386    /// → dst must be fully equal to bg.
12387    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
12388        let bg_color = [42, 99, 200, 255];
12389        let bg = make_bg(64, 64, None, bg_color);
12390        let overlay = MaskOverlay::new().with_background(&bg);
12391
12392        let mut dst = make_dirty_dst(64, 64, None);
12393        processor
12394            .draw_decoded_masks(&mut dst, &[], &[], overlay)
12395            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
12396        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
12397
12398        let mut dst = make_dirty_dst(64, 64, None);
12399        let proto = {
12400            use edgefirst_tensor::{Tensor, TensorDyn};
12401            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12402            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12403            let protos_t =
12404                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12405            ProtoData {
12406                mask_coefficients: TensorDyn::F32(coeff_t),
12407                protos: TensorDyn::F32(protos_t),
12408                layout: ProtoLayout::Nhwc,
12409            }
12410        };
12411        processor
12412            .draw_proto_masks(&mut dst, &[], &proto, overlay)
12413            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
12414        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
12415    }
12416
12417    /// Scenario 3: one detection with a fully-opaque segmentation fill,
12418    /// no background → outside the box dst must be 0x00, inside it must
12419    /// be a non-zero mask colour (the render_segmentation output).
12420    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
12421        use edgefirst_tensor::Segmentation;
12422        use ndarray::Array3;
12423        processor
12424            .set_class_colors(&[[200, 80, 40, 255]])
12425            .expect("set_class_colors");
12426
12427        let detect = DetectBox {
12428            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12429            score: 0.99,
12430            label: 0,
12431        };
12432        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12433        let seg = Segmentation {
12434            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12435                .unwrap()
12436                .into(),
12437            xmin: 0.25,
12438            ymin: 0.25,
12439            xmax: 0.75,
12440            ymax: 0.75,
12441        };
12442
12443        let mut dst = make_dirty_dst(64, 64, None);
12444        processor
12445            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
12446            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
12447
12448        // Outside the bbox (corner): must be cleared black.
12449        let corner = pixel_at(&dst, 2, 2);
12450        assert_eq!(
12451            corner,
12452            [0, 0, 0, 0],
12453            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
12454        );
12455        // Inside the bbox (center): the mask colour must be visible.
12456        // Any non-zero pixel is acceptable — exact rendering varies
12457        // between backends (GL smoothstep, CPU nearest).
12458        let center = pixel_at(&dst, 32, 32);
12459        assert!(
12460            center != [0, 0, 0, 0],
12461            "{case}/decoded: center (32,32) was not coloured: {center:?}"
12462        );
12463    }
12464
12465    /// Scenario 4: detection + background. Outside the box must match
12466    /// bg; inside the box must NOT match bg (mask blended on top).
12467    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
12468        use edgefirst_tensor::Segmentation;
12469        use ndarray::Array3;
12470        processor
12471            .set_class_colors(&[[200, 80, 40, 255]])
12472            .expect("set_class_colors");
12473        let bg_color = [10, 20, 30, 255];
12474        let bg = make_bg(64, 64, None, bg_color);
12475
12476        let detect = DetectBox {
12477            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12478            score: 0.99,
12479            label: 0,
12480        };
12481        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12482        let seg = Segmentation {
12483            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12484                .unwrap()
12485                .into(),
12486            xmin: 0.25,
12487            ymin: 0.25,
12488            xmax: 0.75,
12489            ymax: 0.75,
12490        };
12491
12492        let overlay = MaskOverlay::new().with_background(&bg);
12493        let mut dst = make_dirty_dst(64, 64, None);
12494        processor
12495            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
12496            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
12497
12498        // Outside the bbox (corner): bg colour.
12499        let corner = pixel_at(&dst, 2, 2);
12500        assert_eq!(
12501            corner, bg_color,
12502            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
12503        );
12504        // Inside the bbox (center): mask blended on bg, must differ from
12505        // pure bg (alpha-blend with mask colour produces a distinct shade).
12506        let center = pixel_at(&dst, 32, 32);
12507        assert!(
12508            center != bg_color,
12509            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
12510        );
12511    }
12512
12513    /// Run all 4 scenarios against the processor. Skip gracefully if
12514    /// construction fails (backend unavailable on this host).
12515    fn run_all_scenarios(
12516        force_backend: Option<&'static str>,
12517        case: &'static str,
12518        require_dma_for_bg: bool,
12519    ) {
12520        if require_dma_for_bg && !crate::test_support::is_dma_available() {
12521            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
12522            return;
12523        }
12524        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
12525        let mut processor = match processor_result {
12526            Ok(p) => p,
12527            Err(e) => {
12528                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
12529                return;
12530            }
12531        };
12532        scenario_empty_no_bg(&mut processor, case);
12533        scenario_empty_with_bg(&mut processor, case);
12534        scenario_detect_no_bg(&mut processor, case);
12535        scenario_detect_with_bg(&mut processor, case);
12536    }
12537
12538    #[test]
12539    fn test_draw_masks_4_scenarios_cpu() {
12540        run_all_scenarios(Some("cpu"), "cpu", false);
12541    }
12542
12543    #[test]
12544    fn test_draw_masks_4_scenarios_auto() {
12545        run_all_scenarios(None, "auto", false);
12546    }
12547
12548    #[cfg(any(
12549        target_os = "linux",
12550        target_os = "macos",
12551        target_os = "ios",
12552        target_os = "android",
12553        target_os = "windows"
12554    ))]
12555    #[cfg(feature = "opengl")]
12556    #[test]
12557    fn test_draw_masks_4_scenarios_opengl() {
12558        run_all_scenarios(Some("opengl"), "opengl", false);
12559    }
12560
12561    /// G2D forced backend: exercises the zero-detection empty-frame
12562    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
12563    /// detections) expect `NotImplemented` since G2D has no rasterizer
12564    /// for boxes / masks.
12565    #[cfg(target_os = "linux")]
12566    #[test]
12567    fn test_draw_masks_zero_detection_g2d_forced() {
12568        if !crate::test_support::is_dma_available() {
12569            crate::test_support::report_skip("g2d forced — DMA not available on this host");
12570            return;
12571        }
12572        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
12573        let mut processor = match processor_result {
12574            Ok(p) => p,
12575            Err(e) => {
12576                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
12577                return;
12578            }
12579        };
12580
12581        // Case 1: empty + no bg. G2D requires DMA-backed dst.
12582        let mut dst = TensorDyn::image(
12583            64,
12584            64,
12585            PixelFormat::Rgba,
12586            DType::U8,
12587            Some(TensorMemory::DmaBuf),
12588            edgefirst_tensor::CpuAccess::ReadWrite,
12589        )
12590        .unwrap();
12591        {
12592            use edgefirst_tensor::TensorMapTrait;
12593            let u8t = dst.as_typed_mut::<u8>().unwrap();
12594            let mut map = u8t.map().unwrap();
12595            map.as_mut_slice().fill(0xBB);
12596        }
12597        processor
12598            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12599            .expect("g2d empty+no-bg");
12600        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
12601
12602        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
12603        let bg_color = [7, 11, 13, 255];
12604        let bg = {
12605            let t = TensorDyn::image(
12606                64,
12607                64,
12608                PixelFormat::Rgba,
12609                DType::U8,
12610                Some(TensorMemory::DmaBuf),
12611                edgefirst_tensor::CpuAccess::ReadWrite,
12612            )
12613            .unwrap();
12614            {
12615                use edgefirst_tensor::TensorMapTrait;
12616                let u8t = t.as_typed::<u8>().unwrap();
12617                let mut map = u8t.map().unwrap();
12618                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12619                    chunk.copy_from_slice(&bg_color);
12620                }
12621            }
12622            t
12623        };
12624        let mut dst = TensorDyn::image(
12625            64,
12626            64,
12627            PixelFormat::Rgba,
12628            DType::U8,
12629            Some(TensorMemory::DmaBuf),
12630            edgefirst_tensor::CpuAccess::ReadWrite,
12631        )
12632        .unwrap();
12633        {
12634            use edgefirst_tensor::TensorMapTrait;
12635            let u8t = dst.as_typed_mut::<u8>().unwrap();
12636            let mut map = u8t.map().unwrap();
12637            map.as_mut_slice().fill(0x55);
12638        }
12639        processor
12640            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
12641            .expect("g2d empty+bg");
12642        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
12643
12644        // Case 3 and 4: detect present — must return NotImplemented.
12645        let detect = DetectBox {
12646            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12647            score: 0.9,
12648            label: 0,
12649        };
12650        let mut dst = TensorDyn::image(
12651            64,
12652            64,
12653            PixelFormat::Rgba,
12654            DType::U8,
12655            Some(TensorMemory::DmaBuf),
12656            edgefirst_tensor::CpuAccess::ReadWrite,
12657        )
12658        .unwrap();
12659        let err = processor
12660            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12661            .expect_err("g2d must reject detect-present draw_decoded_masks");
12662        assert!(
12663            matches!(err, Error::NotImplemented(_)),
12664            "g2d case3 wrong error: {err:?}"
12665        );
12666    }
12667
12668    #[test]
12669    fn test_set_format_then_cpu_convert() {
12670        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12671        // test_force_backend_* and test_disable_env_var.
12672        let _lock = acquire_env_lock();
12673        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12674        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12675        let mut processor = ImageProcessor::new().unwrap();
12676
12677        // Load a source image
12678        let image = edgefirst_bench::testdata::read("zidane.jpg");
12679        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12680
12681        // Create a raw tensor, then attach format — simulating the from_fd workflow
12682        let mut dst =
12683            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12684        dst.set_format(PixelFormat::Rgb).unwrap();
12685
12686        // Convert should work with the set_format-annotated tensor
12687        processor.convert(&src, &mut dst, None).unwrap();
12688
12689        // Verify format survived conversion
12690        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12691        assert_eq!(dst.width(), Some(640));
12692        assert_eq!(dst.height(), Some(640));
12693    }
12694
12695    /// Verify that creating multiple ImageProcessors on the same thread and
12696    /// performing a resize on each does not deadlock or error.
12697    ///
12698    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12699    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12700    #[test]
12701    fn test_multiple_image_processors_same_thread() {
12702        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12703        // seen by ImageProcessor::new() calls during this test.
12704        let _lock = acquire_env_lock();
12705        let mut processors: Vec<ImageProcessor> = (0..4)
12706            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12707            .collect();
12708
12709        for proc in &mut processors {
12710            let src = proc
12711                .create_image(
12712                    128,
12713                    128,
12714                    PixelFormat::Rgb,
12715                    DType::U8,
12716                    None,
12717                    edgefirst_tensor::CpuAccess::ReadWrite,
12718                )
12719                .expect("create src failed");
12720            let mut dst = proc
12721                .create_image(
12722                    64,
12723                    64,
12724                    PixelFormat::Rgb,
12725                    DType::U8,
12726                    None,
12727                    edgefirst_tensor::CpuAccess::ReadWrite,
12728                )
12729                .expect("create dst failed");
12730            proc.convert(&src, &mut dst, None).expect("convert failed");
12731            assert_eq!(dst.width(), Some(64));
12732            assert_eq!(dst.height(), Some(64));
12733        }
12734    }
12735
12736    /// Verify that creating ImageProcessors on separate threads and performing
12737    /// a resize on each does not deadlock or error.
12738    ///
12739    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12740    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12741    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12742    #[test]
12743    fn test_multiple_image_processors_separate_threads() {
12744        use std::sync::mpsc;
12745        use std::time::Duration;
12746
12747        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12748        // EGL context teardown — four processors spun up on four threads here
12749        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12750        // panic). The bug is the driver's, not the HAL's; this test is kept so
12751        // it still exercises the multi-context path on every other GPU. The
12752        // board lane and scripts/on-target-test.sh set
12753        // EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS on any board with /dev/galcore
12754        // to skip just this case there, while the run stays red anywhere else
12755        // a regression appears. (Skip, not #[ignore]: the platform is decided
12756        // at runtime, not compile time.)
12757        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12758            crate::test_support::report_skip(
12759                "test_multiple_image_processors_separate_threads — known Vivante \
12760                 GC7000UL concurrent-EGL-teardown double-free \
12761                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12762            );
12763            return;
12764        }
12765
12766        const TIMEOUT: Duration = Duration::from_secs(60);
12767
12768        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12769        // calls made inside the spawned threads during this test.
12770        let _lock = acquire_env_lock();
12771
12772        let (tx, rx) = mpsc::channel::<()>();
12773
12774        std::thread::spawn(move || {
12775            let handles: Vec<_> = (0..4)
12776                .map(|i| {
12777                    std::thread::spawn(move || {
12778                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12779                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12780                        });
12781                        let src = proc
12782                            .create_image(
12783                                128,
12784                                128,
12785                                PixelFormat::Rgb,
12786                                DType::U8,
12787                                None,
12788                                edgefirst_tensor::CpuAccess::ReadWrite,
12789                            )
12790                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12791                        let mut dst = proc
12792                            .create_image(
12793                                64,
12794                                64,
12795                                PixelFormat::Rgb,
12796                                DType::U8,
12797                                None,
12798                                edgefirst_tensor::CpuAccess::ReadWrite,
12799                            )
12800                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12801                        proc.convert(&src, &mut dst, None)
12802                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12803                        assert_eq!(dst.width(), Some(64));
12804                        assert_eq!(dst.height(), Some(64));
12805                    })
12806                })
12807                .collect();
12808
12809            for (i, h) in handles.into_iter().enumerate() {
12810                h.join()
12811                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12812            }
12813
12814            let _ = tx.send(());
12815        });
12816
12817        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12818            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12819        });
12820    }
12821
12822    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12823    /// all operate concurrently without deadlocking each other.
12824    ///
12825    /// All processors are created first, then a barrier synchronizes them so
12826    /// they all start converting at the same instant — maximizing contention.
12827    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12828    #[test]
12829    fn test_image_processors_concurrent_operations() {
12830        use std::sync::{mpsc, Arc, Barrier};
12831        use std::time::Duration;
12832
12833        const N: usize = 4;
12834        const ROUNDS: usize = 10;
12835        const TIMEOUT: Duration = Duration::from_secs(60);
12836
12837        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12838        // calls made inside the spawned threads during this test.
12839        let _lock = acquire_env_lock();
12840
12841        let (tx, rx) = mpsc::channel::<()>();
12842
12843        std::thread::spawn(move || {
12844            let barrier = Arc::new(Barrier::new(N));
12845
12846            let handles: Vec<_> = (0..N)
12847                .map(|i| {
12848                    let barrier = Arc::clone(&barrier);
12849                    std::thread::spawn(move || {
12850                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12851                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12852                        });
12853
12854                        // All threads wait here until every processor is initialized.
12855                        barrier.wait();
12856
12857                        // Now all 4 hammer the GPU concurrently.
12858                        for round in 0..ROUNDS {
12859                            let src = proc
12860                                .create_image(
12861                                    128,
12862                                    128,
12863                                    PixelFormat::Rgb,
12864                                    DType::U8,
12865                                    None,
12866                                    edgefirst_tensor::CpuAccess::ReadWrite,
12867                                )
12868                                .unwrap_or_else(|e| {
12869                                    panic!("create src failed on thread {i} round {round}: {e}")
12870                                });
12871                            let mut dst = proc
12872                                .create_image(
12873                                    64,
12874                                    64,
12875                                    PixelFormat::Rgb,
12876                                    DType::U8,
12877                                    None,
12878                                    edgefirst_tensor::CpuAccess::ReadWrite,
12879                                )
12880                                .unwrap_or_else(|e| {
12881                                    panic!("create dst failed on thread {i} round {round}: {e}")
12882                                });
12883                            proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
12884                                panic!("convert failed on thread {i} round {round}: {e}")
12885                            });
12886                            assert_eq!(dst.width(), Some(64));
12887                            assert_eq!(dst.height(), Some(64));
12888                        }
12889                    })
12890                })
12891                .collect();
12892
12893            for (i, h) in handles.into_iter().enumerate() {
12894                h.join()
12895                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12896            }
12897
12898            let _ = tx.send(());
12899        });
12900
12901        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12902            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12903        });
12904    }
12905
12906    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12907    /// threads, each with its own GL context and worker, converting
12908    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12909    /// every output must byte-match the thread's own pre-barrier sequential
12910    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12911    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12912    /// GPU; on Vivante they serialize via the Full policy and the test still
12913    /// must pass. Distinct inputs make any cross-processor state leakage
12914    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12915    /// diff rather than a coincidental match — proven by a scratch
12916    /// cross-wire run (neighbor's input post-oracle) failing on every
12917    /// thread with ~53% of bytes diverged.
12918    ///
12919    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12920    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12921    /// can abort intermittently on concurrent multi-processor lifecycles
12922    /// regardless of locking (P0 spike: reproduces fully serialized).
12923    #[test]
12924    fn test_parallel_processors_unique_outputs() {
12925        use std::sync::{mpsc, Arc, Barrier};
12926        use std::time::Duration;
12927
12928        const N: usize = 4;
12929        const ROUNDS: usize = 25;
12930        const TIMEOUT: Duration = Duration::from_secs(60);
12931
12932        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12933            crate::test_support::report_skip(
12934                "test_parallel_processors_unique_outputs — known Vivante \
12935                 GC7000UL concurrent-multi-processor driver abort \
12936                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12937            );
12938            return;
12939        }
12940
12941        let _lock = acquire_env_lock();
12942        // Probed here, where the policy can name the test; worker threads are unnamed.
12943        let use_dma = crate::test_support::is_dma_available();
12944        if !use_dma {
12945            crate::test_support::report_skip(&format!(
12946                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
12947                function!()
12948            ));
12949        }
12950        let (tx, rx) = mpsc::channel::<()>();
12951
12952        std::thread::spawn(move || {
12953            let barrier = Arc::new(Barrier::new(N));
12954            let handles: Vec<_> = (0..N)
12955                .map(|i| {
12956                    let barrier = Arc::clone(&barrier);
12957                    std::thread::spawn(move || {
12958                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12959                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12960                        });
12961                        // CI-weight geometry (llvmpipe renders on the CPU).
12962                        let (w, h) = (640usize, 480usize);
12963                        let mem = if use_dma {
12964                            Some(TensorMemory::DmaBuf)
12965                        } else {
12966                            Some(TensorMemory::Mem)
12967                        };
12968                        let src = proc
12969                            .create_image(
12970                                w,
12971                                h,
12972                                PixelFormat::Nv12,
12973                                DType::U8,
12974                                mem,
12975                                edgefirst_tensor::CpuAccess::ReadWrite,
12976                            )
12977                            .unwrap();
12978                        {
12979                            let t = src.as_typed::<u8>().unwrap();
12980                            let mut m = t.map().unwrap();
12981                            let s = m.as_mut_slice();
12982                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12983                                *b = ((i * 53 + j) % 200 + 16) as u8;
12984                            }
12985                            for b in &mut s[w * h..] {
12986                                *b = (80 + i * 24) as u8;
12987                            }
12988                        }
12989                        let lb = Crop::letterbox([114, 114, 114, 255]);
12990                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12991                            let mut dst = proc
12992                                .create_image(
12993                                    320,
12994                                    320,
12995                                    PixelFormat::Rgba,
12996                                    DType::U8,
12997                                    mem,
12998                                    edgefirst_tensor::CpuAccess::ReadWrite,
12999                                )
13000                                .unwrap();
13001                            proc.convert(
13002                                &src,
13003                                &mut dst,
13004                                Some(crate::ConvertOptions::new().crop(lb)),
13005                            )
13006                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
13007                            let t = dst.as_typed::<u8>().unwrap();
13008                            let m = t.map().unwrap();
13009                            m.as_slice().to_vec()
13010                        };
13011
13012                        let oracle = convert_once(&mut proc);
13013                        barrier.wait();
13014                        for round in 0..ROUNDS {
13015                            let out = convert_once(&mut proc);
13016                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
13017                            assert!(
13018                                diffs == 0,
13019                                "thread {i} round {round}: {diffs}/{} bytes diverged \
13020                                 from this processor's own oracle — cross-processor \
13021                                 GL state leakage under parallel execution",
13022                                oracle.len()
13023                            );
13024                        }
13025                    })
13026                })
13027                .collect();
13028
13029            for (i, h) in handles.into_iter().enumerate() {
13030                h.join()
13031                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
13032            }
13033            let _ = tx.send(());
13034        });
13035
13036        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
13037            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
13038        });
13039    }
13040
13041    /// Heavy on-demand stressor for the GL serialization policy: 4
13042    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
13043    /// converts (DMA where available); every output must byte-match the
13044    /// thread's own pre-barrier sequential oracle from the same processor.
13045    /// Ignored by default (heavy; board tool — the CI-weight version is
13046    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
13047    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
13048    ///   <test binary> stress_parallel_processors_oracle --ignored
13049    #[test]
13050    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
13051    fn stress_parallel_processors_oracle() {
13052        use std::sync::{mpsc, Arc, Barrier};
13053        use std::time::Duration;
13054
13055        const N: usize = 4;
13056        const ROUNDS: usize = 200;
13057        const TIMEOUT: Duration = Duration::from_secs(600);
13058
13059        let _lock = acquire_env_lock();
13060        // Probed here, where the policy can name the test; worker threads are unnamed.
13061        let use_dma = crate::test_support::is_dma_available();
13062        if !use_dma {
13063            crate::test_support::report_skip(&format!(
13064                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
13065                function!()
13066            ));
13067        }
13068        let (tx, rx) = mpsc::channel::<()>();
13069
13070        std::thread::spawn(move || {
13071            let barrier = Arc::new(Barrier::new(N));
13072            let handles: Vec<_> = (0..N)
13073                .map(|i| {
13074                    let barrier = Arc::clone(&barrier);
13075                    std::thread::spawn(move || {
13076                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
13077                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
13078                        });
13079                        let (w, h) = (1280usize, 720usize);
13080                        let mem = if use_dma {
13081                            Some(TensorMemory::DmaBuf)
13082                        } else {
13083                            Some(TensorMemory::Mem)
13084                        };
13085
13086                        // Per-thread-distinct synthetic NV12 so cross-wired
13087                        // GL state between processors shows up as a byte diff.
13088                        let src = proc
13089                            .create_image(
13090                                w,
13091                                h,
13092                                PixelFormat::Nv12,
13093                                DType::U8,
13094                                mem,
13095                                edgefirst_tensor::CpuAccess::ReadWrite,
13096                            )
13097                            .unwrap();
13098                        {
13099                            let t = src.as_typed::<u8>().unwrap();
13100                            let mut m = t.map().unwrap();
13101                            let s = m.as_mut_slice();
13102                            for (j, b) in s[..w * h].iter_mut().enumerate() {
13103                                *b = ((i * 37 + j) % 200 + 16) as u8;
13104                            }
13105                            for b in &mut s[w * h..] {
13106                                *b = (96 + i * 16) as u8;
13107                            }
13108                        }
13109                        let lb = Crop::letterbox([114, 114, 114, 255]);
13110
13111                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
13112                            let mut dst = proc
13113                                .create_image(
13114                                    640,
13115                                    640,
13116                                    PixelFormat::Rgb,
13117                                    DType::U8,
13118                                    mem,
13119                                    edgefirst_tensor::CpuAccess::ReadWrite,
13120                                )
13121                                .unwrap();
13122                            proc.convert(
13123                                &src,
13124                                &mut dst,
13125                                Some(crate::ConvertOptions::new().crop(lb)),
13126                            )
13127                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
13128                            let t = dst.as_typed::<u8>().unwrap();
13129                            let m = t.map().unwrap();
13130                            m.as_slice().to_vec()
13131                        };
13132
13133                        let oracle = convert_once(&mut proc);
13134                        barrier.wait();
13135                        for round in 0..ROUNDS {
13136                            let out = convert_once(&mut proc);
13137                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
13138                            assert!(
13139                                diffs == 0,
13140                                "thread {i} round {round}: {diffs}/{} bytes diverged \
13141                                 from the pre-barrier oracle",
13142                                oracle.len()
13143                            );
13144                        }
13145                    })
13146                })
13147                .collect();
13148
13149            for (i, h) in handles.into_iter().enumerate() {
13150                h.join()
13151                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
13152            }
13153            let _ = tx.send(());
13154        });
13155
13156        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
13157            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
13158        });
13159    }
13160
13161    // =========================================================================
13162    // F16 / F32 auto-chain fallback integration tests
13163    // =========================================================================
13164
13165    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
13166    /// combo the GL path does NOT cover.
13167    ///
13168    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
13169    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
13170    /// chain falls through to the CPU float path. Before commit 868a7649
13171    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
13172    /// must return `Ok` with output in `[0, 1]` and all values finite.
13173    #[test]
13174    fn convert_f32_auto_never_errors_non_gl_combo() {
13175        const W: usize = 64;
13176        const H: usize = 64;
13177
13178        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
13179        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
13180        let src = TensorDyn::image(
13181            W,
13182            H,
13183            PixelFormat::Yuyv,
13184            DType::U8,
13185            Some(TensorMemory::Mem),
13186            edgefirst_tensor::CpuAccess::ReadWrite,
13187        )
13188        .unwrap();
13189        {
13190            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13191            let data = map.as_mut_slice();
13192            for chunk in data.as_chunks_mut::<4>().0 {
13193                chunk[0] = 128; // Y0
13194                chunk[1] = 128; // U
13195                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
13196                chunk[3] = 128; // V
13197            }
13198        }
13199
13200        let mut dst = TensorDyn::image(
13201            W,
13202            H,
13203            PixelFormat::Rgb,
13204            DType::F32,
13205            Some(TensorMemory::Mem),
13206            edgefirst_tensor::CpuAccess::ReadWrite,
13207        )
13208        .unwrap();
13209
13210        let mut proc = ImageProcessor::new().unwrap();
13211        let result = proc.convert(&src, &mut dst, None);
13212        assert!(
13213            result.is_ok(),
13214            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
13215            result.err()
13216        );
13217
13218        // Verify all output values are finite and in [0, 1].
13219        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13220        let floats = map.as_slice();
13221        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
13222        for (i, &v) in floats.iter().enumerate() {
13223            assert!(
13224                v.is_finite() && (0.0..=1.0).contains(&v),
13225                "output[{i}]={v} is not finite or not in [0,1]"
13226            );
13227        }
13228
13229        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
13230        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
13231        // path never wrote to it) this assertion catches the regression.
13232        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
13233        assert!(
13234            first_non_zero.is_some(),
13235            "all-zero output detected — CPU path likely did not write to the destination buffer"
13236        );
13237        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
13238        // (which carries luma for a near-grey YUV source).
13239        let r0 = floats[0];
13240        assert!(
13241            (r0 - 0.502_f32).abs() < 0.05,
13242            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
13243        );
13244    }
13245
13246    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
13247    ///
13248    /// Uses a source with clearly distinct per-channel values so a
13249    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
13250    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
13251    #[test]
13252    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
13253    // `{ backend, ..Default::default() }` is a genuine update on Linux but
13254    // covers no remaining fields on macOS, where `clippy::needless_update`
13255    // then fires. `allow` (not `expect`) because the lint is platform-
13256    // conditional — it does not fire on Linux.
13257    #[allow(clippy::needless_update)]
13258    fn convert_f16_forced_cpu_correct() {
13259        const W: usize = 16;
13260        const H: usize = 16;
13261        const TOL: f32 = 1.0 / 512.0; // 2^-9
13262
13263        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
13264        let src = TensorDyn::image(
13265            W,
13266            H,
13267            PixelFormat::Rgba,
13268            DType::U8,
13269            Some(TensorMemory::Mem),
13270            edgefirst_tensor::CpuAccess::ReadWrite,
13271        )
13272        .unwrap();
13273        {
13274            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13275            let data = map.as_mut_slice();
13276            for y in 0..H {
13277                for x in 0..W {
13278                    let i = y * W + x;
13279                    data[i * 4] = (50 + x) as u8; // R: 50..65
13280                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
13281                    data[i * 4 + 2] = 200; // B: constant
13282                    data[i * 4 + 3] = 255;
13283                }
13284            }
13285        }
13286
13287        let mut dst = TensorDyn::image(
13288            W,
13289            H,
13290            PixelFormat::PlanarRgb,
13291            DType::F16,
13292            Some(TensorMemory::Mem),
13293            edgefirst_tensor::CpuAccess::ReadWrite,
13294        )
13295        .unwrap();
13296
13297        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13298            backend: ComputeBackend::Cpu,
13299            ..Default::default()
13300        })
13301        .unwrap();
13302        proc.convert(&src, &mut dst, None)
13303            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13304
13305        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
13306        let src_bytes = src_map.as_slice();
13307        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13308        let dst_halfs = dst_map.as_slice();
13309
13310        let plane = W * H;
13311        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
13312
13313        for y in 0..H {
13314            for x in 0..W {
13315                let i = y * W + x;
13316                let r_exp = src_bytes[i * 4] as f32 / 255.0;
13317                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
13318                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
13319
13320                let r_got = dst_halfs[i].to_f32();
13321                let g_got = dst_halfs[plane + i].to_f32();
13322                let b_got = dst_halfs[2 * plane + i].to_f32();
13323
13324                assert!(
13325                    (r_got - r_exp).abs() <= TOL,
13326                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
13327                );
13328                assert!(
13329                    (g_got - g_exp).abs() <= TOL,
13330                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
13331                );
13332                assert!(
13333                    (b_got - b_exp).abs() <= TOL,
13334                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
13335                );
13336
13337                // Catch plane-swap: R and G must differ (they have different formulas).
13338                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
13339                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
13340                }
13341            }
13342        }
13343    }
13344
13345    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
13346    /// rotation set.
13347    ///
13348    /// The GL float render path rejects any call where rotation ≠ None,
13349    /// returning an error that causes the chain to continue. Before the CPU
13350    /// float fallback this would have produced an error at the end of the
13351    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
13352    #[test]
13353    fn convert_f32_with_rotation_falls_back() {
13354        const W: usize = 16;
13355        const H: usize = 16;
13356
13357        // RGBA8 source with a known gradient (distinct per-channel values).
13358        let src = TensorDyn::image(
13359            W,
13360            H,
13361            PixelFormat::Rgba,
13362            DType::U8,
13363            Some(TensorMemory::Mem),
13364            edgefirst_tensor::CpuAccess::ReadWrite,
13365        )
13366        .unwrap();
13367        {
13368            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13369            let data = map.as_mut_slice();
13370            for y in 0..H {
13371                for x in 0..W {
13372                    let i = y * W + x;
13373                    data[i * 4] = (x * 16) as u8; // R
13374                    data[i * 4 + 1] = (y * 16) as u8; // G
13375                    data[i * 4 + 2] = 128; // B
13376                    data[i * 4 + 3] = 255;
13377                }
13378            }
13379        }
13380
13381        // Rotation swaps W and H, so dst is [W, H] (H×W output).
13382        let mut dst = TensorDyn::image(
13383            H, // dst W = src H after 90° rotation
13384            W, // dst H = src W after 90° rotation
13385            PixelFormat::Rgb,
13386            DType::F32,
13387            Some(TensorMemory::Mem),
13388            edgefirst_tensor::CpuAccess::ReadWrite,
13389        )
13390        .unwrap();
13391
13392        let mut proc = ImageProcessor::new().unwrap();
13393        let result = proc.convert(
13394            &src,
13395            &mut dst,
13396            Some(crate::ConvertOptions::new().rotation(Rotation::Clockwise90)),
13397        );
13398        assert!(
13399            result.is_ok(),
13400            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
13401            result.err()
13402        );
13403
13404        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13405        let floats = map.as_slice();
13406        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
13407        for (i, &v) in floats.iter().enumerate() {
13408            assert!(
13409                v.is_finite() && (0.0..=1.0).contains(&v),
13410                "output[{i}]={v} is not finite or not in [0,1]"
13411            );
13412        }
13413    }
13414
13415    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
13416    ///
13417    /// Converts the same RGBA8 source via the `OpenGl` backend and the
13418    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
13419    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
13420    /// or F16 render is unavailable, and when the host cannot hold the
13421    /// zero-copy F16 destination the GL float route needs.
13422    ///
13423    /// **The GL route is asserted, not assumed** (issue #179).
13424    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
13425    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
13426    /// serves the same convert. Both sides of the comparison would then be
13427    /// the CPU's answer and the parity claim would be a tautology — which is
13428    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
13429    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
13430    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
13431    /// an extension query, not a completed render. `convert_fallback_count()`
13432    /// staying flat across the GL convert is the proof, and a decline is
13433    /// reported as a skip rather than a failure because it is a property of
13434    /// the host's driver, not of this code — except under
13435    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
13436    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
13437    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
13438    /// come up. A lane that has asserted it has GL should not be quietly
13439    /// losing this test's coverage.
13440    #[test]
13441    #[cfg(all(
13442        any(
13443            target_os = "linux",
13444            target_os = "macos",
13445            target_os = "ios",
13446            target_os = "android",
13447            target_os = "windows"
13448        ),
13449        feature = "opengl"
13450    ))]
13451    fn convert_f16_gl_cpu_parity_identity() {
13452        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
13453        // passes but deliberately hobbles pass 1 (unsigned binaries, the
13454        // ANGLE dlopen gate closed). Excluded there the same way
13455        // `gl_backend_available_canary` excludes it, so the gate below
13456        // enforces in pass 2 only.
13457        #[cfg(target_os = "macos")]
13458        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
13459            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
13460        {
13461            crate::test_support::report_skip(
13462                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
13463            );
13464            return;
13465        }
13466        if !is_opengl_available() {
13467            crate::test_support::report_skip(
13468                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
13469            );
13470            return;
13471        }
13472
13473        // 32 F16 pixels make a 64-byte row, the pitch IOSurface requires for a planar destination.
13474        const W: usize = 32;
13475        const H: usize = 16;
13476        const TOL: f32 = 1.0 / 256.0; // 2^-8
13477
13478        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
13479        let src = TensorDyn::image(
13480            W,
13481            H,
13482            PixelFormat::Rgba,
13483            DType::U8,
13484            Some(TensorMemory::Mem),
13485            edgefirst_tensor::CpuAccess::ReadWrite,
13486        )
13487        .unwrap();
13488        {
13489            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13490            let data = map.as_mut_slice();
13491            for y in 0..H {
13492                for x in 0..W {
13493                    let i = y * W + x;
13494                    data[i * 4] = (40 + x) as u8; // R
13495                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
13496                    data[i * 4 + 2] = 180; // B
13497                    data[i * 4 + 3] = 255;
13498                }
13499            }
13500        }
13501
13502        // GL path.
13503        let gl_result = {
13504            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
13505                backend: ComputeBackend::OpenGl,
13506                ..Default::default()
13507            }) {
13508                Ok(p) => p,
13509                Err(e) => {
13510                    crate::test_support::report_skip(&format!(
13511                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
13512                    ));
13513                    return;
13514                }
13515            };
13516
13517            if !gl_proc.supported_render_dtypes().f16 {
13518                crate::test_support::report_skip(
13519                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
13520                );
13521                return;
13522            }
13523
13524            // A destination the GL float route actually serves. A
13525            // `TensorMemory::Mem` float destination has no arm in
13526            // `float_dispatch::classify_float_render` at all: it classifies as
13527            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
13528            // CPU serves every convert. With a host destination the GL half
13529            // below was therefore unreachable and this test could only ever
13530            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
13531            // is the zero-copy float tuple EVERY platform's capability set
13532            // serves: it is unconditional in `classify_float_render`, so it
13533            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
13534            // declare as well as the `All` set Windows declares.
13535            //
13536            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
13537            // portable name for the platform-native zero-copy GPU buffer --
13538            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
13539            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
13540            // and the classifier matches on that one variant for all of them,
13541            // so this needs no `cfg`. Where the platform cannot hold one the
13542            // allocation fails or falls back, and both are reported below.
13543            // Checked before the HAL_TEST_REQUIRE_GL gate below, which covers a declined destination.
13544            let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
13545                TensorDyn::image(
13546                    W,
13547                    H,
13548                    PixelFormat::PlanarRgb,
13549                    DType::F16,
13550                    Some(TensorMemory::DmaBuf),
13551                    edgefirst_tensor::CpuAccess::ReadWrite,
13552                ),
13553                "zero-copy F16 PlanarRgb destination",
13554            ) else {
13555                return;
13556            };
13557            if dst.memory() != TensorMemory::DmaBuf {
13558                crate::test_support::report_skip(&format!(
13559                    "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
13560                     destination fell back to {:?}",
13561                    dst.memory()
13562                ));
13563                return;
13564            }
13565            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
13566            let fallbacks_before = gl_proc.convert_fallback_count();
13567            match gl_proc.convert(&src, &mut dst, None) {
13568                Ok(()) => {
13569                    if gl_proc.convert_fallback_count() != fallbacks_before {
13570                        assert!(
13571                            !require_gl,
13572                            "HAL_TEST_REQUIRE_GL=1 but \
13573                             convert_f16_gl_cpu_parity_identity would have skipped \
13574                             while reporting success: the GL F16 path declined and \
13575                             the CPU served the convert"
13576                        );
13577                        crate::test_support::report_skip(
13578                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
13579                             declined and the CPU served the convert; comparing that \
13580                             against the forced-CPU result would prove nothing",
13581                        );
13582                        return;
13583                    }
13584                    dst
13585                }
13586                Err(e) => {
13587                    crate::test_support::report_skip(&format!(
13588                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
13589                    ));
13590                    return;
13591                }
13592            }
13593        };
13594
13595        // CPU path.
13596        let cpu_result = {
13597            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
13598                backend: ComputeBackend::Cpu,
13599                ..Default::default()
13600            })
13601            .unwrap();
13602            let mut dst = TensorDyn::image(
13603                W,
13604                H,
13605                PixelFormat::PlanarRgb,
13606                DType::F16,
13607                Some(TensorMemory::Mem),
13608                edgefirst_tensor::CpuAccess::ReadWrite,
13609            )
13610            .unwrap();
13611            cpu_proc
13612                .convert(&src, &mut dst, None)
13613                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13614            dst
13615        };
13616
13617        // Compare element-wise. Both sides are read through `copy_to_flat`:
13618        // the GL destination is a packed zero-copy surface whose rows can be
13619        // spaced at a driver pitch, so its mapping is not a flat run of the
13620        // logical elements.
13621        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
13622            let typed = t.as_typed::<half::f16>().expect("F16 image");
13623            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
13624            typed
13625                .copy_to_flat(&mut bytes)
13626                .expect("compact any padded rows");
13627            bytes
13628                .as_chunks::<2>()
13629                .0
13630                .iter()
13631                .map(|&c| half::f16::from_le_bytes(c))
13632                .collect()
13633        };
13634        let gl_halfs = flat_f16(&gl_result);
13635        let cpu_halfs = flat_f16(&cpu_result);
13636
13637        assert_eq!(
13638            gl_halfs.len(),
13639            cpu_halfs.len(),
13640            "GL and CPU output sizes differ"
13641        );
13642
13643        let plane = W * H;
13644        let channel_names = ["R", "G", "B"];
13645        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
13646            let gl_v = gl_h.to_f32();
13647            let cpu_v = cpu_h.to_f32();
13648            let err = (gl_v - cpu_v).abs();
13649            let ch = channel_names[idx / plane];
13650            let pixel = idx % plane;
13651            assert!(
13652                err <= TOL,
13653                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13654            );
13655        }
13656    }
13657
13658    // =========================================================================
13659    // GAP-1: supported_render_dtypes() Linux smoke test
13660    // =========================================================================
13661
13662    /// Exercises the real Linux/Windows GL path that reads
13663    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13664    /// available (CI/host without a GPU).
13665    #[test]
13666    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13667    fn supported_render_dtypes_linux_smoke() {
13668        let proc = match ImageProcessor::new() {
13669            Ok(p) => p,
13670            Err(e) => {
13671                crate::test_support::report_skip(&format!(
13672                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13673                ));
13674                return;
13675            }
13676        };
13677        if proc.opengl.is_none() {
13678            crate::test_support::report_skip(
13679                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13680            );
13681            return;
13682        }
13683        // The call must complete without panicking or deadlocking.
13684        let support = proc.supported_render_dtypes();
13685        eprintln!(
13686            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13687            support.f16, support.f32
13688        );
13689        // No assertion on the specific values — they are hardware-dependent.
13690    }
13691
13692    // =========================================================================
13693    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13694    // =========================================================================
13695
13696    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13697    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13698    /// The auto-chain must transparently fall through to the CPU path, which has
13699    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13700    #[test]
13701    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13702        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13703        const H: usize = 16;
13704
13705        // RGBA8 source filled with a flat mid-grey.
13706        let src = TensorDyn::image(
13707            W,
13708            H,
13709            PixelFormat::Rgba,
13710            DType::U8,
13711            Some(TensorMemory::Mem),
13712            edgefirst_tensor::CpuAccess::ReadWrite,
13713        )
13714        .unwrap();
13715        {
13716            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13717            let data = map.as_mut_slice();
13718            for chunk in data.as_chunks_mut::<4>().0 {
13719                chunk[0] = 128;
13720                chunk[1] = 64;
13721                chunk[2] = 200;
13722                chunk[3] = 255;
13723            }
13724        }
13725
13726        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13727        // to exercise the fallback chain without hardware dependency).
13728        let mut dst = TensorDyn::image(
13729            W,
13730            H,
13731            PixelFormat::PlanarRgb,
13732            DType::F16,
13733            Some(TensorMemory::Mem),
13734            edgefirst_tensor::CpuAccess::ReadWrite,
13735        )
13736        .unwrap();
13737
13738        // Use the default auto-chain so the GL path can attempt and reject,
13739        // then the CPU path succeeds.
13740        let mut proc = ImageProcessor::new().unwrap();
13741        let result = proc.convert(&src, &mut dst, None);
13742        assert!(
13743            result.is_ok(),
13744            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13745            result.err()
13746        );
13747
13748        // All output values must be finite and in [0, 1].
13749        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13750        let halfs = map.as_slice();
13751        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13752        for (i, h) in halfs.iter().enumerate() {
13753            let v = h.to_f32();
13754            assert!(
13755                v.is_finite() && (0.0..=1.0).contains(&v),
13756                "output[{i}]={v} is not finite or not in [0,1]"
13757            );
13758        }
13759    }
13760
13761    // =========================================================================
13762    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13763    // =========================================================================
13764
13765    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13766    ///
13767    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13768    /// which was untested. A wrong intermediate format selection silently
13769    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13770    #[test]
13771    // Linux-only `egl_display` field makes `..Default::default()` needless
13772    // on macOS only; see `convert_f16_forced_cpu_correct`.
13773    #[allow(clippy::needless_update)]
13774    fn convert_nv12_to_rgb_f32_cpu() {
13775        const W: usize = 16;
13776        const H: usize = 16; // must be even for NV12
13777
13778        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13779        let src = TensorDyn::image(
13780            W,
13781            H,
13782            PixelFormat::Nv12,
13783            DType::U8,
13784            Some(TensorMemory::Mem),
13785            edgefirst_tensor::CpuAccess::ReadWrite,
13786        )
13787        .unwrap();
13788        {
13789            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13790            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13791        }
13792
13793        let mut dst = TensorDyn::image(
13794            W,
13795            H,
13796            PixelFormat::Rgb,
13797            DType::F32,
13798            Some(TensorMemory::Mem),
13799            edgefirst_tensor::CpuAccess::ReadWrite,
13800        )
13801        .unwrap();
13802
13803        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13804            backend: ComputeBackend::Cpu,
13805            ..Default::default()
13806        })
13807        .unwrap();
13808
13809        let result = proc.convert(&src, &mut dst, None);
13810        assert!(
13811            result.is_ok(),
13812            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13813            result.err()
13814        );
13815
13816        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13817        let floats = map.as_slice();
13818        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13819        for (i, &v) in floats.iter().enumerate() {
13820            assert!(
13821                v.is_finite() && (0.0..=1.0).contains(&v),
13822                "output[{i}]={v} is not finite or not in [0,1]"
13823            );
13824        }
13825        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13826        let non_zero = floats.iter().any(|&v| v > 0.01);
13827        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13828    }
13829
13830    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13831    ///
13832    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13833    #[test]
13834    // Linux-only `egl_display` field makes `..Default::default()` needless
13835    // on macOS only; see `convert_f16_forced_cpu_correct`.
13836    #[allow(clippy::needless_update)]
13837    fn convert_nv12_to_planar_rgb_f16_cpu() {
13838        const W: usize = 16;
13839        const H: usize = 16;
13840
13841        let src = TensorDyn::image(
13842            W,
13843            H,
13844            PixelFormat::Nv12,
13845            DType::U8,
13846            Some(TensorMemory::Mem),
13847            edgefirst_tensor::CpuAccess::ReadWrite,
13848        )
13849        .unwrap();
13850        {
13851            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13852            map.as_mut_slice().fill(128);
13853        }
13854
13855        let mut dst = TensorDyn::image(
13856            W,
13857            H,
13858            PixelFormat::PlanarRgb,
13859            DType::F16,
13860            Some(TensorMemory::Mem),
13861            edgefirst_tensor::CpuAccess::ReadWrite,
13862        )
13863        .unwrap();
13864
13865        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13866            backend: ComputeBackend::Cpu,
13867            ..Default::default()
13868        })
13869        .unwrap();
13870
13871        let result = proc.convert(&src, &mut dst, None);
13872        assert!(
13873            result.is_ok(),
13874            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13875            result.err()
13876        );
13877
13878        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13879        let halfs = map.as_slice();
13880        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13881        for (i, h) in halfs.iter().enumerate() {
13882            let v = h.to_f32();
13883            assert!(
13884                v.is_finite() && (0.0..=1.0).contains(&v),
13885                "output[{i}]={v} is not finite or not in [0,1]"
13886            );
13887        }
13888        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13889        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13890    }
13891
13892    // =========================================================================
13893    // GAP-5: create_image F32 + DMA must return NotSupported
13894    // =========================================================================
13895
13896    fn access_test_src(w: usize, h: usize) -> TensorDyn {
13897        use edgefirst_tensor::TensorMapTrait;
13898        let t = TensorDyn::image(
13899            w,
13900            h,
13901            PixelFormat::Rgba,
13902            DType::U8,
13903            Some(TensorMemory::Mem),
13904            edgefirst_tensor::CpuAccess::ReadWrite,
13905        )
13906        .unwrap();
13907        for (i, b) in t
13908            .as_u8()
13909            .unwrap()
13910            .map_mut()
13911            .unwrap()
13912            .as_mut_slice()
13913            .iter_mut()
13914            .enumerate()
13915        {
13916            *b = (i % 251) as u8;
13917        }
13918        t
13919    }
13920
13921    /// With only the CPU backend, `create_image` declares `ReadWrite` for the
13922    /// destinations whose CPU convert reads back what it wrote (the `I8`
13923    /// bias, the BGRA swizzle), and the convert into them succeeds.
13924    #[test]
13925    fn create_image_adds_readwrite_for_read_modify_write_passes() {
13926        use edgefirst_tensor::CpuAccess;
13927        let _lock = acquire_env_lock();
13928        let _guard = EnvGuard::snapshot(&[
13929            "EDGEFIRST_FORCE_BACKEND",
13930            "EDGEFIRST_DISABLE_GL",
13931            "EDGEFIRST_DISABLE_G2D",
13932            "EDGEFIRST_DISABLE_CPU",
13933        ]);
13934        unsafe {
13935            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
13936            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
13937            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
13938            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
13939        }
13940        let mut proc = ImageProcessor::new().unwrap();
13941        let src = access_test_src(128, 96);
13942        for (format, dtype) in [
13943            (PixelFormat::Rgb, DType::I8),
13944            (PixelFormat::Rgba, DType::I8),
13945            (PixelFormat::Bgra, DType::U8),
13946        ] {
13947            for requested in [CpuAccess::None, CpuAccess::Write] {
13948                let mut dst = proc
13949                    .create_image(64, 48, format, dtype, None, requested)
13950                    .unwrap();
13951                assert_eq!(
13952                    dst.cpu_access(),
13953                    CpuAccess::ReadWrite,
13954                    "{format:?}/{dtype:?} requested {requested:?}"
13955                );
13956                proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
13957                    panic!("CPU convert into {format:?}/{dtype:?} {requested:?}: {e}")
13958                });
13959            }
13960        }
13961    }
13962
13963    /// The CPU convert refuses up front, asking for `ReadWrite`, a
13964    /// `Write`-only destination it would read back (the `I8` bias, the BGRA
13965    /// swizzle); a BGRA-to-BGRA copy has no swizzle and needs only `Write`.
13966    #[test]
13967    fn cpu_convert_refuses_a_write_only_destination_it_reads_back() {
13968        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13969        let mut cpu = CPUProcessor::new();
13970        let src = access_test_src(32, 24);
13971        for (format, dtype) in [
13972            (PixelFormat::Rgb, DType::I8),
13973            (PixelFormat::Bgra, DType::U8),
13974        ] {
13975            let mut dst = TensorDyn::image(
13976                32,
13977                24,
13978                format,
13979                dtype,
13980                Some(TensorMemory::Mem),
13981                CpuAccess::Write,
13982            )
13983            .unwrap();
13984            match cpu.convert(&src, &mut dst, None) {
13985                Err(Error::Tensor(TensorError::CpuAccessDenied {
13986                    declared: CpuAccess::Write,
13987                    requested: CpuAccess::ReadWrite,
13988                })) => {}
13989                other => panic!("{format:?}/{dtype:?}: {other:?}"),
13990            }
13991        }
13992        let bgra_src = TensorDyn::image(
13993            32,
13994            24,
13995            PixelFormat::Bgra,
13996            DType::U8,
13997            Some(TensorMemory::Mem),
13998            CpuAccess::ReadWrite,
13999        )
14000        .unwrap();
14001        let mut dst = TensorDyn::image(
14002            32,
14003            24,
14004            PixelFormat::Bgra,
14005            DType::U8,
14006            Some(TensorMemory::Mem),
14007            CpuAccess::Write,
14008        )
14009        .unwrap();
14010        cpu.convert(&bgra_src, &mut dst, None).unwrap();
14011    }
14012
14013    /// The prediction `create_image` widens with, per backend mix: the CPU
14014    /// and G2D post-passes when either may run, nothing when the GPU renders
14015    /// the destination, `Write` for a host-memory readback.
14016    #[test]
14017    fn predicted_cpu_access_unions_the_backends_that_may_run() {
14018        use edgefirst_tensor::CpuAccess::{self, None as No, Read, ReadWrite, Write};
14019        let p = predicted_cpu_access;
14020        let takes = |a: CpuAccess| Some(Some(a));
14021        let declines = Some(None);
14022        // A forced backend is the only one that runs.
14023        assert_eq!(
14024            p(
14025                Some(ForcedBackend::G2d),
14026                None,
14027                takes(ReadWrite),
14028                takes(Write)
14029            ),
14030            ReadWrite
14031        );
14032        assert_eq!(
14033            p(Some(ForcedBackend::G2d), None, declines, takes(Write)),
14034            No
14035        );
14036        assert_eq!(
14037            p(
14038                Some(ForcedBackend::OpenGl),
14039                takes(Write),
14040                None,
14041                takes(ReadWrite)
14042            ),
14043            Write
14044        );
14045        assert_eq!(
14046            p(Some(ForcedBackend::Cpu), takes(No), None, takes(Write)),
14047            Write
14048        );
14049        // A GL render with no CPU access ends the chain.
14050        assert_eq!(p(None, takes(No), takes(ReadWrite), takes(ReadWrite)), No);
14051        // A host-memory readback leaves the fallbacks predicted.
14052        assert_eq!(p(None, takes(Write), None, takes(ReadWrite)), ReadWrite);
14053        // G2D may decline at run time, so the CPU stays predicted after it.
14054        assert_eq!(p(None, declines, takes(No), takes(Write)), Write);
14055        assert_eq!(p(None, declines, takes(ReadWrite), None), ReadWrite);
14056        assert_eq!(p(None, None, None, takes(Read)), Read);
14057        assert_eq!(p(None, None, None, None), No);
14058    }
14059
14060    /// The GL prediction follows the float classifier: a float destination
14061    /// GL cannot render (host memory, an unsupported format) is predicted
14062    /// for the CPU fallback.
14063    #[cfg(all(target_os = "linux", feature = "opengl"))]
14064    #[test]
14065    fn float_destinations_gl_declines_are_predicted_for_the_cpu() {
14066        use edgefirst_tensor::CpuAccess;
14067        let Ok(processor) = ImageProcessor::new() else {
14068            eprintln!("WARNING: SKIPPED: float prediction - no image processor");
14069            return;
14070        };
14071        let Some(gl) = processor.opengl.as_ref() else {
14072            eprintln!("WARNING: SKIPPED: float prediction - no OpenGL backend");
14073            return;
14074        };
14075        assert_eq!(
14076            gl.dst_cpu_access(TensorMemory::Mem, PixelFormat::PlanarRgb, DType::F16),
14077            None
14078        );
14079        assert_eq!(
14080            gl.dst_cpu_access(TensorMemory::DmaBuf, PixelFormat::Grey, DType::F32),
14081            None
14082        );
14083        let image = processor
14084            .create_image(
14085                64,
14086                64,
14087                PixelFormat::Rgb,
14088                DType::F32,
14089                Some(TensorMemory::Mem),
14090                CpuAccess::None,
14091            )
14092            .unwrap();
14093        assert!(image.cpu_access().writes());
14094    }
14095
14096    /// When a GPU backend declined and the CPU fallback is refused for
14097    /// access, the error carries the decline; otherwise it is the CPU's.
14098    #[test]
14099    fn a_refused_cpu_fallback_reports_the_gpu_decline() {
14100        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14101        let denied = || {
14102            Error::Tensor(TensorError::CpuAccessDenied {
14103                declared: CpuAccess::None,
14104                requested: CpuAccess::Write,
14105            })
14106        };
14107        let declined = || {
14108            vec![(
14109                "opengl",
14110                Error::NotSupported("unsupported source format".into()),
14111            )]
14112        };
14113        let e = with_gpu_declines(denied(), declined());
14114        assert!(
14115            matches!(
14116                &e,
14117                Error::CpuFallbackDenied {
14118                    denied: TensorError::CpuAccessDenied { .. },
14119                    ..
14120                }
14121            ),
14122            "{e:?}"
14123        );
14124        let msg = e.to_string();
14125        assert!(msg.contains("unsupported source format"), "{msg}");
14126        assert!(msg.contains("CPU access denied"), "{msg}");
14127        assert!(e.cpu_access_denied().is_some());
14128        assert!(matches!(
14129            with_gpu_declines(denied(), Vec::new()),
14130            Error::Tensor(TensorError::CpuAccessDenied { .. })
14131        ));
14132        assert!(matches!(
14133            with_gpu_declines(Error::NoConverter, declined()),
14134            Error::NoConverter
14135        ));
14136    }
14137
14138    /// With only the CPU backend, `create_image` widens a `Read` request to
14139    /// `ReadWrite` and a `None` request to `Write`, so the processor's own
14140    /// convert can fill the image.
14141    #[test]
14142    fn create_image_adds_write_when_the_cpu_backend_fills_it() {
14143        use edgefirst_tensor::CpuAccess;
14144        let _lock = acquire_env_lock();
14145        let _guard = EnvGuard::snapshot(&[
14146            "EDGEFIRST_FORCE_BACKEND",
14147            "EDGEFIRST_DISABLE_GL",
14148            "EDGEFIRST_DISABLE_G2D",
14149            "EDGEFIRST_DISABLE_CPU",
14150        ]);
14151        unsafe {
14152            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
14153            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
14154            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
14155            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
14156        }
14157        let mut proc = ImageProcessor::new().unwrap();
14158        let src = access_test_src(128, 96);
14159        for (requested, declared) in [
14160            (CpuAccess::Read, CpuAccess::ReadWrite),
14161            (CpuAccess::None, CpuAccess::Write),
14162            (CpuAccess::Write, CpuAccess::Write),
14163            (CpuAccess::ReadWrite, CpuAccess::ReadWrite),
14164        ] {
14165            let mut dst = proc
14166                .create_image(64, 48, PixelFormat::Rgb, DType::U8, None, requested)
14167                .unwrap();
14168            assert_eq!(dst.cpu_access(), declared, "requested {requested:?}");
14169            proc.convert(&src, &mut dst, None)
14170                .unwrap_or_else(|e| panic!("CPU convert into a {requested:?} request: {e}"));
14171        }
14172    }
14173
14174    /// A CPU convert refuses up front a caller destination declared without
14175    /// writes, and a source declared without reads, naming both accesses.
14176    #[test]
14177    fn cpu_convert_refuses_tensors_outside_their_declaration() {
14178        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14179        let mut cpu = CPUProcessor::new();
14180        let src = access_test_src(32, 24);
14181        for declared in [CpuAccess::Read, CpuAccess::None] {
14182            let mut dst = TensorDyn::image(
14183                32,
14184                24,
14185                PixelFormat::Rgb,
14186                DType::U8,
14187                Some(TensorMemory::Mem),
14188                declared,
14189            )
14190            .unwrap();
14191            match cpu.convert(&src, &mut dst, None) {
14192                Err(Error::Tensor(TensorError::CpuAccessDenied {
14193                    declared: d,
14194                    requested: CpuAccess::Write,
14195                })) => assert_eq!(d, declared),
14196                other => panic!("convert into a {declared:?} dst: {other:?}"),
14197            }
14198        }
14199        let write_only = TensorDyn::image(
14200            32,
14201            24,
14202            PixelFormat::Rgba,
14203            DType::U8,
14204            Some(TensorMemory::Mem),
14205            CpuAccess::Write,
14206        )
14207        .unwrap();
14208        let mut dst = TensorDyn::image(
14209            32,
14210            24,
14211            PixelFormat::Rgb,
14212            DType::U8,
14213            Some(TensorMemory::Mem),
14214            CpuAccess::ReadWrite,
14215        )
14216        .unwrap();
14217        let err = cpu.convert(&write_only, &mut dst, None).unwrap_err();
14218        assert!(
14219            matches!(
14220                err,
14221                Error::Tensor(TensorError::CpuAccessDenied {
14222                    declared: CpuAccess::Write,
14223                    requested: CpuAccess::Read,
14224                })
14225            ),
14226            "{err:?}"
14227        );
14228        assert!(err
14229            .to_string()
14230            .contains("declare CpuAccess::ReadWrite (the default)"));
14231    }
14232
14233    /// The CPU mask draw blends into its destination, so a `Write`-only
14234    /// destination is refused before anything is drawn.
14235    #[test]
14236    fn cpu_draw_requires_readwrite_on_the_destination() {
14237        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14238        let mut cpu = CPUProcessor::new();
14239        let mut dst = TensorDyn::image(
14240            32,
14241            24,
14242            PixelFormat::Rgba,
14243            DType::U8,
14244            Some(TensorMemory::Mem),
14245            CpuAccess::Write,
14246        )
14247        .unwrap();
14248        let err = cpu
14249            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
14250            .unwrap_err();
14251        assert!(
14252            matches!(
14253                err,
14254                Error::Tensor(TensorError::CpuAccessDenied {
14255                    declared: CpuAccess::Write,
14256                    requested: CpuAccess::ReadWrite,
14257                })
14258            ),
14259            "{err:?}"
14260        );
14261    }
14262
14263    /// `create_image_desc` without a compression request is exactly
14264    /// `create_image` (the processor's memory negotiation applies); with
14265    /// `Compression::Any` off-Android it resolves linear and counts the
14266    /// fallback in the processor-visible mirror.
14267    #[test]
14268    fn create_image_desc_negotiates_and_counts_fallbacks() {
14269        use edgefirst_tensor::{CpuAccess, ImageDesc};
14270        let proc = ImageProcessor::new().unwrap();
14271
14272        let desc =
14273            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
14274        let plain = proc.create_image_desc(&desc).unwrap();
14275        let classic = proc
14276            .create_image(
14277                64,
14278                64,
14279                PixelFormat::Rgba,
14280                DType::U8,
14281                None,
14282                CpuAccess::ReadWrite,
14283            )
14284            .unwrap();
14285        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
14286        assert_eq!(plain.compression(), None);
14287
14288        #[cfg(not(target_os = "android"))]
14289        {
14290            use edgefirst_tensor::Compression;
14291            let before = proc.compression_fallback_count();
14292            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
14293                .with_access(CpuAccess::None)
14294                .with_compression(Compression::Any);
14295            let t = proc.create_image_desc(&desc).unwrap();
14296            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
14297            assert!(
14298                proc.compression_fallback_count() > before,
14299                "Any resolving linear must count"
14300            );
14301        }
14302    }
14303
14304    /// A contiguous request through `create_image_desc` is a CMA DMA-BUF,
14305    /// or an error where there is no CMA heap; never a PBO or host memory.
14306    #[test]
14307    #[cfg(target_os = "linux")]
14308    fn create_image_desc_contiguous_is_cma_dma_or_an_error() {
14309        use edgefirst_tensor::{Contiguity, ImageDesc, TensorMemory};
14310        let proc = ImageProcessor::new().unwrap();
14311        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_contiguous(true);
14312        match proc.create_image_desc(&desc) {
14313            Ok(t) => {
14314                assert_eq!(t.memory(), TensorMemory::DmaBuf);
14315                assert_eq!(t.contiguity(), Contiguity::Contiguous);
14316            }
14317            Err(e) => {
14318                assert!(e.to_string().contains("linux,cma"), "{e}");
14319                crate::test_support::report_skip(&format!(
14320                    "{} - no CMA heap, the contiguous DMA-BUF arm did not run: {e}",
14321                    function!()
14322                ));
14323            }
14324        }
14325    }
14326
14327    /// There is no DRM FourCC for F32 images, so `create_image` with
14328    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
14329    #[test]
14330    #[cfg(target_os = "linux")]
14331    fn create_image_f32_dma_rejected() {
14332        let proc = ImageProcessor::new().unwrap();
14333        let result = proc.create_image(
14334            64,
14335            64,
14336            PixelFormat::Rgb,
14337            DType::F32,
14338            Some(TensorMemory::DmaBuf),
14339            edgefirst_tensor::CpuAccess::ReadWrite,
14340        );
14341        assert!(
14342            result.is_err(),
14343            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
14344        );
14345    }
14346
14347    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
14348    /// the returned `TensorDyn`.
14349    ///
14350    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
14351    /// unavailable we skip the DMA call but still verify the storage contract
14352    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
14353    /// constructed the same way the function body does it — and we confirm via
14354    /// code-read that the new parameter is unconditionally stored.
14355    #[test]
14356    #[cfg(target_os = "linux")]
14357    fn import_image_carries_colorimetry() {
14358        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
14359
14360        let expected = Colorimetry::default()
14361            .with_encoding(ColorEncoding::Bt709)
14362            .with_range(ColorRange::Limited);
14363
14364        if !is_dma_available() {
14365            // DMA unavailable on this host: exercise the storage path via
14366            // TensorDyn directly (mirrors what import_image does internally).
14367            let mut t = TensorDyn::image(
14368                8,
14369                8,
14370                PixelFormat::Rgba,
14371                DType::U8,
14372                Some(TensorMemory::Mem),
14373                edgefirst_tensor::CpuAccess::ReadWrite,
14374            )
14375            .expect("alloc");
14376            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
14377            t.set_colorimetry(Some(expected));
14378            assert_eq!(
14379                t.colorimetry(),
14380                Some(expected),
14381                "set_colorimetry must round-trip"
14382            );
14383            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
14384            return;
14385        }
14386
14387        // DMA is available: allocate a real DMA tensor, extract its fd, and
14388        // call import_image with an explicit Colorimetry.
14389        use edgefirst_tensor::{PlaneDescriptor, Tensor};
14390
14391        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
14392        let dma_tensor = Tensor::<u8>::new(
14393            &[rgba_bytes],
14394            Some(TensorMemory::DmaBuf),
14395            Some("import_test"),
14396        )
14397        .expect("dma alloc");
14398        let pd =
14399            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
14400
14401        let proc = ImageProcessor::new().expect("ImageProcessor");
14402        let result = proc.import_image(
14403            pd,
14404            None,
14405            64,
14406            64,
14407            PixelFormat::Rgba,
14408            DType::U8,
14409            Some(expected),
14410        );
14411        let tensor = result.expect("import_image must succeed on DMA fd");
14412        assert_eq!(
14413            tensor.colorimetry(),
14414            Some(expected),
14415            "import_image must store the supplied colorimetry on the returned TensorDyn"
14416        );
14417    }
14418}