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    /// A `Grey` source in zero-copy GPU memory converts to `Rgba`, `Rgb` and
6052    /// `PlanarRgb` with its luma in every colour channel, matching the CPU.
6053    /// The Linux import is R8, which samples as (l, 0, 0, 1) until the
6054    /// sampler replicates it, so this runs on every GL platform and fails
6055    /// rather than skips wherever a zero-copy `Grey` source exists. 62 is a
6056    /// width that is not a multiple of 4 but whose pitch is aligned.
6057    #[test]
6058    #[cfg(any(
6059        target_os = "linux",
6060        target_os = "macos",
6061        target_os = "ios",
6062        target_os = "android",
6063        target_os = "windows"
6064    ))]
6065    #[cfg(feature = "opengl")]
6066    fn test_opengl_grey_zero_copy_source_to_rgb() {
6067        if !is_opengl_available() {
6068            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6069            return;
6070        }
6071        if !crate::test_support::is_gpu_buffer_available() {
6072            crate::test_support::report_skip(&format!(
6073                "{} - zero-copy GPU buffer not available on this host",
6074                function!()
6075            ));
6076            return;
6077        }
6078        let mut gl = GLProcessorThreaded::new(None).unwrap();
6079        let h = 40usize;
6080        for w in [64usize, 62] {
6081            let grey = |memory| -> Option<TensorDyn> {
6082                let t = TensorDyn::image(
6083                    w,
6084                    h,
6085                    PixelFormat::Grey,
6086                    DType::U8,
6087                    Some(memory),
6088                    edgefirst_tensor::CpuAccess::ReadWrite,
6089                )
6090                .ok()?;
6091                let stride = t.effective_row_stride().unwrap_or(w);
6092                {
6093                    let mut map = t.as_u8().unwrap().map().unwrap();
6094                    let buf = map.as_mut_slice();
6095                    for r in 0..h {
6096                        for c in 0..w {
6097                            buf[r * stride + c] = ((r * 13 + c * 7) as u8).wrapping_add(3);
6098                        }
6099                    }
6100                }
6101                Some(t)
6102            };
6103            let Some(src_gpu) = grey(TensorMemory::DmaBuf) else {
6104                crate::test_support::report_skip(&format!(
6105                    "{} - no zero-copy Grey {w}x{h} buffer on this host",
6106                    function!()
6107                ));
6108                return;
6109            };
6110            let src_mem = grey(TensorMemory::Mem).unwrap();
6111            for fmt in [PixelFormat::Rgba, PixelFormat::Rgb, PixelFormat::PlanarRgb] {
6112                if fmt == PixelFormat::Rgb && (w * 3) % 4 != 0 {
6113                    continue;
6114                }
6115                let dst = || {
6116                    TensorDyn::image(
6117                        w,
6118                        h,
6119                        fmt,
6120                        DType::U8,
6121                        None,
6122                        edgefirst_tensor::CpuAccess::ReadWrite,
6123                    )
6124                    .unwrap()
6125                };
6126                let mut cpu_dst = dst();
6127                CPUProcessor::new()
6128                    .convert(&src_mem, &mut cpu_dst, None)
6129                    .unwrap();
6130                let mut gl_dst = dst();
6131                let before = gl.convert_stats().unwrap();
6132                gl.convert(&src_gpu, &mut gl_dst, None).unwrap();
6133                let after = gl.convert_stats().unwrap();
6134                let imports = after.src_imports - before.src_imports;
6135                let uploads = after.src_uploads - before.src_uploads;
6136                log::info!(
6137                    "{}: {w}x{h} Grey -> {fmt:?} imports={imports} uploads={uploads}",
6138                    function!()
6139                );
6140                // Linux imports a zero-copy Grey source as an R8 EGLImage,
6141                // the route whose sampler has to replicate the luma. A forced
6142                // upload transfer (`EDGEFIRST_FORCE_TRANSFER=pbo` or `sync`)
6143                // uploads it on purpose.
6144                #[cfg(target_os = "linux")]
6145                if gl.transfer_backend().is_zero_copy() {
6146                    assert_eq!(
6147                        imports, 1,
6148                        "{w}x{h} Grey -> {fmt:?}: the DMA-BUF source was not imported"
6149                    );
6150                }
6151                assert_grey_rgb_rows_match(&gl_dst, &cpu_dst, fmt, w, h);
6152            }
6153        }
6154    }
6155
6156    /// Compare every image row of a GL and a CPU `Rgba`/`Rgb`/`PlanarRgb`
6157    /// destination within ±4: V3D samples a step of 249 between neighbouring
6158    /// texels up to 2 off, while a missing channel is off by up to 255.
6159    #[cfg(feature = "opengl")]
6160    fn assert_grey_rgb_rows_match(
6161        gl_dst: &TensorDyn,
6162        cpu_dst: &TensorDyn,
6163        fmt: PixelFormat,
6164        w: usize,
6165        h: usize,
6166    ) {
6167        let (rows, row_bytes) = match fmt {
6168            PixelFormat::PlanarRgb => (3 * h, w),
6169            _ => (h, w * fmt.channels()),
6170        };
6171        let gl_t = gl_dst.as_u8().unwrap();
6172        let cpu_t = cpu_dst.as_u8().unwrap();
6173        let gl_stride = gl_t.effective_row_stride().unwrap_or(row_bytes);
6174        let cpu_stride = cpu_t.effective_row_stride().unwrap_or(row_bytes);
6175        let gl_map = gl_t.map().unwrap();
6176        let cpu_map = cpu_t.map().unwrap();
6177        for r in 0..rows {
6178            let g = &gl_map.as_slice()[r * gl_stride..][..row_bytes];
6179            let c = &cpu_map.as_slice()[r * cpu_stride..][..row_bytes];
6180            if let Some(i) = (0..row_bytes).find(|&i| g[i].abs_diff(c[i]) > 4) {
6181                panic!(
6182                    "{w}x{h} Grey -> {fmt:?}: row {r} byte {i} is {} on GL, {} on CPU",
6183                    g[i], c[i]
6184                );
6185            }
6186        }
6187    }
6188
6189    #[test]
6190    #[cfg(target_os = "linux")]
6191    fn test_g2d_src_crop() {
6192        if !is_g2d_available() {
6193            crate::test_support::report_skip(
6194                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
6195            );
6196            return;
6197        }
6198        if !is_dma_available() {
6199            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6200            return;
6201        }
6202
6203        let dst_width = 640;
6204        let dst_height = 640;
6205        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6206        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6207
6208        let cpu_dst = TensorDyn::image(
6209            dst_width,
6210            dst_height,
6211            PixelFormat::Rgba,
6212            DType::U8,
6213            None,
6214            edgefirst_tensor::CpuAccess::ReadWrite,
6215        )
6216        .unwrap();
6217        let mut cpu_converter = CPUProcessor::new();
6218        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
6219        let (result, src, cpu_dst) = convert_img(
6220            &mut cpu_converter,
6221            src,
6222            cpu_dst,
6223            Rotation::None,
6224            Flip::None,
6225            crop,
6226        );
6227        result.unwrap();
6228
6229        let g2d_dst = TensorDyn::image(
6230            dst_width,
6231            dst_height,
6232            PixelFormat::Rgba,
6233            DType::U8,
6234            None,
6235            edgefirst_tensor::CpuAccess::ReadWrite,
6236        )
6237        .unwrap();
6238        let mut g2d_converter = G2DProcessor::new().unwrap();
6239        let (result, _src, g2d_dst) = convert_img(
6240            &mut g2d_converter,
6241            src,
6242            g2d_dst,
6243            Rotation::None,
6244            Flip::None,
6245            crop,
6246        );
6247        result.unwrap();
6248
6249        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6250        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6251        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6252        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6253        // structural gap not exercised by these limited-range fixtures.
6254        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6255    }
6256
6257    #[test]
6258    #[cfg(target_os = "linux")]
6259    fn test_g2d_dst_crop() {
6260        if !is_g2d_available() {
6261            crate::test_support::report_skip(
6262                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
6263            );
6264            return;
6265        }
6266        if !is_dma_available() {
6267            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6268            return;
6269        }
6270
6271        let dst_width = 640;
6272        let dst_height = 640;
6273        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6274        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6275
6276        let cpu_dst = TensorDyn::image(
6277            dst_width,
6278            dst_height,
6279            PixelFormat::Rgba,
6280            DType::U8,
6281            None,
6282            edgefirst_tensor::CpuAccess::ReadWrite,
6283        )
6284        .unwrap();
6285        let mut cpu_converter = CPUProcessor::new();
6286        let crop = Crop::new();
6287        let (result, src, cpu_dst) = convert_img(
6288            &mut cpu_converter,
6289            src,
6290            cpu_dst,
6291            Rotation::None,
6292            Flip::None,
6293            crop,
6294        );
6295        result.unwrap();
6296
6297        let g2d_dst = TensorDyn::image(
6298            dst_width,
6299            dst_height,
6300            PixelFormat::Rgba,
6301            DType::U8,
6302            None,
6303            edgefirst_tensor::CpuAccess::ReadWrite,
6304        )
6305        .unwrap();
6306        let mut g2d_converter = G2DProcessor::new().unwrap();
6307        let (result, _src, g2d_dst) = convert_img(
6308            &mut g2d_converter,
6309            src,
6310            g2d_dst,
6311            Rotation::None,
6312            Flip::None,
6313            crop,
6314        );
6315        result.unwrap();
6316
6317        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6318        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6319        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6320        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6321        // structural gap not exercised by these limited-range fixtures.
6322        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6323    }
6324
6325    #[test]
6326    #[cfg(target_os = "linux")]
6327    fn test_g2d_all_rgba() {
6328        if !is_g2d_available() {
6329            crate::test_support::report_skip(
6330                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
6331            );
6332            return;
6333        }
6334        if !is_dma_available() {
6335            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6336            return;
6337        }
6338
6339        let dst_width = 640;
6340        let dst_height = 640;
6341        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6342        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6343        let src_dyn = src;
6344
6345        let mut cpu_dst = TensorDyn::image(
6346            dst_width,
6347            dst_height,
6348            PixelFormat::Rgba,
6349            DType::U8,
6350            None,
6351            edgefirst_tensor::CpuAccess::ReadWrite,
6352        )
6353        .unwrap();
6354        let mut cpu_converter = CPUProcessor::new();
6355        let mut g2d_dst = TensorDyn::image(
6356            dst_width,
6357            dst_height,
6358            PixelFormat::Rgba,
6359            DType::U8,
6360            None,
6361            edgefirst_tensor::CpuAccess::ReadWrite,
6362        )
6363        .unwrap();
6364        let mut g2d_converter = G2DProcessor::new().unwrap();
6365
6366        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
6367
6368        for rot in [
6369            Rotation::None,
6370            Rotation::Clockwise90,
6371            Rotation::Rotate180,
6372            Rotation::CounterClockwise90,
6373        ] {
6374            cpu_dst
6375                .as_typed::<u8>()
6376                .unwrap()
6377                .map()
6378                .unwrap()
6379                .as_mut_slice()
6380                .fill(114);
6381            g2d_dst
6382                .as_typed::<u8>()
6383                .unwrap()
6384                .map()
6385                .unwrap()
6386                .as_mut_slice()
6387                .fill(114);
6388            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
6389                let mut cpu_dst_dyn = cpu_dst;
6390                cpu_converter
6391                    .convert(
6392                        &src_dyn,
6393                        &mut cpu_dst_dyn,
6394                        Some(crate::ConvertOptions::new().crop(crop)),
6395                    )
6396                    .unwrap();
6397                cpu_dst = {
6398                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
6399                    __t.set_format(PixelFormat::Rgba).unwrap();
6400                    TensorDyn::from(__t)
6401                };
6402
6403                let mut g2d_dst_dyn = g2d_dst;
6404                g2d_converter
6405                    .convert(
6406                        &src_dyn,
6407                        &mut g2d_dst_dyn,
6408                        Some(crate::ConvertOptions::new().crop(crop)),
6409                    )
6410                    .unwrap();
6411                g2d_dst = {
6412                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
6413                    __t.set_format(PixelFormat::Rgba).unwrap();
6414                    TensorDyn::from(__t)
6415                };
6416
6417                compare_images(
6418                    &g2d_dst,
6419                    &cpu_dst,
6420                    0.98,
6421                    &format!("{} {:?} {:?}", function!(), rot, flip),
6422                );
6423            }
6424        }
6425    }
6426
6427    #[test]
6428    #[cfg(any(
6429        target_os = "linux",
6430        target_os = "macos",
6431        target_os = "ios",
6432        target_os = "android",
6433        target_os = "windows"
6434    ))]
6435    #[cfg(feature = "opengl")]
6436    fn test_opengl_src_crop() {
6437        if !is_opengl_available() {
6438            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6439            return;
6440        }
6441
6442        let dst_width = 640;
6443        let dst_height = 360;
6444        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6445        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6446        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
6447
6448        let cpu_dst = TensorDyn::image(
6449            dst_width,
6450            dst_height,
6451            PixelFormat::Rgba,
6452            DType::U8,
6453            None,
6454            edgefirst_tensor::CpuAccess::ReadWrite,
6455        )
6456        .unwrap();
6457        let mut cpu_converter = CPUProcessor::new();
6458        let (result, src, cpu_dst) = convert_img(
6459            &mut cpu_converter,
6460            src,
6461            cpu_dst,
6462            Rotation::None,
6463            Flip::None,
6464            crop,
6465        );
6466        result.unwrap();
6467
6468        let gl_dst = TensorDyn::image(
6469            dst_width,
6470            dst_height,
6471            PixelFormat::Rgba,
6472            DType::U8,
6473            None,
6474            edgefirst_tensor::CpuAccess::ReadWrite,
6475        )
6476        .unwrap();
6477        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6478        let (result, _src, gl_dst) = convert_img(
6479            &mut gl_converter,
6480            src,
6481            gl_dst,
6482            Rotation::None,
6483            Flip::None,
6484            crop,
6485        );
6486        result.unwrap();
6487
6488        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6489    }
6490
6491    #[test]
6492    #[cfg(any(
6493        target_os = "linux",
6494        target_os = "macos",
6495        target_os = "ios",
6496        target_os = "android",
6497        target_os = "windows"
6498    ))]
6499    #[cfg(feature = "opengl")]
6500    fn test_opengl_dst_crop() {
6501        if !is_opengl_available() {
6502            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6503            return;
6504        }
6505
6506        let dst_width = 640;
6507        let dst_height = 640;
6508        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6509        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6510
6511        let cpu_dst = TensorDyn::image(
6512            dst_width,
6513            dst_height,
6514            PixelFormat::Rgba,
6515            DType::U8,
6516            None,
6517            edgefirst_tensor::CpuAccess::ReadWrite,
6518        )
6519        .unwrap();
6520        let mut cpu_converter = CPUProcessor::new();
6521        let crop = Crop::new();
6522        let (result, src, cpu_dst) = convert_img(
6523            &mut cpu_converter,
6524            src,
6525            cpu_dst,
6526            Rotation::None,
6527            Flip::None,
6528            crop,
6529        );
6530        result.unwrap();
6531
6532        let gl_dst = TensorDyn::image(
6533            dst_width,
6534            dst_height,
6535            PixelFormat::Rgba,
6536            DType::U8,
6537            None,
6538            edgefirst_tensor::CpuAccess::ReadWrite,
6539        )
6540        .unwrap();
6541        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6542        let (result, _src, gl_dst) = convert_img(
6543            &mut gl_converter,
6544            src,
6545            gl_dst,
6546            Rotation::None,
6547            Flip::None,
6548            crop,
6549        );
6550        result.unwrap();
6551
6552        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6553    }
6554    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
6555    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
6556    /// padded allocation still gets the pattern at the right columns.
6557    #[cfg(all(target_os = "windows", feature = "opengl"))]
6558    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
6559        let t = TensorDyn::image(
6560            w,
6561            h,
6562            PixelFormat::Rgba,
6563            DType::U8,
6564            Some(TensorMemory::Mem),
6565            edgefirst_tensor::CpuAccess::ReadWrite,
6566        )
6567        .unwrap();
6568        fill_synthetic_rgba(&t);
6569        t
6570    }
6571
6572    /// Every pixel of `actual` equals `expected`, compared through the
6573    /// tensors' own row compaction so a padded texture and a tight `Mem`
6574    /// image compare as images. `compare_images` scores similarity and
6575    /// accepts anything at a 0.0 threshold; this is the exact check.
6576    #[cfg(all(target_os = "windows", feature = "opengl"))]
6577    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
6578        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
6579        let flat = |t: &TensorDyn| {
6580            let typed = t.as_typed::<u8>().expect("u8 image");
6581            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6582            typed.copy_to_flat(&mut out).expect("compact the rows");
6583            out
6584        };
6585        let (a, e) = (flat(actual), flat(expected));
6586        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
6587        assert_eq!(
6588            mismatches,
6589            0,
6590            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
6591            a.len(),
6592            &a[..16.min(a.len())],
6593            &e[..16.min(e.len())]
6594        );
6595    }
6596
6597    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
6598    /// a texture included, at its own row stride.
6599    #[cfg(all(target_os = "windows", feature = "opengl"))]
6600    fn fill_synthetic_rgba(t: &TensorDyn) {
6601        let w = t.width().unwrap();
6602        let h = t.height().unwrap();
6603        let bound = t.as_typed::<u8>().unwrap();
6604        let stride = bound.effective_row_stride().unwrap_or(w * 4);
6605        let mut m = bound.map().unwrap();
6606        let px = m.as_mut_slice();
6607        for y in 0..h {
6608            for x in 0..w {
6609                let o = y * stride + x * 4;
6610                px[o] = x as u8;
6611                px[o + 1] = y as u8;
6612                px[o + 2] = (x + y) as u8;
6613                px[o + 3] = 255;
6614            }
6615        }
6616    }
6617
6618    /// Element-wise comparison of two float images of the same format and
6619    /// dtype.
6620    ///
6621    /// Both sides are read through `copy_to_flat`, the tensor's own row
6622    /// compaction, for the reason `compare_images` does: a Windows texture
6623    /// destination pads its rows, so reading the head of the mapping as a
6624    /// tight image would read padding as values. F16 values are widened with
6625    /// `half::f16::to_f64` before the comparison.
6626    #[cfg(all(target_os = "windows", feature = "opengl"))]
6627    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6628        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6629        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6630        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6631        let values = |t: &TensorDyn| -> Vec<f64> {
6632            let elems: usize = t.shape().iter().product();
6633            match t.dtype() {
6634                DType::F32 => {
6635                    let typed = t.as_typed::<f32>().expect("F32 image");
6636                    let mut bytes = vec![0u8; elems * 4];
6637                    typed
6638                        .copy_to_flat(&mut bytes)
6639                        .expect("compact the padded rows");
6640                    bytes
6641                        .as_chunks::<4>()
6642                        .0
6643                        .iter()
6644                        .map(|c| f32::from_le_bytes(*c) as f64)
6645                        .collect()
6646                }
6647                DType::F16 => {
6648                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6649                    let mut bytes = vec![0u8; elems * 2];
6650                    typed
6651                        .copy_to_flat(&mut bytes)
6652                        .expect("compact the padded rows");
6653                    bytes
6654                        .as_chunks::<2>()
6655                        .0
6656                        .iter()
6657                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6658                        .collect()
6659                }
6660                other => panic!("{what}: {other:?} is not a float dtype"),
6661            }
6662        };
6663        let (av, bv) = (values(a), values(b));
6664        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6665        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6666            assert!(
6667                (x - y).abs() <= tol,
6668                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6669                (x - y).abs()
6670            );
6671        }
6672    }
6673
6674    /// The float PBO readback has to land each destination row at the
6675    /// destination's own pitch, exactly as the u8 PBO readback does.
6676    ///
6677    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6678    /// 3H)` RGBA16F texels, one plane row per surface row, for
6679    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6680    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6681    /// destination declared. A pool tensor narrowed with `configure_image`
6682    /// that keeps the pool's pitch is exactly that destination, so every row
6683    /// after the first landed short.
6684    ///
6685    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6686    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6687    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6688    /// not divide, so those rows are read tight and spread afterwards. The
6689    /// tight stride is the control: it must still take the read it always
6690    /// did.
6691    ///
6692    /// The oracle is the same convert into a tight PBO of the same geometry.
6693    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6694    /// tensor's own row compaction, so it compares logical elements and not
6695    /// mapping offsets.
6696    #[test]
6697    #[cfg(all(target_os = "windows", feature = "opengl"))]
6698    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6699        if !is_opengl_available() {
6700            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6701            return;
6702        }
6703        const W: usize = 64;
6704        const H: usize = 32;
6705        const POOL_W: usize = 96;
6706
6707        let mut gpu = ImageProcessor::new().unwrap();
6708        let support = gpu.supported_render_dtypes();
6709        let src = synthetic_rgba(W, H);
6710
6711        // (format, dtype, supported, the pitches to declare)
6712        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6713            (
6714                PixelFormat::PlanarRgb,
6715                DType::F16,
6716                support.f16,
6717                // Tight plane row, the pool's pitch (a whole texel count),
6718                // and a 4-byte pad that no 8-byte texel divides.
6719                &[W * 2, POOL_W * 2, W * 2 + 4],
6720            ),
6721            (
6722                PixelFormat::Rgb,
6723                DType::F32,
6724                support.f32,
6725                &[W * 3 * 4, POOL_W * 3 * 4],
6726            ),
6727        ];
6728
6729        for (fmt, dtype, supported, strides) in cases {
6730            if !supported {
6731                crate::test_support::report_skip(&format!(
6732                    "{} - {fmt:?}/{dtype:?} not reported",
6733                    function!()
6734                ));
6735                continue;
6736            }
6737            let Some(gl) = gpu.opengl.as_ref() else {
6738                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6739                return;
6740            };
6741            // The oracle: a tight PBO of the target geometry, same route.
6742            let mut reference = gl
6743                .create_pbo_image_dtype(W, H, fmt, dtype)
6744                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight reference PBO: {e:?}"));
6745            assert_eq!(
6746                reference.memory(),
6747                TensorMemory::Pbo,
6748                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6749            );
6750            gpu.convert(&src, &mut reference, None)
6751                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6752
6753            for &stride in strides {
6754                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6755                // Allocate at the pool width, then narrow the geometry and
6756                // declare the pitch, as the u8 stride tests do.
6757                let mut padded = gpu
6758                    .opengl
6759                    .as_ref()
6760                    .expect("GL processor")
6761                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6762                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6763                padded
6764                    .configure_image(W, H, fmt)
6765                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6766                padded
6767                    .set_row_stride(stride)
6768                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6769                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6770
6771                let before = gpu.convert_fallback_count();
6772                gpu.convert(&src, &mut padded, None)
6773                    .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6774                // A CPU fallback writes the destination with the stride-aware
6775                // CPU writers and would pass without the GL readback running.
6776                assert_eq!(
6777                    gpu.convert_fallback_count(),
6778                    before,
6779                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6780                );
6781
6782                let tol = if dtype == DType::F16 {
6783                    1.0 / 1024.0
6784                } else {
6785                    1e-6
6786                };
6787                compare_float_images(&padded, &reference, tol, &label);
6788            }
6789        }
6790    }
6791
6792    /// Every float destination layout the GL backend reports it can render
6793    /// must render zero-copy into the texture and match the CPU converter
6794    /// element for element: F16 and F32 planar (three and four planes),
6795    /// interleaved float RGB, and float RGBA.
6796    ///
6797    /// The destination memory is requested explicitly so each layout is
6798    /// exercised as a texture whatever `create_image`'s own allocation
6799    /// policy would pick for it; the policy itself is covered by
6800    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6801    #[test]
6802    #[cfg(all(target_os = "windows", feature = "opengl"))]
6803    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6804        if !is_opengl_available() {
6805            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6806            return;
6807        }
6808        if !crate::test_support::is_gpu_buffer_available() {
6809            crate::test_support::report_skip(&format!(
6810                "{} - no zero-copy GPU buffers",
6811                function!()
6812            ));
6813            return;
6814        }
6815        let mut gpu = ImageProcessor::new().unwrap();
6816        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6817            backend: ComputeBackend::Cpu,
6818            ..Default::default()
6819        })
6820        .unwrap();
6821        let support = gpu.supported_render_dtypes();
6822        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6823        let src = synthetic_rgba(640, 480);
6824        for (fmt, dtype, need) in [
6825            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6826            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6827            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6828            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6829            (PixelFormat::Rgb, DType::F16, support.f16),
6830            (PixelFormat::Rgb, DType::F32, support.f32),
6831            (PixelFormat::Rgba, DType::F16, support.f16),
6832            (PixelFormat::Rgba, DType::F32, support.f32),
6833        ] {
6834            if !need {
6835                crate::test_support::report_skip(&format!(
6836                    "{} - {fmt:?}/{dtype:?} not reported",
6837                    function!()
6838                ));
6839                continue;
6840            }
6841            let mut dst_gpu = gpu
6842                .create_image(
6843                    640,
6844                    480,
6845                    fmt,
6846                    dtype,
6847                    Some(TensorMemory::DmaBuf),
6848                    edgefirst_tensor::CpuAccess::ReadWrite,
6849                )
6850                .unwrap();
6851            assert_eq!(
6852                dst_gpu.memory(),
6853                TensorMemory::DmaBuf,
6854                "{fmt:?}/{dtype:?} must be a texture"
6855            );
6856            let mut dst_cpu = TensorDyn::image(
6857                640,
6858                480,
6859                fmt,
6860                dtype,
6861                Some(TensorMemory::Mem),
6862                edgefirst_tensor::CpuAccess::ReadWrite,
6863            )
6864            .unwrap();
6865            let before = gpu.convert_fallback_count();
6866            gpu.convert(&src, &mut dst_gpu, None).unwrap();
6867            cpu.convert(&src, &mut dst_cpu, None).unwrap();
6868            assert_eq!(
6869                gpu.convert_fallback_count(),
6870                before,
6871                "{fmt:?}/{dtype:?} fell back to CPU"
6872            );
6873            let tol = if dtype == DType::F16 {
6874                1.0 / 1024.0
6875            } else {
6876                1e-6
6877            };
6878            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6879        }
6880    }
6881
6882    // The tests below wait on a Windows completion fence directly, so they
6883    // need the raw wait call — no crate in this workspace wraps it.
6884    #[cfg(target_os = "windows")]
6885    #[link(name = "kernel32")]
6886    extern "system" {
6887        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6888    }
6889
6890    /// A batch of deferred converts records one distinct fence value per
6891    /// tile and submits once, at `flush`.
6892    ///
6893    /// The values are what a GPU consumer waits on, so they have to stay
6894    /// per-destination and strictly increasing; the submission is what
6895    /// batching exists to spare, so there must be exactly one of it. The
6896    /// destinations are compared against the same convert done standalone,
6897    /// which is what proves the deferred submission did not lose a tile.
6898    #[cfg(all(target_os = "windows", feature = "opengl"))]
6899    #[test]
6900    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6901        use edgefirst_tensor::CpuAccess;
6902
6903        if !crate::test_support::is_gpu_buffer_available() {
6904            crate::test_support::report_skip(&format!(
6905                "{} - no zero-copy GPU buffers",
6906                function!()
6907            ));
6908            return;
6909        }
6910        let mut p = match ImageProcessor::new() {
6911            Ok(p) => p,
6912            Err(e) => {
6913                crate::test_support::report_skip(&format!(
6914                    "{} - ImageProcessor init failed ({e:?})",
6915                    function!()
6916                ));
6917                return;
6918            }
6919        };
6920        let (w, h, tiles) = (640usize, 480usize, 4usize);
6921        let src = synthetic_rgba(w, h);
6922        let mut dsts: Vec<TensorDyn> = (0..tiles)
6923            .map(|_| {
6924                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6925                    .unwrap()
6926            })
6927            .collect();
6928        if dsts[0].memory() != TensorMemory::DmaBuf {
6929            crate::test_support::report_skip(&format!(
6930                "{} - destinations are not textures",
6931                function!()
6932            ));
6933            return;
6934        }
6935        for dst in &mut dsts {
6936            p.convert_deferred(&src, dst, None).unwrap();
6937        }
6938        let values: Vec<u64> = dsts
6939            .iter()
6940            .map(|d| {
6941                d.gpu_completion()
6942                    .unwrap()
6943                    .expect("each deferred convert records its own completion")
6944                    .value
6945            })
6946            .collect();
6947        for pair in values.windows(2) {
6948            assert!(
6949                pair[1] > pair[0],
6950                "the batch allocated {values:?}, which is not one increasing value per tile"
6951            );
6952        }
6953        p.flush().unwrap();
6954        // The one submission the batch owed has happened, so every recorded
6955        // value is reachable.
6956        let dev = edgefirst_tensor::d3d11::device().unwrap();
6957        let last = *values.last().unwrap();
6958        let ev = dev.event_for(last).unwrap();
6959        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6960        // this call.
6961        assert_eq!(
6962            unsafe {
6963                WaitForSingleObject(
6964                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6965                    5000,
6966                )
6967            },
6968            0,
6969            "the batch's newest value {last} never arrived after flush"
6970        );
6971
6972        let mut solo = p
6973            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6974            .unwrap();
6975        p.convert(&src, &mut solo, None).unwrap();
6976        // Read through the tensor's own compaction: a Windows texture's
6977        // staging rows are pitch-padded, so a flat read of the mapping would
6978        // compare padding as pixels.
6979        let flat = |img: &TensorDyn| -> Vec<u8> {
6980            let t = img.as_typed::<u8>().expect("u8 image");
6981            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6982            t.copy_to_flat(&mut out).expect("compact the padded rows");
6983            out
6984        };
6985        let want = flat(&solo);
6986        for (i, dst) in dsts.iter().enumerate() {
6987            assert_eq!(
6988                flat(dst),
6989                want,
6990                "tile {i} does not match a standalone convert"
6991            );
6992        }
6993    }
6994
6995    /// A processor dropped with a batch still open submits what that batch
6996    /// recorded.
6997    ///
6998    /// The values are already published on destinations that outlive the
6999    /// processor, so a consumer may be waiting on one; a deferred signal that
7000    /// is never submitted is a wait nothing can satisfy. `flush` is what
7001    /// normally pays it, and the whole point here is that the caller never
7002    /// calls it.
7003    #[cfg(all(target_os = "windows", feature = "opengl"))]
7004    #[test]
7005    fn windows_dropping_a_processor_submits_an_open_batch() {
7006        use edgefirst_tensor::CpuAccess;
7007
7008        if !crate::test_support::is_gpu_buffer_available() {
7009            crate::test_support::report_skip(&format!(
7010                "{} - no zero-copy GPU buffers",
7011                function!()
7012            ));
7013            return;
7014        }
7015        let src = synthetic_rgba(320, 240);
7016        let recorded = {
7017            let mut p = match ImageProcessor::new() {
7018                Ok(p) => p,
7019                Err(e) => {
7020                    crate::test_support::report_skip(&format!(
7021                        "{} - init failed ({e:?})",
7022                        function!()
7023                    ));
7024                    return;
7025                }
7026            };
7027            let mut dst = p
7028                .create_image(
7029                    320,
7030                    240,
7031                    PixelFormat::Rgba,
7032                    DType::U8,
7033                    None,
7034                    CpuAccess::Read,
7035                )
7036                .unwrap();
7037            if dst.memory() != TensorMemory::DmaBuf {
7038                crate::test_support::report_skip(&format!(
7039                    "{} - destination is not a texture",
7040                    function!()
7041                ));
7042                return;
7043            }
7044            p.convert_deferred(&src, &mut dst, None).unwrap();
7045            // No `flush`: the drop below is what has to settle the batch.
7046            dst.gpu_completion()
7047                .unwrap()
7048                .expect("the deferred convert recorded a completion")
7049                .value
7050        };
7051
7052        let dev = edgefirst_tensor::d3d11::device().unwrap();
7053        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7054        while dev.completed_value() < recorded {
7055            assert!(
7056                std::time::Instant::now() < deadline,
7057                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
7058                dev.completed_value()
7059            );
7060            std::thread::yield_now();
7061        }
7062    }
7063
7064    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
7065    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
7066    /// `convert_with_fence` is available on every platform: on Windows it
7067    /// returns a manual-reset event set when the GPU work completes.
7068    #[cfg(all(target_os = "windows", feature = "opengl"))]
7069    #[test]
7070    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
7071        use edgefirst_tensor::CpuAccess;
7072        use std::os::windows::io::AsRawHandle;
7073
7074        if !crate::test_support::is_gpu_buffer_available() {
7075            crate::test_support::report_skip(&format!(
7076                "{} - no zero-copy GPU buffers",
7077                function!()
7078            ));
7079            return;
7080        }
7081        let mut p = ImageProcessor::new().unwrap();
7082        let src = synthetic_rgba(320, 240);
7083        let mut dst = p
7084            .create_image(
7085                320,
7086                240,
7087                PixelFormat::Rgba,
7088                DType::U8,
7089                None,
7090                CpuAccess::Read,
7091            )
7092            .unwrap();
7093        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7094        // answers host memory and there is no fence to wait on; the lanes
7095        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
7096        if dst.memory() != TensorMemory::DmaBuf
7097            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7098        {
7099            crate::test_support::report_skip(&format!(
7100                "{} - create_image gave {:?}, not a texture (no GL backend)",
7101                function!(),
7102                dst.memory()
7103            ));
7104            return;
7105        }
7106        assert_eq!(
7107            dst.memory(),
7108            TensorMemory::DmaBuf,
7109            "create_image(None) must prefer a texture on Windows"
7110        );
7111        // Read before the wait: the recorded value must be the one the fence
7112        // names, not an older point the destination happened to carry.
7113        let before = dst.gpu_completion().unwrap().map(|c| c.value);
7114        let fence = p
7115            .convert_with_fence(&src, &mut dst, None)
7116            .unwrap()
7117            .expect("Windows exports a fence");
7118        let recorded = dst
7119            .gpu_completion()
7120            .unwrap()
7121            .expect("the convert recorded a completion")
7122            .value;
7123        assert!(
7124            before.is_none_or(|b| recorded > b),
7125            "the fenced convert recorded a newer value than {before:?}"
7126        );
7127        // One flush and one signal for the whole call: the value the
7128        // destination carries is the newest the device has allocated, so
7129        // nothing was signalled after `record_completion`. Two signals (the
7130        // shape this replaced) would leave `last_signalled` one past it.
7131        assert_eq!(
7132            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
7133            recorded,
7134            "a fenced convert must signal exactly once"
7135        );
7136        // SAFETY: `fence` owns a live Win32 event handle returned by
7137        // `convert_with_fence`; WaitForSingleObject only reads the handle,
7138        // which stays valid for the duration of this call because `fence`
7139        // is not dropped until after it.
7140        assert_eq!(
7141            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
7142            0,
7143            "fence was not set within 5 seconds"
7144        );
7145        // The event and the destination's own completion name the same
7146        // value, so a consumer may wait on either: the event fired, and the
7147        // device's completed value has reached the value `dst` reports.
7148        assert!(
7149            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
7150            "the event fired, so the recorded value {recorded} is complete"
7151        );
7152        assert_eq!(
7153            dst.gpu_completion().unwrap().map(|c| c.value),
7154            Some(recorded),
7155            "the destination still reports the value the event waited on"
7156        );
7157        let mut expected = TensorDyn::image(
7158            320,
7159            240,
7160            PixelFormat::Rgba,
7161            DType::U8,
7162            Some(TensorMemory::Mem),
7163            CpuAccess::ReadWrite,
7164        )
7165        .unwrap();
7166        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7167            backend: ComputeBackend::Cpu,
7168            ..Default::default()
7169        })
7170        .unwrap();
7171        cpu.convert(&src, &mut expected, None).unwrap();
7172        compare_images(&dst, &expected, 0.0, "fenced convert");
7173    }
7174
7175    /// A Windows GL test's destination must be a texture; without a GL backend
7176    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
7177    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
7178    /// texture-first claim a hard assertion.
7179    #[cfg(all(target_os = "windows", feature = "opengl"))]
7180    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
7181        if t.memory() == TensorMemory::DmaBuf {
7182            return true;
7183        }
7184        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
7185            panic!(
7186                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
7187                t.memory()
7188            );
7189        }
7190        crate::test_support::report_skip(&format!(
7191            "{test} - create_image gave {:?}, not a texture (no GL backend)",
7192            t.memory()
7193        ));
7194        false
7195    }
7196
7197    /// A draw into a texture destination records a completion on it the way
7198    /// a convert does, so a device consumer can wait on the drawn frame. The
7199    /// destination is `CpuAccess::Read`: only the GL backend can write it,
7200    /// so a CPU fallback fails the draw outright instead of leaving the
7201    /// completion empty.
7202    #[cfg(all(target_os = "windows", feature = "opengl"))]
7203    #[test]
7204    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
7205        use edgefirst_tensor::{CpuAccess, Segmentation};
7206        use ndarray::Array3;
7207
7208        if !crate::test_support::is_gpu_buffer_available() {
7209            crate::test_support::report_skip(&format!(
7210                "{} - no zero-copy GPU buffers",
7211                function!()
7212            ));
7213            return;
7214        }
7215        let mut p = ImageProcessor::new().unwrap();
7216        let mut dst = p
7217            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7218            .unwrap();
7219        if !texture_or_skip(&dst, function!()) {
7220            return;
7221        }
7222        assert!(
7223            dst.gpu_completion().unwrap().is_none(),
7224            "nothing has written this texture yet"
7225        );
7226
7227        let detect = DetectBox {
7228            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7229            score: 0.99,
7230            label: 0,
7231        };
7232        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7233        let seg = Segmentation {
7234            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7235                .unwrap()
7236                .into(),
7237            xmin: 0.25,
7238            ymin: 0.25,
7239            xmax: 0.75,
7240            ymax: 0.75,
7241        };
7242        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
7243            .expect("draw into a read-only texture");
7244
7245        let completion = dst
7246            .gpu_completion()
7247            .unwrap()
7248            .expect("the draw recorded no completion on dst");
7249        assert!(completion.value > 0, "completion value must be non-zero");
7250
7251        // The drawn frame is read back through the texture's staging copy;
7252        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
7253        // needs only a read mapping, which the read-only texture allows.
7254        // The corner is cleared and the box centre carries the mask colour.
7255        let mut flat = vec![0u8; 64 * 64 * 4];
7256        dst.as_typed::<u8>()
7257            .unwrap()
7258            .copy_to_flat(&mut flat)
7259            .expect("read the drawn texture");
7260        let px = |x: usize, y: usize| -> [u8; 4] {
7261            let o = (y * 64 + x) * 4;
7262            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
7263        };
7264        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
7265        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
7266    }
7267
7268    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
7269    /// completion on a texture destination too.
7270    #[cfg(all(target_os = "windows", feature = "opengl"))]
7271    #[test]
7272    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
7273        use edgefirst_tensor::{CpuAccess, Tensor};
7274
7275        if !crate::test_support::is_gpu_buffer_available() {
7276            crate::test_support::report_skip(&format!(
7277                "{} - no zero-copy GPU buffers",
7278                function!()
7279            ));
7280            return;
7281        }
7282        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
7283        // coverage pass); that is the same skip the texture guard below gives,
7284        // unless the lane set HAL_TEST_REQUIRE_GL=1.
7285        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
7286            Ok(p) => p,
7287            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
7288                crate::test_support::report_skip(&format!(
7289                    "{} - no OpenGL backend: {e}",
7290                    function!()
7291                ));
7292                return;
7293            }
7294            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
7295        };
7296        let mut dst = p
7297            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7298            .unwrap();
7299        if !texture_or_skip(&dst, function!()) {
7300            return;
7301        }
7302
7303        let detect = DetectBox {
7304            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7305            score: 0.99,
7306            label: 0,
7307        };
7308        // One prototype channel of ones and a positive coefficient: every
7309        // pixel of the box is foreground.
7310        let proto = ProtoData {
7311            mask_coefficients: TensorDyn::F32(
7312                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
7313            ),
7314            protos: TensorDyn::F32(
7315                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
7316            ),
7317            layout: ProtoLayout::Nhwc,
7318        };
7319        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
7320            .expect("forced-GL proto draw into a read-only texture");
7321
7322        let completion = dst
7323            .gpu_completion()
7324            .unwrap()
7325            .expect("the proto draw recorded no completion on dst");
7326        assert!(completion.value > 0, "completion value must be non-zero");
7327    }
7328
7329    /// An empty draw with a host-memory background onto a texture
7330    /// destination leaves the background in the texture, rows in order.
7331    /// The gradient pattern makes a missing or vertically flipped base
7332    /// layer differ from the expected image.
7333    #[cfg(all(target_os = "windows", feature = "opengl"))]
7334    #[test]
7335    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
7336        use edgefirst_tensor::CpuAccess;
7337
7338        if !crate::test_support::is_gpu_buffer_available() {
7339            crate::test_support::report_skip(&format!(
7340                "{} - no zero-copy GPU buffers",
7341                function!()
7342            ));
7343            return;
7344        }
7345        let mut p = ImageProcessor::new().unwrap();
7346        let bg = synthetic_rgba(64, 48);
7347        let mut dst = p
7348            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7349            .unwrap();
7350        if !texture_or_skip(&dst, function!()) {
7351            return;
7352        }
7353        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7354            .expect("empty draw with a host background onto a texture");
7355        assert_pixels_identical(&dst, &bg, "host background onto a texture");
7356        assert!(dst.gpu_completion().unwrap().is_some());
7357    }
7358
7359    /// The same draw with the background in a texture of its own: the
7360    /// base layer is sampled from the background texture directly.
7361    #[cfg(all(target_os = "windows", feature = "opengl"))]
7362    #[test]
7363    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
7364        use edgefirst_tensor::CpuAccess;
7365
7366        if !crate::test_support::is_gpu_buffer_available() {
7367            crate::test_support::report_skip(&format!(
7368                "{} - no zero-copy GPU buffers",
7369                function!()
7370            ));
7371            return;
7372        }
7373        let mut p = ImageProcessor::new().unwrap();
7374        let expected = synthetic_rgba(64, 48);
7375        let bg = p
7376            .create_image(
7377                64,
7378                48,
7379                PixelFormat::Rgba,
7380                DType::U8,
7381                None,
7382                CpuAccess::ReadWrite,
7383            )
7384            .unwrap();
7385        if !texture_or_skip(&bg, function!()) {
7386            return;
7387        }
7388        fill_synthetic_rgba(&bg);
7389        let mut dst = p
7390            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7391            .unwrap();
7392        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
7393        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7394            .expect("empty draw with a texture background onto a texture");
7395        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
7396        assert!(dst.gpu_completion().unwrap().is_some());
7397    }
7398
7399    /// A draw into a `view()` of a texture fills that band and leaves its
7400    /// sibling alone. The GL draw declines a sub-region destination
7401    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
7402    /// the parent, takes it; the zero-copy arm would otherwise have cleared
7403    /// the parent's first rows instead of the view's.
7404    #[cfg(all(target_os = "windows", feature = "opengl"))]
7405    #[test]
7406    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
7407        use edgefirst_tensor::CpuAccess;
7408
7409        if !crate::test_support::is_gpu_buffer_available() {
7410            crate::test_support::report_skip(&format!(
7411                "{} - no zero-copy GPU buffers",
7412                function!()
7413            ));
7414            return;
7415        }
7416        let (w, h) = (32usize, 64usize);
7417        let mut p = ImageProcessor::new().unwrap();
7418        let parent = p
7419            .create_image(
7420                w,
7421                h,
7422                PixelFormat::Rgba,
7423                DType::U8,
7424                None,
7425                CpuAccess::ReadWrite,
7426            )
7427            .unwrap();
7428        if !texture_or_skip(&parent, function!()) {
7429            return;
7430        }
7431        fill_synthetic_rgba(&parent);
7432
7433        // The lower band only. An empty draw with no background clears its
7434        // destination, so a write at the parent's origin would show up as
7435        // cleared pixels in the upper band.
7436        let mut band = parent
7437            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7438            .unwrap();
7439        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7440            .expect("empty draw into a texture view");
7441        drop(band);
7442
7443        let mut flat = vec![0u8; w * h * 4];
7444        parent
7445            .as_typed::<u8>()
7446            .unwrap()
7447            .copy_to_flat(&mut flat)
7448            .expect("read the parent texture");
7449        for y in 0..h {
7450            for x in 0..w {
7451                let o = (y * w + x) * 4;
7452                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
7453                let want = if y < h / 2 {
7454                    [x as u8, y as u8, (x + y) as u8, 255]
7455                } else {
7456                    [0, 0, 0, 0]
7457                };
7458                assert_eq!(got, want, "pixel ({x},{y})");
7459            }
7460        }
7461
7462        // The GL decline is what sent the draw above to the CPU: with GL
7463        // forced there is no CPU arm to fall through to, so it surfaces.
7464        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
7465        let mut band = parent
7466            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7467            .unwrap();
7468        let err = forced
7469            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7470            .expect_err("forced GL must decline a view destination");
7471        assert!(
7472            matches!(err, Error::NotSupported(_)),
7473            "expected NotSupported, got {err:?}"
7474        );
7475    }
7476
7477    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
7478    /// a draw GL declines still renders the caller's palette. The view
7479    /// destination is the reachable decline: `check_draw_dst_placement`
7480    /// refuses it and the CPU backend draws the mask.
7481    #[cfg(all(target_os = "windows", feature = "opengl"))]
7482    #[test]
7483    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
7484        use edgefirst_tensor::{CpuAccess, Segmentation};
7485        use ndarray::Array3;
7486
7487        if !crate::test_support::is_gpu_buffer_available() {
7488            crate::test_support::report_skip(&format!(
7489                "{} - no zero-copy GPU buffers",
7490                function!()
7491            ));
7492            return;
7493        }
7494        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
7495        assert_ne!(
7496            CUSTOM[..3],
7497            DEFAULT_COLORS_U8[0][..3],
7498            "the custom colour must differ from the default palette's, or \
7499             this test cannot tell them apart"
7500        );
7501
7502        let (w, h) = (32usize, 64usize);
7503        let mut p = ImageProcessor::new().unwrap();
7504        let parent = p
7505            .create_image(
7506                w,
7507                h,
7508                PixelFormat::Rgba,
7509                DType::U8,
7510                None,
7511                CpuAccess::ReadWrite,
7512            )
7513            .unwrap();
7514        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7515        // answers host memory and nothing declines the draw; the test needs
7516        // the texture-backed parent so the view is the reachable decline. The
7517        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
7518        if parent.memory() != TensorMemory::DmaBuf
7519            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7520        {
7521            crate::test_support::report_skip(&format!(
7522                "{} - create_image gave {:?}, not a texture (no GL backend)",
7523                function!(),
7524                parent.memory()
7525            ));
7526            return;
7527        }
7528        p.set_class_colors(&[CUSTOM]).unwrap();
7529
7530        let detect = DetectBox {
7531            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7532            score: 0.99,
7533            label: 0,
7534        };
7535        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7536        let seg = Segmentation {
7537            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7538                .unwrap()
7539                .into(),
7540            xmin: 0.25,
7541            ymin: 0.25,
7542            xmax: 0.75,
7543            ymax: 0.75,
7544        };
7545        let mut band = parent
7546            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7547            .unwrap();
7548        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
7549            .expect("draw into a texture view");
7550        drop(band);
7551
7552        let mut flat = vec![0u8; w * h * 4];
7553        parent
7554            .as_typed::<u8>()
7555            .unwrap()
7556            .copy_to_flat(&mut flat)
7557            .expect("read the parent texture");
7558        // The band's centre, inside the box. The mask blend writes RGB only;
7559        // alpha is whatever the base layer left, which is the clear.
7560        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
7561        assert_eq!(
7562            &flat[o..o + 3],
7563            &CUSTOM[..3],
7564            "the CPU backend drew the default palette, so it never received \
7565             set_class_colors"
7566        );
7567    }
7568
7569    /// Two independently-created `ImageProcessor`s, each converting into its
7570    /// own texture destination in lockstep, must not bleed GL state into
7571    /// each other across 200 iterations (the destination created by
7572    /// `create_image(None)` is the texture arm this task adds).
7573    #[cfg(all(target_os = "windows", feature = "opengl"))]
7574    #[test]
7575    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
7576        use edgefirst_tensor::CpuAccess;
7577
7578        if !crate::test_support::is_gpu_buffer_available() {
7579            crate::test_support::report_skip(&format!(
7580                "{} - no zero-copy GPU buffers",
7581                function!()
7582            ));
7583            return;
7584        }
7585        let mut a = ImageProcessor::new().unwrap();
7586        let mut b = ImageProcessor::new().unwrap();
7587        let src_a = synthetic_rgba(512, 512);
7588        let src_b = synthetic_rgba(256, 256);
7589        let mut dst_a = a
7590            .create_image(
7591                512,
7592                512,
7593                PixelFormat::Rgba,
7594                DType::U8,
7595                None,
7596                CpuAccess::Read,
7597            )
7598            .unwrap();
7599        let mut dst_b = b
7600            .create_image(
7601                256,
7602                256,
7603                PixelFormat::Rgba,
7604                DType::U8,
7605                None,
7606                CpuAccess::Read,
7607            )
7608            .unwrap();
7609        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7610            backend: ComputeBackend::Cpu,
7611            ..Default::default()
7612        })
7613        .unwrap();
7614        let mut ref_a = TensorDyn::image(
7615            512,
7616            512,
7617            PixelFormat::Rgba,
7618            DType::U8,
7619            Some(TensorMemory::Mem),
7620            CpuAccess::ReadWrite,
7621        )
7622        .unwrap();
7623        let mut ref_b = TensorDyn::image(
7624            256,
7625            256,
7626            PixelFormat::Rgba,
7627            DType::U8,
7628            Some(TensorMemory::Mem),
7629            CpuAccess::ReadWrite,
7630        )
7631        .unwrap();
7632        cpu.convert(&src_a, &mut ref_a, None).unwrap();
7633        cpu.convert(&src_b, &mut ref_b, None).unwrap();
7634        for _ in 0..200 {
7635            a.convert(&src_a, &mut dst_a, None).unwrap();
7636            b.convert(&src_b, &mut dst_b, None).unwrap();
7637            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7638            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7639        }
7640    }
7641
7642    #[test]
7643    #[cfg(any(
7644        target_os = "linux",
7645        target_os = "macos",
7646        target_os = "ios",
7647        target_os = "android",
7648        target_os = "windows"
7649    ))]
7650    #[cfg(feature = "opengl")]
7651    fn test_opengl_all_rgba() {
7652        if !is_opengl_available() {
7653            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7654            return;
7655        }
7656
7657        let dst_width = 640;
7658        let dst_height = 640;
7659        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7660
7661        let mut cpu_converter = CPUProcessor::new();
7662
7663        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7664
7665        // Every backing this host can allocate: Shm is Unix-only, and the
7666        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7667        // Windows yet).
7668        let mut mem = vec![None, Some(TensorMemory::Mem)];
7669        if edgefirst_tensor::is_shm_available() {
7670            mem.push(Some(TensorMemory::Shm));
7671        }
7672        if crate::test_support::is_gpu_buffer_available() {
7673            mem.push(Some(TensorMemory::DmaBuf));
7674        } else {
7675            crate::test_support::report_skip(&format!(
7676                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7677                function!()
7678            ));
7679        }
7680        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7681        for m in mem {
7682            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7683            let src_dyn = src;
7684
7685            for rot in [
7686                Rotation::None,
7687                Rotation::Clockwise90,
7688                Rotation::Rotate180,
7689                Rotation::CounterClockwise90,
7690            ] {
7691                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7692                    let cpu_dst = TensorDyn::image(
7693                        dst_width,
7694                        dst_height,
7695                        PixelFormat::Rgba,
7696                        DType::U8,
7697                        m,
7698                        edgefirst_tensor::CpuAccess::ReadWrite,
7699                    )
7700                    .unwrap();
7701                    let gl_dst = TensorDyn::image(
7702                        dst_width,
7703                        dst_height,
7704                        PixelFormat::Rgba,
7705                        DType::U8,
7706                        m,
7707                        edgefirst_tensor::CpuAccess::ReadWrite,
7708                    )
7709                    .unwrap();
7710                    cpu_dst
7711                        .as_typed::<u8>()
7712                        .unwrap()
7713                        .map()
7714                        .unwrap()
7715                        .as_mut_slice()
7716                        .fill(114);
7717                    gl_dst
7718                        .as_typed::<u8>()
7719                        .unwrap()
7720                        .map()
7721                        .unwrap()
7722                        .as_mut_slice()
7723                        .fill(114);
7724
7725                    let mut cpu_dst_dyn = cpu_dst;
7726                    cpu_converter
7727                        .convert(
7728                            &src_dyn,
7729                            &mut cpu_dst_dyn,
7730                            Some(crate::ConvertOptions::new().crop(crop)),
7731                        )
7732                        .unwrap();
7733                    let cpu_dst = {
7734                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7735                        __t.set_format(PixelFormat::Rgba).unwrap();
7736                        TensorDyn::from(__t)
7737                    };
7738
7739                    let mut gl_dst_dyn = gl_dst;
7740                    gl_converter
7741                        .convert(
7742                            &src_dyn,
7743                            &mut gl_dst_dyn,
7744                            Some(crate::ConvertOptions::new().crop(crop)),
7745                        )
7746                        .map_err(|e| {
7747                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7748                            e
7749                        })
7750                        .unwrap();
7751                    let gl_dst = {
7752                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7753                        __t.set_format(PixelFormat::Rgba).unwrap();
7754                        TensorDyn::from(__t)
7755                    };
7756
7757                    compare_images(
7758                        &gl_dst,
7759                        &cpu_dst,
7760                        0.98,
7761                        &format!("{} {:?} {:?}", function!(), rot, flip),
7762                    );
7763                }
7764            }
7765        }
7766    }
7767
7768    #[test]
7769    #[cfg(target_os = "linux")]
7770    fn test_cpu_rotate() {
7771        for rot in [
7772            Rotation::Clockwise90,
7773            Rotation::Rotate180,
7774            Rotation::CounterClockwise90,
7775        ] {
7776            test_cpu_rotate_(rot);
7777        }
7778    }
7779
7780    #[cfg(target_os = "linux")]
7781    fn test_cpu_rotate_(rot: Rotation) {
7782        // This test rotates the image 4 times and checks that the image was returned to
7783        // be the same Currently doesn't check if rotations actually rotated in
7784        // right direction
7785        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7786
7787        let unchanged_src =
7788            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7789        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7790
7791        let (dst_width, dst_height) = match rot {
7792            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7793            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7794                (src.height().unwrap(), src.width().unwrap())
7795            }
7796        };
7797
7798        let cpu_dst = TensorDyn::image(
7799            dst_width,
7800            dst_height,
7801            PixelFormat::Rgba,
7802            DType::U8,
7803            None,
7804            edgefirst_tensor::CpuAccess::ReadWrite,
7805        )
7806        .unwrap();
7807        let mut cpu_converter = CPUProcessor::new();
7808
7809        // After rotating 4 times, the image should be the same as the original
7810
7811        let (result, src, cpu_dst) = convert_img(
7812            &mut cpu_converter,
7813            src,
7814            cpu_dst,
7815            rot,
7816            Flip::None,
7817            Crop::no_crop(),
7818        );
7819        result.unwrap();
7820
7821        let (result, cpu_dst, src) = convert_img(
7822            &mut cpu_converter,
7823            cpu_dst,
7824            src,
7825            rot,
7826            Flip::None,
7827            Crop::no_crop(),
7828        );
7829        result.unwrap();
7830
7831        let (result, src, cpu_dst) = convert_img(
7832            &mut cpu_converter,
7833            src,
7834            cpu_dst,
7835            rot,
7836            Flip::None,
7837            Crop::no_crop(),
7838        );
7839        result.unwrap();
7840
7841        let (result, _cpu_dst, src) = convert_img(
7842            &mut cpu_converter,
7843            cpu_dst,
7844            src,
7845            rot,
7846            Flip::None,
7847            Crop::no_crop(),
7848        );
7849        result.unwrap();
7850
7851        compare_images(&src, &unchanged_src, 0.98, function!());
7852    }
7853
7854    #[test]
7855    #[cfg(any(
7856        target_os = "linux",
7857        target_os = "macos",
7858        target_os = "ios",
7859        target_os = "android",
7860        target_os = "windows"
7861    ))]
7862    #[cfg(feature = "opengl")]
7863    fn test_opengl_rotate() {
7864        if !is_opengl_available() {
7865            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7866            return;
7867        }
7868
7869        let size = (1280, 720);
7870        // Same backing list as `test_opengl_all_rgba`.
7871        let mut mem = vec![None, Some(TensorMemory::Mem)];
7872        if edgefirst_tensor::is_shm_available() {
7873            mem.push(Some(TensorMemory::Shm));
7874        }
7875        if crate::test_support::is_gpu_buffer_available() {
7876            mem.push(Some(TensorMemory::DmaBuf));
7877        } else {
7878            crate::test_support::report_skip(&format!(
7879                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7880                function!()
7881            ));
7882        }
7883        for m in mem {
7884            for rot in [
7885                Rotation::Clockwise90,
7886                Rotation::Rotate180,
7887                Rotation::CounterClockwise90,
7888            ] {
7889                test_opengl_rotate_(size, rot, m);
7890            }
7891        }
7892    }
7893
7894    #[cfg(any(
7895        target_os = "linux",
7896        target_os = "macos",
7897        target_os = "ios",
7898        target_os = "android",
7899        target_os = "windows"
7900    ))]
7901    #[cfg(feature = "opengl")]
7902    fn test_opengl_rotate_(
7903        size: (usize, usize),
7904        rot: Rotation,
7905        tensor_memory: Option<TensorMemory>,
7906    ) {
7907        let (dst_width, dst_height) = match rot {
7908            Rotation::None | Rotation::Rotate180 => size,
7909            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7910        };
7911
7912        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7913        let src =
7914            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7915
7916        let cpu_dst = TensorDyn::image(
7917            dst_width,
7918            dst_height,
7919            PixelFormat::Rgba,
7920            DType::U8,
7921            None,
7922            edgefirst_tensor::CpuAccess::ReadWrite,
7923        )
7924        .unwrap();
7925        let mut cpu_converter = CPUProcessor::new();
7926
7927        let (result, mut src, cpu_dst) = convert_img(
7928            &mut cpu_converter,
7929            src,
7930            cpu_dst,
7931            rot,
7932            Flip::None,
7933            Crop::no_crop(),
7934        );
7935        result.unwrap();
7936
7937        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7938
7939        for _ in 0..5 {
7940            let gl_dst = TensorDyn::image(
7941                dst_width,
7942                dst_height,
7943                PixelFormat::Rgba,
7944                DType::U8,
7945                tensor_memory,
7946                edgefirst_tensor::CpuAccess::ReadWrite,
7947            )
7948            .unwrap();
7949            let (result, src_back, gl_dst) = convert_img(
7950                &mut gl_converter,
7951                src,
7952                gl_dst,
7953                rot,
7954                Flip::None,
7955                Crop::no_crop(),
7956            );
7957            result.unwrap();
7958            src = src_back;
7959            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7960        }
7961    }
7962
7963    #[test]
7964    #[cfg(target_os = "linux")]
7965    fn test_g2d_rotate() {
7966        if !is_g2d_available() {
7967            crate::test_support::report_skip(
7968                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7969            );
7970            return;
7971        }
7972        if !is_dma_available() {
7973            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7974            return;
7975        }
7976
7977        let size = (1280, 720);
7978        for rot in [
7979            Rotation::Clockwise90,
7980            Rotation::Rotate180,
7981            Rotation::CounterClockwise90,
7982        ] {
7983            test_g2d_rotate_(size, rot);
7984        }
7985    }
7986
7987    #[cfg(target_os = "linux")]
7988    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7989        let (dst_width, dst_height) = match rot {
7990            Rotation::None | Rotation::Rotate180 => size,
7991            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7992        };
7993
7994        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7995        let src = crate::load_image_test_helper(
7996            &file,
7997            Some(PixelFormat::Rgba),
7998            Some(TensorMemory::DmaBuf),
7999        )
8000        .unwrap();
8001
8002        let cpu_dst = TensorDyn::image(
8003            dst_width,
8004            dst_height,
8005            PixelFormat::Rgba,
8006            DType::U8,
8007            None,
8008            edgefirst_tensor::CpuAccess::ReadWrite,
8009        )
8010        .unwrap();
8011        let mut cpu_converter = CPUProcessor::new();
8012
8013        let (result, src, cpu_dst) = convert_img(
8014            &mut cpu_converter,
8015            src,
8016            cpu_dst,
8017            rot,
8018            Flip::None,
8019            Crop::no_crop(),
8020        );
8021        result.unwrap();
8022
8023        let g2d_dst = TensorDyn::image(
8024            dst_width,
8025            dst_height,
8026            PixelFormat::Rgba,
8027            DType::U8,
8028            Some(TensorMemory::DmaBuf),
8029            edgefirst_tensor::CpuAccess::ReadWrite,
8030        )
8031        .unwrap();
8032        let mut g2d_converter = G2DProcessor::new().unwrap();
8033
8034        let (result, _src, g2d_dst) = convert_img(
8035            &mut g2d_converter,
8036            src,
8037            g2d_dst,
8038            rot,
8039            Flip::None,
8040            Crop::no_crop(),
8041        );
8042        result.unwrap();
8043
8044        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
8045        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
8046        // the YUV-matrix delta that forced 0.95 has closed; tightened to
8047        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
8048        // structural gap not exercised by these limited-range fixtures.
8049        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8050    }
8051
8052    #[test]
8053    fn test_rgba_to_yuyv_resize_cpu() {
8054        let src = load_bytes_to_tensor(
8055            1280,
8056            720,
8057            PixelFormat::Rgba,
8058            None,
8059            &edgefirst_bench::testdata::read("camera720p.rgba"),
8060        )
8061        .unwrap();
8062
8063        let (dst_width, dst_height) = (640, 360);
8064
8065        let dst = TensorDyn::image(
8066            dst_width,
8067            dst_height,
8068            PixelFormat::Yuyv,
8069            DType::U8,
8070            None,
8071            edgefirst_tensor::CpuAccess::ReadWrite,
8072        )
8073        .unwrap();
8074
8075        let dst_through_yuyv = TensorDyn::image(
8076            dst_width,
8077            dst_height,
8078            PixelFormat::Rgba,
8079            DType::U8,
8080            None,
8081            edgefirst_tensor::CpuAccess::ReadWrite,
8082        )
8083        .unwrap();
8084        let dst_direct = TensorDyn::image(
8085            dst_width,
8086            dst_height,
8087            PixelFormat::Rgba,
8088            DType::U8,
8089            None,
8090            edgefirst_tensor::CpuAccess::ReadWrite,
8091        )
8092        .unwrap();
8093
8094        let mut cpu_converter = CPUProcessor::new();
8095
8096        let (result, src, dst) = convert_img(
8097            &mut cpu_converter,
8098            src,
8099            dst,
8100            Rotation::None,
8101            Flip::None,
8102            Crop::no_crop(),
8103        );
8104        result.unwrap();
8105
8106        let (result, _dst, dst_through_yuyv) = convert_img(
8107            &mut cpu_converter,
8108            dst,
8109            dst_through_yuyv,
8110            Rotation::None,
8111            Flip::None,
8112            Crop::no_crop(),
8113        );
8114        result.unwrap();
8115
8116        let (result, _src, dst_direct) = convert_img(
8117            &mut cpu_converter,
8118            src,
8119            dst_direct,
8120            Rotation::None,
8121            Flip::None,
8122            Crop::no_crop(),
8123        );
8124        result.unwrap();
8125
8126        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
8127    }
8128
8129    #[test]
8130    #[cfg(any(
8131        target_os = "linux",
8132        target_os = "macos",
8133        target_os = "ios",
8134        target_os = "android",
8135        target_os = "windows"
8136    ))]
8137    #[cfg(feature = "opengl")]
8138    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
8139    fn test_rgba_to_yuyv_resize_opengl() {
8140        if !is_opengl_available() {
8141            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8142            return;
8143        }
8144
8145        if !crate::test_support::is_gpu_buffer_available() {
8146            crate::test_support::report_skip(&format!(
8147                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8148                function!()
8149            ));
8150            return;
8151        }
8152
8153        let src = load_bytes_to_tensor(
8154            1280,
8155            720,
8156            PixelFormat::Rgba,
8157            None,
8158            &edgefirst_bench::testdata::read("camera720p.rgba"),
8159        )
8160        .unwrap();
8161
8162        let (dst_width, dst_height) = (640, 360);
8163
8164        let dst = TensorDyn::image(
8165            dst_width,
8166            dst_height,
8167            PixelFormat::Yuyv,
8168            DType::U8,
8169            Some(TensorMemory::DmaBuf),
8170            edgefirst_tensor::CpuAccess::ReadWrite,
8171        )
8172        .unwrap();
8173
8174        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8175
8176        let (result, src, dst) = convert_img(
8177            &mut gl_converter,
8178            src,
8179            dst,
8180            Rotation::None,
8181            Flip::None,
8182            Crop::letterbox([255, 255, 255, 255]),
8183        );
8184        result.unwrap();
8185
8186        std::fs::write(
8187            "rgba_to_yuyv_opengl.yuyv",
8188            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
8189        )
8190        .unwrap();
8191        let cpu_dst = TensorDyn::image(
8192            dst_width,
8193            dst_height,
8194            PixelFormat::Yuyv,
8195            DType::U8,
8196            Some(TensorMemory::DmaBuf),
8197            edgefirst_tensor::CpuAccess::ReadWrite,
8198        )
8199        .unwrap();
8200        let (result, _src, cpu_dst) = convert_img(
8201            &mut CPUProcessor::new(),
8202            src,
8203            cpu_dst,
8204            Rotation::None,
8205            Flip::None,
8206            Crop::no_crop(),
8207        );
8208        result.unwrap();
8209
8210        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
8211    }
8212
8213    #[test]
8214    #[cfg(target_os = "linux")]
8215    fn test_rgba_to_yuyv_resize_g2d() {
8216        if !is_g2d_available() {
8217            crate::test_support::report_skip(
8218                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
8219            );
8220            return;
8221        }
8222        if !is_dma_available() {
8223            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8224            return;
8225        }
8226
8227        let src = load_bytes_to_tensor(
8228            1280,
8229            720,
8230            PixelFormat::Rgba,
8231            Some(TensorMemory::DmaBuf),
8232            &edgefirst_bench::testdata::read("camera720p.rgba"),
8233        )
8234        .unwrap();
8235
8236        let (dst_width, dst_height) = (1280, 720);
8237
8238        let cpu_dst = TensorDyn::image(
8239            dst_width,
8240            dst_height,
8241            PixelFormat::Yuyv,
8242            DType::U8,
8243            Some(TensorMemory::DmaBuf),
8244            edgefirst_tensor::CpuAccess::ReadWrite,
8245        )
8246        .unwrap();
8247
8248        let g2d_dst = TensorDyn::image(
8249            dst_width,
8250            dst_height,
8251            PixelFormat::Yuyv,
8252            DType::U8,
8253            Some(TensorMemory::DmaBuf),
8254            edgefirst_tensor::CpuAccess::ReadWrite,
8255        )
8256        .unwrap();
8257
8258        let mut g2d_converter = G2DProcessor::new().unwrap();
8259        let crop = Crop::new();
8260
8261        g2d_dst
8262            .as_typed::<u8>()
8263            .unwrap()
8264            .map()
8265            .unwrap()
8266            .as_mut_slice()
8267            .fill(128);
8268        let (result, src, g2d_dst) = convert_img(
8269            &mut g2d_converter,
8270            src,
8271            g2d_dst,
8272            Rotation::None,
8273            Flip::None,
8274            crop,
8275        );
8276        result.unwrap();
8277
8278        let cpu_dst_img = cpu_dst;
8279        cpu_dst_img
8280            .as_typed::<u8>()
8281            .unwrap()
8282            .map()
8283            .unwrap()
8284            .as_mut_slice()
8285            .fill(128);
8286        let (result, _src, cpu_dst) = convert_img(
8287            &mut CPUProcessor::new(),
8288            src,
8289            cpu_dst_img,
8290            Rotation::None,
8291            Flip::None,
8292            crop,
8293        );
8294        result.unwrap();
8295
8296        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
8297    }
8298
8299    #[test]
8300    fn test_yuyv_to_rgba_cpu() {
8301        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
8302        let src = TensorDyn::image(
8303            1280,
8304            720,
8305            PixelFormat::Yuyv,
8306            DType::U8,
8307            None,
8308            edgefirst_tensor::CpuAccess::ReadWrite,
8309        )
8310        .unwrap();
8311        src.as_typed::<u8>()
8312            .unwrap()
8313            .map()
8314            .unwrap()
8315            .as_mut_slice()
8316            .copy_from_slice(&file);
8317
8318        let dst = TensorDyn::image(
8319            1280,
8320            720,
8321            PixelFormat::Rgba,
8322            DType::U8,
8323            None,
8324            edgefirst_tensor::CpuAccess::ReadWrite,
8325        )
8326        .unwrap();
8327        let mut cpu_converter = CPUProcessor::new();
8328
8329        let (result, _src, dst) = convert_img(
8330            &mut cpu_converter,
8331            src,
8332            dst,
8333            Rotation::None,
8334            Flip::None,
8335            Crop::no_crop(),
8336        );
8337        result.unwrap();
8338
8339        let target_image = TensorDyn::image(
8340            1280,
8341            720,
8342            PixelFormat::Rgba,
8343            DType::U8,
8344            None,
8345            edgefirst_tensor::CpuAccess::ReadWrite,
8346        )
8347        .unwrap();
8348        target_image
8349            .as_typed::<u8>()
8350            .unwrap()
8351            .map()
8352            .unwrap()
8353            .as_mut_slice()
8354            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8355
8356        // CPU path resolves the untagged 720p source to BT.709 limited (height
8357        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8358        compare_images(&dst, &target_image, 0.98, function!());
8359    }
8360
8361    #[test]
8362    fn test_yuyv_to_rgb_cpu() {
8363        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
8364        let src = TensorDyn::image(
8365            1280,
8366            720,
8367            PixelFormat::Yuyv,
8368            DType::U8,
8369            None,
8370            edgefirst_tensor::CpuAccess::ReadWrite,
8371        )
8372        .unwrap();
8373        src.as_typed::<u8>()
8374            .unwrap()
8375            .map()
8376            .unwrap()
8377            .as_mut_slice()
8378            .copy_from_slice(&file);
8379
8380        let dst = TensorDyn::image(
8381            1280,
8382            720,
8383            PixelFormat::Rgb,
8384            DType::U8,
8385            None,
8386            edgefirst_tensor::CpuAccess::ReadWrite,
8387        )
8388        .unwrap();
8389        let mut cpu_converter = CPUProcessor::new();
8390
8391        let (result, _src, dst) = convert_img(
8392            &mut cpu_converter,
8393            src,
8394            dst,
8395            Rotation::None,
8396            Flip::None,
8397            Crop::no_crop(),
8398        );
8399        result.unwrap();
8400
8401        let target_image = TensorDyn::image(
8402            1280,
8403            720,
8404            PixelFormat::Rgb,
8405            DType::U8,
8406            None,
8407            edgefirst_tensor::CpuAccess::ReadWrite,
8408        )
8409        .unwrap();
8410        target_image
8411            .as_typed::<u8>()
8412            .unwrap()
8413            .map()
8414            .unwrap()
8415            .as_mut_slice()
8416            .as_chunks_mut::<3>()
8417            .0
8418            .iter_mut()
8419            .zip(
8420                edgefirst_bench::testdata::read("camera720p.rgba")
8421                    .as_chunks::<4>()
8422                    .0,
8423            )
8424            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8425
8426        // CPU path resolves the untagged 720p source to BT.709 limited (height
8427        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8428        compare_images(&dst, &target_image, 0.98, function!());
8429    }
8430
8431    #[test]
8432    #[cfg(target_os = "linux")]
8433    fn test_yuyv_to_rgba_g2d() {
8434        if !is_g2d_available() {
8435            crate::test_support::report_skip(
8436                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
8437            );
8438            return;
8439        }
8440        if !is_dma_available() {
8441            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8442            return;
8443        }
8444
8445        let src = load_bytes_to_tensor(
8446            1280,
8447            720,
8448            PixelFormat::Yuyv,
8449            None,
8450            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8451        )
8452        .unwrap();
8453
8454        let dst = TensorDyn::image(
8455            1280,
8456            720,
8457            PixelFormat::Rgba,
8458            DType::U8,
8459            Some(TensorMemory::DmaBuf),
8460            edgefirst_tensor::CpuAccess::ReadWrite,
8461        )
8462        .unwrap();
8463        let mut g2d_converter = G2DProcessor::new().unwrap();
8464
8465        let (result, _src, dst) = convert_img(
8466            &mut g2d_converter,
8467            src,
8468            dst,
8469            Rotation::None,
8470            Flip::None,
8471            Crop::no_crop(),
8472        );
8473        result.unwrap();
8474
8475        let target_image = TensorDyn::image(
8476            1280,
8477            720,
8478            PixelFormat::Rgba,
8479            DType::U8,
8480            None,
8481            edgefirst_tensor::CpuAccess::ReadWrite,
8482        )
8483        .unwrap();
8484        target_image
8485            .as_typed::<u8>()
8486            .unwrap()
8487            .map()
8488            .unwrap()
8489            .as_mut_slice()
8490            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8491
8492        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8493        // so the matrix delta vs the reference that forced 0.95 has closed;
8494        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8495        compare_images(&dst, &target_image, 0.98, function!());
8496    }
8497
8498    #[test]
8499    #[cfg(any(
8500        target_os = "linux",
8501        target_os = "macos",
8502        target_os = "ios",
8503        target_os = "android",
8504        target_os = "windows"
8505    ))]
8506    #[cfg(feature = "opengl")]
8507    fn test_yuyv_to_rgba_opengl() {
8508        if !is_opengl_available() {
8509            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8510            return;
8511        }
8512        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
8513        // D3D11 texture on Windows; skips where no such backing exists.
8514        if !crate::test_support::is_gpu_buffer_available() {
8515            crate::test_support::report_skip(&format!(
8516                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8517                function!()
8518            ));
8519            return;
8520        }
8521
8522        let src = load_bytes_to_tensor(
8523            1280,
8524            720,
8525            PixelFormat::Yuyv,
8526            Some(TensorMemory::DmaBuf),
8527            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8528        )
8529        .unwrap();
8530
8531        let dst = TensorDyn::image(
8532            1280,
8533            720,
8534            PixelFormat::Rgba,
8535            DType::U8,
8536            Some(TensorMemory::DmaBuf),
8537            edgefirst_tensor::CpuAccess::ReadWrite,
8538        )
8539        .unwrap();
8540        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8541
8542        let (result, _src, dst) = convert_img(
8543            &mut gl_converter,
8544            src,
8545            dst,
8546            Rotation::None,
8547            Flip::None,
8548            Crop::no_crop(),
8549        );
8550        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8551        // Production ImageProcessor falls back to CPU; this oracle skips.
8552        if let Err(crate::Error::NotSupported(msg)) = &result {
8553            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
8554            return;
8555        }
8556        result.unwrap();
8557
8558        let target_image = TensorDyn::image(
8559            1280,
8560            720,
8561            PixelFormat::Rgba,
8562            DType::U8,
8563            None,
8564            edgefirst_tensor::CpuAccess::ReadWrite,
8565        )
8566        .unwrap();
8567        target_image
8568            .as_typed::<u8>()
8569            .unwrap()
8570            .map()
8571            .unwrap()
8572            .as_mut_slice()
8573            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8574
8575        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8576        // so the matrix delta vs the reference that forced 0.95 has closed;
8577        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8578        compare_images(&dst, &target_image, 0.98, function!());
8579    }
8580
8581    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8582    /// IOSurface backend end-to-end and compares against the same
8583    /// reference image. Skips silently if ANGLE isn't installed so the
8584    /// test suite still passes on CI hosts without the Homebrew tap.
8585    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8586    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8587    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8588    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8589    /// luma round-trips to R=G=B (identity GREY→RGB).
8590    #[test]
8591    #[cfg(target_os = "macos")]
8592    #[cfg(feature = "opengl")]
8593    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8594        let mut proc = match GLProcessorThreaded::new(None) {
8595            Ok(p) => p,
8596            Err(e) => {
8597                crate::test_support::report_skip(&format!(
8598                    "{} — GL engine init failed ({e:?})",
8599                    function!()
8600                ));
8601                return;
8602            }
8603        };
8604
8605        let (w, h) = (16usize, 16usize);
8606        let src = TensorDyn::image(
8607            w,
8608            h,
8609            PixelFormat::Grey,
8610            DType::U8,
8611            Some(TensorMemory::DmaBuf),
8612            edgefirst_tensor::CpuAccess::ReadWrite,
8613        )
8614        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8615        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8616        {
8617            let su8 = src.as_typed::<u8>().unwrap();
8618            let stride = src
8619                .as_typed::<u8>()
8620                .unwrap()
8621                .effective_row_stride()
8622                .unwrap();
8623            let mut m = su8.map().unwrap();
8624            let buf = m.as_mut_slice();
8625            for y in 0..h {
8626                for x in 0..w {
8627                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8628                }
8629            }
8630        }
8631
8632        let dst = TensorDyn::image(
8633            w,
8634            h,
8635            PixelFormat::Rgba,
8636            DType::U8,
8637            Some(TensorMemory::DmaBuf),
8638            edgefirst_tensor::CpuAccess::ReadWrite,
8639        )
8640        .unwrap();
8641        let (result, src_back, dst) = convert_img(
8642            &mut proc,
8643            src,
8644            dst,
8645            Rotation::None,
8646            Flip::None,
8647            Crop::no_crop(),
8648        );
8649        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8650
8651        let src_stride = src_back
8652            .as_typed::<u8>()
8653            .unwrap()
8654            .effective_row_stride()
8655            .unwrap();
8656        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8657        let sbytes = src_map.as_slice();
8658        let dst_stride = dst
8659            .as_typed::<u8>()
8660            .unwrap()
8661            .effective_row_stride()
8662            .unwrap();
8663        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8664        let dbytes = dst_map.as_slice();
8665        for y in 0..h {
8666            for x in 0..w {
8667                let yv = sbytes[y * src_stride + x] as i16;
8668                let p = y * dst_stride + x * 4;
8669                for c in 0..3 {
8670                    assert!(
8671                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8672                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8673                        dbytes[p + c]
8674                    );
8675                }
8676            }
8677        }
8678    }
8679
8680    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8681    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8682    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8683    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8684    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8685    #[test]
8686    #[cfg(target_os = "macos")]
8687    #[cfg(feature = "opengl")]
8688    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8689        let mut gpu = match GLProcessorThreaded::new(None) {
8690            Ok(p) => p,
8691            Err(e) => {
8692                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8693                return;
8694            }
8695        };
8696        let (w, h) = (64usize, 64usize);
8697        let Some(src) = crate::test_support::gpu_buffer_allocated(
8698            TensorDyn::image(
8699                w,
8700                h,
8701                PixelFormat::Nv12,
8702                DType::U8,
8703                Some(TensorMemory::DmaBuf),
8704                edgefirst_tensor::CpuAccess::ReadWrite,
8705            ),
8706            "NV12 IOSurface",
8707        ) else {
8708            return;
8709        };
8710        src.as_typed::<u8>()
8711            .unwrap()
8712            .map()
8713            .unwrap()
8714            .as_mut_slice()
8715            .fill(128); // Y=U=V=128
8716
8717        let Some(dst) = crate::test_support::gpu_buffer_allocated(
8718            TensorDyn::image(
8719                w,
8720                h,
8721                PixelFormat::PlanarRgb,
8722                DType::F16,
8723                Some(TensorMemory::DmaBuf),
8724                edgefirst_tensor::CpuAccess::ReadWrite,
8725            ),
8726            "F16 PlanarRgb IOSurface",
8727        ) else {
8728            return;
8729        };
8730        let mut dst = dst;
8731        // Call convert directly (the convert_img helper restores u8 only).
8732        if let Err(e) = ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, None) {
8733            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8734            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8735            // fail on a capability gap (the same policy as the F16 path tests).
8736            crate::test_support::report_skip(&format!(
8737                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8738                function!()
8739            ));
8740            return;
8741        }
8742        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8743        let map = dt.map().unwrap();
8744        let vals = map.as_slice();
8745        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8746        // mediump YUV math + half-float rounding.
8747        let mut checked = 0usize;
8748        for &v in vals.iter() {
8749            let f = f32::from(v);
8750            assert!(
8751                (0.40..=0.60).contains(&f),
8752                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8753            );
8754            checked += 1;
8755        }
8756        assert!(
8757            checked >= w * h * 3,
8758            "expected >= 3 planes of samples, got {checked}"
8759        );
8760    }
8761
8762    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8763    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8764    /// frame, filled at the preserved physical stride, and converted with a
8765    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8766    /// exactly the orchestrator's preprocess. Guards against the pooled
8767    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8768    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8769    #[test]
8770    #[cfg(target_os = "macos")]
8771    #[cfg(feature = "opengl")]
8772    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8773        let mut gpu = match GLProcessorThreaded::new(None) {
8774            Ok(p) => p,
8775            Err(e) => {
8776                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8777                return;
8778            }
8779        };
8780        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8781        let (fw, fh) = (96usize, 64usize);
8782        let (pool_w, pool_h) = (256usize, 768usize);
8783        let (model_w, model_h) = (128usize, 128usize);
8784
8785        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8786            TensorDyn::image(
8787                pool_w,
8788                pool_h,
8789                PixelFormat::Grey,
8790                DType::U8,
8791                Some(TensorMemory::DmaBuf),
8792                edgefirst_tensor::CpuAccess::ReadWrite,
8793            ),
8794            "R8 pool",
8795        ) else {
8796            return;
8797        };
8798        src.configure_image(fw, fh, PixelFormat::Nv12)
8799            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8800        let stride = src
8801            .as_typed::<u8>()
8802            .unwrap()
8803            .effective_row_stride()
8804            .unwrap();
8805        src.as_typed::<u8>()
8806            .unwrap()
8807            .map()
8808            .unwrap()
8809            .as_mut_slice()
8810            .fill(128); // neutral grey
8811
8812        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8813            TensorDyn::image(
8814                model_w,
8815                model_h,
8816                PixelFormat::PlanarRgb,
8817                DType::F16,
8818                Some(TensorMemory::DmaBuf),
8819                edgefirst_tensor::CpuAccess::ReadWrite,
8820            ),
8821            "F16 PlanarRgb dst",
8822        ) else {
8823            return;
8824        };
8825
8826        // Letterbox crop like the profiler: the backend computes the band.
8827        let _ = model_w;
8828        let crop = Crop::new()
8829            .with_source(Some(Region::new(0, 0, fw, fh)))
8830            .with_fit(Fit::Letterbox {
8831                pad: [0, 0, 0, 255],
8832            });
8833        if let Err(e) = ImageProcessorTrait::convert(
8834            &mut gpu,
8835            &src,
8836            &mut dst,
8837            Some(ConvertOptions::new().crop(crop)),
8838        ) {
8839            crate::test_support::report_skip(&format!(
8840                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8841                function!()
8842            ));
8843            return;
8844        }
8845        let _ = stride;
8846        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8847        // completed and produced finite values (no hang, no NaN garbage).
8848        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8849        let map = dt.map().unwrap();
8850        let any_half = map.as_slice().iter().any(|&v| {
8851            let f = f32::from(v);
8852            (0.40..=0.60).contains(&f)
8853        });
8854        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8855    }
8856
8857    /// Mirrors the orchestrator: the GL processor is created on one thread
8858    /// and `convert()` is called from a *different* thread (the profiler's
8859    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8860    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8861    /// fails loudly rather than hanging the whole test binary.
8862    #[test]
8863    #[cfg(target_os = "macos")]
8864    #[cfg(feature = "opengl")]
8865    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8866        use std::sync::mpsc;
8867        // Public ImageProcessor (Send) created HERE (the main test thread),
8868        // exactly like the orchestrator builds `config.processor` during setup.
8869        let mut proc = match ImageProcessor::new() {
8870            Ok(p) => p,
8871            Err(e) => {
8872                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8873                return;
8874            }
8875        };
8876        let (fw, fh) = (96usize, 64usize);
8877        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8878            TensorDyn::image(
8879                256,
8880                768,
8881                PixelFormat::Grey,
8882                DType::U8,
8883                Some(TensorMemory::DmaBuf),
8884                edgefirst_tensor::CpuAccess::ReadWrite,
8885            ),
8886            "NV12 R8 pool",
8887        ) else {
8888            return;
8889        };
8890        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8891        src.as_typed::<u8>()
8892            .unwrap()
8893            .map()
8894            .unwrap()
8895            .as_mut_slice()
8896            .fill(128);
8897        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8898            TensorDyn::image(
8899                128,
8900                128,
8901                PixelFormat::PlanarRgb,
8902                DType::F16,
8903                Some(TensorMemory::DmaBuf),
8904                edgefirst_tensor::CpuAccess::ReadWrite,
8905            ),
8906            "F16 PlanarRgb dst",
8907        ) else {
8908            return;
8909        };
8910        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8911
8912        // ...then MOVED to a worker thread where convert() runs — exactly the
8913        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8914        let (tx, rx) = mpsc::channel::<bool>();
8915        let worker = std::thread::spawn(move || {
8916            let _ = ImageProcessorTrait::convert(
8917                &mut proc,
8918                &src,
8919                &mut dst,
8920                Some(ConvertOptions::new().crop(crop)),
8921            );
8922            let _ = tx.send(true);
8923        });
8924        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8925            Ok(_) => { let _ = worker.join(); }
8926            Err(_) => panic!(
8927                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8928            ),
8929        }
8930    }
8931
8932    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8933    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8934    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8935    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8936    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8937    /// (no runaway) over many iterations.
8938    #[test]
8939    #[cfg(target_os = "macos")]
8940    #[cfg(feature = "opengl")]
8941    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8942        let mut gpu = match GLProcessorThreaded::new(None) {
8943            Ok(p) => p,
8944            Err(e) => {
8945                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8946                return;
8947            }
8948        };
8949        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8950        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8951        let (max_w, max_h) = (640usize, 640usize);
8952        let depth = 4usize;
8953        let mut srcs = Vec::new();
8954        let mut dsts = Vec::new();
8955        for _ in 0..depth {
8956            let Some(src) = crate::test_support::gpu_buffer_allocated(
8957                TensorDyn::image(
8958                    max_w,
8959                    max_h * 3,
8960                    PixelFormat::Grey,
8961                    DType::U8,
8962                    Some(TensorMemory::DmaBuf),
8963                    edgefirst_tensor::CpuAccess::ReadWrite,
8964                ),
8965                "R8 pool",
8966            ) else {
8967                return;
8968            };
8969            srcs.push(src);
8970            let Some(dst) = crate::test_support::gpu_buffer_allocated(
8971                TensorDyn::image(
8972                    640,
8973                    640,
8974                    PixelFormat::PlanarRgb,
8975                    DType::F16,
8976                    Some(TensorMemory::DmaBuf),
8977                    edgefirst_tensor::CpuAccess::ReadWrite,
8978                ),
8979                "F16 PlanarRgb dst",
8980            ) else {
8981                return;
8982            };
8983            dsts.push(dst);
8984        }
8985        // COCO-like assorted frame sizes (all ≤ max), cycled.
8986        let sizes = [
8987            (640, 480),
8988            (500, 375),
8989            (640, 427),
8990            (333, 500),
8991            (480, 640),
8992            (612, 612),
8993            (428, 640),
8994            (576, 432),
8995        ];
8996        let mut first_ms = 0f64;
8997        let mut last_ms = 0f64;
8998        let iters = 40usize;
8999        for i in 0..iters {
9000            let (fw, fh) = sizes[i % sizes.len()];
9001            let src = &mut srcs[i % depth];
9002            let dst = &mut dsts[i % depth];
9003            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
9004            src.as_typed::<u8>()
9005                .unwrap()
9006                .map()
9007                .unwrap()
9008                .as_mut_slice()
9009                .fill(128);
9010            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
9011            let t0 = std::time::Instant::now();
9012            ImageProcessorTrait::convert(
9013                &mut gpu,
9014                src,
9015                dst,
9016                Some(ConvertOptions::new().crop(crop)),
9017            )
9018            .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
9019            let ms = t0.elapsed().as_secs_f64() * 1e3;
9020            if i == 2 {
9021                first_ms = ms;
9022            }
9023            if i == iters - 1 {
9024                last_ms = ms;
9025            }
9026        }
9027        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
9028        assert!(
9029            last_ms < first_ms * 5.0 + 5.0,
9030            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
9031        );
9032    }
9033
9034    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
9035    /// must match the CPU `yuv` kernels within shader rounding. Fills an
9036    /// IOSurface source and a Mem source from the same logical YUV pattern
9037    /// (each at its own row stride), converts the IOSurface on the GPU and the
9038    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
9039    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
9040    #[test]
9041    #[cfg(target_os = "macos")]
9042    #[cfg(feature = "opengl")]
9043    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
9044        let mut gpu = match GLProcessorThreaded::new(None) {
9045            Ok(p) => p,
9046            Err(e) => {
9047                crate::test_support::report_skip(&format!(
9048                    "{} — GL engine init failed ({e:?})",
9049                    function!()
9050                ));
9051                return;
9052            }
9053        };
9054        let mut cpu = CPUProcessor::new();
9055
9056        // Fill Y plus the interleaved UV plane in the canonical semi-planar
9057        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
9058        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
9059        // plane starts at `h * stride`; each chroma row advances
9060        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
9061        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
9062        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
9063        // stride-correct for both the tight Mem buffer and the padded IOSurface.
9064        //
9065        // Takes explicit w/h so the closure can be reused across multiple frame
9066        // sizes (even and odd) without capturing a fixed outer variable.
9067        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
9068            for y in 0..h {
9069                for x in 0..w {
9070                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
9071                }
9072            }
9073            let (cw, ch, uv_grid_rows) = match fmt {
9074                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
9075                PixelFormat::Nv16 => (w / 2, h, 1usize),
9076                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
9077            };
9078            let uv_plane = h * stride;
9079            for cy in 0..ch {
9080                for cx in 0..cw {
9081                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
9082                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
9083                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
9084                }
9085            }
9086        };
9087
9088        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
9089            for (w, h) in [
9090                (16usize, 16usize), // original even-dim case
9091                (15, 16),           // odd-W
9092                (16, 15),           // odd-H
9093            ] {
9094                let mem = TensorDyn::image(
9095                    w,
9096                    h,
9097                    fmt,
9098                    DType::U8,
9099                    None,
9100                    edgefirst_tensor::CpuAccess::ReadWrite,
9101                )
9102                .unwrap();
9103                let mem_stride = mem
9104                    .as_typed::<u8>()
9105                    .unwrap()
9106                    .effective_row_stride()
9107                    .unwrap();
9108                fill(
9109                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9110                    mem_stride,
9111                    fmt,
9112                    w,
9113                    h,
9114                );
9115                let cpu_dst = TensorDyn::image(
9116                    w,
9117                    h,
9118                    PixelFormat::Rgba,
9119                    DType::U8,
9120                    None,
9121                    edgefirst_tensor::CpuAccess::ReadWrite,
9122                )
9123                .unwrap();
9124                let (r, _s, cpu_dst) = convert_img(
9125                    &mut cpu,
9126                    mem,
9127                    cpu_dst,
9128                    Rotation::None,
9129                    Flip::None,
9130                    Crop::no_crop(),
9131                );
9132                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
9133
9134                let ios = TensorDyn::image(
9135                    w,
9136                    h,
9137                    fmt,
9138                    DType::U8,
9139                    Some(TensorMemory::DmaBuf),
9140                    edgefirst_tensor::CpuAccess::ReadWrite,
9141                )
9142                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
9143                let ios_stride = ios
9144                    .as_typed::<u8>()
9145                    .unwrap()
9146                    .effective_row_stride()
9147                    .unwrap();
9148                fill(
9149                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9150                    ios_stride,
9151                    fmt,
9152                    w,
9153                    h,
9154                );
9155                let gpu_dst = TensorDyn::image(
9156                    w,
9157                    h,
9158                    PixelFormat::Rgba,
9159                    DType::U8,
9160                    Some(TensorMemory::DmaBuf),
9161                    edgefirst_tensor::CpuAccess::ReadWrite,
9162                )
9163                .unwrap();
9164                let (r, _s, gpu_dst) = convert_img(
9165                    &mut gpu,
9166                    ios,
9167                    gpu_dst,
9168                    Rotation::None,
9169                    Flip::None,
9170                    Crop::no_crop(),
9171                );
9172                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
9173
9174                let cs = cpu_dst
9175                    .as_typed::<u8>()
9176                    .unwrap()
9177                    .effective_row_stride()
9178                    .unwrap();
9179                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9180                let cb = cmap.as_slice();
9181                let gs = gpu_dst
9182                    .as_typed::<u8>()
9183                    .unwrap()
9184                    .effective_row_stride()
9185                    .unwrap();
9186                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9187                let gb = gmap.as_slice();
9188                let mut max_d = 0i16;
9189                for y in 0..h {
9190                    for x in 0..w {
9191                        for c in 0..3 {
9192                            let cv = cb[y * cs + x * 4 + c] as i16;
9193                            let gv = gb[y * gs + x * 4 + c] as i16;
9194                            max_d = max_d.max((cv - gv).abs());
9195                        }
9196                    }
9197                }
9198                assert!(
9199                    max_d <= 3,
9200                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
9201                );
9202            }
9203        }
9204    }
9205
9206    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
9207    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
9208    /// the frame's even width) is reconfigured to each NV frame, filled at the
9209    /// preserved physical stride, and converted on the GPU. This proves ANGLE
9210    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
9211    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
9212    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
9213    #[test]
9214    #[cfg(target_os = "macos")]
9215    #[cfg(feature = "opengl")]
9216    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
9217        let mut gpu = match GLProcessorThreaded::new(None) {
9218            Ok(p) => p,
9219            Err(e) => {
9220                crate::test_support::report_skip(&format!(
9221                    "{} — GL engine init failed ({e:?})",
9222                    function!()
9223                ));
9224                return;
9225            }
9226        };
9227        let mut cpu = CPUProcessor::new();
9228        // The pool is generously oversized (256-wide → bpr 256, well beyond any
9229        // frame's even width) and tall enough for NV24's 3·H at the test frames.
9230        let (pool_w, pool_h) = (256usize, 256usize);
9231
9232        // Canonical semi-planar fill: Y plane at the row stride, then the UV
9233        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
9234        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
9235        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
9236            for y in 0..h {
9237                for x in 0..w {
9238                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
9239                }
9240            }
9241            let (cw, ch, uv_grid_rows) = match fmt {
9242                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
9243                PixelFormat::Nv16 => (w / 2, h, 1usize),
9244                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
9245            };
9246            let uv_plane = h * stride;
9247            for cy in 0..ch {
9248                for cx in 0..cw {
9249                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
9250                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
9251                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
9252                }
9253            }
9254        };
9255
9256        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
9257            for (w, h) in [
9258                (40usize, 24usize), // original even-dim case
9259                (15, 16),           // odd-W
9260                (16, 15),           // odd-H
9261            ] {
9262                // `ew` is the minimum even extent; the pool stride must exceed it
9263                // to actually exercise the physical-stride shader decoupling.
9264                let ew = w.next_multiple_of(2);
9265
9266                // CPU reference from a tightly-packed Mem tensor at the frame size.
9267                let mem = TensorDyn::image(
9268                    w,
9269                    h,
9270                    fmt,
9271                    DType::U8,
9272                    None,
9273                    edgefirst_tensor::CpuAccess::ReadWrite,
9274                )
9275                .unwrap();
9276                let mem_stride = mem
9277                    .as_typed::<u8>()
9278                    .unwrap()
9279                    .effective_row_stride()
9280                    .unwrap();
9281                fill(
9282                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9283                    mem_stride,
9284                    fmt,
9285                    w,
9286                    h,
9287                );
9288                let cpu_dst = TensorDyn::image(
9289                    w,
9290                    h,
9291                    PixelFormat::Rgba,
9292                    DType::U8,
9293                    None,
9294                    edgefirst_tensor::CpuAccess::ReadWrite,
9295                )
9296                .unwrap();
9297                let (r, _s, cpu_dst) = convert_img(
9298                    &mut cpu,
9299                    mem,
9300                    cpu_dst,
9301                    Rotation::None,
9302                    Flip::None,
9303                    Crop::no_crop(),
9304                );
9305                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
9306
9307                // GPU source: a LARGER R8 pool surface, reconfigured down to the
9308                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
9309                // tensor's row stride; the fill writes the frame at that stride.
9310                let Some(mut ios) = crate::test_support::gpu_buffer_allocated(
9311                    TensorDyn::image(
9312                        pool_w,
9313                        pool_h,
9314                        PixelFormat::Grey,
9315                        DType::U8,
9316                        Some(TensorMemory::DmaBuf),
9317                        edgefirst_tensor::CpuAccess::ReadWrite,
9318                    ),
9319                    "R8 pool IOSurface",
9320                ) else {
9321                    return;
9322                };
9323                ios.configure_image(w, h, fmt)
9324                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
9325                let ios_stride = ios
9326                    .as_typed::<u8>()
9327                    .unwrap()
9328                    .effective_row_stride()
9329                    .unwrap();
9330                assert!(
9331                    ios_stride > ew,
9332                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
9333                     (test must exercise padding)"
9334                );
9335                fill(
9336                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9337                    ios_stride,
9338                    fmt,
9339                    w,
9340                    h,
9341                );
9342
9343                let gpu_dst = TensorDyn::image(
9344                    w,
9345                    h,
9346                    PixelFormat::Rgba,
9347                    DType::U8,
9348                    Some(TensorMemory::DmaBuf),
9349                    edgefirst_tensor::CpuAccess::ReadWrite,
9350                )
9351                .unwrap();
9352                let (r, _s, gpu_dst) = convert_img(
9353                    &mut gpu,
9354                    ios,
9355                    gpu_dst,
9356                    Rotation::None,
9357                    Flip::None,
9358                    Crop::no_crop(),
9359                );
9360                r.unwrap_or_else(|e| {
9361                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
9362                });
9363
9364                let cs = cpu_dst
9365                    .as_typed::<u8>()
9366                    .unwrap()
9367                    .effective_row_stride()
9368                    .unwrap();
9369                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9370                let cb = cmap.as_slice();
9371                let gs = gpu_dst
9372                    .as_typed::<u8>()
9373                    .unwrap()
9374                    .effective_row_stride()
9375                    .unwrap();
9376                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9377                let gb = gmap.as_slice();
9378                let mut max_d = 0i16;
9379                for y in 0..h {
9380                    for x in 0..w {
9381                        for c in 0..3 {
9382                            let cv = cb[y * cs + x * 4 + c] as i16;
9383                            let gv = gb[y * gs + x * 4 + c] as i16;
9384                            max_d = max_d.max((cv - gv).abs());
9385                        }
9386                    }
9387                }
9388                assert!(
9389                    max_d <= 3,
9390                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
9391                );
9392            }
9393        }
9394    }
9395
9396    #[test]
9397    #[cfg(target_os = "macos")]
9398    #[cfg(feature = "opengl")]
9399    fn test_yuyv_to_rgba_opengl_macos() {
9400        let mut proc = match GLProcessorThreaded::new(None) {
9401            Ok(p) => p,
9402            Err(e) => {
9403                crate::test_support::report_skip(&format!(
9404                    "{} — GL engine init failed ({e:?}). \
9405                     Install ANGLE via `brew install startergo/angle/angle` \
9406                     and re-sign per README.md § macOS GPU Acceleration to \
9407                     run this test.",
9408                    function!()
9409                ));
9410                return;
9411            }
9412        };
9413
9414        let src = load_bytes_to_tensor(
9415            1280,
9416            720,
9417            PixelFormat::Yuyv,
9418            Some(TensorMemory::DmaBuf),
9419            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9420        )
9421        .unwrap();
9422
9423        let dst = TensorDyn::image(
9424            1280,
9425            720,
9426            PixelFormat::Rgba,
9427            DType::U8,
9428            Some(TensorMemory::DmaBuf),
9429            edgefirst_tensor::CpuAccess::ReadWrite,
9430        )
9431        .unwrap();
9432
9433        let (result, _src, dst) = convert_img(
9434            &mut proc,
9435            src,
9436            dst,
9437            Rotation::None,
9438            Flip::None,
9439            Crop::no_crop(),
9440        );
9441        result.unwrap();
9442
9443        let target_image = TensorDyn::image(
9444            1280,
9445            720,
9446            PixelFormat::Rgba,
9447            DType::U8,
9448            None,
9449            edgefirst_tensor::CpuAccess::ReadWrite,
9450        )
9451        .unwrap();
9452        target_image
9453            .as_typed::<u8>()
9454            .unwrap()
9455            .map()
9456            .unwrap()
9457            .as_mut_slice()
9458            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9459
9460        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
9461        // camera720p source resolves to BT.709 limited (matching the BT.709
9462        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
9463        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
9464        compare_images(&dst, &target_image, 0.98, function!());
9465    }
9466
9467    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
9468    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
9469    /// both filled with a synthetic mid-grey pattern. Validates the
9470    /// shader math at the chroma-pairing boundary on small textures
9471    /// and exercises the IOSurface bytes-per-row alignment path at 4K
9472    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
9473    ///
9474    /// Resolutions below 32 pixels wide aren't tested because the
9475    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
9476    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
9477    /// which exercises a sampling pattern that's ANGLE-version
9478    /// dependent rather than HAL-correctness dependent.
9479    ///
9480    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
9481    /// pixel-exact correctness against a reference image at 720p) by
9482    /// ensuring the pipeline does not crash or produce gross errors at
9483    /// resolution extremes. Pixel-exact validation at 4K would require
9484    /// a 30 MB reference file we don't want to bundle.
9485    #[test]
9486    #[cfg(target_os = "macos")]
9487    #[cfg(feature = "opengl")]
9488    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
9489        let mut proc = match GLProcessorThreaded::new(None) {
9490            Ok(p) => p,
9491            Err(e) => {
9492                crate::test_support::report_skip(&format!(
9493                    "{} — GL engine init failed ({e:?})",
9494                    function!()
9495                ));
9496                return;
9497            }
9498        };
9499
9500        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
9501            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
9502            // chroma) → RGB grey at the output.
9503            let bytes_per_row = w * 2;
9504            let mut yuyv = vec![0u8; bytes_per_row * h];
9505            for chunk in yuyv.as_chunks_mut::<4>().0 {
9506                chunk[0] = 128; // Y0
9507                chunk[1] = 128; // U
9508                chunk[2] = 128; // Y1
9509                chunk[3] = 128; // V
9510            }
9511
9512            let src =
9513                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9514                    .unwrap();
9515
9516            let dst = TensorDyn::image(
9517                w,
9518                h,
9519                PixelFormat::Rgba,
9520                DType::U8,
9521                Some(TensorMemory::DmaBuf),
9522                edgefirst_tensor::CpuAccess::ReadWrite,
9523            )
9524            .unwrap();
9525
9526            let (result, _src, dst) = convert_img(
9527                &mut proc,
9528                src,
9529                dst,
9530                Rotation::None,
9531                Flip::None,
9532                Crop::no_crop(),
9533            );
9534            result.expect("GL convert should succeed at this resolution");
9535
9536            // The neutral-chroma input must produce a near-grey output;
9537            // BT.709 limited-range maps Y=128/UV=128 → roughly
9538            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
9539            // rounding.
9540            let dst_u8 = dst.as_typed::<u8>().unwrap();
9541            let dst_map = dst_u8.map().unwrap();
9542            let dst_bytes = dst_map.as_slice();
9543            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9544            for px in dst_bytes.as_chunks::<4>().0 {
9545                for (i, &c) in px[..3].iter().enumerate() {
9546                    assert!(
9547                        (120..=140).contains(&c),
9548                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9549                        function!(),
9550                    );
9551                }
9552                assert_eq!(px[3], 255, "alpha must be 1.0");
9553            }
9554        }
9555    }
9556
9557    /// Verify that two consecutive convert() calls on the same source
9558    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9559    /// added with the macOS GL backend hardening — without it, each
9560    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9561    ///
9562    /// This is a behaviour test rather than a perf test (the timing
9563    /// difference is 100-200µs which is too noisy to assert on); we
9564    /// check that the second call succeeds and produces a result
9565    /// identical to the first.
9566    #[test]
9567    #[cfg(target_os = "macos")]
9568    #[cfg(feature = "opengl")]
9569    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9570        let mut proc = match GLProcessorThreaded::new(None) {
9571            Ok(p) => p,
9572            Err(e) => {
9573                crate::test_support::report_skip(&format!(
9574                    "{} — GL engine init failed ({e:?})",
9575                    function!()
9576                ));
9577                return;
9578            }
9579        };
9580
9581        // Allocate one source + one destination, run convert twice.
9582        let mut yuyv = vec![0u8; 64 * 32 * 2];
9583        for chunk in yuyv.as_chunks_mut::<4>().0 {
9584            chunk[0] = 200;
9585            chunk[1] = 100;
9586            chunk[2] = 200;
9587            chunk[3] = 156;
9588        }
9589        let src =
9590            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9591                .unwrap();
9592        let dst = TensorDyn::image(
9593            64,
9594            32,
9595            PixelFormat::Rgba,
9596            DType::U8,
9597            Some(TensorMemory::DmaBuf),
9598            edgefirst_tensor::CpuAccess::ReadWrite,
9599        )
9600        .unwrap();
9601
9602        let (r1, src, dst) = convert_img(
9603            &mut proc,
9604            src,
9605            dst,
9606            Rotation::None,
9607            Flip::None,
9608            Crop::no_crop(),
9609        );
9610        r1.unwrap();
9611        let first: Vec<u8> = dst
9612            .as_typed::<u8>()
9613            .unwrap()
9614            .map()
9615            .unwrap()
9616            .as_slice()
9617            .to_vec();
9618
9619        let (r2, _src, dst) = convert_img(
9620            &mut proc,
9621            src,
9622            dst,
9623            Rotation::None,
9624            Flip::None,
9625            Crop::no_crop(),
9626        );
9627        r2.unwrap();
9628        let second: Vec<u8> = dst
9629            .as_typed::<u8>()
9630            .unwrap()
9631            .map()
9632            .unwrap()
9633            .as_slice()
9634            .to_vec();
9635
9636        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9637    }
9638
9639    /// Steady-state import gate (macOS half of the Linux
9640    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9641    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9642    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9643    /// stay flat while hits grow. Counter-based hardening of
9644    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9645    /// re-imports per frame passes the pixel-equality test but fails this.
9646    #[test]
9647    #[cfg(target_os = "macos")]
9648    #[cfg(feature = "opengl")]
9649    fn test_macos_gl_pbuffer_cache_steady_state() {
9650        let mut proc = match GLProcessorThreaded::new(None) {
9651            Ok(p) => p,
9652            Err(e) => {
9653                crate::test_support::report_skip(&format!(
9654                    "{} — GL engine init failed ({e:?})",
9655                    function!()
9656                ));
9657                return;
9658            }
9659        };
9660
9661        let (w, h) = (64usize, 32usize);
9662        const POOL: usize = 3;
9663        const FRAMES: usize = 100;
9664
9665        let yuyv = vec![128u8; w * h * 2];
9666        let pool: Vec<TensorDyn> = (0..POOL)
9667            .map(|_| {
9668                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9669                    .unwrap()
9670            })
9671            .collect();
9672        let mut dst = TensorDyn::image(
9673            w,
9674            h,
9675            PixelFormat::Rgba,
9676            DType::U8,
9677            Some(TensorMemory::DmaBuf),
9678            edgefirst_tensor::CpuAccess::ReadWrite,
9679        )
9680        .unwrap();
9681
9682        // Warmup: two passes over the pool import every surface once.
9683        for src in pool.iter().cycle().take(POOL * 2) {
9684            proc.convert(src, &mut dst, None).unwrap();
9685        }
9686        let warm = proc.egl_cache_stats().unwrap();
9687
9688        for src in pool.iter().cycle().take(FRAMES) {
9689            proc.convert(src, &mut dst, None).unwrap();
9690        }
9691        let steady = proc.egl_cache_stats().unwrap();
9692
9693        assert_eq!(
9694            warm.total_misses(),
9695            steady.total_misses(),
9696            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9697        );
9698        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits + s.nv_uv.hits;
9699        assert!(
9700            hits(&steady) - hits(&warm) >= FRAMES as u64,
9701            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9702            hits(&steady) - hits(&warm)
9703        );
9704    }
9705
9706    /// Backend assertion for the F16 zero-copy path: when the GL backend
9707    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9708    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9709    /// engine — proven by the engine's import-cache counters moving, not
9710    /// just by output correctness. This is the guard against the
9711    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9712    /// destination keeps every output-correctness test green while
9713    /// quietly running ~10× slower on CPU; only a backend observable
9714    /// catches it. Skips ONLY when GL itself is unavailable or the
9715    /// configuration lacks F16 — a convert error or a CPU-routed convert
9716    /// with the capability present is a FAILURE.
9717    #[test]
9718    #[cfg(target_os = "macos")]
9719    #[cfg(feature = "opengl")]
9720    fn test_macos_gl_f16_planar_is_gl_backed() {
9721        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9722        let Some(ref gl) = proc.opengl else {
9723            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9724            return;
9725        };
9726        if !gl.supported_render_dtypes().f16 {
9727            crate::test_support::report_skip(&format!(
9728                "{} — configuration lacks F16 color-buffer support",
9729                function!()
9730            ));
9731            return;
9732        }
9733        let stats_before = gl.egl_cache_stats().expect("cache stats");
9734
9735        let src = TensorDyn::image(
9736            1280,
9737            720,
9738            PixelFormat::Nv12,
9739            DType::U8,
9740            Some(TensorMemory::DmaBuf),
9741            edgefirst_tensor::CpuAccess::ReadWrite,
9742        )
9743        .unwrap();
9744        {
9745            let t = src.as_typed::<u8>().unwrap();
9746            let mut m = t.map().unwrap();
9747            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9748                *b = ((i * 31) % 211) as u8;
9749            }
9750        }
9751        let mut dst = TensorDyn::image(
9752            640,
9753            640,
9754            PixelFormat::PlanarRgb,
9755            DType::F16,
9756            Some(TensorMemory::DmaBuf),
9757            edgefirst_tensor::CpuAccess::ReadWrite,
9758        )
9759        .unwrap();
9760
9761        proc.convert(
9762            &src,
9763            &mut dst,
9764            Some(crate::ConvertOptions::new().crop(Crop::letterbox([114, 114, 114, 255]))),
9765        )
9766        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9767        let stats_after = proc
9768            .opengl
9769            .as_ref()
9770            .expect("GL backend present")
9771            .egl_cache_stats()
9772            .expect("cache stats");
9773        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9774        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9775        // convert that imported its source, failed mid-engine, and fell back
9776        // to CPU (1 miss) cannot satisfy this gate.
9777        assert!(
9778            stats_after.total_misses() >= stats_before.total_misses() + 2,
9779            "convert succeeded but the GL engine did not import both the \
9780             source and the F16 destination — the work did not (fully) run \
9781             on the GL backend (silent CPU fallback); misses before={} after={}",
9782            stats_before.total_misses(),
9783            stats_after.total_misses()
9784        );
9785    }
9786
9787    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9788    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9789    /// render). New on every platform with F16 render support — macOS
9790    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9791    /// backend's reference within the float-path tolerance.
9792    #[test]
9793    #[cfg(feature = "opengl")]
9794    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9795        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9796            backend: ComputeBackend::OpenGl,
9797            ..Default::default()
9798        }) {
9799            Ok(p) if p.opengl.is_some() => p,
9800            _ => {
9801                crate::test_support::report_skip(&format!(
9802                    "{} — GL backend unavailable",
9803                    function!()
9804                ));
9805                return;
9806            }
9807        };
9808        if !gl
9809            .opengl
9810            .as_ref()
9811            .map(|g| g.supported_render_dtypes().f16)
9812            .unwrap_or(false)
9813        {
9814            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9815            return;
9816        }
9817        let mem = if crate::test_support::is_gpu_buffer_available() {
9818            TensorMemory::DmaBuf
9819        } else {
9820            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9821            return;
9822        };
9823
9824        let src = TensorDyn::image(
9825            1280,
9826            720,
9827            PixelFormat::Nv12,
9828            DType::U8,
9829            Some(mem),
9830            edgefirst_tensor::CpuAccess::ReadWrite,
9831        )
9832        .unwrap();
9833        {
9834            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9835            // chroma with different kernels (nearest vs bilinear), which
9836            // legitimately diverges on per-texel chroma noise. Gradients
9837            // keep that kernel difference sub-LSB while still exercising
9838            // the full matrix math and the letterbox geometry.
9839            let t = src.as_typed::<u8>().unwrap();
9840            let mut m = t.map().unwrap();
9841            let buf = m.as_mut_slice();
9842            let (w, h) = (1280usize, 720usize);
9843            for y in 0..h {
9844                for x in 0..w {
9845                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9846                }
9847            }
9848            for y in 0..(h / 2) {
9849                for x in 0..(w / 2) {
9850                    let o = h * w + y * w + 2 * x;
9851                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9852                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9853                }
9854            }
9855        }
9856        let crop = Crop::letterbox([114, 114, 114, 255]);
9857        let mut gl_dst = TensorDyn::image(
9858            640,
9859            640,
9860            PixelFormat::PlanarRgb,
9861            DType::F16,
9862            Some(mem),
9863            edgefirst_tensor::CpuAccess::ReadWrite,
9864        )
9865        .unwrap();
9866        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9867        // silently falls back to CPU on a GL error, turning this oracle into
9868        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9869        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9870        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9871        // take the production CPU fallback; this oracle cannot run there.
9872        match gl.opengl.as_mut().expect("GL backend present").convert(
9873            &src,
9874            &mut gl_dst,
9875            Some(crate::ConvertOptions::new().crop(crop)),
9876        ) {
9877            Ok(()) => {}
9878            Err(crate::Error::NotSupported(msg)) => {
9879                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9880                return;
9881            }
9882            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9883        }
9884
9885        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9886            backend: ComputeBackend::Cpu,
9887            ..Default::default()
9888        })
9889        .unwrap();
9890        let mut cpu_dst = TensorDyn::image(
9891            640,
9892            640,
9893            PixelFormat::PlanarRgb,
9894            DType::F16,
9895            Some(TensorMemory::Mem),
9896            edgefirst_tensor::CpuAccess::ReadWrite,
9897        )
9898        .unwrap();
9899        cpu.convert(
9900            &src,
9901            &mut cpu_dst,
9902            Some(crate::ConvertOptions::new().crop(crop)),
9903        )
9904        .expect("CPU reference convert");
9905
9906        let g = gl_dst
9907            .as_typed::<half::f16>()
9908            .unwrap()
9909            .map()
9910            .unwrap()
9911            .as_slice()
9912            .to_vec();
9913        let c = cpu_dst
9914            .as_typed::<half::f16>()
9915            .unwrap()
9916            .map()
9917            .unwrap()
9918            .as_slice()
9919            .to_vec();
9920        assert_eq!(g.len(), c.len());
9921        let mut max_diff = 0.0f32;
9922        let mut max_at = 0usize;
9923        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9924            let d = (a.to_f32() - b.to_f32()).abs();
9925            if d > max_diff {
9926                max_diff = d;
9927                max_at = i;
9928            }
9929        }
9930        // Localize: plane (R/G/B), row, col of the worst element.
9931        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9932        let (row, col) = (rem / 640, rem % 640);
9933        eprintln!(
9934            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9935             gl={} cpu={}",
9936            g[max_at].to_f32(),
9937            c[max_at].to_f32()
9938        );
9939        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9940        // CPU's direct path: allow a few 8-bit steps of divergence.
9941        assert!(
9942            max_diff <= 4.0 / 255.0 + 1e-3,
9943            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9944        );
9945    }
9946
9947    /// Zero-copy source → heap destination through the GL engine,
9948    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9949    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9950    /// the macOS `glReadnPixels` failure: imported source, rendered,
9951    /// then errored at the heap readback). RGBA→BGRA so the convert is
9952    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9953    /// (Vivante's NV fast path legitimately diverges from the CPU
9954    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9955    /// readbacks).
9956    #[test]
9957    #[cfg(feature = "opengl")]
9958    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9959        let mut proc = match ImageProcessor::new() {
9960            Ok(p) if p.opengl.is_some() => p,
9961            _ => {
9962                crate::test_support::report_skip(&format!(
9963                    "{} — GL backend unavailable",
9964                    function!()
9965                ));
9966                return;
9967            }
9968        };
9969        if !crate::test_support::is_gpu_buffer_available() {
9970            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9971            return;
9972        }
9973
9974        let src = TensorDyn::image(
9975            1280,
9976            720,
9977            PixelFormat::Rgba,
9978            DType::U8,
9979            Some(TensorMemory::DmaBuf),
9980            edgefirst_tensor::CpuAccess::ReadWrite,
9981        )
9982        .unwrap();
9983        {
9984            let t = src.as_typed::<u8>().unwrap();
9985            let mut m = t.map().unwrap();
9986            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9987                *b = ((i * 31) % 211) as u8;
9988            }
9989        }
9990        let mut gl_dst = TensorDyn::image(
9991            1280,
9992            720,
9993            PixelFormat::Bgra,
9994            DType::U8,
9995            Some(TensorMemory::Mem),
9996            edgefirst_tensor::CpuAccess::ReadWrite,
9997        )
9998        .unwrap();
9999        proc.opengl
10000            .as_mut()
10001            .expect("GL backend present")
10002            .convert(&src, &mut gl_dst, None)
10003            .expect("zero-copy src → heap dst GL convert");
10004
10005        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
10006            backend: ComputeBackend::Cpu,
10007            ..Default::default()
10008        })
10009        .unwrap();
10010        let mut cpu_dst = TensorDyn::image(
10011            1280,
10012            720,
10013            PixelFormat::Bgra,
10014            DType::U8,
10015            Some(TensorMemory::Mem),
10016            edgefirst_tensor::CpuAccess::ReadWrite,
10017        )
10018        .unwrap();
10019        cpu.convert(&src, &mut cpu_dst, None)
10020            .expect("CPU reference convert");
10021
10022        let g = gl_dst
10023            .as_typed::<u8>()
10024            .unwrap()
10025            .map()
10026            .unwrap()
10027            .as_slice()
10028            .to_vec();
10029        let c = cpu_dst
10030            .as_typed::<u8>()
10031            .unwrap()
10032            .map()
10033            .unwrap()
10034            .as_slice()
10035            .to_vec();
10036        assert_eq!(g.len(), c.len());
10037        let max_diff = g
10038            .iter()
10039            .zip(c.iter())
10040            .map(|(a, b)| a.abs_diff(*b))
10041            .max()
10042            .unwrap();
10043        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
10044        // only rounding slack.
10045        assert!(
10046            max_diff <= 2,
10047            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
10048        );
10049    }
10050
10051    #[test]
10052    #[cfg(target_os = "linux")]
10053    fn test_yuyv_to_rgb_g2d() {
10054        if !is_g2d_available() {
10055            crate::test_support::report_skip(
10056                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10057            );
10058            return;
10059        }
10060        if !is_dma_available() {
10061            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10062            return;
10063        }
10064
10065        let src = load_bytes_to_tensor(
10066            1280,
10067            720,
10068            PixelFormat::Yuyv,
10069            None,
10070            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10071        )
10072        .unwrap();
10073
10074        let g2d_dst = TensorDyn::image(
10075            1280,
10076            720,
10077            PixelFormat::Rgb,
10078            DType::U8,
10079            Some(TensorMemory::DmaBuf),
10080            edgefirst_tensor::CpuAccess::ReadWrite,
10081        )
10082        .unwrap();
10083        let mut g2d_converter = G2DProcessor::new().unwrap();
10084
10085        let (result, src, g2d_dst) = convert_img(
10086            &mut g2d_converter,
10087            src,
10088            g2d_dst,
10089            Rotation::None,
10090            Flip::None,
10091            Crop::no_crop(),
10092        );
10093        result.unwrap();
10094
10095        let cpu_dst = TensorDyn::image(
10096            1280,
10097            720,
10098            PixelFormat::Rgb,
10099            DType::U8,
10100            None,
10101            edgefirst_tensor::CpuAccess::ReadWrite,
10102        )
10103        .unwrap();
10104        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10105
10106        let (result, _src, cpu_dst) = convert_img(
10107            &mut cpu_converter,
10108            src,
10109            cpu_dst,
10110            Rotation::None,
10111            Flip::None,
10112            Crop::no_crop(),
10113        );
10114        result.unwrap();
10115
10116        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10117        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10118        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10119        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10120        // structural gap not exercised by these limited-range fixtures.
10121        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10122    }
10123
10124    #[test]
10125    #[cfg(target_os = "linux")]
10126    fn test_yuyv_to_yuyv_resize_g2d() {
10127        if !is_g2d_available() {
10128            crate::test_support::report_skip(
10129                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
10130            );
10131            return;
10132        }
10133        if !is_dma_available() {
10134            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10135            return;
10136        }
10137
10138        let src = load_bytes_to_tensor(
10139            1280,
10140            720,
10141            PixelFormat::Yuyv,
10142            None,
10143            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10144        )
10145        .unwrap();
10146
10147        let g2d_dst = TensorDyn::image(
10148            600,
10149            400,
10150            PixelFormat::Yuyv,
10151            DType::U8,
10152            Some(TensorMemory::DmaBuf),
10153            edgefirst_tensor::CpuAccess::ReadWrite,
10154        )
10155        .unwrap();
10156        let mut g2d_converter = G2DProcessor::new().unwrap();
10157
10158        let (result, src, g2d_dst) = convert_img(
10159            &mut g2d_converter,
10160            src,
10161            g2d_dst,
10162            Rotation::None,
10163            Flip::None,
10164            Crop::no_crop(),
10165        );
10166        result.unwrap();
10167
10168        let cpu_dst = TensorDyn::image(
10169            600,
10170            400,
10171            PixelFormat::Yuyv,
10172            DType::U8,
10173            None,
10174            edgefirst_tensor::CpuAccess::ReadWrite,
10175        )
10176        .unwrap();
10177        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10178
10179        let (result, _src, cpu_dst) = convert_img(
10180            &mut cpu_converter,
10181            src,
10182            cpu_dst,
10183            Rotation::None,
10184            Flip::None,
10185            Crop::no_crop(),
10186        );
10187        result.unwrap();
10188
10189        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
10190        // diverges from the CPU reference by enough that the similarity score
10191        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
10192        // The threshold is held at 0.85 so the test guards against gross
10193        // regressions while tolerating the driver's inherent colorimetry error.
10194        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
10195        eprintln!(
10196            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
10197             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
10198        );
10199        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
10200    }
10201
10202    #[test]
10203    fn test_yuyv_to_rgba_resize_cpu() {
10204        let src = load_bytes_to_tensor(
10205            1280,
10206            720,
10207            PixelFormat::Yuyv,
10208            None,
10209            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10210        )
10211        .unwrap();
10212
10213        let (dst_width, dst_height) = (960, 540);
10214
10215        let dst = TensorDyn::image(
10216            dst_width,
10217            dst_height,
10218            PixelFormat::Rgba,
10219            DType::U8,
10220            None,
10221            edgefirst_tensor::CpuAccess::ReadWrite,
10222        )
10223        .unwrap();
10224        let mut cpu_converter = CPUProcessor::new();
10225
10226        let (result, _src, dst) = convert_img(
10227            &mut cpu_converter,
10228            src,
10229            dst,
10230            Rotation::None,
10231            Flip::None,
10232            Crop::no_crop(),
10233        );
10234        result.unwrap();
10235
10236        let dst_target = TensorDyn::image(
10237            dst_width,
10238            dst_height,
10239            PixelFormat::Rgba,
10240            DType::U8,
10241            None,
10242            edgefirst_tensor::CpuAccess::ReadWrite,
10243        )
10244        .unwrap();
10245        let src_target = load_bytes_to_tensor(
10246            1280,
10247            720,
10248            PixelFormat::Rgba,
10249            None,
10250            &edgefirst_bench::testdata::read("camera720p.rgba"),
10251        )
10252        .unwrap();
10253        let (result, _src_target, dst_target) = convert_img(
10254            &mut cpu_converter,
10255            src_target,
10256            dst_target,
10257            Rotation::None,
10258            Flip::None,
10259            Crop::no_crop(),
10260        );
10261        result.unwrap();
10262
10263        // CPU path resolves the untagged 720p source to BT.709 limited (height
10264        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10265        compare_images(&dst, &dst_target, 0.98, function!());
10266    }
10267
10268    #[test]
10269    #[cfg(target_os = "linux")]
10270    fn test_yuyv_to_rgba_crop_flip_g2d() {
10271        if !is_g2d_available() {
10272            crate::test_support::report_skip(
10273                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
10274            );
10275            return;
10276        }
10277        if !is_dma_available() {
10278            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10279            return;
10280        }
10281
10282        let src = load_bytes_to_tensor(
10283            1280,
10284            720,
10285            PixelFormat::Yuyv,
10286            Some(TensorMemory::DmaBuf),
10287            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10288        )
10289        .unwrap();
10290
10291        let (dst_width, dst_height) = (640, 640);
10292
10293        let dst_g2d = TensorDyn::image(
10294            dst_width,
10295            dst_height,
10296            PixelFormat::Rgba,
10297            DType::U8,
10298            Some(TensorMemory::DmaBuf),
10299            edgefirst_tensor::CpuAccess::ReadWrite,
10300        )
10301        .unwrap();
10302        let mut g2d_converter = G2DProcessor::new().unwrap();
10303        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
10304
10305        let (result, src, dst_g2d) = convert_img(
10306            &mut g2d_converter,
10307            src,
10308            dst_g2d,
10309            Rotation::None,
10310            Flip::Horizontal,
10311            crop,
10312        );
10313        result.unwrap();
10314
10315        let dst_cpu = TensorDyn::image(
10316            dst_width,
10317            dst_height,
10318            PixelFormat::Rgba,
10319            DType::U8,
10320            Some(TensorMemory::DmaBuf),
10321            edgefirst_tensor::CpuAccess::ReadWrite,
10322        )
10323        .unwrap();
10324        let mut cpu_converter = CPUProcessor::new();
10325
10326        let (result, _src, dst_cpu) = convert_img(
10327            &mut cpu_converter,
10328            src,
10329            dst_cpu,
10330            Rotation::None,
10331            Flip::Horizontal,
10332            crop,
10333        );
10334        result.unwrap();
10335        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10336        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10337        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10338        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10339        // structural gap not exercised by these limited-range fixtures.
10340        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
10341    }
10342
10343    #[test]
10344    #[cfg(any(
10345        target_os = "linux",
10346        target_os = "macos",
10347        target_os = "ios",
10348        target_os = "android",
10349        target_os = "windows"
10350    ))]
10351    #[cfg(feature = "opengl")]
10352    fn test_yuyv_to_rgba_crop_flip_opengl() {
10353        if !is_opengl_available() {
10354            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10355            return;
10356        }
10357
10358        if !crate::test_support::is_gpu_buffer_available() {
10359            crate::test_support::report_skip(&format!(
10360                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10361                function!()
10362            ));
10363            return;
10364        }
10365
10366        let src = load_bytes_to_tensor(
10367            1280,
10368            720,
10369            PixelFormat::Yuyv,
10370            Some(TensorMemory::DmaBuf),
10371            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10372        )
10373        .unwrap();
10374
10375        let (dst_width, dst_height) = (640, 640);
10376
10377        let dst_gl = TensorDyn::image(
10378            dst_width,
10379            dst_height,
10380            PixelFormat::Rgba,
10381            DType::U8,
10382            Some(TensorMemory::DmaBuf),
10383            edgefirst_tensor::CpuAccess::ReadWrite,
10384        )
10385        .unwrap();
10386        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10387        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
10388
10389        let (result, src, dst_gl) = convert_img(
10390            &mut gl_converter,
10391            src,
10392            dst_gl,
10393            Rotation::None,
10394            Flip::Horizontal,
10395            crop,
10396        );
10397        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
10398        if let Err(crate::Error::NotSupported(msg)) = &result {
10399            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
10400            return;
10401        }
10402        result.unwrap();
10403
10404        let dst_cpu = TensorDyn::image(
10405            dst_width,
10406            dst_height,
10407            PixelFormat::Rgba,
10408            DType::U8,
10409            Some(TensorMemory::DmaBuf),
10410            edgefirst_tensor::CpuAccess::ReadWrite,
10411        )
10412        .unwrap();
10413        let mut cpu_converter = CPUProcessor::new();
10414
10415        let (result, _src, dst_cpu) = convert_img(
10416            &mut cpu_converter,
10417            src,
10418            dst_cpu,
10419            Rotation::None,
10420            Flip::Horizontal,
10421            crop,
10422        );
10423        result.unwrap();
10424        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
10425        // YUV color-space/sample-range hints, so the matrix delta that forced
10426        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
10427        // on the GPU lanes).
10428        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
10429    }
10430
10431    #[test]
10432    fn test_vyuy_to_rgba_cpu() {
10433        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10434        let src = TensorDyn::image(
10435            1280,
10436            720,
10437            PixelFormat::Vyuy,
10438            DType::U8,
10439            None,
10440            edgefirst_tensor::CpuAccess::ReadWrite,
10441        )
10442        .unwrap();
10443        src.as_typed::<u8>()
10444            .unwrap()
10445            .map()
10446            .unwrap()
10447            .as_mut_slice()
10448            .copy_from_slice(&file);
10449
10450        let dst = TensorDyn::image(
10451            1280,
10452            720,
10453            PixelFormat::Rgba,
10454            DType::U8,
10455            None,
10456            edgefirst_tensor::CpuAccess::ReadWrite,
10457        )
10458        .unwrap();
10459        let mut cpu_converter = CPUProcessor::new();
10460
10461        let (result, _src, dst) = convert_img(
10462            &mut cpu_converter,
10463            src,
10464            dst,
10465            Rotation::None,
10466            Flip::None,
10467            Crop::no_crop(),
10468        );
10469        result.unwrap();
10470
10471        let target_image = TensorDyn::image(
10472            1280,
10473            720,
10474            PixelFormat::Rgba,
10475            DType::U8,
10476            None,
10477            edgefirst_tensor::CpuAccess::ReadWrite,
10478        )
10479        .unwrap();
10480        target_image
10481            .as_typed::<u8>()
10482            .unwrap()
10483            .map()
10484            .unwrap()
10485            .as_mut_slice()
10486            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10487
10488        // CPU path resolves the untagged 720p source to BT.709 limited (height
10489        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10490        compare_images(&dst, &target_image, 0.98, function!());
10491    }
10492
10493    #[test]
10494    fn test_vyuy_to_rgb_cpu() {
10495        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10496        let src = TensorDyn::image(
10497            1280,
10498            720,
10499            PixelFormat::Vyuy,
10500            DType::U8,
10501            None,
10502            edgefirst_tensor::CpuAccess::ReadWrite,
10503        )
10504        .unwrap();
10505        src.as_typed::<u8>()
10506            .unwrap()
10507            .map()
10508            .unwrap()
10509            .as_mut_slice()
10510            .copy_from_slice(&file);
10511
10512        let dst = TensorDyn::image(
10513            1280,
10514            720,
10515            PixelFormat::Rgb,
10516            DType::U8,
10517            None,
10518            edgefirst_tensor::CpuAccess::ReadWrite,
10519        )
10520        .unwrap();
10521        let mut cpu_converter = CPUProcessor::new();
10522
10523        let (result, _src, dst) = convert_img(
10524            &mut cpu_converter,
10525            src,
10526            dst,
10527            Rotation::None,
10528            Flip::None,
10529            Crop::no_crop(),
10530        );
10531        result.unwrap();
10532
10533        let target_image = TensorDyn::image(
10534            1280,
10535            720,
10536            PixelFormat::Rgb,
10537            DType::U8,
10538            None,
10539            edgefirst_tensor::CpuAccess::ReadWrite,
10540        )
10541        .unwrap();
10542        target_image
10543            .as_typed::<u8>()
10544            .unwrap()
10545            .map()
10546            .unwrap()
10547            .as_mut_slice()
10548            .as_chunks_mut::<3>()
10549            .0
10550            .iter_mut()
10551            .zip(
10552                edgefirst_bench::testdata::read("camera720p.rgba")
10553                    .as_chunks::<4>()
10554                    .0,
10555            )
10556            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10557
10558        // CPU path resolves the untagged 720p source to BT.709 limited (height
10559        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10560        compare_images(&dst, &target_image, 0.98, function!());
10561    }
10562
10563    #[test]
10564    #[cfg(target_os = "linux")]
10565    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10566    fn test_vyuy_to_rgba_g2d() {
10567        if !is_g2d_available() {
10568            crate::test_support::report_skip(
10569                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10570            );
10571            return;
10572        }
10573        if !is_dma_available() {
10574            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10575            return;
10576        }
10577
10578        let src = load_bytes_to_tensor(
10579            1280,
10580            720,
10581            PixelFormat::Vyuy,
10582            None,
10583            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10584        )
10585        .unwrap();
10586
10587        let dst = TensorDyn::image(
10588            1280,
10589            720,
10590            PixelFormat::Rgba,
10591            DType::U8,
10592            Some(TensorMemory::DmaBuf),
10593            edgefirst_tensor::CpuAccess::ReadWrite,
10594        )
10595        .unwrap();
10596        let mut g2d_converter = G2DProcessor::new().unwrap();
10597
10598        let (result, _src, dst) = convert_img(
10599            &mut g2d_converter,
10600            src,
10601            dst,
10602            Rotation::None,
10603            Flip::None,
10604            Crop::no_crop(),
10605        );
10606        match result {
10607            Err(Error::G2D(_)) => {
10608                crate::test_support::report_skip(
10609                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10610                );
10611                return;
10612            }
10613            r => r.unwrap(),
10614        }
10615
10616        let target_image = TensorDyn::image(
10617            1280,
10618            720,
10619            PixelFormat::Rgba,
10620            DType::U8,
10621            None,
10622            edgefirst_tensor::CpuAccess::ReadWrite,
10623        )
10624        .unwrap();
10625        target_image
10626            .as_typed::<u8>()
10627            .unwrap()
10628            .map()
10629            .unwrap()
10630            .as_mut_slice()
10631            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10632
10633        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10634        // so the matrix delta vs the reference that forced 0.95 has closed;
10635        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10636        compare_images(&dst, &target_image, 0.98, function!());
10637    }
10638
10639    #[test]
10640    #[cfg(target_os = "linux")]
10641    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10642    fn test_vyuy_to_rgb_g2d() {
10643        if !is_g2d_available() {
10644            crate::test_support::report_skip(
10645                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10646            );
10647            return;
10648        }
10649        if !is_dma_available() {
10650            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10651            return;
10652        }
10653
10654        let src = load_bytes_to_tensor(
10655            1280,
10656            720,
10657            PixelFormat::Vyuy,
10658            None,
10659            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10660        )
10661        .unwrap();
10662
10663        let g2d_dst = TensorDyn::image(
10664            1280,
10665            720,
10666            PixelFormat::Rgb,
10667            DType::U8,
10668            Some(TensorMemory::DmaBuf),
10669            edgefirst_tensor::CpuAccess::ReadWrite,
10670        )
10671        .unwrap();
10672        let mut g2d_converter = G2DProcessor::new().unwrap();
10673
10674        let (result, src, g2d_dst) = convert_img(
10675            &mut g2d_converter,
10676            src,
10677            g2d_dst,
10678            Rotation::None,
10679            Flip::None,
10680            Crop::no_crop(),
10681        );
10682        match result {
10683            Err(Error::G2D(_)) => {
10684                crate::test_support::report_skip(
10685                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10686                );
10687                return;
10688            }
10689            r => r.unwrap(),
10690        }
10691
10692        let cpu_dst = TensorDyn::image(
10693            1280,
10694            720,
10695            PixelFormat::Rgb,
10696            DType::U8,
10697            None,
10698            edgefirst_tensor::CpuAccess::ReadWrite,
10699        )
10700        .unwrap();
10701        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10702
10703        let (result, _src, cpu_dst) = convert_img(
10704            &mut cpu_converter,
10705            src,
10706            cpu_dst,
10707            Rotation::None,
10708            Flip::None,
10709            Crop::no_crop(),
10710        );
10711        result.unwrap();
10712
10713        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10714        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10715        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10716        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10717        // structural gap not exercised by these limited-range fixtures.
10718        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10719    }
10720
10721    #[test]
10722    #[cfg(any(
10723        target_os = "linux",
10724        target_os = "macos",
10725        target_os = "ios",
10726        target_os = "android",
10727        target_os = "windows"
10728    ))]
10729    #[cfg(feature = "opengl")]
10730    fn test_vyuy_to_rgba_opengl() {
10731        if !is_opengl_available() {
10732            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10733            return;
10734        }
10735        if !crate::test_support::is_gpu_buffer_available() {
10736            crate::test_support::report_skip(&format!(
10737                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10738                function!()
10739            ));
10740            return;
10741        }
10742
10743        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on macOS/iOS.
10744        let expect = if cfg!(target_os = "linux") {
10745            crate::test_support::Expect::Succeeds
10746        } else {
10747            crate::test_support::Expect::MayFail("no zero-copy VYUY mapping on this platform")
10748        };
10749        let Some(src) = crate::test_support::allocated(
10750            load_bytes_to_tensor(
10751                1280,
10752                720,
10753                PixelFormat::Vyuy,
10754                Some(TensorMemory::DmaBuf),
10755                &edgefirst_bench::testdata::read("camera720p.vyuy"),
10756            ),
10757            "zero-copy VYUY source",
10758            expect,
10759        ) else {
10760            return;
10761        };
10762
10763        let dst = TensorDyn::image(
10764            1280,
10765            720,
10766            PixelFormat::Rgba,
10767            DType::U8,
10768            Some(TensorMemory::DmaBuf),
10769            edgefirst_tensor::CpuAccess::ReadWrite,
10770        )
10771        .unwrap();
10772        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10773
10774        let (result, _src, dst) = convert_img(
10775            &mut gl_converter,
10776            src,
10777            dst,
10778            Rotation::None,
10779            Flip::None,
10780            Crop::no_crop(),
10781        );
10782        match result {
10783            Err(Error::NotSupported(_)) => {
10784                crate::test_support::report_skip(&format!(
10785                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10786                    function!()
10787                ));
10788                return;
10789            }
10790            r => r.unwrap(),
10791        }
10792
10793        let target_image = TensorDyn::image(
10794            1280,
10795            720,
10796            PixelFormat::Rgba,
10797            DType::U8,
10798            None,
10799            edgefirst_tensor::CpuAccess::ReadWrite,
10800        )
10801        .unwrap();
10802        target_image
10803            .as_typed::<u8>()
10804            .unwrap()
10805            .map()
10806            .unwrap()
10807            .as_mut_slice()
10808            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10809
10810        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10811        // so the matrix delta vs the reference that forced 0.95 has closed;
10812        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10813        compare_images(&dst, &target_image, 0.98, function!());
10814    }
10815
10816    #[test]
10817    fn test_nv12_to_rgba_cpu() {
10818        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10819        let src = TensorDyn::image(
10820            1280,
10821            720,
10822            PixelFormat::Nv12,
10823            DType::U8,
10824            None,
10825            edgefirst_tensor::CpuAccess::ReadWrite,
10826        )
10827        .unwrap();
10828        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10829            .copy_from_slice(&file);
10830
10831        let dst = TensorDyn::image(
10832            1280,
10833            720,
10834            PixelFormat::Rgba,
10835            DType::U8,
10836            None,
10837            edgefirst_tensor::CpuAccess::ReadWrite,
10838        )
10839        .unwrap();
10840        let mut cpu_converter = CPUProcessor::new();
10841
10842        let (result, _src, dst) = convert_img(
10843            &mut cpu_converter,
10844            src,
10845            dst,
10846            Rotation::None,
10847            Flip::None,
10848            Crop::no_crop(),
10849        );
10850        result.unwrap();
10851
10852        let target_image = crate::load_image_test_helper(
10853            &edgefirst_bench::testdata::read("zidane.jpg"),
10854            Some(PixelFormat::Rgba),
10855            None,
10856        )
10857        .unwrap();
10858
10859        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10860        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10861        // decode), so it differs slightly from the RGBA derived from the
10862        // separate `zidane.nv12` fixture.
10863        compare_images(&dst, &target_image, 0.95, function!());
10864    }
10865
10866    #[test]
10867    fn test_nv12_odd_height_to_rgb_cpu() {
10868        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10869        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10870        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10871        // must convert to a uniform grey RGB, exercising the odd-height
10872        // chroma-row count and the logical-height derivation in convert.
10873        // (Odd *width* is rounded to an even buffer at allocation, so it is
10874        // covered by the decode integration tests rather than here.)
10875        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10876        // DMA on i.MX, which would leave the dst's row padding unconverted and
10877        // break the flat byte scan below).
10878        let mut src = TensorDyn::image(
10879            8,
10880            5,
10881            PixelFormat::Nv12,
10882            DType::U8,
10883            Some(TensorMemory::Mem),
10884            edgefirst_tensor::CpuAccess::ReadWrite,
10885        )
10886        .unwrap();
10887        assert_eq!(src.shape(), &[8, 8]);
10888        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10889        src.as_typed::<u8>()
10890            .unwrap()
10891            .map()
10892            .unwrap()
10893            .as_mut_slice()
10894            .fill(128);
10895        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10896        // identity this test asserts). Without a tag, the colorimetry heuristic
10897        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10898        src.set_colorimetry(Some(
10899            edgefirst_tensor::Colorimetry::default()
10900                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10901                .with_range(edgefirst_tensor::ColorRange::Full),
10902        ));
10903
10904        let dst = TensorDyn::image(
10905            8,
10906            5,
10907            PixelFormat::Rgb,
10908            DType::U8,
10909            Some(TensorMemory::Mem),
10910            edgefirst_tensor::CpuAccess::ReadWrite,
10911        )
10912        .unwrap();
10913        let mut cpu_converter = CPUProcessor::new();
10914        let (result, _src, dst) = convert_img(
10915            &mut cpu_converter,
10916            src,
10917            dst,
10918            Rotation::None,
10919            Flip::None,
10920            Crop::no_crop(),
10921        );
10922        result.unwrap();
10923
10924        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10925        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10926        for (i, &b) in map.as_slice().iter().enumerate() {
10927            assert!(
10928                (b as i16 - 128).abs() <= 2,
10929                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10930            );
10931        }
10932    }
10933
10934    #[test]
10935    fn test_nv24_to_rgb_cpu() {
10936        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10937        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10938        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10939        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10940        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10941        let mut src = TensorDyn::image(
10942            8,
10943            4,
10944            PixelFormat::Nv24,
10945            DType::U8,
10946            Some(TensorMemory::Mem),
10947            edgefirst_tensor::CpuAccess::ReadWrite,
10948        )
10949        .unwrap();
10950        assert_eq!(src.shape(), &[12, 8]);
10951        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10952        src.as_typed::<u8>()
10953            .unwrap()
10954            .map()
10955            .unwrap()
10956            .as_mut_slice()
10957            .fill(128);
10958        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10959        // the heuristic picks limited range and Y=128 expands to ~131.
10960        src.set_colorimetry(Some(
10961            edgefirst_tensor::Colorimetry::default()
10962                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10963                .with_range(edgefirst_tensor::ColorRange::Full),
10964        ));
10965
10966        let dst = TensorDyn::image(
10967            8,
10968            4,
10969            PixelFormat::Rgb,
10970            DType::U8,
10971            Some(TensorMemory::Mem),
10972            edgefirst_tensor::CpuAccess::ReadWrite,
10973        )
10974        .unwrap();
10975        let mut cpu_converter = CPUProcessor::new();
10976        let (result, _src, dst) = convert_img(
10977            &mut cpu_converter,
10978            src,
10979            dst,
10980            Rotation::None,
10981            Flip::None,
10982            Crop::no_crop(),
10983        );
10984        result.unwrap();
10985
10986        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10987        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10988        for (i, &b) in map.as_slice().iter().enumerate() {
10989            assert!(
10990                (b as i16 - 128).abs() <= 2,
10991                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10992            );
10993        }
10994    }
10995
10996    #[test]
10997    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10998        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10999        // YUV→RGB matrix — not just the range — drives the result. Decoding the
11000        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
11001        // distinct RGB triples, proving the CPU path honours the source's tagged
11002        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
11003        // falls through to this CPU path; QA F9.)
11004        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
11005        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
11006            let mut src = TensorDyn::image(
11007                8,
11008                4,
11009                PixelFormat::Nv12,
11010                DType::U8,
11011                Some(TensorMemory::Mem),
11012                edgefirst_tensor::CpuAccess::ReadWrite,
11013            )
11014            .unwrap();
11015            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
11016            assert_eq!(src.shape(), &[6, 8]);
11017            {
11018                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11019                let buf = map.as_mut_slice();
11020                buf[..32].fill(120); // Y
11021                for px in buf[32..].as_chunks_mut::<2>().0 {
11022                    px[0] = 180; // U / Cb
11023                    px[1] = 64; // V / Cr
11024                }
11025            }
11026            // Range held constant (Limited) across all three so only the encoding
11027            // matrix varies between runs.
11028            src.set_colorimetry(Some(
11029                edgefirst_tensor::Colorimetry::default()
11030                    .with_encoding(enc)
11031                    .with_range(edgefirst_tensor::ColorRange::Limited),
11032            ));
11033            let dst = TensorDyn::image(
11034                8,
11035                4,
11036                PixelFormat::Rgb,
11037                DType::U8,
11038                Some(TensorMemory::Mem),
11039                edgefirst_tensor::CpuAccess::ReadWrite,
11040            )
11041            .unwrap();
11042            let mut cpu = CPUProcessor::new();
11043            let (result, _src, dst) = convert_img(
11044                &mut cpu,
11045                src,
11046                dst,
11047                Rotation::None,
11048                Flip::None,
11049                Crop::no_crop(),
11050            );
11051            result.unwrap();
11052            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
11053            let s = map.as_slice();
11054            [s[0], s[1], s[2]]
11055        }
11056
11057        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
11058        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
11059        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
11060
11061        assert_ne!(
11062            bt2020, bt601,
11063            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
11064        );
11065        assert_ne!(
11066            bt2020, bt709,
11067            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
11068        );
11069        assert_ne!(
11070            bt601, bt709,
11071            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
11072        );
11073    }
11074
11075    #[test]
11076    fn test_nv12_to_rgb_cpu() {
11077        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
11078        let src = TensorDyn::image(
11079            1280,
11080            720,
11081            PixelFormat::Nv12,
11082            DType::U8,
11083            None,
11084            edgefirst_tensor::CpuAccess::ReadWrite,
11085        )
11086        .unwrap();
11087        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
11088            .copy_from_slice(&file);
11089
11090        let dst = TensorDyn::image(
11091            1280,
11092            720,
11093            PixelFormat::Rgb,
11094            DType::U8,
11095            None,
11096            edgefirst_tensor::CpuAccess::ReadWrite,
11097        )
11098        .unwrap();
11099        let mut cpu_converter = CPUProcessor::new();
11100
11101        let (result, _src, dst) = convert_img(
11102            &mut cpu_converter,
11103            src,
11104            dst,
11105            Rotation::None,
11106            Flip::None,
11107            Crop::no_crop(),
11108        );
11109        result.unwrap();
11110
11111        let target_image = crate::load_image_test_helper(
11112            &edgefirst_bench::testdata::read("zidane.jpg"),
11113            Some(PixelFormat::Rgb),
11114            None,
11115        )
11116        .unwrap();
11117
11118        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
11119        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
11120        // decode), so it differs slightly from the RGB derived from the
11121        // separate `zidane.nv12` fixture.
11122        compare_images(&dst, &target_image, 0.95, function!());
11123    }
11124
11125    #[test]
11126    fn test_nv12_to_grey_cpu() {
11127        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
11128        let src = TensorDyn::image(
11129            1280,
11130            720,
11131            PixelFormat::Nv12,
11132            DType::U8,
11133            None,
11134            edgefirst_tensor::CpuAccess::ReadWrite,
11135        )
11136        .unwrap();
11137        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
11138            .copy_from_slice(&file);
11139
11140        let dst = TensorDyn::image(
11141            1280,
11142            720,
11143            PixelFormat::Grey,
11144            DType::U8,
11145            None,
11146            edgefirst_tensor::CpuAccess::ReadWrite,
11147        )
11148        .unwrap();
11149        let mut cpu_converter = CPUProcessor::new();
11150
11151        let (result, _src, dst) = convert_img(
11152            &mut cpu_converter,
11153            src,
11154            dst,
11155            Rotation::None,
11156            Flip::None,
11157            Crop::no_crop(),
11158        );
11159        result.unwrap();
11160
11161        let target_image = crate::load_image_test_helper(
11162            &edgefirst_bench::testdata::read("zidane.jpg"),
11163            Some(PixelFormat::Grey),
11164            None,
11165        )
11166        .unwrap();
11167
11168        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
11169        // the colour JPEG decoded to native NV12 and then converted to GREY
11170        // (was a direct JPEG → GREY decode), so it differs slightly from the
11171        // grey derived from the `zidane.nv12` fixture.
11172        compare_images(&dst, &target_image, 0.95, function!());
11173    }
11174
11175    #[test]
11176    fn test_nv12_to_yuyv_cpu() {
11177        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
11178        let src = TensorDyn::image(
11179            1280,
11180            720,
11181            PixelFormat::Nv12,
11182            DType::U8,
11183            None,
11184            edgefirst_tensor::CpuAccess::ReadWrite,
11185        )
11186        .unwrap();
11187        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
11188            .copy_from_slice(&file);
11189
11190        let dst = TensorDyn::image(
11191            1280,
11192            720,
11193            PixelFormat::Yuyv,
11194            DType::U8,
11195            None,
11196            edgefirst_tensor::CpuAccess::ReadWrite,
11197        )
11198        .unwrap();
11199        let mut cpu_converter = CPUProcessor::new();
11200
11201        let (result, _src, dst) = convert_img(
11202            &mut cpu_converter,
11203            src,
11204            dst,
11205            Rotation::None,
11206            Flip::None,
11207            Crop::no_crop(),
11208        );
11209        result.unwrap();
11210
11211        let target_image = crate::load_image_test_helper(
11212            &edgefirst_bench::testdata::read("zidane.jpg"),
11213            Some(PixelFormat::Rgb),
11214            None,
11215        )
11216        .unwrap();
11217
11218        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
11219        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
11220        // decode), so it differs slightly from the YUYV-sourced frame derived
11221        // from the separate `zidane.nv12` fixture.
11222        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
11223    }
11224
11225    #[test]
11226    fn test_cpu_resize_nv16() {
11227        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11228        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11229
11230        let cpu_nv16_dst = TensorDyn::image(
11231            640,
11232            640,
11233            PixelFormat::Nv16,
11234            DType::U8,
11235            None,
11236            edgefirst_tensor::CpuAccess::ReadWrite,
11237        )
11238        .unwrap();
11239        let cpu_rgb_dst = TensorDyn::image(
11240            640,
11241            640,
11242            PixelFormat::Rgb,
11243            DType::U8,
11244            None,
11245            edgefirst_tensor::CpuAccess::ReadWrite,
11246        )
11247        .unwrap();
11248        let mut cpu_converter = CPUProcessor::new();
11249        let crop = Crop::letterbox([255, 128, 0, 255]);
11250
11251        let (result, src, cpu_nv16_dst) = convert_img(
11252            &mut cpu_converter,
11253            src,
11254            cpu_nv16_dst,
11255            Rotation::None,
11256            Flip::None,
11257            crop,
11258        );
11259        result.unwrap();
11260
11261        let (result, _src, cpu_rgb_dst) = convert_img(
11262            &mut cpu_converter,
11263            src,
11264            cpu_rgb_dst,
11265            Rotation::None,
11266            Flip::None,
11267            crop,
11268        );
11269        result.unwrap();
11270        compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
11271    }
11272
11273    fn load_bytes_to_tensor(
11274        width: usize,
11275        height: usize,
11276        format: PixelFormat,
11277        memory: Option<TensorMemory>,
11278        bytes: &[u8],
11279    ) -> Result<TensorDyn, Error> {
11280        let src = TensorDyn::image(
11281            width,
11282            height,
11283            format,
11284            DType::U8,
11285            memory,
11286            edgefirst_tensor::CpuAccess::ReadWrite,
11287        )?;
11288        src.as_typed::<u8>()
11289            .unwrap()
11290            .map()?
11291            .as_mut_slice()
11292            .copy_from_slice(bytes);
11293        Ok(src)
11294    }
11295
11296    // DEDUP: this function is also defined verbatim in
11297    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
11298    // must be kept in sync. Cross-module sharing would require either a
11299    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
11300    // separate test-utils crate — both are disproportionate for a single
11301    // helper. If the implementation ever diverges, extract to a shared
11302    // `test_helpers` module in this crate.
11303    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
11304        assert_eq!(img1.height(), img2.height(), "Heights differ");
11305        assert_eq!(img1.width(), img2.width(), "Widths differ");
11306        assert_eq!(
11307            img1.format().unwrap(),
11308            img2.format().unwrap(),
11309            "PixelFormat differ"
11310        );
11311        assert!(
11312            matches!(
11313                img1.format().unwrap(),
11314                PixelFormat::Rgb
11315                    | PixelFormat::Rgba
11316                    | PixelFormat::Grey
11317                    | PixelFormat::PlanarRgb
11318                    | PixelFormat::PlanarRgba
11319            ),
11320            "format must be Rgb or Rgba for comparison"
11321        );
11322
11323        // A pitch-padded destination -- the D3D11 staging texture behind a
11324        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
11325        // -- spaces its rows at `effective_row_stride()`, so reading the head
11326        // of the mapping as a tight image would read padding as pixels.
11327        // `copy_to_flat` is the tensor's own compaction and degenerates to one
11328        // memcpy when the tensor is tight.
11329        fn flat(img: &TensorDyn) -> Vec<u8> {
11330            let t = img.as_typed::<u8>().expect("u8 image");
11331            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
11332            t.copy_to_flat(&mut out).expect("compact the padded rows");
11333            out
11334        }
11335
11336        let image1 = match img1.format().unwrap() {
11337            PixelFormat::Rgb => image::RgbImage::from_vec(
11338                img1.width().unwrap() as u32,
11339                img1.height().unwrap() as u32,
11340                flat(img1),
11341            )
11342            .unwrap(),
11343            PixelFormat::Rgba => image::RgbaImage::from_vec(
11344                img1.width().unwrap() as u32,
11345                img1.height().unwrap() as u32,
11346                flat(img1),
11347            )
11348            .unwrap()
11349            .convert(),
11350            PixelFormat::Grey => image::GrayImage::from_vec(
11351                img1.width().unwrap() as u32,
11352                img1.height().unwrap() as u32,
11353                flat(img1),
11354            )
11355            .unwrap()
11356            .convert(),
11357            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11358                img1.width().unwrap() as u32,
11359                (img1.height().unwrap() * img1.format().unwrap().channels()) as u32,
11360                flat(img1),
11361            )
11362            .unwrap()
11363            .convert(),
11364            _ => return,
11365        };
11366
11367        let image2 = match img2.format().unwrap() {
11368            PixelFormat::Rgb => image::RgbImage::from_vec(
11369                img2.width().unwrap() as u32,
11370                img2.height().unwrap() as u32,
11371                flat(img2),
11372            )
11373            .unwrap(),
11374            PixelFormat::Rgba => image::RgbaImage::from_vec(
11375                img2.width().unwrap() as u32,
11376                img2.height().unwrap() as u32,
11377                flat(img2),
11378            )
11379            .unwrap()
11380            .convert(),
11381            PixelFormat::Grey => image::GrayImage::from_vec(
11382                img2.width().unwrap() as u32,
11383                img2.height().unwrap() as u32,
11384                flat(img2),
11385            )
11386            .unwrap()
11387            .convert(),
11388            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11389                img2.width().unwrap() as u32,
11390                (img2.height().unwrap() * img2.format().unwrap().channels()) as u32,
11391                flat(img2),
11392            )
11393            .unwrap()
11394            .convert(),
11395            _ => return,
11396        };
11397
11398        let similarity = image_compare::rgb_similarity_structure(
11399            &image_compare::Algorithm::RootMeanSquared,
11400            &image1,
11401            &image2,
11402        )
11403        .expect("Image Comparison failed");
11404        if similarity.score < threshold {
11405            // image1.save(format!("{name}_1.png"));
11406            // image2.save(format!("{name}_2.png"));
11407            similarity
11408                .image
11409                .to_color_map()
11410                .save(format!("{name}.png"))
11411                .unwrap();
11412            panic!(
11413                "{name}: converted image and target image have similarity score too low: {} < {}",
11414                similarity.score, threshold
11415            )
11416        }
11417    }
11418
11419    fn compare_images_convert_to_rgb(
11420        img1: &TensorDyn,
11421        img2: &TensorDyn,
11422        threshold: f64,
11423        name: &str,
11424    ) {
11425        assert_eq!(img1.height(), img2.height(), "Heights differ");
11426        assert_eq!(img1.width(), img2.width(), "Widths differ");
11427
11428        let mut img_rgb1 = TensorDyn::image(
11429            img1.width().unwrap(),
11430            img1.height().unwrap(),
11431            PixelFormat::Rgb,
11432            DType::U8,
11433            Some(TensorMemory::Mem),
11434            edgefirst_tensor::CpuAccess::ReadWrite,
11435        )
11436        .unwrap();
11437        let mut img_rgb2 = TensorDyn::image(
11438            img1.width().unwrap(),
11439            img1.height().unwrap(),
11440            PixelFormat::Rgb,
11441            DType::U8,
11442            Some(TensorMemory::Mem),
11443            edgefirst_tensor::CpuAccess::ReadWrite,
11444        )
11445        .unwrap();
11446        let mut __cv = CPUProcessor::default();
11447        let r1 = __cv.convert(img1, &mut img_rgb1, None);
11448        let r2 = __cv.convert(img2, &mut img_rgb2, None);
11449        if r1.is_err() || r2.is_err() {
11450            // Fallback: compare raw bytes as greyscale strip
11451            let w = img1.width().unwrap() as u32;
11452            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11453            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11454            let h1 = (data1.len() as u32) / w;
11455            let h2 = (data2.len() as u32) / w;
11456            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
11457            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
11458            let similarity = image_compare::gray_similarity_structure(
11459                &image_compare::Algorithm::RootMeanSquared,
11460                &g1,
11461                &g2,
11462            )
11463            .expect("Image Comparison failed");
11464            if similarity.score < threshold {
11465                panic!(
11466                    "{name}: converted image and target image have similarity score too low: {} < {}",
11467                    similarity.score, threshold
11468                )
11469            }
11470            return;
11471        }
11472
11473        let image1 = image::RgbImage::from_vec(
11474            img_rgb1.width().unwrap() as u32,
11475            img_rgb1.height().unwrap() as u32,
11476            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11477        )
11478        .unwrap();
11479
11480        let image2 = image::RgbImage::from_vec(
11481            img_rgb2.width().unwrap() as u32,
11482            img_rgb2.height().unwrap() as u32,
11483            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11484        )
11485        .unwrap();
11486
11487        let similarity = image_compare::rgb_similarity_structure(
11488            &image_compare::Algorithm::RootMeanSquared,
11489            &image1,
11490            &image2,
11491        )
11492        .expect("Image Comparison failed");
11493        if similarity.score < threshold {
11494            // image1.save(format!("{name}_1.png"));
11495            // image2.save(format!("{name}_2.png"));
11496            similarity
11497                .image
11498                .to_color_map()
11499                .save(format!("{name}.png"))
11500                .unwrap();
11501            panic!(
11502                "{name}: converted image and target image have similarity score too low: {} < {}",
11503                similarity.score, threshold
11504            )
11505        }
11506    }
11507
11508    // =========================================================================
11509    // PixelFormat::Nv12 Format Tests
11510    // =========================================================================
11511
11512    #[test]
11513    fn test_nv12_image_creation() {
11514        let width = 640;
11515        let height = 480;
11516        let img = TensorDyn::image(
11517            width,
11518            height,
11519            PixelFormat::Nv12,
11520            DType::U8,
11521            None,
11522            edgefirst_tensor::CpuAccess::ReadWrite,
11523        )
11524        .unwrap();
11525
11526        assert_eq!(img.width(), Some(width));
11527        assert_eq!(img.height(), Some(height));
11528        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11529        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11530        assert_eq!(
11531            img.as_typed::<u8>().unwrap().shape(),
11532            &[height * 3 / 2, width]
11533        );
11534    }
11535
11536    #[test]
11537    fn test_nv12_channels() {
11538        let img = TensorDyn::image(
11539            640,
11540            480,
11541            PixelFormat::Nv12,
11542            DType::U8,
11543            None,
11544            edgefirst_tensor::CpuAccess::ReadWrite,
11545        )
11546        .unwrap();
11547        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11548        assert_eq!(img.format().unwrap().channels(), 1);
11549    }
11550
11551    // =========================================================================
11552    // Tensor Format Metadata Tests
11553    // =========================================================================
11554
11555    #[test]
11556    fn test_tensor_set_format_planar() {
11557        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11558        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11559        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11560        assert_eq!(tensor.width(), Some(640));
11561        assert_eq!(tensor.height(), Some(480));
11562    }
11563
11564    #[test]
11565    fn test_tensor_set_format_interleaved() {
11566        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11567        tensor.set_format(PixelFormat::Rgba).unwrap();
11568        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11569        assert_eq!(tensor.width(), Some(640));
11570        assert_eq!(tensor.height(), Some(480));
11571    }
11572
11573    #[test]
11574    fn test_tensordyn_image_rgb() {
11575        let img = TensorDyn::image(
11576            640,
11577            480,
11578            PixelFormat::Rgb,
11579            DType::U8,
11580            None,
11581            edgefirst_tensor::CpuAccess::ReadWrite,
11582        )
11583        .unwrap();
11584        assert_eq!(img.width(), Some(640));
11585        assert_eq!(img.height(), Some(480));
11586        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11587    }
11588
11589    #[test]
11590    fn test_tensordyn_image_planar_rgb() {
11591        let img = TensorDyn::image(
11592            640,
11593            480,
11594            PixelFormat::PlanarRgb,
11595            DType::U8,
11596            None,
11597            edgefirst_tensor::CpuAccess::ReadWrite,
11598        )
11599        .unwrap();
11600        assert_eq!(img.width(), Some(640));
11601        assert_eq!(img.height(), Some(480));
11602        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11603    }
11604
11605    #[test]
11606    fn test_rgb_int8_format() {
11607        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11608        let img = TensorDyn::image(
11609            1280,
11610            720,
11611            PixelFormat::Rgb,
11612            DType::I8,
11613            Some(TensorMemory::Mem),
11614            edgefirst_tensor::CpuAccess::ReadWrite,
11615        )
11616        .unwrap();
11617        assert_eq!(img.width(), Some(1280));
11618        assert_eq!(img.height(), Some(720));
11619        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11620        assert_eq!(img.dtype(), DType::I8);
11621    }
11622
11623    #[test]
11624    fn test_planar_rgb_int8_format() {
11625        let img = TensorDyn::image(
11626            1280,
11627            720,
11628            PixelFormat::PlanarRgb,
11629            DType::I8,
11630            Some(TensorMemory::Mem),
11631            edgefirst_tensor::CpuAccess::ReadWrite,
11632        )
11633        .unwrap();
11634        assert_eq!(img.width(), Some(1280));
11635        assert_eq!(img.height(), Some(720));
11636        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11637        assert_eq!(img.dtype(), DType::I8);
11638    }
11639
11640    #[test]
11641    fn test_rgb_from_tensor() {
11642        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11643        tensor.set_format(PixelFormat::Rgb).unwrap();
11644        let img = TensorDyn::from(tensor);
11645        assert_eq!(img.width(), Some(1280));
11646        assert_eq!(img.height(), Some(720));
11647        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11648    }
11649
11650    #[test]
11651    fn test_planar_rgb_from_tensor() {
11652        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11653        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11654        let img = TensorDyn::from(tensor);
11655        assert_eq!(img.width(), Some(1280));
11656        assert_eq!(img.height(), Some(720));
11657        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11658    }
11659
11660    #[test]
11661    fn test_dtype_determines_int8() {
11662        // DType::I8 indicates int8 data
11663        let u8_img = TensorDyn::image(
11664            64,
11665            64,
11666            PixelFormat::Rgb,
11667            DType::U8,
11668            None,
11669            edgefirst_tensor::CpuAccess::ReadWrite,
11670        )
11671        .unwrap();
11672        let i8_img = TensorDyn::image(
11673            64,
11674            64,
11675            PixelFormat::Rgb,
11676            DType::I8,
11677            None,
11678            edgefirst_tensor::CpuAccess::ReadWrite,
11679        )
11680        .unwrap();
11681        assert_eq!(u8_img.dtype(), DType::U8);
11682        assert_eq!(i8_img.dtype(), DType::I8);
11683    }
11684
11685    #[test]
11686    fn test_pixel_layout_packed_vs_planar() {
11687        // Packed vs planar layout classification
11688        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11689        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11690        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11691        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11692    }
11693
11694    /// Integration test that exercises the PBO-to-PBO convert path.
11695    /// A PBO cannot hand out a host address that outlives its map guard, and
11696    /// the error has to say why rather than reading as unfinished work.
11697    ///
11698    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11699    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11700    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11701    /// would mean holding the buffer mapped across GL work, which is the
11702    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11703    /// answer, but that is a buffer-allocation feature, not wiring.
11704    #[cfg(target_os = "linux")]
11705    #[cfg(feature = "opengl")]
11706    #[test]
11707    fn pbo_reports_why_it_cannot_pin() {
11708        use edgefirst_tensor::{Error, TensorTrait};
11709
11710        let converter = ImageProcessor::new().unwrap();
11711        let is_pbo = converter
11712            .opengl
11713            .as_ref()
11714            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11715        if !is_pbo {
11716            crate::test_support::report_skip(
11717                "pbo_reports_why_it_cannot_pin - transfer backend is not PBO",
11718            );
11719            return;
11720        }
11721
11722        let t = converter
11723            .create_image(
11724                64,
11725                64,
11726                PixelFormat::Rgba,
11727                DType::U8,
11728                None,
11729                edgefirst_tensor::CpuAccess::ReadWrite,
11730            )
11731            .unwrap();
11732        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11733        let t = t.as_typed::<u8>().unwrap();
11734
11735        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11736            Err(Error::NotImplemented(m)) => {
11737                assert!(
11738                    m.contains("glMapBufferRange"),
11739                    "must name the constraint: {m}"
11740                );
11741                assert!(m.contains("map()"), "must point at the supported API: {m}");
11742            }
11743            other => panic!("expected an explained NotImplemented, got {other:?}"),
11744        }
11745        // Sync is unavailable for the same reason, and must not silently succeed.
11746        assert!(matches!(
11747            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11748            Err(Error::NotImplemented(_))
11749        ));
11750
11751        // The supported path still works, which is what makes the refusal safe.
11752        let g = t.map().expect("map must work where pin cannot");
11753        assert!(!g.as_slice().is_empty());
11754    }
11755
11756    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11757    /// then converts between them. Skipped when GL is unavailable or the
11758    /// backend is not PBO (e.g. DMA-buf systems).
11759    #[cfg(target_os = "linux")]
11760    #[cfg(feature = "opengl")]
11761    #[test]
11762    fn test_convert_pbo_to_pbo() {
11763        let mut converter = ImageProcessor::new().unwrap();
11764
11765        // Skip if GL is not available or backend is not PBO
11766        let is_pbo = converter
11767            .opengl
11768            .as_ref()
11769            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11770        if !is_pbo {
11771            crate::test_support::report_skip(
11772                "test_convert_pbo_to_pbo - transfer backend is not PBO",
11773            );
11774            return;
11775        }
11776
11777        let src_w = 640;
11778        let src_h = 480;
11779        let dst_w = 320;
11780        let dst_h = 240;
11781
11782        // Create PBO-backed source image
11783        let pbo_src = converter
11784            .create_image(
11785                src_w,
11786                src_h,
11787                PixelFormat::Rgba,
11788                DType::U8,
11789                None,
11790                edgefirst_tensor::CpuAccess::ReadWrite,
11791            )
11792            .unwrap();
11793        assert_eq!(
11794            pbo_src.as_typed::<u8>().unwrap().memory(),
11795            TensorMemory::Pbo,
11796            "create_image should produce a PBO tensor"
11797        );
11798
11799        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11800        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11801        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11802
11803        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11804        let mem_src = TensorDyn::image(
11805            src_w,
11806            src_h,
11807            PixelFormat::Rgba,
11808            DType::U8,
11809            Some(TensorMemory::Mem),
11810            edgefirst_tensor::CpuAccess::ReadWrite,
11811        )
11812        .unwrap();
11813        let (result, _jpeg_src, mem_src) = convert_img(
11814            &mut CPUProcessor::new(),
11815            jpeg_src,
11816            mem_src,
11817            Rotation::None,
11818            Flip::None,
11819            Crop::no_crop(),
11820        );
11821        result.unwrap();
11822
11823        // Copy pixel data into the PBO source by mapping it
11824        {
11825            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11826            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11827            pbo_map.copy_from_slice(&src_data);
11828        }
11829
11830        // Create PBO-backed destination image
11831        let pbo_dst = converter
11832            .create_image(
11833                dst_w,
11834                dst_h,
11835                PixelFormat::Rgba,
11836                DType::U8,
11837                None,
11838                edgefirst_tensor::CpuAccess::ReadWrite,
11839            )
11840            .unwrap();
11841        assert_eq!(
11842            pbo_dst.as_typed::<u8>().unwrap().memory(),
11843            TensorMemory::Pbo
11844        );
11845
11846        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11847        let mut pbo_dst = pbo_dst;
11848        let result = converter.convert(&pbo_src, &mut pbo_dst, None);
11849        result.unwrap();
11850
11851        // Verify: compare with CPU-only conversion of the same input
11852        let cpu_dst = TensorDyn::image(
11853            dst_w,
11854            dst_h,
11855            PixelFormat::Rgba,
11856            DType::U8,
11857            Some(TensorMemory::Mem),
11858            edgefirst_tensor::CpuAccess::ReadWrite,
11859        )
11860        .unwrap();
11861        let (result, _mem_src, cpu_dst) = convert_img(
11862            &mut CPUProcessor::new(),
11863            mem_src,
11864            cpu_dst,
11865            Rotation::None,
11866            Flip::None,
11867            Crop::no_crop(),
11868        );
11869        result.unwrap();
11870
11871        let pbo_dst_img = {
11872            let mut __t = pbo_dst.into_u8().unwrap();
11873            __t.set_format(PixelFormat::Rgba).unwrap();
11874            TensorDyn::from(__t)
11875        };
11876        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11877        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11878    }
11879
11880    #[test]
11881    fn test_image_bgra() {
11882        let img = TensorDyn::image(
11883            640,
11884            480,
11885            PixelFormat::Bgra,
11886            DType::U8,
11887            Some(edgefirst_tensor::TensorMemory::Mem),
11888            edgefirst_tensor::CpuAccess::ReadWrite,
11889        )
11890        .unwrap();
11891        assert_eq!(img.width(), Some(640));
11892        assert_eq!(img.height(), Some(480));
11893        assert_eq!(img.format().unwrap().channels(), 4);
11894        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11895    }
11896
11897    // ========================================================================
11898    // Tests for EDGEFIRST_FORCE_BACKEND env var
11899    // ========================================================================
11900
11901    #[test]
11902    fn test_force_backend_cpu() {
11903        let _lock = acquire_env_lock();
11904        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11905        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11906        let converter = ImageProcessor::new().unwrap();
11907        assert!(converter.cpu.is_some());
11908        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11909    }
11910
11911    #[test]
11912    fn test_force_backend_invalid() {
11913        let _lock = acquire_env_lock();
11914        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11915        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11916        let result = ImageProcessor::new();
11917        assert!(
11918            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11919            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11920        );
11921    }
11922
11923    #[test]
11924    fn test_force_backend_unset() {
11925        let _lock = acquire_env_lock();
11926        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11927        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11928        let converter = ImageProcessor::new().unwrap();
11929        assert!(converter.forced_backend.is_none());
11930    }
11931
11932    // ========================================================================
11933    // Tests for hybrid mask path error handling
11934    // ========================================================================
11935
11936    #[test]
11937    fn test_draw_proto_masks_no_cpu_returns_error() {
11938        // Serialize against all other env-var-mutating tests.
11939        let _lock = acquire_env_lock();
11940        let _guard = EnvGuard::snapshot(&[
11941            "EDGEFIRST_FORCE_BACKEND",
11942            "EDGEFIRST_DISABLE_GL",
11943            "EDGEFIRST_DISABLE_G2D",
11944            "EDGEFIRST_DISABLE_CPU",
11945        ]);
11946
11947        // Disable all backends so cpu.is_none() after construction.
11948        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11949        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11950        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11951
11952        let mut converter = ImageProcessor::new().unwrap();
11953        assert!(converter.cpu.is_none(), "CPU should be disabled");
11954
11955        let dst = TensorDyn::image(
11956            640,
11957            480,
11958            PixelFormat::Rgba,
11959            DType::U8,
11960            Some(TensorMemory::Mem),
11961            edgefirst_tensor::CpuAccess::ReadWrite,
11962        )
11963        .unwrap();
11964        let mut dst_dyn = dst;
11965        let det = [DetectBox {
11966            bbox: edgefirst_tensor::BoundingBox {
11967                xmin: 0.1,
11968                ymin: 0.1,
11969                xmax: 0.5,
11970                ymax: 0.5,
11971            },
11972            score: 0.9,
11973            label: 0,
11974        }];
11975        let proto_data = {
11976            use edgefirst_tensor::{Tensor, TensorDyn};
11977            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11978            let protos_t =
11979                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11980            ProtoData {
11981                mask_coefficients: TensorDyn::F32(coeff_t),
11982                protos: TensorDyn::F32(protos_t),
11983                layout: ProtoLayout::Nhwc,
11984            }
11985        };
11986        let result =
11987            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11988        assert!(
11989            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11990            "draw_proto_masks without CPU should return Internal error: {result:?}"
11991        );
11992    }
11993
11994    #[test]
11995    fn test_draw_proto_masks_cpu_fallback_works() {
11996        // Force CPU-only backend to ensure the CPU fallback path executes.
11997        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11998        let _lock = acquire_env_lock();
11999        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12000        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12001        let mut converter = ImageProcessor::new().unwrap();
12002        assert!(converter.cpu.is_some());
12003
12004        let dst = TensorDyn::image(
12005            64,
12006            64,
12007            PixelFormat::Rgba,
12008            DType::U8,
12009            Some(TensorMemory::Mem),
12010            edgefirst_tensor::CpuAccess::ReadWrite,
12011        )
12012        .unwrap();
12013        let mut dst_dyn = dst;
12014        let det = [DetectBox {
12015            bbox: edgefirst_tensor::BoundingBox {
12016                xmin: 0.1,
12017                ymin: 0.1,
12018                xmax: 0.5,
12019                ymax: 0.5,
12020            },
12021            score: 0.9,
12022            label: 0,
12023        }];
12024        let proto_data = {
12025            use edgefirst_tensor::{Tensor, TensorDyn};
12026            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
12027            let protos_t =
12028                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12029            ProtoData {
12030                mask_coefficients: TensorDyn::F32(coeff_t),
12031                protos: TensorDyn::F32(protos_t),
12032                layout: ProtoLayout::Nhwc,
12033            }
12034        };
12035        let result =
12036            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
12037        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
12038    }
12039
12040    // ============================================================
12041    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
12042    // verified tests. Exercises each backend against the full
12043    // output-contract matrix:
12044    //
12045    //   | detections | background | expected dst             |
12046    //   |------------|------------|--------------------------|
12047    //   | empty      | none       | fully cleared (0x00)     |
12048    //   | empty      | set        | fully equal to bg        |
12049    //   | set        | none       | cleared outside box +    |
12050    //   |            |            | mask-coloured inside     |
12051    //   | set        | set        | bg outside box + mask    |
12052    //   |            |            | blended inside           |
12053    //
12054    // Every test pre-fills dst with a non-zero "dirty" pattern so
12055    // that any silent `return Ok(())` leaks the pattern into the
12056    // asserted output and fails loudly.
12057    // ============================================================
12058
12059    // =========================================================================
12060    // Env-var serialisation helpers
12061    //
12062    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
12063    // ENV_MUTEX for their full duration.  This single mutex serialises
12064    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
12065    // test_force_backend_*, with_force_backend, and with_env — preventing
12066    // any two of them from racing in a parallel `cargo test` run.
12067    // =========================================================================
12068
12069    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
12070    /// kept alive for the entire duration of the test body.
12071    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
12072        use std::sync::{Mutex, OnceLock};
12073        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
12074        ENV_MUTEX
12075            .get_or_init(|| Mutex::new(()))
12076            .lock()
12077            .unwrap_or_else(|e| e.into_inner())
12078    }
12079
12080    /// RAII guard that snapshots a set of env vars on construction and
12081    /// restores them on `Drop`, even if the test panics.
12082    struct EnvGuard {
12083        vars: Vec<(&'static str, Option<String>)>,
12084    }
12085
12086    impl EnvGuard {
12087        /// Snapshot the current values of `names`.  Call this while holding
12088        /// the env lock (the lock is not taken here — that is the caller's
12089        /// responsibility so the lock scope can be wider than the guard).
12090        fn snapshot(names: &[&'static str]) -> Self {
12091            Self {
12092                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
12093            }
12094        }
12095    }
12096
12097    impl Drop for EnvGuard {
12098        fn drop(&mut self) {
12099            for (k, v) in &self.vars {
12100                match v {
12101                    Some(s) => unsafe { std::env::set_var(k, s) },
12102                    None => unsafe { std::env::remove_var(k) },
12103                }
12104            }
12105        }
12106    }
12107
12108    /// `EDGEFIRST_INTERPOLATION` accepts `nearest` and `bilinear` in any
12109    /// case and ignores anything else.
12110    #[test]
12111    fn test_interpolation_env_parse() {
12112        let _lock = acquire_env_lock();
12113        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12114        for (value, want) in [
12115            (Some("nearest"), Some(Interpolation::Nearest)),
12116            (Some(" BILINEAR "), Some(Interpolation::Bilinear)),
12117            (Some("lanczos"), None),
12118            (Some(""), None),
12119            (None, None),
12120        ] {
12121            match value {
12122                Some(v) => unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", v) },
12123                None => unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") },
12124            }
12125            assert_eq!(
12126                interpolation_from_env(),
12127                want,
12128                "EDGEFIRST_INTERPOLATION={value:?}"
12129            );
12130        }
12131    }
12132
12133    /// `ImageProcessorConfig::interpolation` reaches the CPU backend, and
12134    /// `EDGEFIRST_INTERPOLATION` overrides it.
12135    #[test]
12136    fn test_interpolation_config_and_env_reach_cpu() {
12137        let _lock = acquire_env_lock();
12138        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12139        let mut checker = Vec::with_capacity(8 * 8 * 4);
12140        for y in 0..8 {
12141            for x in 0..8 {
12142                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
12143                checker.extend_from_slice(&[v, v, v, 255]);
12144            }
12145        }
12146        let resize = |processor: &mut dyn ImageProcessorTrait| {
12147            let mut src = TensorDyn::image(
12148                8,
12149                8,
12150                PixelFormat::Rgba,
12151                DType::U8,
12152                Some(TensorMemory::Mem),
12153                edgefirst_tensor::CpuAccess::ReadWrite,
12154            )
12155            .unwrap();
12156            src.as_u8_mut()
12157                .unwrap()
12158                .map_mut()
12159                .unwrap()
12160                .as_mut_slice()
12161                .copy_from_slice(&checker);
12162            let mut dst = TensorDyn::image(
12163                12,
12164                12,
12165                PixelFormat::Rgba,
12166                DType::U8,
12167                Some(TensorMemory::Mem),
12168                edgefirst_tensor::CpuAccess::ReadWrite,
12169            )
12170            .unwrap();
12171            processor.convert(&src, &mut dst, None).unwrap();
12172            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12173        };
12174        let nearest = resize(&mut CPUProcessor::new_nearest());
12175        let bilinear = resize(&mut CPUProcessor::new());
12176        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
12177        let config = |interpolation| ImageProcessorConfig {
12178            backend: ComputeBackend::Cpu,
12179            interpolation,
12180            ..Default::default()
12181        };
12182
12183        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12184        let mut p = ImageProcessor::with_config(config(Interpolation::Nearest)).unwrap();
12185        assert_eq!(resize(&mut p), nearest, "config Nearest");
12186        p.set_interpolation(Interpolation::Bilinear).unwrap();
12187        assert_eq!(resize(&mut p), bilinear, "set_interpolation(Bilinear)");
12188
12189        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "nearest") };
12190        let mut p = ImageProcessor::with_config(config(Interpolation::Bilinear)).unwrap();
12191        assert_eq!(
12192            resize(&mut p),
12193            nearest,
12194            "EDGEFIRST_INTERPOLATION pins Nearest"
12195        );
12196        p.set_interpolation(Interpolation::Bilinear).unwrap();
12197        assert_eq!(
12198            resize(&mut p),
12199            nearest,
12200            "the env pin outlives set_interpolation"
12201        );
12202    }
12203
12204    /// A per-convert interpolation overrides the processor's for that
12205    /// convert only, so two option sets on one processor do not interfere;
12206    /// `EDGEFIRST_INTERPOLATION` pins both.
12207    #[test]
12208    fn test_per_convert_interpolation_overrides_processor() {
12209        let _lock = acquire_env_lock();
12210        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12211        let mut checker = Vec::with_capacity(8 * 8 * 4);
12212        for y in 0..8 {
12213            for x in 0..8 {
12214                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
12215                checker.extend_from_slice(&[v, v, v, 255]);
12216            }
12217        }
12218        let resize = |processor: &mut dyn ImageProcessorTrait, options: Option<ConvertOptions>| {
12219            let mut src = TensorDyn::image(
12220                8,
12221                8,
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(&checker);
12234            let mut dst = TensorDyn::image(
12235                12,
12236                12,
12237                PixelFormat::Rgba,
12238                DType::U8,
12239                Some(TensorMemory::Mem),
12240                edgefirst_tensor::CpuAccess::ReadWrite,
12241            )
12242            .unwrap();
12243            processor.convert(&src, &mut dst, options).unwrap();
12244            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12245        };
12246        let nearest = resize(&mut CPUProcessor::new_nearest(), None);
12247        let bilinear = resize(&mut CPUProcessor::new(), None);
12248        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
12249        let cpu_only = || {
12250            ImageProcessor::with_config(ImageProcessorConfig {
12251                backend: ComputeBackend::Cpu,
12252                ..Default::default()
12253            })
12254            .unwrap()
12255        };
12256        let model_a = ConvertOptions::new().interpolation(Interpolation::Nearest);
12257        let model_b = ConvertOptions::new().interpolation(Interpolation::Bicubic);
12258
12259        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12260        let mut p = cpu_only();
12261        assert_eq!(p.interpolation(), Interpolation::Bilinear);
12262        for _ in 0..2 {
12263            assert_eq!(resize(&mut p, Some(model_a)), nearest, "model A: nearest");
12264            assert_eq!(resize(&mut p, Some(model_b)), bilinear, "model B: bicubic");
12265            assert_eq!(resize(&mut p, None), bilinear, "processor setting kept");
12266        }
12267        p.set_interpolation(Interpolation::Nearest).unwrap();
12268        assert_eq!(resize(&mut p, Some(model_b)), bilinear, "options win");
12269        assert_eq!(resize(&mut p, None), nearest, "processor fallback");
12270
12271        let mut spec = PreprocessSpec::new();
12272        spec.interpolation = Interpolation::Nearest;
12273        let from_spec = ConvertOptions::from_spec(&spec).unwrap();
12274        assert_eq!(from_spec.get_interpolation(), Some(Interpolation::Nearest));
12275        let mut p = cpu_only();
12276        let stretch = from_spec.crop(Crop::default());
12277        assert_eq!(
12278            resize(&mut p, Some(stretch)),
12279            nearest,
12280            "from_spec carries it"
12281        );
12282
12283        let cpu = p.cpu.as_mut().unwrap();
12284        cpu.set_interpolation(Interpolation::Nearest);
12285        p.set_interpolation(Interpolation::Bilinear).unwrap();
12286        assert_eq!(
12287            resize(p.cpu.as_mut().unwrap(), None),
12288            nearest,
12289            "an unchanged mode does not reach the backends"
12290        );
12291
12292        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "bilinear") };
12293        let mut p = cpu_only();
12294        assert_eq!(resize(&mut p, Some(model_a)), bilinear, "the env pin wins");
12295    }
12296
12297    /// The threaded GL worker honours a per-convert interpolation and keeps
12298    /// the processor's for converts without one.
12299    #[test]
12300    #[cfg(feature = "opengl")]
12301    fn test_per_convert_interpolation_reaches_gl_worker() {
12302        let _lock = acquire_env_lock();
12303        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12304        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12305        let mut p = match ImageProcessor::with_config(ImageProcessorConfig {
12306            backend: ComputeBackend::OpenGl,
12307            ..Default::default()
12308        }) {
12309            Ok(p) if p.opengl.is_some() => p,
12310            _ => {
12311                crate::test_support::report_skip(&format!(
12312                    "{} — GL backend unavailable",
12313                    function!()
12314                ));
12315                return;
12316            }
12317        };
12318        let (sw, sh, dw, dh) = (40usize, 30usize, 56usize, 42usize);
12319        let mut rgba = Vec::with_capacity(sw * sh * 4);
12320        for y in 0..sh {
12321            for x in 0..sw {
12322                let v = if (x + y) % 2 == 0 { 16u8 } else { 235 };
12323                rgba.extend_from_slice(&[v, 128, 255 - v, 255]);
12324            }
12325        }
12326        let mut src = TensorDyn::image(
12327            sw,
12328            sh,
12329            PixelFormat::Rgba,
12330            DType::U8,
12331            Some(TensorMemory::Mem),
12332            edgefirst_tensor::CpuAccess::ReadWrite,
12333        )
12334        .unwrap();
12335        src.as_u8_mut()
12336            .unwrap()
12337            .map_mut()
12338            .unwrap()
12339            .as_mut_slice()
12340            .copy_from_slice(&rgba);
12341        let gl_convert = |p: &mut ImageProcessor, options: Option<ConvertOptions>| {
12342            let mut dst = TensorDyn::image(
12343                dw,
12344                dh,
12345                PixelFormat::Rgba,
12346                DType::U8,
12347                Some(TensorMemory::Mem),
12348                edgefirst_tensor::CpuAccess::ReadWrite,
12349            )
12350            .unwrap();
12351            let options = p.resolve_interpolation(options.unwrap_or_default());
12352            p.opengl
12353                .as_mut()
12354                .unwrap()
12355                .convert(&src, &mut dst, Some(options))
12356                .unwrap();
12357            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12358        };
12359        let nearest = ConvertOptions::new().interpolation(Interpolation::Nearest);
12360        let per_convert = gl_convert(&mut p, Some(nearest));
12361        let processor_bilinear = gl_convert(&mut p, None);
12362        p.set_interpolation(Interpolation::Nearest).unwrap();
12363        let processor_nearest = gl_convert(&mut p, None);
12364        assert_ne!(processor_bilinear, processor_nearest);
12365        assert_eq!(per_convert, processor_nearest, "per-convert Nearest");
12366        let bilinear = ConvertOptions::new().interpolation(Interpolation::Bilinear);
12367        assert_eq!(gl_convert(&mut p, Some(bilinear)), processor_bilinear);
12368    }
12369
12370    /// Each unsupported interpolation warns at most once per process.
12371    #[test]
12372    fn test_interpolation_fallback_warns_once() {
12373        runnable_interpolation(Interpolation::Area);
12374        assert!(!warn_first_fallback(Interpolation::Area));
12375        assert_eq!(
12376            runnable_interpolation(Interpolation::Area),
12377            Interpolation::Bilinear
12378        );
12379        assert!(!warn_first_fallback(Interpolation::Area));
12380    }
12381
12382    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
12383    /// removed), restoring the prior value afterward. Tests are env-
12384    /// serialized via the process-wide `ENV_MUTEX`.
12385    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
12386        let _lock = acquire_env_lock();
12387        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12388        match value {
12389            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
12390            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
12391        }
12392        body()
12393    }
12394
12395    /// Allocate an RGBA image tensor and pre-fill every byte with a
12396    /// distinctive non-zero pattern. Any test that relies on the old
12397    /// "dst is already cleared" assumption will see this pattern leak
12398    /// through to the output and fail.
12399    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
12400        let dst = TensorDyn::image(
12401            w,
12402            h,
12403            PixelFormat::Rgba,
12404            DType::U8,
12405            mem,
12406            edgefirst_tensor::CpuAccess::ReadWrite,
12407        )
12408        .unwrap();
12409        {
12410            use edgefirst_tensor::TensorMapTrait;
12411            let u8t = dst.as_typed::<u8>().unwrap();
12412            let mut map = u8t.map().unwrap();
12413            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
12414                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
12415            }
12416        }
12417        dst
12418    }
12419
12420    /// Allocate an RGBA background filled with a constant colour.
12421    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
12422        let bg = TensorDyn::image(
12423            w,
12424            h,
12425            PixelFormat::Rgba,
12426            DType::U8,
12427            mem,
12428            edgefirst_tensor::CpuAccess::ReadWrite,
12429        )
12430        .unwrap();
12431        {
12432            use edgefirst_tensor::TensorMapTrait;
12433            let u8t = bg.as_typed::<u8>().unwrap();
12434            let mut map = u8t.map().unwrap();
12435            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12436                chunk.copy_from_slice(&rgba);
12437            }
12438        }
12439        bg
12440    }
12441
12442    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
12443        use edgefirst_tensor::TensorMapTrait;
12444        let w = dst.width().unwrap();
12445        let off = (y * w + x) * 4;
12446        let u8t = dst.as_typed::<u8>().unwrap();
12447        let map = u8t.map().unwrap();
12448        let s = map.as_slice();
12449        [s[off], s[off + 1], s[off + 2], s[off + 3]]
12450    }
12451
12452    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
12453        use edgefirst_tensor::TensorMapTrait;
12454        let u8t = dst.as_typed::<u8>().unwrap();
12455        let map = u8t.map().unwrap();
12456        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
12457            assert_eq!(
12458                chunk, &expected,
12459                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
12460            );
12461        }
12462    }
12463
12464    /// Scenario 1: empty detections, empty segmentation, no background
12465    /// → dst must be fully cleared to 0x00000000.
12466    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
12467        let mut dst = make_dirty_dst(64, 64, None);
12468        processor
12469            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12470            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
12471        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
12472
12473        let mut dst = make_dirty_dst(64, 64, None);
12474        let proto = {
12475            use edgefirst_tensor::{Tensor, TensorDyn};
12476            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12477            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12478            let protos_t =
12479                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12480            ProtoData {
12481                mask_coefficients: TensorDyn::F32(coeff_t),
12482                protos: TensorDyn::F32(protos_t),
12483                layout: ProtoLayout::Nhwc,
12484            }
12485        };
12486        processor
12487            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
12488            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
12489        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
12490    }
12491
12492    /// Scenario 2: empty detections, empty segmentation, background set
12493    /// → dst must be fully equal to bg.
12494    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
12495        let bg_color = [42, 99, 200, 255];
12496        let bg = make_bg(64, 64, None, bg_color);
12497        let overlay = MaskOverlay::new().with_background(&bg);
12498
12499        let mut dst = make_dirty_dst(64, 64, None);
12500        processor
12501            .draw_decoded_masks(&mut dst, &[], &[], overlay)
12502            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
12503        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
12504
12505        let mut dst = make_dirty_dst(64, 64, None);
12506        let proto = {
12507            use edgefirst_tensor::{Tensor, TensorDyn};
12508            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12509            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12510            let protos_t =
12511                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12512            ProtoData {
12513                mask_coefficients: TensorDyn::F32(coeff_t),
12514                protos: TensorDyn::F32(protos_t),
12515                layout: ProtoLayout::Nhwc,
12516            }
12517        };
12518        processor
12519            .draw_proto_masks(&mut dst, &[], &proto, overlay)
12520            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
12521        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
12522    }
12523
12524    /// Scenario 3: one detection with a fully-opaque segmentation fill,
12525    /// no background → outside the box dst must be 0x00, inside it must
12526    /// be a non-zero mask colour (the render_segmentation output).
12527    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
12528        use edgefirst_tensor::Segmentation;
12529        use ndarray::Array3;
12530        processor
12531            .set_class_colors(&[[200, 80, 40, 255]])
12532            .expect("set_class_colors");
12533
12534        let detect = DetectBox {
12535            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12536            score: 0.99,
12537            label: 0,
12538        };
12539        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12540        let seg = Segmentation {
12541            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12542                .unwrap()
12543                .into(),
12544            xmin: 0.25,
12545            ymin: 0.25,
12546            xmax: 0.75,
12547            ymax: 0.75,
12548        };
12549
12550        let mut dst = make_dirty_dst(64, 64, None);
12551        processor
12552            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
12553            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
12554
12555        // Outside the bbox (corner): must be cleared black.
12556        let corner = pixel_at(&dst, 2, 2);
12557        assert_eq!(
12558            corner,
12559            [0, 0, 0, 0],
12560            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
12561        );
12562        // Inside the bbox (center): the mask colour must be visible.
12563        // Any non-zero pixel is acceptable — exact rendering varies
12564        // between backends (GL smoothstep, CPU nearest).
12565        let center = pixel_at(&dst, 32, 32);
12566        assert!(
12567            center != [0, 0, 0, 0],
12568            "{case}/decoded: center (32,32) was not coloured: {center:?}"
12569        );
12570    }
12571
12572    /// Scenario 4: detection + background. Outside the box must match
12573    /// bg; inside the box must NOT match bg (mask blended on top).
12574    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
12575        use edgefirst_tensor::Segmentation;
12576        use ndarray::Array3;
12577        processor
12578            .set_class_colors(&[[200, 80, 40, 255]])
12579            .expect("set_class_colors");
12580        let bg_color = [10, 20, 30, 255];
12581        let bg = make_bg(64, 64, None, bg_color);
12582
12583        let detect = DetectBox {
12584            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12585            score: 0.99,
12586            label: 0,
12587        };
12588        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12589        let seg = Segmentation {
12590            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12591                .unwrap()
12592                .into(),
12593            xmin: 0.25,
12594            ymin: 0.25,
12595            xmax: 0.75,
12596            ymax: 0.75,
12597        };
12598
12599        let overlay = MaskOverlay::new().with_background(&bg);
12600        let mut dst = make_dirty_dst(64, 64, None);
12601        processor
12602            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
12603            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
12604
12605        // Outside the bbox (corner): bg colour.
12606        let corner = pixel_at(&dst, 2, 2);
12607        assert_eq!(
12608            corner, bg_color,
12609            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
12610        );
12611        // Inside the bbox (center): mask blended on bg, must differ from
12612        // pure bg (alpha-blend with mask colour produces a distinct shade).
12613        let center = pixel_at(&dst, 32, 32);
12614        assert!(
12615            center != bg_color,
12616            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
12617        );
12618    }
12619
12620    /// Run all 4 scenarios against the processor. Skip gracefully if
12621    /// construction fails (backend unavailable on this host).
12622    fn run_all_scenarios(
12623        force_backend: Option<&'static str>,
12624        case: &'static str,
12625        require_dma_for_bg: bool,
12626    ) {
12627        if require_dma_for_bg && !crate::test_support::is_dma_available() {
12628            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
12629            return;
12630        }
12631        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
12632        let mut processor = match processor_result {
12633            Ok(p) => p,
12634            Err(e) => {
12635                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
12636                return;
12637            }
12638        };
12639        scenario_empty_no_bg(&mut processor, case);
12640        scenario_empty_with_bg(&mut processor, case);
12641        scenario_detect_no_bg(&mut processor, case);
12642        scenario_detect_with_bg(&mut processor, case);
12643    }
12644
12645    #[test]
12646    fn test_draw_masks_4_scenarios_cpu() {
12647        run_all_scenarios(Some("cpu"), "cpu", false);
12648    }
12649
12650    #[test]
12651    fn test_draw_masks_4_scenarios_auto() {
12652        run_all_scenarios(None, "auto", false);
12653    }
12654
12655    #[cfg(any(
12656        target_os = "linux",
12657        target_os = "macos",
12658        target_os = "ios",
12659        target_os = "android",
12660        target_os = "windows"
12661    ))]
12662    #[cfg(feature = "opengl")]
12663    #[test]
12664    fn test_draw_masks_4_scenarios_opengl() {
12665        run_all_scenarios(Some("opengl"), "opengl", false);
12666    }
12667
12668    /// G2D forced backend: exercises the zero-detection empty-frame
12669    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
12670    /// detections) expect `NotImplemented` since G2D has no rasterizer
12671    /// for boxes / masks.
12672    #[cfg(target_os = "linux")]
12673    #[test]
12674    fn test_draw_masks_zero_detection_g2d_forced() {
12675        if !crate::test_support::is_dma_available() {
12676            crate::test_support::report_skip("g2d forced — DMA not available on this host");
12677            return;
12678        }
12679        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
12680        let mut processor = match processor_result {
12681            Ok(p) => p,
12682            Err(e) => {
12683                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
12684                return;
12685            }
12686        };
12687
12688        // Case 1: empty + no bg. G2D requires DMA-backed dst.
12689        let mut dst = TensorDyn::image(
12690            64,
12691            64,
12692            PixelFormat::Rgba,
12693            DType::U8,
12694            Some(TensorMemory::DmaBuf),
12695            edgefirst_tensor::CpuAccess::ReadWrite,
12696        )
12697        .unwrap();
12698        {
12699            use edgefirst_tensor::TensorMapTrait;
12700            let u8t = dst.as_typed_mut::<u8>().unwrap();
12701            let mut map = u8t.map().unwrap();
12702            map.as_mut_slice().fill(0xBB);
12703        }
12704        processor
12705            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12706            .expect("g2d empty+no-bg");
12707        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
12708
12709        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
12710        let bg_color = [7, 11, 13, 255];
12711        let bg = {
12712            let t = TensorDyn::image(
12713                64,
12714                64,
12715                PixelFormat::Rgba,
12716                DType::U8,
12717                Some(TensorMemory::DmaBuf),
12718                edgefirst_tensor::CpuAccess::ReadWrite,
12719            )
12720            .unwrap();
12721            {
12722                use edgefirst_tensor::TensorMapTrait;
12723                let u8t = t.as_typed::<u8>().unwrap();
12724                let mut map = u8t.map().unwrap();
12725                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12726                    chunk.copy_from_slice(&bg_color);
12727                }
12728            }
12729            t
12730        };
12731        let mut dst = TensorDyn::image(
12732            64,
12733            64,
12734            PixelFormat::Rgba,
12735            DType::U8,
12736            Some(TensorMemory::DmaBuf),
12737            edgefirst_tensor::CpuAccess::ReadWrite,
12738        )
12739        .unwrap();
12740        {
12741            use edgefirst_tensor::TensorMapTrait;
12742            let u8t = dst.as_typed_mut::<u8>().unwrap();
12743            let mut map = u8t.map().unwrap();
12744            map.as_mut_slice().fill(0x55);
12745        }
12746        processor
12747            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
12748            .expect("g2d empty+bg");
12749        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
12750
12751        // Case 3 and 4: detect present — must return NotImplemented.
12752        let detect = DetectBox {
12753            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12754            score: 0.9,
12755            label: 0,
12756        };
12757        let mut dst = TensorDyn::image(
12758            64,
12759            64,
12760            PixelFormat::Rgba,
12761            DType::U8,
12762            Some(TensorMemory::DmaBuf),
12763            edgefirst_tensor::CpuAccess::ReadWrite,
12764        )
12765        .unwrap();
12766        let err = processor
12767            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12768            .expect_err("g2d must reject detect-present draw_decoded_masks");
12769        assert!(
12770            matches!(err, Error::NotImplemented(_)),
12771            "g2d case3 wrong error: {err:?}"
12772        );
12773    }
12774
12775    #[test]
12776    fn test_set_format_then_cpu_convert() {
12777        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12778        // test_force_backend_* and test_disable_env_var.
12779        let _lock = acquire_env_lock();
12780        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12781        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12782        let mut processor = ImageProcessor::new().unwrap();
12783
12784        // Load a source image
12785        let image = edgefirst_bench::testdata::read("zidane.jpg");
12786        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12787
12788        // Create a raw tensor, then attach format — simulating the from_fd workflow
12789        let mut dst =
12790            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12791        dst.set_format(PixelFormat::Rgb).unwrap();
12792
12793        // Convert should work with the set_format-annotated tensor
12794        processor.convert(&src, &mut dst, None).unwrap();
12795
12796        // Verify format survived conversion
12797        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12798        assert_eq!(dst.width(), Some(640));
12799        assert_eq!(dst.height(), Some(640));
12800    }
12801
12802    /// Verify that creating multiple ImageProcessors on the same thread and
12803    /// performing a resize on each does not deadlock or error.
12804    ///
12805    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12806    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12807    #[test]
12808    fn test_multiple_image_processors_same_thread() {
12809        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12810        // seen by ImageProcessor::new() calls during this test.
12811        let _lock = acquire_env_lock();
12812        let mut processors: Vec<ImageProcessor> = (0..4)
12813            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12814            .collect();
12815
12816        for proc in &mut processors {
12817            let src = proc
12818                .create_image(
12819                    128,
12820                    128,
12821                    PixelFormat::Rgb,
12822                    DType::U8,
12823                    None,
12824                    edgefirst_tensor::CpuAccess::ReadWrite,
12825                )
12826                .expect("create src failed");
12827            let mut dst = proc
12828                .create_image(
12829                    64,
12830                    64,
12831                    PixelFormat::Rgb,
12832                    DType::U8,
12833                    None,
12834                    edgefirst_tensor::CpuAccess::ReadWrite,
12835                )
12836                .expect("create dst failed");
12837            proc.convert(&src, &mut dst, None).expect("convert failed");
12838            assert_eq!(dst.width(), Some(64));
12839            assert_eq!(dst.height(), Some(64));
12840        }
12841    }
12842
12843    /// Verify that creating ImageProcessors on separate threads and performing
12844    /// a resize on each does not deadlock or error.
12845    ///
12846    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12847    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12848    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12849    #[test]
12850    fn test_multiple_image_processors_separate_threads() {
12851        use std::sync::mpsc;
12852        use std::time::Duration;
12853
12854        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12855        // EGL context teardown — four processors spun up on four threads here
12856        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12857        // panic). The bug is the driver's, not the HAL's; this test is kept so
12858        // it still exercises the multi-context path on every other GPU. The
12859        // board lane and scripts/on-target-test.sh set
12860        // EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS on any board with /dev/galcore
12861        // to skip just this case there, while the run stays red anywhere else
12862        // a regression appears. (Skip, not #[ignore]: the platform is decided
12863        // at runtime, not compile time.)
12864        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12865            crate::test_support::report_skip(
12866                "test_multiple_image_processors_separate_threads — known Vivante \
12867                 GC7000UL concurrent-EGL-teardown double-free \
12868                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12869            );
12870            return;
12871        }
12872
12873        const TIMEOUT: Duration = Duration::from_secs(60);
12874
12875        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12876        // calls made inside the spawned threads during this test.
12877        let _lock = acquire_env_lock();
12878
12879        let (tx, rx) = mpsc::channel::<()>();
12880
12881        std::thread::spawn(move || {
12882            let handles: Vec<_> = (0..4)
12883                .map(|i| {
12884                    std::thread::spawn(move || {
12885                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12886                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12887                        });
12888                        let src = proc
12889                            .create_image(
12890                                128,
12891                                128,
12892                                PixelFormat::Rgb,
12893                                DType::U8,
12894                                None,
12895                                edgefirst_tensor::CpuAccess::ReadWrite,
12896                            )
12897                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12898                        let mut dst = proc
12899                            .create_image(
12900                                64,
12901                                64,
12902                                PixelFormat::Rgb,
12903                                DType::U8,
12904                                None,
12905                                edgefirst_tensor::CpuAccess::ReadWrite,
12906                            )
12907                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12908                        proc.convert(&src, &mut dst, None)
12909                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12910                        assert_eq!(dst.width(), Some(64));
12911                        assert_eq!(dst.height(), Some(64));
12912                    })
12913                })
12914                .collect();
12915
12916            for (i, h) in handles.into_iter().enumerate() {
12917                h.join()
12918                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12919            }
12920
12921            let _ = tx.send(());
12922        });
12923
12924        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12925            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12926        });
12927    }
12928
12929    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12930    /// all operate concurrently without deadlocking each other.
12931    ///
12932    /// All processors are created first, then a barrier synchronizes them so
12933    /// they all start converting at the same instant — maximizing contention.
12934    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12935    #[test]
12936    fn test_image_processors_concurrent_operations() {
12937        use std::sync::{mpsc, Arc, Barrier};
12938        use std::time::Duration;
12939
12940        const N: usize = 4;
12941        const ROUNDS: usize = 10;
12942        const TIMEOUT: Duration = Duration::from_secs(60);
12943
12944        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12945        // calls made inside the spawned threads during this test.
12946        let _lock = acquire_env_lock();
12947
12948        let (tx, rx) = mpsc::channel::<()>();
12949
12950        std::thread::spawn(move || {
12951            let barrier = Arc::new(Barrier::new(N));
12952
12953            let handles: Vec<_> = (0..N)
12954                .map(|i| {
12955                    let barrier = Arc::clone(&barrier);
12956                    std::thread::spawn(move || {
12957                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12958                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12959                        });
12960
12961                        // All threads wait here until every processor is initialized.
12962                        barrier.wait();
12963
12964                        // Now all 4 hammer the GPU concurrently.
12965                        for round in 0..ROUNDS {
12966                            let src = proc
12967                                .create_image(
12968                                    128,
12969                                    128,
12970                                    PixelFormat::Rgb,
12971                                    DType::U8,
12972                                    None,
12973                                    edgefirst_tensor::CpuAccess::ReadWrite,
12974                                )
12975                                .unwrap_or_else(|e| {
12976                                    panic!("create src failed on thread {i} round {round}: {e}")
12977                                });
12978                            let mut dst = proc
12979                                .create_image(
12980                                    64,
12981                                    64,
12982                                    PixelFormat::Rgb,
12983                                    DType::U8,
12984                                    None,
12985                                    edgefirst_tensor::CpuAccess::ReadWrite,
12986                                )
12987                                .unwrap_or_else(|e| {
12988                                    panic!("create dst failed on thread {i} round {round}: {e}")
12989                                });
12990                            proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
12991                                panic!("convert failed on thread {i} round {round}: {e}")
12992                            });
12993                            assert_eq!(dst.width(), Some(64));
12994                            assert_eq!(dst.height(), Some(64));
12995                        }
12996                    })
12997                })
12998                .collect();
12999
13000            for (i, h) in handles.into_iter().enumerate() {
13001                h.join()
13002                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
13003            }
13004
13005            let _ = tx.send(());
13006        });
13007
13008        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
13009            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
13010        });
13011    }
13012
13013    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
13014    /// threads, each with its own GL context and worker, converting
13015    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
13016    /// every output must byte-match the thread's own pre-barrier sequential
13017    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
13018    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
13019    /// GPU; on Vivante they serialize via the Full policy and the test still
13020    /// must pass. Distinct inputs make any cross-processor state leakage
13021    /// (wrong texture, wrong context, clobbered upload) visible as a byte
13022    /// diff rather than a coincidental match — proven by a scratch
13023    /// cross-wire run (neighbor's input post-oracle) failing on every
13024    /// thread with ~53% of bytes diverged.
13025    ///
13026    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
13027    /// `test_multiple_image_processors_separate_threads`: the galcore driver
13028    /// can abort intermittently on concurrent multi-processor lifecycles
13029    /// regardless of locking (P0 spike: reproduces fully serialized).
13030    #[test]
13031    fn test_parallel_processors_unique_outputs() {
13032        use std::sync::{mpsc, Arc, Barrier};
13033        use std::time::Duration;
13034
13035        const N: usize = 4;
13036        const ROUNDS: usize = 25;
13037        const TIMEOUT: Duration = Duration::from_secs(60);
13038
13039        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
13040            crate::test_support::report_skip(
13041                "test_parallel_processors_unique_outputs — known Vivante \
13042                 GC7000UL concurrent-multi-processor driver abort \
13043                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
13044            );
13045            return;
13046        }
13047
13048        let _lock = acquire_env_lock();
13049        // Probed here, where the policy can name the test; worker threads are unnamed.
13050        let use_dma = crate::test_support::is_dma_available();
13051        if !use_dma {
13052            crate::test_support::report_skip(&format!(
13053                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
13054                function!()
13055            ));
13056        }
13057        let (tx, rx) = mpsc::channel::<()>();
13058
13059        std::thread::spawn(move || {
13060            let barrier = Arc::new(Barrier::new(N));
13061            let handles: Vec<_> = (0..N)
13062                .map(|i| {
13063                    let barrier = Arc::clone(&barrier);
13064                    std::thread::spawn(move || {
13065                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
13066                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
13067                        });
13068                        // CI-weight geometry (llvmpipe renders on the CPU).
13069                        let (w, h) = (640usize, 480usize);
13070                        let mem = if use_dma {
13071                            Some(TensorMemory::DmaBuf)
13072                        } else {
13073                            Some(TensorMemory::Mem)
13074                        };
13075                        let src = proc
13076                            .create_image(
13077                                w,
13078                                h,
13079                                PixelFormat::Nv12,
13080                                DType::U8,
13081                                mem,
13082                                edgefirst_tensor::CpuAccess::ReadWrite,
13083                            )
13084                            .unwrap();
13085                        {
13086                            let t = src.as_typed::<u8>().unwrap();
13087                            let mut m = t.map().unwrap();
13088                            let s = m.as_mut_slice();
13089                            for (j, b) in s[..w * h].iter_mut().enumerate() {
13090                                *b = ((i * 53 + j) % 200 + 16) as u8;
13091                            }
13092                            for b in &mut s[w * h..] {
13093                                *b = (80 + i * 24) as u8;
13094                            }
13095                        }
13096                        let lb = Crop::letterbox([114, 114, 114, 255]);
13097                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
13098                            let mut dst = proc
13099                                .create_image(
13100                                    320,
13101                                    320,
13102                                    PixelFormat::Rgba,
13103                                    DType::U8,
13104                                    mem,
13105                                    edgefirst_tensor::CpuAccess::ReadWrite,
13106                                )
13107                                .unwrap();
13108                            proc.convert(
13109                                &src,
13110                                &mut dst,
13111                                Some(crate::ConvertOptions::new().crop(lb)),
13112                            )
13113                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
13114                            let t = dst.as_typed::<u8>().unwrap();
13115                            let m = t.map().unwrap();
13116                            m.as_slice().to_vec()
13117                        };
13118
13119                        let oracle = convert_once(&mut proc);
13120                        barrier.wait();
13121                        for round in 0..ROUNDS {
13122                            let out = convert_once(&mut proc);
13123                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
13124                            assert!(
13125                                diffs == 0,
13126                                "thread {i} round {round}: {diffs}/{} bytes diverged \
13127                                 from this processor's own oracle — cross-processor \
13128                                 GL state leakage under parallel execution",
13129                                oracle.len()
13130                            );
13131                        }
13132                    })
13133                })
13134                .collect();
13135
13136            for (i, h) in handles.into_iter().enumerate() {
13137                h.join()
13138                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
13139            }
13140            let _ = tx.send(());
13141        });
13142
13143        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
13144            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
13145        });
13146    }
13147
13148    /// Heavy on-demand stressor for the GL serialization policy: 4
13149    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
13150    /// converts (DMA where available); every output must byte-match the
13151    /// thread's own pre-barrier sequential oracle from the same processor.
13152    /// Ignored by default (heavy; board tool — the CI-weight version is
13153    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
13154    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
13155    ///   <test binary> stress_parallel_processors_oracle --ignored
13156    #[test]
13157    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
13158    fn stress_parallel_processors_oracle() {
13159        use std::sync::{mpsc, Arc, Barrier};
13160        use std::time::Duration;
13161
13162        const N: usize = 4;
13163        const ROUNDS: usize = 200;
13164        const TIMEOUT: Duration = Duration::from_secs(600);
13165
13166        let _lock = acquire_env_lock();
13167        // Probed here, where the policy can name the test; worker threads are unnamed.
13168        let use_dma = crate::test_support::is_dma_available();
13169        if !use_dma {
13170            crate::test_support::report_skip(&format!(
13171                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
13172                function!()
13173            ));
13174        }
13175        let (tx, rx) = mpsc::channel::<()>();
13176
13177        std::thread::spawn(move || {
13178            let barrier = Arc::new(Barrier::new(N));
13179            let handles: Vec<_> = (0..N)
13180                .map(|i| {
13181                    let barrier = Arc::clone(&barrier);
13182                    std::thread::spawn(move || {
13183                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
13184                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
13185                        });
13186                        let (w, h) = (1280usize, 720usize);
13187                        let mem = if use_dma {
13188                            Some(TensorMemory::DmaBuf)
13189                        } else {
13190                            Some(TensorMemory::Mem)
13191                        };
13192
13193                        // Per-thread-distinct synthetic NV12 so cross-wired
13194                        // GL state between processors shows up as a byte diff.
13195                        let src = proc
13196                            .create_image(
13197                                w,
13198                                h,
13199                                PixelFormat::Nv12,
13200                                DType::U8,
13201                                mem,
13202                                edgefirst_tensor::CpuAccess::ReadWrite,
13203                            )
13204                            .unwrap();
13205                        {
13206                            let t = src.as_typed::<u8>().unwrap();
13207                            let mut m = t.map().unwrap();
13208                            let s = m.as_mut_slice();
13209                            for (j, b) in s[..w * h].iter_mut().enumerate() {
13210                                *b = ((i * 37 + j) % 200 + 16) as u8;
13211                            }
13212                            for b in &mut s[w * h..] {
13213                                *b = (96 + i * 16) as u8;
13214                            }
13215                        }
13216                        let lb = Crop::letterbox([114, 114, 114, 255]);
13217
13218                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
13219                            let mut dst = proc
13220                                .create_image(
13221                                    640,
13222                                    640,
13223                                    PixelFormat::Rgb,
13224                                    DType::U8,
13225                                    mem,
13226                                    edgefirst_tensor::CpuAccess::ReadWrite,
13227                                )
13228                                .unwrap();
13229                            proc.convert(
13230                                &src,
13231                                &mut dst,
13232                                Some(crate::ConvertOptions::new().crop(lb)),
13233                            )
13234                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
13235                            let t = dst.as_typed::<u8>().unwrap();
13236                            let m = t.map().unwrap();
13237                            m.as_slice().to_vec()
13238                        };
13239
13240                        let oracle = convert_once(&mut proc);
13241                        barrier.wait();
13242                        for round in 0..ROUNDS {
13243                            let out = convert_once(&mut proc);
13244                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
13245                            assert!(
13246                                diffs == 0,
13247                                "thread {i} round {round}: {diffs}/{} bytes diverged \
13248                                 from the pre-barrier oracle",
13249                                oracle.len()
13250                            );
13251                        }
13252                    })
13253                })
13254                .collect();
13255
13256            for (i, h) in handles.into_iter().enumerate() {
13257                h.join()
13258                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
13259            }
13260            let _ = tx.send(());
13261        });
13262
13263        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
13264            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
13265        });
13266    }
13267
13268    // =========================================================================
13269    // F16 / F32 auto-chain fallback integration tests
13270    // =========================================================================
13271
13272    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
13273    /// combo the GL path does NOT cover.
13274    ///
13275    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
13276    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
13277    /// chain falls through to the CPU float path. Before commit 868a7649
13278    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
13279    /// must return `Ok` with output in `[0, 1]` and all values finite.
13280    #[test]
13281    fn convert_f32_auto_never_errors_non_gl_combo() {
13282        const W: usize = 64;
13283        const H: usize = 64;
13284
13285        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
13286        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
13287        let src = TensorDyn::image(
13288            W,
13289            H,
13290            PixelFormat::Yuyv,
13291            DType::U8,
13292            Some(TensorMemory::Mem),
13293            edgefirst_tensor::CpuAccess::ReadWrite,
13294        )
13295        .unwrap();
13296        {
13297            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13298            let data = map.as_mut_slice();
13299            for chunk in data.as_chunks_mut::<4>().0 {
13300                chunk[0] = 128; // Y0
13301                chunk[1] = 128; // U
13302                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
13303                chunk[3] = 128; // V
13304            }
13305        }
13306
13307        let mut dst = TensorDyn::image(
13308            W,
13309            H,
13310            PixelFormat::Rgb,
13311            DType::F32,
13312            Some(TensorMemory::Mem),
13313            edgefirst_tensor::CpuAccess::ReadWrite,
13314        )
13315        .unwrap();
13316
13317        let mut proc = ImageProcessor::new().unwrap();
13318        let result = proc.convert(&src, &mut dst, None);
13319        assert!(
13320            result.is_ok(),
13321            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
13322            result.err()
13323        );
13324
13325        // Verify all output values are finite and in [0, 1].
13326        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13327        let floats = map.as_slice();
13328        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
13329        for (i, &v) in floats.iter().enumerate() {
13330            assert!(
13331                v.is_finite() && (0.0..=1.0).contains(&v),
13332                "output[{i}]={v} is not finite or not in [0,1]"
13333            );
13334        }
13335
13336        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
13337        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
13338        // path never wrote to it) this assertion catches the regression.
13339        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
13340        assert!(
13341            first_non_zero.is_some(),
13342            "all-zero output detected — CPU path likely did not write to the destination buffer"
13343        );
13344        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
13345        // (which carries luma for a near-grey YUV source).
13346        let r0 = floats[0];
13347        assert!(
13348            (r0 - 0.502_f32).abs() < 0.05,
13349            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
13350        );
13351    }
13352
13353    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
13354    ///
13355    /// Uses a source with clearly distinct per-channel values so a
13356    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
13357    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
13358    #[test]
13359    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
13360    // `{ backend, ..Default::default() }` is a genuine update on Linux but
13361    // covers no remaining fields on macOS, where `clippy::needless_update`
13362    // then fires. `allow` (not `expect`) because the lint is platform-
13363    // conditional — it does not fire on Linux.
13364    #[allow(clippy::needless_update)]
13365    fn convert_f16_forced_cpu_correct() {
13366        const W: usize = 16;
13367        const H: usize = 16;
13368        const TOL: f32 = 1.0 / 512.0; // 2^-9
13369
13370        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
13371        let src = TensorDyn::image(
13372            W,
13373            H,
13374            PixelFormat::Rgba,
13375            DType::U8,
13376            Some(TensorMemory::Mem),
13377            edgefirst_tensor::CpuAccess::ReadWrite,
13378        )
13379        .unwrap();
13380        {
13381            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13382            let data = map.as_mut_slice();
13383            for y in 0..H {
13384                for x in 0..W {
13385                    let i = y * W + x;
13386                    data[i * 4] = (50 + x) as u8; // R: 50..65
13387                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
13388                    data[i * 4 + 2] = 200; // B: constant
13389                    data[i * 4 + 3] = 255;
13390                }
13391            }
13392        }
13393
13394        let mut dst = TensorDyn::image(
13395            W,
13396            H,
13397            PixelFormat::PlanarRgb,
13398            DType::F16,
13399            Some(TensorMemory::Mem),
13400            edgefirst_tensor::CpuAccess::ReadWrite,
13401        )
13402        .unwrap();
13403
13404        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13405            backend: ComputeBackend::Cpu,
13406            ..Default::default()
13407        })
13408        .unwrap();
13409        proc.convert(&src, &mut dst, None)
13410            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13411
13412        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
13413        let src_bytes = src_map.as_slice();
13414        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13415        let dst_halfs = dst_map.as_slice();
13416
13417        let plane = W * H;
13418        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
13419
13420        for y in 0..H {
13421            for x in 0..W {
13422                let i = y * W + x;
13423                let r_exp = src_bytes[i * 4] as f32 / 255.0;
13424                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
13425                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
13426
13427                let r_got = dst_halfs[i].to_f32();
13428                let g_got = dst_halfs[plane + i].to_f32();
13429                let b_got = dst_halfs[2 * plane + i].to_f32();
13430
13431                assert!(
13432                    (r_got - r_exp).abs() <= TOL,
13433                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
13434                );
13435                assert!(
13436                    (g_got - g_exp).abs() <= TOL,
13437                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
13438                );
13439                assert!(
13440                    (b_got - b_exp).abs() <= TOL,
13441                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
13442                );
13443
13444                // Catch plane-swap: R and G must differ (they have different formulas).
13445                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
13446                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
13447                }
13448            }
13449        }
13450    }
13451
13452    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
13453    /// rotation set.
13454    ///
13455    /// The GL float render path rejects any call where rotation ≠ None,
13456    /// returning an error that causes the chain to continue. Before the CPU
13457    /// float fallback this would have produced an error at the end of the
13458    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
13459    #[test]
13460    fn convert_f32_with_rotation_falls_back() {
13461        const W: usize = 16;
13462        const H: usize = 16;
13463
13464        // RGBA8 source with a known gradient (distinct per-channel values).
13465        let src = TensorDyn::image(
13466            W,
13467            H,
13468            PixelFormat::Rgba,
13469            DType::U8,
13470            Some(TensorMemory::Mem),
13471            edgefirst_tensor::CpuAccess::ReadWrite,
13472        )
13473        .unwrap();
13474        {
13475            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13476            let data = map.as_mut_slice();
13477            for y in 0..H {
13478                for x in 0..W {
13479                    let i = y * W + x;
13480                    data[i * 4] = (x * 16) as u8; // R
13481                    data[i * 4 + 1] = (y * 16) as u8; // G
13482                    data[i * 4 + 2] = 128; // B
13483                    data[i * 4 + 3] = 255;
13484                }
13485            }
13486        }
13487
13488        // Rotation swaps W and H, so dst is [W, H] (H×W output).
13489        let mut dst = TensorDyn::image(
13490            H, // dst W = src H after 90° rotation
13491            W, // dst H = src W after 90° rotation
13492            PixelFormat::Rgb,
13493            DType::F32,
13494            Some(TensorMemory::Mem),
13495            edgefirst_tensor::CpuAccess::ReadWrite,
13496        )
13497        .unwrap();
13498
13499        let mut proc = ImageProcessor::new().unwrap();
13500        let result = proc.convert(
13501            &src,
13502            &mut dst,
13503            Some(crate::ConvertOptions::new().rotation(Rotation::Clockwise90)),
13504        );
13505        assert!(
13506            result.is_ok(),
13507            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
13508            result.err()
13509        );
13510
13511        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13512        let floats = map.as_slice();
13513        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
13514        for (i, &v) in floats.iter().enumerate() {
13515            assert!(
13516                v.is_finite() && (0.0..=1.0).contains(&v),
13517                "output[{i}]={v} is not finite or not in [0,1]"
13518            );
13519        }
13520    }
13521
13522    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
13523    ///
13524    /// Converts the same RGBA8 source via the `OpenGl` backend and the
13525    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
13526    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
13527    /// or F16 render is unavailable, and when the host cannot hold the
13528    /// zero-copy F16 destination the GL float route needs.
13529    ///
13530    /// **The GL route is asserted, not assumed** (issue #179).
13531    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
13532    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
13533    /// serves the same convert. Both sides of the comparison would then be
13534    /// the CPU's answer and the parity claim would be a tautology — which is
13535    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
13536    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
13537    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
13538    /// an extension query, not a completed render. `convert_fallback_count()`
13539    /// staying flat across the GL convert is the proof, and a decline is
13540    /// reported as a skip rather than a failure because it is a property of
13541    /// the host's driver, not of this code — except under
13542    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
13543    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
13544    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
13545    /// come up. A lane that has asserted it has GL should not be quietly
13546    /// losing this test's coverage.
13547    #[test]
13548    #[cfg(all(
13549        any(
13550            target_os = "linux",
13551            target_os = "macos",
13552            target_os = "ios",
13553            target_os = "android",
13554            target_os = "windows"
13555        ),
13556        feature = "opengl"
13557    ))]
13558    fn convert_f16_gl_cpu_parity_identity() {
13559        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
13560        // passes but deliberately hobbles pass 1 (unsigned binaries, the
13561        // ANGLE dlopen gate closed). Excluded there the same way
13562        // `gl_backend_available_canary` excludes it, so the gate below
13563        // enforces in pass 2 only.
13564        #[cfg(target_os = "macos")]
13565        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
13566            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
13567        {
13568            crate::test_support::report_skip(
13569                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
13570            );
13571            return;
13572        }
13573        if !is_opengl_available() {
13574            crate::test_support::report_skip(
13575                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
13576            );
13577            return;
13578        }
13579
13580        // 32 F16 pixels make a 64-byte row, the pitch IOSurface requires for a planar destination.
13581        const W: usize = 32;
13582        const H: usize = 16;
13583        const TOL: f32 = 1.0 / 256.0; // 2^-8
13584
13585        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
13586        let src = TensorDyn::image(
13587            W,
13588            H,
13589            PixelFormat::Rgba,
13590            DType::U8,
13591            Some(TensorMemory::Mem),
13592            edgefirst_tensor::CpuAccess::ReadWrite,
13593        )
13594        .unwrap();
13595        {
13596            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13597            let data = map.as_mut_slice();
13598            for y in 0..H {
13599                for x in 0..W {
13600                    let i = y * W + x;
13601                    data[i * 4] = (40 + x) as u8; // R
13602                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
13603                    data[i * 4 + 2] = 180; // B
13604                    data[i * 4 + 3] = 255;
13605                }
13606            }
13607        }
13608
13609        // GL path.
13610        let gl_result = {
13611            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
13612                backend: ComputeBackend::OpenGl,
13613                ..Default::default()
13614            }) {
13615                Ok(p) => p,
13616                Err(e) => {
13617                    crate::test_support::report_skip(&format!(
13618                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
13619                    ));
13620                    return;
13621                }
13622            };
13623
13624            if !gl_proc.supported_render_dtypes().f16 {
13625                crate::test_support::report_skip(
13626                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
13627                );
13628                return;
13629            }
13630
13631            // A destination the GL float route actually serves. A
13632            // `TensorMemory::Mem` float destination has no arm in
13633            // `float_dispatch::classify_float_render` at all: it classifies as
13634            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
13635            // CPU serves every convert. With a host destination the GL half
13636            // below was therefore unreachable and this test could only ever
13637            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
13638            // is the zero-copy float tuple EVERY platform's capability set
13639            // serves: it is unconditional in `classify_float_render`, so it
13640            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
13641            // declare as well as the `All` set Windows declares.
13642            //
13643            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
13644            // portable name for the platform-native zero-copy GPU buffer --
13645            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
13646            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
13647            // and the classifier matches on that one variant for all of them,
13648            // so this needs no `cfg`. Where the platform cannot hold one the
13649            // allocation fails or falls back, and both are reported below.
13650            // Checked before the HAL_TEST_REQUIRE_GL gate below, which covers a declined destination.
13651            let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
13652                TensorDyn::image(
13653                    W,
13654                    H,
13655                    PixelFormat::PlanarRgb,
13656                    DType::F16,
13657                    Some(TensorMemory::DmaBuf),
13658                    edgefirst_tensor::CpuAccess::ReadWrite,
13659                ),
13660                "zero-copy F16 PlanarRgb destination",
13661            ) else {
13662                return;
13663            };
13664            if dst.memory() != TensorMemory::DmaBuf {
13665                crate::test_support::report_skip(&format!(
13666                    "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
13667                     destination fell back to {:?}",
13668                    dst.memory()
13669                ));
13670                return;
13671            }
13672            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
13673            let fallbacks_before = gl_proc.convert_fallback_count();
13674            match gl_proc.convert(&src, &mut dst, None) {
13675                Ok(()) => {
13676                    if gl_proc.convert_fallback_count() != fallbacks_before {
13677                        assert!(
13678                            !require_gl,
13679                            "HAL_TEST_REQUIRE_GL=1 but \
13680                             convert_f16_gl_cpu_parity_identity would have skipped \
13681                             while reporting success: the GL F16 path declined and \
13682                             the CPU served the convert"
13683                        );
13684                        crate::test_support::report_skip(
13685                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
13686                             declined and the CPU served the convert; comparing that \
13687                             against the forced-CPU result would prove nothing",
13688                        );
13689                        return;
13690                    }
13691                    dst
13692                }
13693                Err(e) => {
13694                    crate::test_support::report_skip(&format!(
13695                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
13696                    ));
13697                    return;
13698                }
13699            }
13700        };
13701
13702        // CPU path.
13703        let cpu_result = {
13704            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
13705                backend: ComputeBackend::Cpu,
13706                ..Default::default()
13707            })
13708            .unwrap();
13709            let mut dst = TensorDyn::image(
13710                W,
13711                H,
13712                PixelFormat::PlanarRgb,
13713                DType::F16,
13714                Some(TensorMemory::Mem),
13715                edgefirst_tensor::CpuAccess::ReadWrite,
13716            )
13717            .unwrap();
13718            cpu_proc
13719                .convert(&src, &mut dst, None)
13720                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13721            dst
13722        };
13723
13724        // Compare element-wise. Both sides are read through `copy_to_flat`:
13725        // the GL destination is a packed zero-copy surface whose rows can be
13726        // spaced at a driver pitch, so its mapping is not a flat run of the
13727        // logical elements.
13728        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
13729            let typed = t.as_typed::<half::f16>().expect("F16 image");
13730            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
13731            typed
13732                .copy_to_flat(&mut bytes)
13733                .expect("compact any padded rows");
13734            bytes
13735                .as_chunks::<2>()
13736                .0
13737                .iter()
13738                .map(|&c| half::f16::from_le_bytes(c))
13739                .collect()
13740        };
13741        let gl_halfs = flat_f16(&gl_result);
13742        let cpu_halfs = flat_f16(&cpu_result);
13743
13744        assert_eq!(
13745            gl_halfs.len(),
13746            cpu_halfs.len(),
13747            "GL and CPU output sizes differ"
13748        );
13749
13750        let plane = W * H;
13751        let channel_names = ["R", "G", "B"];
13752        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
13753            let gl_v = gl_h.to_f32();
13754            let cpu_v = cpu_h.to_f32();
13755            let err = (gl_v - cpu_v).abs();
13756            let ch = channel_names[idx / plane];
13757            let pixel = idx % plane;
13758            assert!(
13759                err <= TOL,
13760                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13761            );
13762        }
13763    }
13764
13765    // =========================================================================
13766    // GAP-1: supported_render_dtypes() Linux smoke test
13767    // =========================================================================
13768
13769    /// Exercises the real Linux/Windows GL path that reads
13770    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13771    /// available (CI/host without a GPU).
13772    #[test]
13773    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13774    fn supported_render_dtypes_linux_smoke() {
13775        let proc = match ImageProcessor::new() {
13776            Ok(p) => p,
13777            Err(e) => {
13778                crate::test_support::report_skip(&format!(
13779                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13780                ));
13781                return;
13782            }
13783        };
13784        if proc.opengl.is_none() {
13785            crate::test_support::report_skip(
13786                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13787            );
13788            return;
13789        }
13790        // The call must complete without panicking or deadlocking.
13791        let support = proc.supported_render_dtypes();
13792        eprintln!(
13793            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13794            support.f16, support.f32
13795        );
13796        // No assertion on the specific values — they are hardware-dependent.
13797    }
13798
13799    // =========================================================================
13800    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13801    // =========================================================================
13802
13803    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13804    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13805    /// The auto-chain must transparently fall through to the CPU path, which has
13806    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13807    #[test]
13808    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13809        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13810        const H: usize = 16;
13811
13812        // RGBA8 source filled with a flat mid-grey.
13813        let src = TensorDyn::image(
13814            W,
13815            H,
13816            PixelFormat::Rgba,
13817            DType::U8,
13818            Some(TensorMemory::Mem),
13819            edgefirst_tensor::CpuAccess::ReadWrite,
13820        )
13821        .unwrap();
13822        {
13823            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13824            let data = map.as_mut_slice();
13825            for chunk in data.as_chunks_mut::<4>().0 {
13826                chunk[0] = 128;
13827                chunk[1] = 64;
13828                chunk[2] = 200;
13829                chunk[3] = 255;
13830            }
13831        }
13832
13833        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13834        // to exercise the fallback chain without hardware dependency).
13835        let mut dst = TensorDyn::image(
13836            W,
13837            H,
13838            PixelFormat::PlanarRgb,
13839            DType::F16,
13840            Some(TensorMemory::Mem),
13841            edgefirst_tensor::CpuAccess::ReadWrite,
13842        )
13843        .unwrap();
13844
13845        // Use the default auto-chain so the GL path can attempt and reject,
13846        // then the CPU path succeeds.
13847        let mut proc = ImageProcessor::new().unwrap();
13848        let result = proc.convert(&src, &mut dst, None);
13849        assert!(
13850            result.is_ok(),
13851            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13852            result.err()
13853        );
13854
13855        // All output values must be finite and in [0, 1].
13856        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13857        let halfs = map.as_slice();
13858        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13859        for (i, h) in halfs.iter().enumerate() {
13860            let v = h.to_f32();
13861            assert!(
13862                v.is_finite() && (0.0..=1.0).contains(&v),
13863                "output[{i}]={v} is not finite or not in [0,1]"
13864            );
13865        }
13866    }
13867
13868    // =========================================================================
13869    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13870    // =========================================================================
13871
13872    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13873    ///
13874    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13875    /// which was untested. A wrong intermediate format selection silently
13876    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13877    #[test]
13878    // Linux-only `egl_display` field makes `..Default::default()` needless
13879    // on macOS only; see `convert_f16_forced_cpu_correct`.
13880    #[allow(clippy::needless_update)]
13881    fn convert_nv12_to_rgb_f32_cpu() {
13882        const W: usize = 16;
13883        const H: usize = 16; // must be even for NV12
13884
13885        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13886        let src = TensorDyn::image(
13887            W,
13888            H,
13889            PixelFormat::Nv12,
13890            DType::U8,
13891            Some(TensorMemory::Mem),
13892            edgefirst_tensor::CpuAccess::ReadWrite,
13893        )
13894        .unwrap();
13895        {
13896            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13897            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13898        }
13899
13900        let mut dst = TensorDyn::image(
13901            W,
13902            H,
13903            PixelFormat::Rgb,
13904            DType::F32,
13905            Some(TensorMemory::Mem),
13906            edgefirst_tensor::CpuAccess::ReadWrite,
13907        )
13908        .unwrap();
13909
13910        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13911            backend: ComputeBackend::Cpu,
13912            ..Default::default()
13913        })
13914        .unwrap();
13915
13916        let result = proc.convert(&src, &mut dst, None);
13917        assert!(
13918            result.is_ok(),
13919            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13920            result.err()
13921        );
13922
13923        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13924        let floats = map.as_slice();
13925        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13926        for (i, &v) in floats.iter().enumerate() {
13927            assert!(
13928                v.is_finite() && (0.0..=1.0).contains(&v),
13929                "output[{i}]={v} is not finite or not in [0,1]"
13930            );
13931        }
13932        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13933        let non_zero = floats.iter().any(|&v| v > 0.01);
13934        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13935    }
13936
13937    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13938    ///
13939    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13940    #[test]
13941    // Linux-only `egl_display` field makes `..Default::default()` needless
13942    // on macOS only; see `convert_f16_forced_cpu_correct`.
13943    #[allow(clippy::needless_update)]
13944    fn convert_nv12_to_planar_rgb_f16_cpu() {
13945        const W: usize = 16;
13946        const H: usize = 16;
13947
13948        let src = TensorDyn::image(
13949            W,
13950            H,
13951            PixelFormat::Nv12,
13952            DType::U8,
13953            Some(TensorMemory::Mem),
13954            edgefirst_tensor::CpuAccess::ReadWrite,
13955        )
13956        .unwrap();
13957        {
13958            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13959            map.as_mut_slice().fill(128);
13960        }
13961
13962        let mut dst = TensorDyn::image(
13963            W,
13964            H,
13965            PixelFormat::PlanarRgb,
13966            DType::F16,
13967            Some(TensorMemory::Mem),
13968            edgefirst_tensor::CpuAccess::ReadWrite,
13969        )
13970        .unwrap();
13971
13972        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13973            backend: ComputeBackend::Cpu,
13974            ..Default::default()
13975        })
13976        .unwrap();
13977
13978        let result = proc.convert(&src, &mut dst, None);
13979        assert!(
13980            result.is_ok(),
13981            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13982            result.err()
13983        );
13984
13985        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13986        let halfs = map.as_slice();
13987        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13988        for (i, h) in halfs.iter().enumerate() {
13989            let v = h.to_f32();
13990            assert!(
13991                v.is_finite() && (0.0..=1.0).contains(&v),
13992                "output[{i}]={v} is not finite or not in [0,1]"
13993            );
13994        }
13995        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13996        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13997    }
13998
13999    // =========================================================================
14000    // GAP-5: create_image F32 + DMA must return NotSupported
14001    // =========================================================================
14002
14003    fn access_test_src(w: usize, h: usize) -> TensorDyn {
14004        use edgefirst_tensor::TensorMapTrait;
14005        let t = TensorDyn::image(
14006            w,
14007            h,
14008            PixelFormat::Rgba,
14009            DType::U8,
14010            Some(TensorMemory::Mem),
14011            edgefirst_tensor::CpuAccess::ReadWrite,
14012        )
14013        .unwrap();
14014        for (i, b) in t
14015            .as_u8()
14016            .unwrap()
14017            .map_mut()
14018            .unwrap()
14019            .as_mut_slice()
14020            .iter_mut()
14021            .enumerate()
14022        {
14023            *b = (i % 251) as u8;
14024        }
14025        t
14026    }
14027
14028    /// With only the CPU backend, `create_image` declares `ReadWrite` for the
14029    /// destinations whose CPU convert reads back what it wrote (the `I8`
14030    /// bias, the BGRA swizzle), and the convert into them succeeds.
14031    #[test]
14032    fn create_image_adds_readwrite_for_read_modify_write_passes() {
14033        use edgefirst_tensor::CpuAccess;
14034        let _lock = acquire_env_lock();
14035        let _guard = EnvGuard::snapshot(&[
14036            "EDGEFIRST_FORCE_BACKEND",
14037            "EDGEFIRST_DISABLE_GL",
14038            "EDGEFIRST_DISABLE_G2D",
14039            "EDGEFIRST_DISABLE_CPU",
14040        ]);
14041        unsafe {
14042            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
14043            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
14044            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
14045            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
14046        }
14047        let mut proc = ImageProcessor::new().unwrap();
14048        let src = access_test_src(128, 96);
14049        for (format, dtype) in [
14050            (PixelFormat::Rgb, DType::I8),
14051            (PixelFormat::Rgba, DType::I8),
14052            (PixelFormat::Bgra, DType::U8),
14053        ] {
14054            for requested in [CpuAccess::None, CpuAccess::Write] {
14055                let mut dst = proc
14056                    .create_image(64, 48, format, dtype, None, requested)
14057                    .unwrap();
14058                assert_eq!(
14059                    dst.cpu_access(),
14060                    CpuAccess::ReadWrite,
14061                    "{format:?}/{dtype:?} requested {requested:?}"
14062                );
14063                proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
14064                    panic!("CPU convert into {format:?}/{dtype:?} {requested:?}: {e}")
14065                });
14066            }
14067        }
14068    }
14069
14070    /// The CPU convert refuses up front, asking for `ReadWrite`, a
14071    /// `Write`-only destination it would read back (the `I8` bias, the BGRA
14072    /// swizzle); a BGRA-to-BGRA copy has no swizzle and needs only `Write`.
14073    #[test]
14074    fn cpu_convert_refuses_a_write_only_destination_it_reads_back() {
14075        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14076        let mut cpu = CPUProcessor::new();
14077        let src = access_test_src(32, 24);
14078        for (format, dtype) in [
14079            (PixelFormat::Rgb, DType::I8),
14080            (PixelFormat::Bgra, DType::U8),
14081        ] {
14082            let mut dst = TensorDyn::image(
14083                32,
14084                24,
14085                format,
14086                dtype,
14087                Some(TensorMemory::Mem),
14088                CpuAccess::Write,
14089            )
14090            .unwrap();
14091            match cpu.convert(&src, &mut dst, None) {
14092                Err(Error::Tensor(TensorError::CpuAccessDenied {
14093                    declared: CpuAccess::Write,
14094                    requested: CpuAccess::ReadWrite,
14095                })) => {}
14096                other => panic!("{format:?}/{dtype:?}: {other:?}"),
14097            }
14098        }
14099        let bgra_src = TensorDyn::image(
14100            32,
14101            24,
14102            PixelFormat::Bgra,
14103            DType::U8,
14104            Some(TensorMemory::Mem),
14105            CpuAccess::ReadWrite,
14106        )
14107        .unwrap();
14108        let mut dst = TensorDyn::image(
14109            32,
14110            24,
14111            PixelFormat::Bgra,
14112            DType::U8,
14113            Some(TensorMemory::Mem),
14114            CpuAccess::Write,
14115        )
14116        .unwrap();
14117        cpu.convert(&bgra_src, &mut dst, None).unwrap();
14118    }
14119
14120    /// The prediction `create_image` widens with, per backend mix: the CPU
14121    /// and G2D post-passes when either may run, nothing when the GPU renders
14122    /// the destination, `Write` for a host-memory readback.
14123    #[test]
14124    fn predicted_cpu_access_unions_the_backends_that_may_run() {
14125        use edgefirst_tensor::CpuAccess::{self, None as No, Read, ReadWrite, Write};
14126        let p = predicted_cpu_access;
14127        let takes = |a: CpuAccess| Some(Some(a));
14128        let declines = Some(None);
14129        // A forced backend is the only one that runs.
14130        assert_eq!(
14131            p(
14132                Some(ForcedBackend::G2d),
14133                None,
14134                takes(ReadWrite),
14135                takes(Write)
14136            ),
14137            ReadWrite
14138        );
14139        assert_eq!(
14140            p(Some(ForcedBackend::G2d), None, declines, takes(Write)),
14141            No
14142        );
14143        assert_eq!(
14144            p(
14145                Some(ForcedBackend::OpenGl),
14146                takes(Write),
14147                None,
14148                takes(ReadWrite)
14149            ),
14150            Write
14151        );
14152        assert_eq!(
14153            p(Some(ForcedBackend::Cpu), takes(No), None, takes(Write)),
14154            Write
14155        );
14156        // A GL render with no CPU access ends the chain.
14157        assert_eq!(p(None, takes(No), takes(ReadWrite), takes(ReadWrite)), No);
14158        // A host-memory readback leaves the fallbacks predicted.
14159        assert_eq!(p(None, takes(Write), None, takes(ReadWrite)), ReadWrite);
14160        // G2D may decline at run time, so the CPU stays predicted after it.
14161        assert_eq!(p(None, declines, takes(No), takes(Write)), Write);
14162        assert_eq!(p(None, declines, takes(ReadWrite), None), ReadWrite);
14163        assert_eq!(p(None, None, None, takes(Read)), Read);
14164        assert_eq!(p(None, None, None, None), No);
14165    }
14166
14167    /// The GL prediction follows the float classifier: a float destination
14168    /// GL cannot render (host memory, an unsupported format) is predicted
14169    /// for the CPU fallback.
14170    #[cfg(all(target_os = "linux", feature = "opengl"))]
14171    #[test]
14172    fn float_destinations_gl_declines_are_predicted_for_the_cpu() {
14173        use edgefirst_tensor::CpuAccess;
14174        let Ok(processor) = ImageProcessor::new() else {
14175            eprintln!("WARNING: SKIPPED: float prediction - no image processor");
14176            return;
14177        };
14178        let Some(gl) = processor.opengl.as_ref() else {
14179            eprintln!("WARNING: SKIPPED: float prediction - no OpenGL backend");
14180            return;
14181        };
14182        assert_eq!(
14183            gl.dst_cpu_access(TensorMemory::Mem, PixelFormat::PlanarRgb, DType::F16),
14184            None
14185        );
14186        assert_eq!(
14187            gl.dst_cpu_access(TensorMemory::DmaBuf, PixelFormat::Grey, DType::F32),
14188            None
14189        );
14190        let image = processor
14191            .create_image(
14192                64,
14193                64,
14194                PixelFormat::Rgb,
14195                DType::F32,
14196                Some(TensorMemory::Mem),
14197                CpuAccess::None,
14198            )
14199            .unwrap();
14200        assert!(image.cpu_access().writes());
14201    }
14202
14203    /// When a GPU backend declined and the CPU fallback is refused for
14204    /// access, the error carries the decline; otherwise it is the CPU's.
14205    #[test]
14206    fn a_refused_cpu_fallback_reports_the_gpu_decline() {
14207        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14208        let denied = || {
14209            Error::Tensor(TensorError::CpuAccessDenied {
14210                declared: CpuAccess::None,
14211                requested: CpuAccess::Write,
14212            })
14213        };
14214        let declined = || {
14215            vec![(
14216                "opengl",
14217                Error::NotSupported("unsupported source format".into()),
14218            )]
14219        };
14220        let e = with_gpu_declines(denied(), declined());
14221        assert!(
14222            matches!(
14223                &e,
14224                Error::CpuFallbackDenied {
14225                    denied: TensorError::CpuAccessDenied { .. },
14226                    ..
14227                }
14228            ),
14229            "{e:?}"
14230        );
14231        let msg = e.to_string();
14232        assert!(msg.contains("unsupported source format"), "{msg}");
14233        assert!(msg.contains("CPU access denied"), "{msg}");
14234        assert!(e.cpu_access_denied().is_some());
14235        assert!(matches!(
14236            with_gpu_declines(denied(), Vec::new()),
14237            Error::Tensor(TensorError::CpuAccessDenied { .. })
14238        ));
14239        assert!(matches!(
14240            with_gpu_declines(Error::NoConverter, declined()),
14241            Error::NoConverter
14242        ));
14243    }
14244
14245    /// With only the CPU backend, `create_image` widens a `Read` request to
14246    /// `ReadWrite` and a `None` request to `Write`, so the processor's own
14247    /// convert can fill the image.
14248    #[test]
14249    fn create_image_adds_write_when_the_cpu_backend_fills_it() {
14250        use edgefirst_tensor::CpuAccess;
14251        let _lock = acquire_env_lock();
14252        let _guard = EnvGuard::snapshot(&[
14253            "EDGEFIRST_FORCE_BACKEND",
14254            "EDGEFIRST_DISABLE_GL",
14255            "EDGEFIRST_DISABLE_G2D",
14256            "EDGEFIRST_DISABLE_CPU",
14257        ]);
14258        unsafe {
14259            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
14260            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
14261            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
14262            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
14263        }
14264        let mut proc = ImageProcessor::new().unwrap();
14265        let src = access_test_src(128, 96);
14266        for (requested, declared) in [
14267            (CpuAccess::Read, CpuAccess::ReadWrite),
14268            (CpuAccess::None, CpuAccess::Write),
14269            (CpuAccess::Write, CpuAccess::Write),
14270            (CpuAccess::ReadWrite, CpuAccess::ReadWrite),
14271        ] {
14272            let mut dst = proc
14273                .create_image(64, 48, PixelFormat::Rgb, DType::U8, None, requested)
14274                .unwrap();
14275            assert_eq!(dst.cpu_access(), declared, "requested {requested:?}");
14276            proc.convert(&src, &mut dst, None)
14277                .unwrap_or_else(|e| panic!("CPU convert into a {requested:?} request: {e}"));
14278        }
14279    }
14280
14281    /// A CPU convert refuses up front a caller destination declared without
14282    /// writes, and a source declared without reads, naming both accesses.
14283    #[test]
14284    fn cpu_convert_refuses_tensors_outside_their_declaration() {
14285        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14286        let mut cpu = CPUProcessor::new();
14287        let src = access_test_src(32, 24);
14288        for declared in [CpuAccess::Read, CpuAccess::None] {
14289            let mut dst = TensorDyn::image(
14290                32,
14291                24,
14292                PixelFormat::Rgb,
14293                DType::U8,
14294                Some(TensorMemory::Mem),
14295                declared,
14296            )
14297            .unwrap();
14298            match cpu.convert(&src, &mut dst, None) {
14299                Err(Error::Tensor(TensorError::CpuAccessDenied {
14300                    declared: d,
14301                    requested: CpuAccess::Write,
14302                })) => assert_eq!(d, declared),
14303                other => panic!("convert into a {declared:?} dst: {other:?}"),
14304            }
14305        }
14306        let write_only = TensorDyn::image(
14307            32,
14308            24,
14309            PixelFormat::Rgba,
14310            DType::U8,
14311            Some(TensorMemory::Mem),
14312            CpuAccess::Write,
14313        )
14314        .unwrap();
14315        let mut dst = TensorDyn::image(
14316            32,
14317            24,
14318            PixelFormat::Rgb,
14319            DType::U8,
14320            Some(TensorMemory::Mem),
14321            CpuAccess::ReadWrite,
14322        )
14323        .unwrap();
14324        let err = cpu.convert(&write_only, &mut dst, None).unwrap_err();
14325        assert!(
14326            matches!(
14327                err,
14328                Error::Tensor(TensorError::CpuAccessDenied {
14329                    declared: CpuAccess::Write,
14330                    requested: CpuAccess::Read,
14331                })
14332            ),
14333            "{err:?}"
14334        );
14335        assert!(err
14336            .to_string()
14337            .contains("declare CpuAccess::ReadWrite (the default)"));
14338    }
14339
14340    /// The CPU mask draw blends into its destination, so a `Write`-only
14341    /// destination is refused before anything is drawn.
14342    #[test]
14343    fn cpu_draw_requires_readwrite_on_the_destination() {
14344        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14345        let mut cpu = CPUProcessor::new();
14346        let mut dst = TensorDyn::image(
14347            32,
14348            24,
14349            PixelFormat::Rgba,
14350            DType::U8,
14351            Some(TensorMemory::Mem),
14352            CpuAccess::Write,
14353        )
14354        .unwrap();
14355        let err = cpu
14356            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
14357            .unwrap_err();
14358        assert!(
14359            matches!(
14360                err,
14361                Error::Tensor(TensorError::CpuAccessDenied {
14362                    declared: CpuAccess::Write,
14363                    requested: CpuAccess::ReadWrite,
14364                })
14365            ),
14366            "{err:?}"
14367        );
14368    }
14369
14370    /// `create_image_desc` without a compression request is exactly
14371    /// `create_image` (the processor's memory negotiation applies); with
14372    /// `Compression::Any` off-Android it resolves linear and counts the
14373    /// fallback in the processor-visible mirror.
14374    #[test]
14375    fn create_image_desc_negotiates_and_counts_fallbacks() {
14376        use edgefirst_tensor::{CpuAccess, ImageDesc};
14377        let proc = ImageProcessor::new().unwrap();
14378
14379        let desc =
14380            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
14381        let plain = proc.create_image_desc(&desc).unwrap();
14382        let classic = proc
14383            .create_image(
14384                64,
14385                64,
14386                PixelFormat::Rgba,
14387                DType::U8,
14388                None,
14389                CpuAccess::ReadWrite,
14390            )
14391            .unwrap();
14392        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
14393        assert_eq!(plain.compression(), None);
14394
14395        #[cfg(not(target_os = "android"))]
14396        {
14397            use edgefirst_tensor::Compression;
14398            let before = proc.compression_fallback_count();
14399            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
14400                .with_access(CpuAccess::None)
14401                .with_compression(Compression::Any);
14402            let t = proc.create_image_desc(&desc).unwrap();
14403            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
14404            assert!(
14405                proc.compression_fallback_count() > before,
14406                "Any resolving linear must count"
14407            );
14408        }
14409    }
14410
14411    /// A contiguous request through `create_image_desc` is a CMA DMA-BUF,
14412    /// or an error where there is no CMA heap; never a PBO or host memory.
14413    #[test]
14414    #[cfg(target_os = "linux")]
14415    fn create_image_desc_contiguous_is_cma_dma_or_an_error() {
14416        use edgefirst_tensor::{Contiguity, ImageDesc, TensorMemory};
14417        let proc = ImageProcessor::new().unwrap();
14418        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_contiguous(true);
14419        match proc.create_image_desc(&desc) {
14420            Ok(t) => {
14421                assert_eq!(t.memory(), TensorMemory::DmaBuf);
14422                assert_eq!(t.contiguity(), Contiguity::Contiguous);
14423            }
14424            Err(e) => {
14425                assert!(e.to_string().contains("linux,cma"), "{e}");
14426                crate::test_support::report_skip(&format!(
14427                    "{} - no CMA heap, the contiguous DMA-BUF arm did not run: {e}",
14428                    function!()
14429                ));
14430            }
14431        }
14432    }
14433
14434    /// There is no DRM FourCC for F32 images, so `create_image` with
14435    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
14436    #[test]
14437    #[cfg(target_os = "linux")]
14438    fn create_image_f32_dma_rejected() {
14439        let proc = ImageProcessor::new().unwrap();
14440        let result = proc.create_image(
14441            64,
14442            64,
14443            PixelFormat::Rgb,
14444            DType::F32,
14445            Some(TensorMemory::DmaBuf),
14446            edgefirst_tensor::CpuAccess::ReadWrite,
14447        );
14448        assert!(
14449            result.is_err(),
14450            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
14451        );
14452    }
14453
14454    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
14455    /// the returned `TensorDyn`.
14456    ///
14457    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
14458    /// unavailable we skip the DMA call but still verify the storage contract
14459    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
14460    /// constructed the same way the function body does it — and we confirm via
14461    /// code-read that the new parameter is unconditionally stored.
14462    #[test]
14463    #[cfg(target_os = "linux")]
14464    fn import_image_carries_colorimetry() {
14465        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
14466
14467        let expected = Colorimetry::default()
14468            .with_encoding(ColorEncoding::Bt709)
14469            .with_range(ColorRange::Limited);
14470
14471        if !is_dma_available() {
14472            // DMA unavailable on this host: exercise the storage path via
14473            // TensorDyn directly (mirrors what import_image does internally).
14474            let mut t = TensorDyn::image(
14475                8,
14476                8,
14477                PixelFormat::Rgba,
14478                DType::U8,
14479                Some(TensorMemory::Mem),
14480                edgefirst_tensor::CpuAccess::ReadWrite,
14481            )
14482            .expect("alloc");
14483            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
14484            t.set_colorimetry(Some(expected));
14485            assert_eq!(
14486                t.colorimetry(),
14487                Some(expected),
14488                "set_colorimetry must round-trip"
14489            );
14490            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
14491            return;
14492        }
14493
14494        // DMA is available: allocate a real DMA tensor, extract its fd, and
14495        // call import_image with an explicit Colorimetry.
14496        use edgefirst_tensor::{PlaneDescriptor, Tensor};
14497
14498        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
14499        let dma_tensor = Tensor::<u8>::new(
14500            &[rgba_bytes],
14501            Some(TensorMemory::DmaBuf),
14502            Some("import_test"),
14503        )
14504        .expect("dma alloc");
14505        let pd =
14506            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
14507
14508        let proc = ImageProcessor::new().expect("ImageProcessor");
14509        let result = proc.import_image(
14510            pd,
14511            None,
14512            64,
14513            64,
14514            PixelFormat::Rgba,
14515            DType::U8,
14516            Some(expected),
14517        );
14518        let tensor = result.expect("import_image must succeed on DMA fd");
14519        assert_eq!(
14520            tensor.colorimetry(),
14521            Some(expected),
14522            "import_image must store the supplied colorimetry on the returned TensorDyn"
14523        );
14524    }
14525}