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 colors. `1.0` (default)
561///   preserves the class color's alpha unchanged; `0.5` makes masks
562///   semi-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 alpha.
1006    ///
1007    /// A `Bgra` background onto a zero-copy destination (a D3D11 texture, an
1008    /// IOSurface, a dma-buf) returns [`Error::NotSupported`]: the GL
1009    /// base-layer draw has no `Bgra` arm and the CPU backend renders only
1010    /// `Rgba`/`Rgb`. It previously returned `Ok` with `dst` unwritten. A
1011    /// `Bgra` destination with no background is unaffected.
1012    ///
1013    /// # Buffer aliasing
1014    ///
1015    /// `dst` and `overlay.background` must reference **distinct underlying
1016    /// buffers**. An aliased pair returns [`Error::AliasedBuffers`] without
1017    /// dispatching to any backend — the GL path would otherwise read and
1018    /// write the same texture in a single draw, which is undefined behaviour
1019    /// on most drivers. Aliasing is detected via
1020    /// [`TensorDyn::aliases`](edgefirst_tensor::TensorDyn::aliases), which
1021    /// catches both shared-allocation clones and separate imports over the
1022    /// same dmabuf fd.
1023    ///
1024    /// # Migration from v0.16.3 and earlier
1025    ///
1026    /// Prior to v0.16.4 the call silently preserved `dst`'s contents on empty
1027    /// detections. That invariant no longer holds — `dst` is always fully
1028    /// written. Callers who pre-loaded an image into `dst` before calling this
1029    /// function must now pass that image via `overlay.background` instead.
1030    fn draw_decoded_masks(
1031        &mut self,
1032        dst: &mut TensorDyn,
1033        detect: &[DetectBox],
1034        segmentation: &[Segmentation],
1035        overlay: MaskOverlay<'_>,
1036    ) -> Result<()>;
1037
1038    /// Draw masks from proto data onto image (fused decode+draw).
1039    ///
1040    /// For YOLO segmentation models, this avoids materializing intermediate
1041    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
1042    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
1043    /// at the output resolution using bilinear sampling.
1044    ///
1045    /// `detect` and `proto_data.mask_coefficients` must have the same length
1046    /// (enforced by zip — excess entries are silently ignored). An empty
1047    /// `detect` slice is valid and produces the base frame — cleared or
1048    /// background-blitted — via the selected backend's native primitive.
1049    ///
1050    /// # Format requirements and output contract
1051    ///
1052    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks), including
1053    /// the "always fully writes dst" guarantee across all four
1054    /// detection/background combinations.
1055    ///
1056    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
1057    fn draw_proto_masks(
1058        &mut self,
1059        dst: &mut TensorDyn,
1060        detect: &[DetectBox],
1061        proto_data: &ProtoData,
1062        overlay: MaskOverlay<'_>,
1063    ) -> Result<()>;
1064
1065    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
1066    /// can be set.
1067    ///
1068    /// On [`ImageProcessor`] with no forced backend this reaches every
1069    /// available backend rather than the first that accepts, because a
1070    /// later draw may land on any of them. A forced backend receives the
1071    /// colors alone.
1072    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1073
1074    /// Like [`convert`](Self::convert), but does not wait for the GPU to finish.
1075    ///
1076    /// This is the batch-preprocessing primitive: a caller renders `N` tiles
1077    /// into one batched destination by looping
1078    /// `convert_deferred(&src[n], &mut dst.batch(n)?, …)` and then calling
1079    /// [`flush`](Self::flush) **once**. On the OpenGL backend every deferred
1080    /// convert into sibling views of one buffer shares a single EGLImage import
1081    /// (the tile is a `glViewport`/`glScissor` ROI into the parent) and skips the
1082    /// per-tile `glFinish`; `flush` then issues a single GPU sync. The result of
1083    /// a deferred convert is **not** safe to read on the CPU (or map via CUDA)
1084    /// until `flush` returns.
1085    ///
1086    /// The default implementation is eager — it simply calls
1087    /// [`convert`](Self::convert), so CPU/G2D and any backend without a deferred
1088    /// fast path remain correct (each call completes synchronously and `flush`
1089    /// is a no-op).
1090    fn convert_deferred(
1091        &mut self,
1092        src: &TensorDyn,
1093        dst: &mut TensorDyn,
1094        options: Option<ConvertOptions>,
1095    ) -> Result<()> {
1096        self.convert(src, dst, options)
1097    }
1098
1099    /// Complete all work enqueued by [`convert_deferred`](Self::convert_deferred)
1100    /// since the last flush, issuing a single GPU synchronization.
1101    ///
1102    /// After this returns, every deferred destination is finished and safe to
1103    /// read back or `cuda_map`. Backends with no deferred path (the default)
1104    /// return `Ok(())` immediately, since their converts already completed.
1105    fn flush(&mut self) -> Result<()> {
1106        Ok(())
1107    }
1108}
1109
1110/// Configuration for [`ImageProcessor`] construction.
1111///
1112/// Use with [`ImageProcessor::with_config`] to override the default EGL
1113/// display auto-detection and backend selection. The default configuration
1114/// preserves the existing auto-detection behaviour.
1115#[derive(Debug, Clone, Default)]
1116pub struct ImageProcessorConfig {
1117    /// Force OpenGL to use this EGL display type instead of auto-detecting.
1118    ///
1119    /// When `None`, the processor probes displays in priority order: GBM,
1120    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
1121    /// which displays are available on the current system.
1122    ///
1123    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set, and on macOS and
1124    /// Windows (ANGLE is the only display there — Metal and Direct3D 11
1125    /// respectively; a `Some` value logs a debug note and is otherwise
1126    /// ignored).
1127    #[cfg(all(
1128        any(
1129            target_os = "linux",
1130            target_os = "macos",
1131            target_os = "ios",
1132            target_os = "android",
1133            target_os = "windows"
1134        ),
1135        feature = "opengl"
1136    ))]
1137    pub egl_display: Option<EglDisplayKind>,
1138
1139    /// Preferred compute backend.
1140    ///
1141    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
1142    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
1143    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
1144    /// `EDGEFIRST_DISABLE_*` environment variables.
1145    ///
1146    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
1147    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
1148    /// - [`ComputeBackend::Cpu`]: init CPU only
1149    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
1150    pub backend: ComputeBackend,
1151
1152    /// Colorimetry/performance trade-off for `convert()` (see
1153    /// [`ColorimetryMode`]). Defaults to [`ColorimetryMode::Fast`]. The
1154    /// `EDGEFIRST_COLORIMETRY` environment variable (`fast` | `exact`)
1155    /// overrides this setting when present.
1156    pub colorimetry: ColorimetryMode,
1157
1158    /// How `convert()` resamples when the source (or source crop) and the
1159    /// destination differ in size (see [`Interpolation`]), for converts whose
1160    /// options do not choose their own ([`ConvertOptions::interpolation`]).
1161    /// Defaults to [`Interpolation::Bilinear`]. The `EDGEFIRST_INTERPOLATION`
1162    /// environment variable (`nearest` | `bilinear`), read when the processor
1163    /// is created, overrides this setting and every per-convert choice.
1164    pub interpolation: Interpolation,
1165
1166    /// How many buffer imports the OpenGL backend keeps cached, per cache.
1167    ///
1168    /// `None` (the default) uses `EDGEFIRST_EGL_CACHE_CAPACITY` if set, and
1169    /// 16 otherwise. Precedence is:
1170    ///
1171    /// ```text
1172    /// egl_cache_capacity  >  EDGEFIRST_EGL_CACHE_CAPACITY  >  16
1173    /// ```
1174    ///
1175    /// Config beats the environment deliberately, matching
1176    /// [`backend`](Self::backend): an embedder that has measured its own
1177    /// pool must not be silently overridden by a stray environment variable
1178    /// in the deployment. `Some(0)` is rejected and falls through to the
1179    /// environment, then the default, exactly as an unparseable env value
1180    /// does.
1181    ///
1182    /// # This is a buffer *count*, not a size
1183    ///
1184    /// A cached import pins its buffer — the underlying dma-buf or IOSurface
1185    /// cannot be freed while an entry names it (that is what makes the cache
1186    /// correct as well as fast; see the "EGL image cache inside HAL" section
1187    /// of `ARCHITECTURE.md`). So the memory this holds depends entirely on
1188    /// how big the caller's buffers are:
1189    ///
1190    /// ```text
1191    /// worst-case pinned bytes                                (upper bound)
1192    ///     = capacity x 3 caches x frame size x live ImageProcessors
1193    ///
1194    /// capacity 16, 640x640 RGB   (1.2 MB)  ->   59 MB per processor
1195    /// capacity 16, 1080p NV12    (3.1 MB)  ->  149 MB per processor
1196    /// capacity 16, 4K NV12      (12.4 MB)  ->  597 MB per processor
1197    /// ```
1198    ///
1199    /// A ceiling rather than an exact total: the source and NV R8 caches
1200    /// often hold two imports of the *same* buffer, and two entries naming
1201    /// one buffer pin it once. The per-cache figure is the exact one.
1202    ///
1203    /// The bound is **per [`ImageProcessor`], times three caches** (source,
1204    /// destination, and the NV R8 source cache) — not per process and not
1205    /// per library. Two `ImageProcessor`s in one process have six
1206    /// independent caches and six independent capacities. A fourth cache,
1207    /// the chroma-plane views of hardware-filtered NV resizes, has the same
1208    /// capacity but imports only buffers the NV R8 cache also holds, so it
1209    /// adds entries without pinning more memory.
1210    ///
1211    /// Whether the worst case is ever approached is up to the caller. A
1212    /// pooled producer — V4L2 capture, typically 4–8 buffers over one or two
1213    /// sizes — settles at its pool size and never nears the bound. A stream
1214    /// that allocates a fresh buffer per frame holds the last `capacity` of
1215    /// them outright, which is the difference between "16 is free" and "16
1216    /// is 597 MB".
1217    ///
1218    /// Size it from measurement rather than guesswork:
1219    /// [`CacheStats::peak_entries`] reports the working set actually
1220    /// reached, and [`CacheStats::evictions`] tells you when the bound is
1221    /// costing you re-imports (it should be zero in a steady state).
1222    ///
1223    /// Ignored when the OpenGL backend is not in use.
1224    pub egl_cache_capacity: Option<usize>,
1225}
1226
1227/// How `convert()` trades colorimetric exactness against speed on platforms
1228/// where the exact path is expensive.
1229///
1230/// Today this affects one decision: NV12 sources on Vivante GC7000UL
1231/// (i.MX 8M Plus), where the hardware external sampler converts ~12× faster
1232/// than the colorimetry-exact in-shader matrix (2.5 ms vs 29 ms at 720p)
1233/// but applies the driver's fixed BT.601-limited matrix regardless of the
1234/// source's tagged colorimetry. Platforms where the exact path is already
1235/// the fastest correct path (Mali, V3D, Tegra, ANGLE) behave identically in
1236/// both modes.
1237///
1238/// Override at runtime with `EDGEFIRST_COLORIMETRY=fast|exact` (takes
1239/// precedence over the config field), or per-source by forcing a path with
1240/// `EDGEFIRST_NV_CONVERT_PATH`.
1241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1242pub enum ColorimetryMode {
1243    /// Prefer the fastest path whose output is correct-enough video RGB
1244    /// (default; issue #106 policy). On Vivante, NV12 takes the hardware
1245    /// sampler even when the source is not BT.601-limited.
1246    #[default]
1247    Fast,
1248    /// Prefer bit-exact colorimetry everywhere: the fast path is used only
1249    /// when it matches the source's resolved (encoding, range) exactly.
1250    Exact,
1251}
1252
1253/// `mode` if the backends run it, else [`Interpolation::supported`] with a
1254/// warning logged once per mode: bicubic and area describe reference
1255/// pipelines this crate does not implement.
1256pub(crate) fn runnable_interpolation(mode: Interpolation) -> Interpolation {
1257    let run = mode.supported();
1258    if run != mode && warn_first_fallback(mode) {
1259        log::warn!(
1260            "{} interpolation is not implemented; using {}",
1261            mode.as_str(),
1262            run.as_str()
1263        );
1264    }
1265    run
1266}
1267
1268/// Unsupported interpolations already warned about, one bit per code.
1269static INTERPOLATION_FALLBACKS_WARNED: std::sync::atomic::AtomicU32 =
1270    std::sync::atomic::AtomicU32::new(0);
1271
1272/// Whether this is the first fallback from `mode` in the process.
1273fn warn_first_fallback(mode: Interpolation) -> bool {
1274    let bit = 1u32 << (mode.code() % 32);
1275    INTERPOLATION_FALLBACKS_WARNED.fetch_or(bit, std::sync::atomic::Ordering::Relaxed) & bit == 0
1276}
1277
1278/// The interpolation selected by `EDGEFIRST_INTERPOLATION`, if it is set to
1279/// a recognised value. An unrecognised value is logged and ignored.
1280pub(crate) fn interpolation_from_env() -> Option<Interpolation> {
1281    let value = std::env::var("EDGEFIRST_INTERPOLATION").ok()?;
1282    let value = value.trim().to_ascii_lowercase();
1283    if value.is_empty() {
1284        return None;
1285    }
1286    let mode = Interpolation::from_str_code(&value);
1287    if mode.is_none() {
1288        log::warn!(
1289            "EDGEFIRST_INTERPOLATION={value:?} is not recognised \
1290             (expected nearest or bilinear); ignoring it"
1291        );
1292    }
1293    mode
1294}
1295
1296edgefirst_tensor::ef_vocabulary! {
1297    /// Compute backend selection for [`ImageProcessor`].
1298    ///
1299    /// Use with [`ImageProcessorConfig::backend`] to select which backend the
1300    /// processor should prefer. When a specific backend is selected, the
1301    /// processor initializes that backend plus CPU as a fallback. When `Auto`
1302    /// is used, the existing environment-variable-driven selection applies.
1303    ///
1304    /// The code is the one the C `EF_COMPUTE_BACKEND_*` enumerators use.
1305    #[derive(Default)]
1306    pub enum ComputeBackend {
1307        /// Auto-detect based on available hardware and environment variables.
1308        #[default]
1309        Auto = 0, "auto", AUTO,
1310        /// CPU-only processing (no hardware acceleration).
1311        Cpu = 1, "cpu", CPU,
1312        /// Prefer G2D hardware blitter (+ CPU fallback).
1313        G2d = 2, "g2d", G2D,
1314        /// Prefer OpenGL ES (+ CPU fallback).
1315        OpenGl = 3, "opengl", OPENGL,
1316    }
1317    #[doc(hidden)]
1318    pub mod compute_backend_code;
1319}
1320
1321/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
1322/// or [`ImageProcessorConfig::backend`].
1323///
1324/// When set, the [`ImageProcessor`] only initializes and dispatches to the
1325/// selected backend — no fallback chain is used.
1326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1327pub(crate) enum ForcedBackend {
1328    Cpu,
1329    G2d,
1330    OpenGl,
1331}
1332
1333/// The CPU fallback's error, carrying the GPU decline reasons when the CPU
1334/// was refused for access: the decline is what the caller has to fix.
1335/// `declined` pairs each declining backend's name with its error, formatted
1336/// only here so a frame the fallback serves pays for no message.
1337fn with_gpu_declines(e: Error, declined: Vec<(&'static str, Error)>) -> Error {
1338    match e {
1339        Error::Tensor(denied @ edgefirst_tensor::Error::CpuAccessDenied { .. })
1340            if !declined.is_empty() =>
1341        {
1342            let declined: Vec<String> = declined
1343                .iter()
1344                .map(|(backend, e)| format!("{backend}: {e}"))
1345                .collect();
1346            Error::CpuFallbackDenied {
1347                declined: declined.join("; "),
1348                denied,
1349            }
1350        }
1351        other => other,
1352    }
1353}
1354
1355/// The CPU access an [`ImageProcessor`]'s own convert may need on a
1356/// destination, from what each present backend needs when it takes it
1357/// (`Some`) or declines it (`None`).
1358///
1359/// A forced backend is the only one that runs. Otherwise a GL render that
1360/// needs no CPU access ends the chain, while G2D and the CPU fallback may
1361/// each still run, so their needs are all included.
1362fn predicted_cpu_access(
1363    forced: Option<ForcedBackend>,
1364    gl: Option<Option<edgefirst_tensor::CpuAccess>>,
1365    g2d: Option<Option<edgefirst_tensor::CpuAccess>>,
1366    cpu: Option<Option<edgefirst_tensor::CpuAccess>>,
1367) -> edgefirst_tensor::CpuAccess {
1368    use edgefirst_tensor::CpuAccess;
1369    let only = |b: Option<Option<CpuAccess>>| b.flatten().unwrap_or(CpuAccess::None);
1370    match forced {
1371        Some(ForcedBackend::Cpu) => return only(cpu),
1372        Some(ForcedBackend::G2d) => return only(g2d),
1373        Some(ForcedBackend::OpenGl) => return only(gl),
1374        None => {}
1375    }
1376    let gl = gl.flatten();
1377    if gl == Some(CpuAccess::None) {
1378        return CpuAccess::None;
1379    }
1380    [gl, g2d.flatten(), cpu.flatten()]
1381        .into_iter()
1382        .flatten()
1383        .fold(CpuAccess::None, CpuAccess::union)
1384}
1385
1386/// Reports which float-color-buffer extensions the GPU backend detected.
1387/// Returned by [`ImageProcessor::supported_render_dtypes`]; the two flags
1388/// are independent.
1389///
1390/// **Linux:** reflects the real probe results from `GL_EXT_color_buffer_half_float`
1391/// and `GL_EXT_color_buffer_float`. On V3D (RPi 5) and Mali-G310 (i.MX 95)
1392/// both flags are typically `true`; on Vivante GC7000UL both are forced
1393/// `false` (float readback measured 170–320 ms — disabled). Tegra Orin
1394/// exposes both via PBO; the flags match the GPU report.
1395///
1396/// **macOS (ANGLE):** `f16 == true` gates the RGBA16F-packed IOSurface
1397/// path for F16 `PlanarRgb` destinations. `f32` reflects the GL
1398/// extension probe but is not actionable — ANGLE's
1399/// `EGL_ANGLE_iosurface_client_buffer` rejects every `(GL_FLOAT, *)`
1400/// combination with `EGL_BAD_ATTRIBUTE`, so there is no F32 IOSurface
1401/// path.
1402///
1403/// **Windows (ANGLE/D3D11):** `f16` enables the RGBA16F texture paths and
1404/// `f32` the RGBA32F ones for every float layout (`float_dispatch.rs`) —
1405/// `PlanarRgb` and `PlanarRgba` NCHW, interleaved `Rgb`, and `Rgba`.
1406///
1407/// Regardless of these flags, [`ImageProcessor::convert`] never returns
1408/// an error due to float capability — it falls back to CPU when the GPU
1409/// path is unavailable.
1410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1411pub struct RenderDtypeSupport {
1412    /// `GL_EXT_color_buffer_float` is available on the current GPU.
1413    ///
1414    /// On Linux, `true` enables F32 `Rgb` NHWC PBO readback. On macOS
1415    /// this flag is informational only — no F32 IOSurface path exists.
1416    /// On Windows, `true` enables the RGBA32F zero-copy texture render
1417    /// for every F32 layout.
1418    pub f32: bool,
1419    /// `GL_EXT_color_buffer_half_float` is available on the current GPU.
1420    ///
1421    /// On Linux, `true` enables F16 `PlanarRgb` NCHW PBO readback and,
1422    /// on V3D/Mali, zero-copy DMA-BUF render. On macOS, `true` enables
1423    /// F16 `PlanarRgb` via RGBA16F-packed IOSurface (zero-copy). On
1424    /// Windows, `true` enables the RGBA16F zero-copy texture render for
1425    /// every F16 layout.
1426    pub f16: bool,
1427}
1428
1429/// Returns `true` when a float PBO destination should be attempted for `dtype`.
1430///
1431/// Only F16 and F32 are eligible, and only when the corresponding flag in
1432/// `support` is set. U8/I8 and all other dtypes return `false` — they are
1433/// handled by the existing `dtype.size() == 1` PBO gate.
1434///
1435/// The same question `create_image` asks before trying a zero-copy float
1436/// destination, which is why that gate calls this rather than restating it:
1437/// a float image is only worth allocating on the GPU where the display can
1438/// render into that dtype at all.
1439///
1440/// Compiled on every platform with a GL backend -- the name is historical
1441/// (it began as the float *PBO* gate) and both callers are live everywhere
1442/// the `opengl` feature builds a backend.
1443#[cfg(all(
1444    any(
1445        target_os = "linux",
1446        target_os = "windows",
1447        target_os = "macos",
1448        target_os = "ios",
1449        target_os = "android"
1450    ),
1451    feature = "opengl"
1452))]
1453pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1454    match dtype {
1455        DType::F16 => support.f16,
1456        DType::F32 => support.f32,
1457        _ => false,
1458    }
1459}
1460
1461/// Image converter that uses available hardware acceleration or CPU as a
1462/// fallback.
1463#[derive(Debug)]
1464pub struct ImageProcessor {
1465    /// CPU-based image converter as a fallback. This is only None if the
1466    /// EDGEFIRST_DISABLE_CPU environment variable is set.
1467    pub cpu: Option<CPUProcessor>,
1468
1469    #[cfg(target_os = "linux")]
1470    /// G2D-based image converter for Linux systems. This is only available if
1471    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
1472    /// is available.
1473    pub g2d: Option<G2DProcessor>,
1474    #[cfg(target_os = "linux")]
1475    #[cfg(feature = "opengl")]
1476    /// OpenGL-based image converter for Linux systems. This is only available
1477    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
1478    /// ES is available.
1479    pub opengl: Option<GLProcessorThreaded>,
1480    #[cfg(any(
1481        target_os = "macos",
1482        target_os = "ios",
1483        target_os = "android",
1484        target_os = "windows"
1485    ))]
1486    #[cfg(feature = "opengl")]
1487    /// OpenGL-based image converter — the same unified
1488    /// `GLProcessorThreaded` engine as Linux (its worker owns a
1489    /// per-processor context). macOS/iOS run it via ANGLE + IOSurface
1490    /// (available when ANGLE's libEGL.dylib can be loaded — see
1491    /// README.md § macOS GPU Acceleration); Android runs it via the
1492    /// native EGL driver + AHardwareBuffer; Windows runs it via ANGLE +
1493    /// Direct3D 11 with PBO transfers (available when ANGLE's libEGL.dll
1494    /// can be loaded — see README.md § Windows GPU Acceleration).
1495    pub opengl: Option<GLProcessorThreaded>,
1496
1497    /// When set, only the specified backend is used — no fallback chain.
1498    pub(crate) forced_backend: Option<ForcedBackend>,
1499
1500    /// Converts where the GL backend declined and the auto chain fell
1501    /// through toward G2D/CPU. Owned by the dispatcher (the GL worker never
1502    /// sees these frames), unlike the per-feed counters in
1503    /// `GLProcessorThreaded::convert_stats`. Read via
1504    /// [`convert_fallback_count`](Self::convert_fallback_count).
1505    pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1506
1507    /// The interpolation a convert without its own uses, as the backends run it.
1508    pub(crate) interpolation: Interpolation,
1509
1510    /// `EDGEFIRST_INTERPOLATION`, read once at construction; it pins every convert.
1511    pub(crate) env_interpolation: Option<Interpolation>,
1512}
1513
1514unsafe impl Send for ImageProcessor {}
1515unsafe impl Sync for ImageProcessor {}
1516
1517impl ImageProcessor {
1518    /// Creates a new `ImageProcessor` instance, initializing available
1519    /// hardware converters based on the system capabilities and environment
1520    /// variables.
1521    ///
1522    /// # Examples
1523    /// ```rust,no_run
1524    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait};
1525    /// # use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
1526    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, DType, Tensor, TensorMemory};
1527    /// # fn main() -> Result<(), edgefirst_image::Error> {
1528    /// let image = std::fs::read("zidane.jpg")?;
1529    /// // The codec emits the source's native format (a colour JPEG decodes to
1530    /// // NV12) and configures the destination tensor during the decode.
1531    /// let info = peek_info(&image).expect("peek");
1532    /// let mut src = Tensor::<u8>::image(info.width, info.height, info.format,
1533    ///                                    Some(TensorMemory::Mem), edgefirst_tensor::CpuAccess::ReadWrite)?;
1534    /// let mut decoder = ImageDecoder::new();
1535    /// src.load_image(&mut decoder, &image).expect("decode");
1536    /// let mut converter = ImageProcessor::new()?;
1537    /// let mut dst =
1538    ///     converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None, CpuAccess::ReadWrite)?;
1539    /// converter.convert(&src.into(), &mut dst, None)?;
1540    /// # Ok(())
1541    /// # }
1542    /// ```
1543    pub fn new() -> Result<Self> {
1544        Self::with_config(ImageProcessorConfig::default())
1545    }
1546
1547    /// Number of converts where the auto chain's GL backend declined and the
1548    /// frame fell through toward G2D/CPU — each one silently lost any
1549    /// zero-copy guarantee. A pipeline that expects to stay on the GPU
1550    /// asserts this stays flat across its steady-state loop; pair with
1551    /// `GLProcessorThreaded::convert_stats` to see how the frames that DID
1552    /// reach GL were fed. Always 0 under a forced backend (no chain).
1553    pub fn convert_fallback_count(&self) -> u64 {
1554        self.convert_fallbacks
1555            .load(std::sync::atomic::Ordering::Relaxed)
1556    }
1557
1558    /// Number of [`Compression::Any`](edgefirst_tensor::Compression)
1559    /// requests since process start that resolved to a linear layout
1560    /// (process-wide mirror of
1561    /// [`edgefirst_tensor::compression_fallback_count`], surfaced here
1562    /// beside [`convert_fallback_count`](Self::convert_fallback_count)
1563    /// so pipeline telemetry reads from one place). A pipeline that
1564    /// expects compressed convert destinations asserts this stays flat
1565    /// after warmup.
1566    pub fn compression_fallback_count(&self) -> u64 {
1567        edgefirst_tensor::compression_fallback_count()
1568    }
1569
1570    /// Convert and return a native fence that signals when the GPU work
1571    /// completes, instead of blocking the CPU — the GL→NPU handoff
1572    /// (`EGL_ANDROID_native_fence_sync` on Android). On Windows the handle
1573    /// is a manual-reset event set when the GPU work completes; wait on it
1574    /// with `WaitForSingleObject`.
1575    ///
1576    /// `Ok(Some(fence))`: the destination buffer is still in flight; hand
1577    /// the fence to the consumer (e.g.
1578    /// `ANeuralNetworksExecution_startComputeWithDependencies` on Android,
1579    /// or `WaitForSingleObject` on Windows) or poll it before reading.
1580    /// `Ok(None)`: the convert completed with the normal blocking contract
1581    /// (no native fence on this platform, or a non-GL backend handled the
1582    /// frame) — the destination is already safe. Semantics are otherwise
1583    /// identical to [`convert`](ImageProcessorTrait::convert), including
1584    /// the GL→G2D→CPU fallback chain.
1585    ///
1586    /// On Windows, **for a texture destination**, the event and the
1587    /// destination's own `Tensor::gpu_completion` name the same fence value:
1588    /// one flush and one signal cover the convert, and the value recorded on
1589    /// `dst` is the one this event waits for, so a consumer may use either.
1590    /// A destination that is not a texture records nothing, and the event
1591    /// then carries a value of its own that only it can be waited on for.
1592    pub fn convert_with_fence(
1593        &mut self,
1594        src: &TensorDyn,
1595        dst: &mut TensorDyn,
1596        options: Option<ConvertOptions>,
1597    ) -> Result<Option<CompletionFence>> {
1598        let options = self.resolve_interpolation(options.unwrap_or_default());
1599        #[cfg(any(
1600            target_os = "linux",
1601            target_os = "macos",
1602            target_os = "ios",
1603            target_os = "android",
1604            target_os = "windows"
1605        ))]
1606        #[cfg(feature = "opengl")]
1607        {
1608            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1609            if self.forced_backend.is_none() || gl_forced {
1610                if let Some(opengl) = self.opengl.as_mut() {
1611                    match opengl.convert_with_fence(src, dst, Some(options)) {
1612                        Ok(fd) => return Ok(fd),
1613                        Err(e) if gl_forced => return Err(e),
1614                        Err(e) => {
1615                            // Not counted here: the blocking chain below
1616                            // re-runs the dispatcher, whose decline arm
1617                            // counts the fallback exactly once.
1618                            log::debug!(
1619                                "convert_with_fence: opengl declined, \
1620                                 falling back to the blocking chain: {e}"
1621                            );
1622                        }
1623                    }
1624                } else if gl_forced {
1625                    return Err(Error::ForcedBackendUnavailable("opengl".into()));
1626                }
1627            }
1628        }
1629        // Blocking chain (G2D/CPU, forced non-GL, or non-GL builds):
1630        // completion == return, so no fence is needed.
1631        self.convert(src, dst, Some(options))?;
1632        Ok(None)
1633    }
1634
1635    /// Report which float dtypes the GPU can render to.
1636    ///
1637    /// Probes `GL_EXT_color_buffer_half_float` and
1638    /// `GL_EXT_color_buffer_float` once at `ImageProcessor::new()` time
1639    /// and caches the result. Call this once at startup to decide whether
1640    /// to request F16 or F32 destination tensors; [`create_image`] uses
1641    /// the result internally to auto-select float PBO when supported.
1642    ///
1643    /// Returns `RenderDtypeSupport { f32: false, f16: false }` when no
1644    /// OpenGL backend is active or the `opengl` feature is disabled.
1645    ///
1646    /// [`create_image`]: Self::create_image
1647    pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1648        #[cfg(all(
1649            any(
1650                target_os = "macos",
1651                target_os = "ios",
1652                target_os = "android",
1653                target_os = "windows"
1654            ),
1655            feature = "opengl"
1656        ))]
1657        if let Some(gl) = self.opengl.as_ref() {
1658            return gl.supported_render_dtypes();
1659        }
1660        #[cfg(all(target_os = "linux", feature = "opengl"))]
1661        if let Some(gl) = self.opengl.as_ref() {
1662            return gl.supported_render_dtypes();
1663        }
1664        RenderDtypeSupport {
1665            f32: false,
1666            f16: false,
1667        }
1668    }
1669
1670    /// Creates a new `ImageProcessor` with the given configuration.
1671    ///
1672    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
1673    /// environment variables are ignored and the processor initializes the
1674    /// requested backend plus CPU as a fallback.
1675    ///
1676    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
1677    /// `EDGEFIRST_DISABLE_*` environment variables apply.
1678    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1679        let interpolation = config.interpolation;
1680        let mut processor = Self::with_config_backends(config)?;
1681        processor.env_interpolation = interpolation_from_env().map(runnable_interpolation);
1682        processor.set_interpolation(interpolation)?;
1683        Ok(processor)
1684    }
1685
1686    /// The backend selection half of [`Self::with_config`].
1687    #[allow(unused_variables)]
1688    fn with_config_backends(config: ImageProcessorConfig) -> Result<Self> {
1689        // ── Config-driven backend selection ──────────────────────────
1690        // When the caller explicitly requests a backend via the config,
1691        // skip all environment variable logic.
1692        match config.backend {
1693            ComputeBackend::Cpu => {
1694                log::info!("ComputeBackend::Cpu — CPU only");
1695                return Ok(Self {
1696                    cpu: Some(CPUProcessor::new()),
1697                    #[cfg(target_os = "linux")]
1698                    g2d: None,
1699                    #[cfg(target_os = "linux")]
1700                    #[cfg(feature = "opengl")]
1701                    opengl: None,
1702                    #[cfg(any(
1703                        target_os = "macos",
1704                        target_os = "ios",
1705                        target_os = "android",
1706                        target_os = "windows"
1707                    ))]
1708                    #[cfg(feature = "opengl")]
1709                    opengl: None,
1710                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1711                    interpolation: Interpolation::Bilinear,
1712                    env_interpolation: None,
1713                    forced_backend: None,
1714                });
1715            }
1716            ComputeBackend::G2d => {
1717                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1718                #[cfg(target_os = "linux")]
1719                {
1720                    let g2d = match G2DProcessor::new() {
1721                        Ok(g) => Some(g),
1722                        Err(e) => {
1723                            log::warn!("G2D requested but failed to initialize: {e:?}");
1724                            None
1725                        }
1726                    };
1727                    return Ok(Self {
1728                        cpu: Some(CPUProcessor::new()),
1729                        g2d,
1730                        #[cfg(feature = "opengl")]
1731                        opengl: None,
1732                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1733                        interpolation: Interpolation::Bilinear,
1734                        env_interpolation: None,
1735                        forced_backend: None,
1736                    });
1737                }
1738                #[cfg(not(target_os = "linux"))]
1739                {
1740                    log::warn!("G2D requested but not available on this platform, using CPU");
1741                    return Ok(Self {
1742                        cpu: Some(CPUProcessor::new()),
1743                        #[cfg(any(
1744                            target_os = "macos",
1745                            target_os = "ios",
1746                            target_os = "android",
1747                            target_os = "windows"
1748                        ))]
1749                        #[cfg(feature = "opengl")]
1750                        opengl: None,
1751                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1752                        interpolation: Interpolation::Bilinear,
1753                        env_interpolation: None,
1754                        forced_backend: None,
1755                    });
1756                }
1757            }
1758            ComputeBackend::OpenGl => {
1759                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1760                #[cfg(target_os = "linux")]
1761                {
1762                    #[cfg(feature = "opengl")]
1763                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1764                        config.egl_display,
1765                        config.egl_cache_capacity,
1766                    ) {
1767                        Ok(gl) => Some(gl),
1768                        Err(e) => {
1769                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
1770                            None
1771                        }
1772                    };
1773                    return Ok(Self {
1774                        cpu: Some(CPUProcessor::new()),
1775                        g2d: None,
1776                        #[cfg(feature = "opengl")]
1777                        opengl,
1778                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1779                        interpolation: Interpolation::Bilinear,
1780                        env_interpolation: None,
1781                        forced_backend: None,
1782                    }
1783                    .apply_colorimetry_mode(config.colorimetry));
1784                }
1785                #[cfg(any(target_os = "macos", target_os = "ios"))]
1786                {
1787                    #[cfg(feature = "opengl")]
1788                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1789                        config.egl_display,
1790                        config.egl_cache_capacity,
1791                    ) {
1792                        Ok(gl) => Some(gl),
1793                        Err(e) => {
1794                            log::warn!(
1795                                "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1796                                 (install ANGLE via `brew install startergo/angle/angle` \
1797                                 and re-sign the dylibs — see README.md § macOS GPU \
1798                                 Acceleration). Falling back to CPU."
1799                            );
1800                            None
1801                        }
1802                    };
1803                    return Ok(Self {
1804                        cpu: Some(CPUProcessor::new()),
1805                        #[cfg(feature = "opengl")]
1806                        opengl,
1807                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1808                        interpolation: Interpolation::Bilinear,
1809                        env_interpolation: None,
1810                        forced_backend: None,
1811                    }
1812                    .apply_colorimetry_mode(config.colorimetry));
1813                }
1814                #[cfg(target_os = "android")]
1815                {
1816                    #[cfg(feature = "opengl")]
1817                    let opengl = match GLProcessorThreaded::with_cache_capacity(
1818                        config.egl_display,
1819                        config.egl_cache_capacity,
1820                    ) {
1821                        Ok(gl) => Some(gl),
1822                        Err(e) => {
1823                            log::warn!(
1824                                "OpenGL requested but native EGL init failed: {e:?}. \
1825                                 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 = "windows")]
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 on Windows but ANGLE/D3D11 init failed: {e:?} \
1852                                 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1853                                 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1854                                 libGLESv2.dll, or place the two DLLs next to the executable — \
1855                                 see README.md § Windows GPU Acceleration). Falling back to CPU."
1856                            );
1857                            None
1858                        }
1859                    };
1860                    return Ok(Self {
1861                        cpu: Some(CPUProcessor::new()),
1862                        #[cfg(feature = "opengl")]
1863                        opengl,
1864                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1865                        interpolation: Interpolation::Bilinear,
1866                        env_interpolation: None,
1867                        forced_backend: None,
1868                    }
1869                    .apply_colorimetry_mode(config.colorimetry));
1870                }
1871                #[cfg(not(any(
1872                    target_os = "linux",
1873                    target_os = "macos",
1874                    target_os = "ios",
1875                    target_os = "android",
1876                    target_os = "windows"
1877                )))]
1878                {
1879                    log::warn!("OpenGL requested but not available on this platform, using CPU");
1880                    return Ok(Self {
1881                        cpu: Some(CPUProcessor::new()),
1882                        convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1883                        interpolation: Interpolation::Bilinear,
1884                        env_interpolation: None,
1885                        forced_backend: None,
1886                    });
1887                }
1888            }
1889            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
1890        }
1891
1892        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
1893        // When set, only the requested backend is initialised and no
1894        // fallback chain is used. Accepted values (case-insensitive):
1895        //   "cpu", "g2d", "opengl"
1896        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1897            let val_lower = val.to_lowercase();
1898            let forced = match val_lower.as_str() {
1899                "cpu" => ForcedBackend::Cpu,
1900                "g2d" => ForcedBackend::G2d,
1901                "opengl" => ForcedBackend::OpenGl,
1902                other => {
1903                    return Err(Error::ForcedBackendUnavailable(format!(
1904                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1905                    )));
1906                }
1907            };
1908
1909            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1910
1911            return match forced {
1912                ForcedBackend::Cpu => Ok(Self {
1913                    cpu: Some(CPUProcessor::new()),
1914                    #[cfg(target_os = "linux")]
1915                    g2d: None,
1916                    #[cfg(target_os = "linux")]
1917                    #[cfg(feature = "opengl")]
1918                    opengl: None,
1919                    #[cfg(any(
1920                        target_os = "macos",
1921                        target_os = "ios",
1922                        target_os = "android",
1923                        target_os = "windows"
1924                    ))]
1925                    #[cfg(feature = "opengl")]
1926                    opengl: None,
1927                    convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1928                    interpolation: Interpolation::Bilinear,
1929                    env_interpolation: None,
1930                    forced_backend: Some(ForcedBackend::Cpu),
1931                }),
1932                ForcedBackend::G2d => {
1933                    #[cfg(target_os = "linux")]
1934                    {
1935                        let g2d = G2DProcessor::new().map_err(|e| {
1936                            Error::ForcedBackendUnavailable(format!(
1937                                "g2d forced but failed to initialize: {e:?}"
1938                            ))
1939                        })?;
1940                        Ok(Self {
1941                            cpu: None,
1942                            g2d: Some(g2d),
1943                            #[cfg(feature = "opengl")]
1944                            opengl: None,
1945                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1946                            interpolation: Interpolation::Bilinear,
1947                            env_interpolation: None,
1948                            forced_backend: Some(ForcedBackend::G2d),
1949                        })
1950                    }
1951                    #[cfg(not(target_os = "linux"))]
1952                    {
1953                        Err(Error::ForcedBackendUnavailable(
1954                            "g2d backend is only available on Linux".into(),
1955                        ))
1956                    }
1957                }
1958                ForcedBackend::OpenGl => {
1959                    #[cfg(target_os = "linux")]
1960                    #[cfg(feature = "opengl")]
1961                    {
1962                        let opengl = GLProcessorThreaded::with_cache_capacity(
1963                            config.egl_display,
1964                            config.egl_cache_capacity,
1965                        )
1966                        .map_err(|e| {
1967                            Error::ForcedBackendUnavailable(format!(
1968                                "opengl forced but failed to initialize: {e:?}"
1969                            ))
1970                        })?;
1971                        Ok(Self {
1972                            cpu: None,
1973                            g2d: None,
1974                            opengl: Some(opengl),
1975                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1976                            interpolation: Interpolation::Bilinear,
1977                            env_interpolation: None,
1978                            forced_backend: Some(ForcedBackend::OpenGl),
1979                        }
1980                        .apply_colorimetry_mode(config.colorimetry))
1981                    }
1982                    #[cfg(any(target_os = "macos", target_os = "ios"))]
1983                    #[cfg(feature = "opengl")]
1984                    {
1985                        let opengl = GLProcessorThreaded::with_cache_capacity(
1986                            config.egl_display,
1987                            config.egl_cache_capacity,
1988                        )
1989                        .map_err(|e| {
1990                            Error::ForcedBackendUnavailable(format!(
1991                                "opengl forced on macOS but ANGLE init failed: {e:?}"
1992                            ))
1993                        })?;
1994                        Ok(Self {
1995                            cpu: None,
1996                            opengl: Some(opengl),
1997                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1998                            interpolation: Interpolation::Bilinear,
1999                            env_interpolation: None,
2000                            forced_backend: Some(ForcedBackend::OpenGl),
2001                        }
2002                        .apply_colorimetry_mode(config.colorimetry))
2003                    }
2004                    #[cfg(target_os = "android")]
2005                    #[cfg(feature = "opengl")]
2006                    {
2007                        let opengl = GLProcessorThreaded::with_cache_capacity(
2008                            config.egl_display,
2009                            config.egl_cache_capacity,
2010                        )
2011                        .map_err(|e| {
2012                            Error::ForcedBackendUnavailable(format!(
2013                                "opengl forced but native EGL init failed: {e:?}"
2014                            ))
2015                        })?;
2016                        Ok(Self {
2017                            cpu: None,
2018                            opengl: Some(opengl),
2019                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2020                            interpolation: Interpolation::Bilinear,
2021                            env_interpolation: None,
2022                            forced_backend: Some(ForcedBackend::OpenGl),
2023                        }
2024                        .apply_colorimetry_mode(config.colorimetry))
2025                    }
2026                    #[cfg(target_os = "windows")]
2027                    #[cfg(feature = "opengl")]
2028                    {
2029                        let opengl = GLProcessorThreaded::with_cache_capacity(
2030                            config.egl_display,
2031                            config.egl_cache_capacity,
2032                        )
2033                        .map_err(|e| {
2034                            Error::ForcedBackendUnavailable(format!(
2035                                "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
2036                            ))
2037                        })?;
2038                        Ok(Self {
2039                            cpu: None,
2040                            opengl: Some(opengl),
2041                            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2042                            interpolation: Interpolation::Bilinear,
2043                            env_interpolation: None,
2044                            forced_backend: Some(ForcedBackend::OpenGl),
2045                        }
2046                        .apply_colorimetry_mode(config.colorimetry))
2047                    }
2048                    #[cfg(not(all(
2049                        any(
2050                            target_os = "linux",
2051                            target_os = "macos",
2052                            target_os = "ios",
2053                            target_os = "android",
2054                            target_os = "windows"
2055                        ),
2056                        feature = "opengl"
2057                    )))]
2058                    {
2059                        Err(Error::ForcedBackendUnavailable(
2060                            "opengl backend requires Linux, macOS, Android or Windows with the \
2061                             'opengl' feature enabled"
2062                                .into(),
2063                        ))
2064                    }
2065                }
2066            };
2067        }
2068
2069        // ── Existing DISABLE logic (unchanged) ──────────────────────
2070        #[cfg(target_os = "linux")]
2071        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
2072            .map(|x| x != "0" && x.to_lowercase() != "false")
2073            .unwrap_or(false)
2074        {
2075            log::debug!("EDGEFIRST_DISABLE_G2D is set");
2076            None
2077        } else {
2078            match G2DProcessor::new() {
2079                Ok(g2d_converter) => Some(g2d_converter),
2080                Err(err) => {
2081                    log::warn!("Failed to initialize G2D converter: {err:?}");
2082                    None
2083                }
2084            }
2085        };
2086
2087        #[cfg(target_os = "linux")]
2088        #[cfg(feature = "opengl")]
2089        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2090            .map(|x| x != "0" && x.to_lowercase() != "false")
2091            .unwrap_or(false)
2092        {
2093            log::debug!("EDGEFIRST_DISABLE_GL is set");
2094            None
2095        } else {
2096            match GLProcessorThreaded::with_cache_capacity(
2097                config.egl_display,
2098                config.egl_cache_capacity,
2099            ) {
2100                Ok(gl_converter) => Some(gl_converter),
2101                Err(err) => {
2102                    log::warn!("Failed to initialize GL converter: {err:?}");
2103                    None
2104                }
2105            }
2106        };
2107
2108        #[cfg(any(
2109            target_os = "macos",
2110            target_os = "ios",
2111            target_os = "android",
2112            target_os = "windows"
2113        ))]
2114        #[cfg(feature = "opengl")]
2115        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
2116            .map(|x| x != "0" && x.to_lowercase() != "false")
2117            .unwrap_or(false)
2118        {
2119            log::debug!("EDGEFIRST_DISABLE_GL is set");
2120            None
2121        } else {
2122            match GLProcessorThreaded::with_cache_capacity(
2123                config.egl_display,
2124                config.egl_cache_capacity,
2125            ) {
2126                Ok(gl_converter) => Some(gl_converter),
2127                Err(err) => {
2128                    log::debug!(
2129                        "GL backend unavailable: {err:?} \
2130                         (CPU fallback will be used)"
2131                    );
2132                    None
2133                }
2134            }
2135        };
2136
2137        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
2138            .map(|x| x != "0" && x.to_lowercase() != "false")
2139            .unwrap_or(false)
2140        {
2141            log::debug!("EDGEFIRST_DISABLE_CPU is set");
2142            None
2143        } else {
2144            Some(CPUProcessor::new())
2145        };
2146        Ok(Self {
2147            cpu,
2148            #[cfg(target_os = "linux")]
2149            g2d,
2150            #[cfg(target_os = "linux")]
2151            #[cfg(feature = "opengl")]
2152            opengl,
2153            #[cfg(any(
2154                target_os = "macos",
2155                target_os = "ios",
2156                target_os = "android",
2157                target_os = "windows"
2158            ))]
2159            #[cfg(feature = "opengl")]
2160            opengl,
2161            convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
2162            interpolation: Interpolation::Bilinear,
2163            env_interpolation: None,
2164            forced_backend: None,
2165        }
2166        .apply_colorimetry_mode(config.colorimetry))
2167    }
2168
2169    /// Apply the configured [`ColorimetryMode`] to whichever backend honours
2170    /// it (currently the Linux GL backend); no-op elsewhere. Constructor
2171    /// plumbing for [`ImageProcessorConfig::colorimetry`].
2172    fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
2173        #[cfg(all(
2174            any(
2175                target_os = "linux",
2176                target_os = "macos",
2177                target_os = "ios",
2178                target_os = "android",
2179                target_os = "windows"
2180            ),
2181            feature = "opengl"
2182        ))]
2183        {
2184            let mut me = self;
2185            if let Err(e) = me.set_colorimetry_mode(_mode) {
2186                log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
2187            }
2188            me
2189        }
2190        #[cfg(not(all(
2191            any(
2192                target_os = "linux",
2193                target_os = "macos",
2194                target_os = "ios",
2195                target_os = "android",
2196                target_os = "windows"
2197            ),
2198            feature = "opengl"
2199        )))]
2200        {
2201            let _ = _mode;
2202            self
2203        }
2204    }
2205
2206    /// Sets the colorimetry/performance trade-off (see [`ColorimetryMode`])
2207    /// on the OpenGL backend. No-op if OpenGL is not available. The
2208    /// `EDGEFIRST_COLORIMETRY` environment variable takes precedence — when
2209    /// it is set, this call logs and keeps the env-selected mode.
2210    #[cfg(all(
2211        any(
2212            target_os = "linux",
2213            target_os = "macos",
2214            target_os = "ios",
2215            target_os = "android",
2216            target_os = "windows"
2217        ),
2218        feature = "opengl"
2219    ))]
2220    pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
2221        if let Some(ref mut gl) = self.opengl {
2222            gl.set_colorimetry_mode(mode)?;
2223        }
2224        Ok(())
2225    }
2226
2227    /// Sets how `convert()` resamples when it resizes (see [`Interpolation`])
2228    /// on every backend this processor holds, for converts whose options do
2229    /// not choose their own ([`ConvertOptions::interpolation`]). Bicubic and
2230    /// area run as [`Interpolation::supported`], with a warning logged once
2231    /// per mode. The `EDGEFIRST_INTERPOLATION` environment variable, read
2232    /// when the processor is created, takes precedence: when it is set, this
2233    /// call keeps the env-selected mode.
2234    pub fn set_interpolation(&mut self, mode: Interpolation) -> Result<()> {
2235        let mode = match self.env_interpolation {
2236            Some(env) => {
2237                if env != mode.supported() {
2238                    log::debug!(
2239                        "EDGEFIRST_INTERPOLATION pins Interpolation::{env:?}; \
2240                         ignoring Interpolation::{mode:?}"
2241                    );
2242                }
2243                env
2244            }
2245            None => runnable_interpolation(mode),
2246        };
2247        if mode == self.interpolation {
2248            return Ok(());
2249        }
2250        if let Some(cpu) = self.cpu.as_mut() {
2251            cpu.set_interpolation(mode);
2252        }
2253        #[cfg(target_os = "linux")]
2254        if let Some(g2d) = self.g2d.as_mut() {
2255            g2d.set_interpolation(mode);
2256        }
2257        #[cfg(all(
2258            any(
2259                target_os = "linux",
2260                target_os = "macos",
2261                target_os = "ios",
2262                target_os = "android",
2263                target_os = "windows"
2264            ),
2265            feature = "opengl"
2266        ))]
2267        if let Some(gl) = self.opengl.as_mut() {
2268            gl.set_interpolation(mode)?;
2269        }
2270        self.interpolation = mode;
2271        Ok(())
2272    }
2273
2274    /// The interpolation the processor uses for converts that do not choose
2275    /// their own, as the backends run it.
2276    pub fn interpolation(&self) -> Interpolation {
2277        self.interpolation
2278    }
2279
2280    /// `options` with the interpolation this convert runs: the
2281    /// `EDGEFIRST_INTERPOLATION` pin, else the options' own (as
2282    /// [`runnable_interpolation`] maps it), else the processor's.
2283    fn resolve_interpolation(&self, options: ConvertOptions) -> ConvertOptions {
2284        let mode = match (self.env_interpolation, options.get_interpolation()) {
2285            (Some(env), _) => env,
2286            (None, Some(mode)) => runnable_interpolation(mode),
2287            (None, None) => self.interpolation,
2288        };
2289        options.with_interpolation(Some(mode))
2290    }
2291
2292    /// Sets the interpolation mode for int8 proto textures on the OpenGL
2293    /// backend. No-op if OpenGL is not available.
2294    #[cfg(all(
2295        any(
2296            target_os = "linux",
2297            target_os = "macos",
2298            target_os = "ios",
2299            target_os = "android",
2300            target_os = "windows"
2301        ),
2302        feature = "opengl"
2303    ))]
2304    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2305        if let Some(ref mut gl) = self.opengl {
2306            gl.set_int8_interpolation_mode(mode)?;
2307        }
2308        Ok(())
2309    }
2310
2311    /// The declaration [`create_image`](Self::create_image) allocates a
2312    /// `memory` destination with: the caller's `access` plus whatever this
2313    /// processor's own convert may need on the CPU (see
2314    /// [`predicted_cpu_access`]). `views` marks a destination written through
2315    /// views, where any CPU write is partial and so needs `ReadWrite`.
2316    fn producer_access(
2317        &self,
2318        access: edgefirst_tensor::CpuAccess,
2319        format: PixelFormat,
2320        dtype: DType,
2321        memory: TensorMemory,
2322        views: bool,
2323    ) -> edgefirst_tensor::CpuAccess {
2324        use edgefirst_tensor::CpuAccess;
2325        #[cfg(all(
2326            any(
2327                target_os = "linux",
2328                target_os = "macos",
2329                target_os = "ios",
2330                target_os = "android",
2331                target_os = "windows"
2332            ),
2333            feature = "opengl"
2334        ))]
2335        let gl = self
2336            .opengl
2337            .as_ref()
2338            .map(|gl| gl.dst_cpu_access(memory, format, dtype));
2339        #[cfg(not(all(
2340            any(
2341                target_os = "linux",
2342                target_os = "macos",
2343                target_os = "ios",
2344                target_os = "android",
2345                target_os = "windows"
2346            ),
2347            feature = "opengl"
2348        )))]
2349        let gl = None;
2350        #[cfg(target_os = "linux")]
2351        let g2d = self
2352            .g2d
2353            .as_ref()
2354            .map(|_| g2d::dst_cpu_access(memory, format, dtype));
2355        #[cfg(not(target_os = "linux"))]
2356        let g2d = None;
2357        let cpu = self
2358            .cpu
2359            .as_ref()
2360            .map(|_| Some(cpu::convert_dst_access(None, format, dtype)));
2361        let needed = predicted_cpu_access(self.forced_backend, gl, g2d, cpu);
2362        let needed = if views && needed.writes() {
2363            CpuAccess::ReadWrite
2364        } else {
2365            needed
2366        };
2367        access.union(needed)
2368    }
2369
2370    /// Allocate an image tensor from a declarative
2371    /// [`ImageDesc`](edgefirst_tensor::ImageDesc) request — the
2372    /// full-featured variant of [`create_image`](Self::create_image).
2373    ///
2374    /// Without a compression or contiguous request this is exactly
2375    /// `create_image` (the processor's memory negotiation applies). With
2376    /// one, the allocation rides the tensor desc path un-negotiated: the
2377    /// layout decision belongs to the platform allocator, and the request's
2378    /// guards and fallback counting live there (see
2379    /// [`Tensor::image_desc`](edgefirst_tensor::Tensor::image_desc)). A
2380    /// contiguous request is a CMA DMA-BUF at the same 64-byte GPU pitch
2381    /// `create_image` uses, or an error; it never falls back to a PBO or
2382    /// host memory. Its CPU access gets the same widening as `create_image`;
2383    /// a compression request's `CpuAccess::None` does not.
2384    pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2385        // Tile compression requires `CpuAccess::None`, so it gets no widening.
2386        if desc.compression().is_some() {
2387            return Ok(TensorDyn::image_desc(desc)?);
2388        }
2389        if !desc.contiguous() {
2390            return self.create_image(
2391                desc.width(),
2392                desc.height(),
2393                desc.format(),
2394                desc.dtype(),
2395                desc.memory(),
2396                desc.access(),
2397            );
2398        }
2399        let access = self.producer_access(
2400            desc.access(),
2401            desc.format(),
2402            desc.dtype(),
2403            TensorMemory::DmaBuf,
2404            false,
2405        );
2406        Ok(TensorDyn::image_desc(&desc.clone().with_access(access))?)
2407    }
2408
2409    /// Create a [`TensorDyn`] image with the best available memory backend.
2410    ///
2411    /// Priority: DMA-buf → float PBO (F16/F32) → u8/i8 PBO → system memory.
2412    ///
2413    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
2414    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
2415    /// memory backing (including PBO for GPU zero-copy) which direct
2416    /// allocation cannot achieve.
2417    ///
2418    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
2419    /// because optimal allocation requires knowledge of the active compute
2420    /// backends (e.g. the GL context handle for PBO allocation). Individual
2421    /// backend implementations ([`CPUProcessor`], etc.) do not have this
2422    /// cross-backend visibility.
2423    ///
2424    /// **Float dtype behaviour:** when `dtype` is `F16` or `F32` and
2425    /// [`supported_render_dtypes`] reports the GPU supports that type,
2426    /// `memory: None` auto-selects a float PBO (Linux) or IOSurface (macOS
2427    /// F16 only). If GPU float support is absent the allocation falls through
2428    /// to `TensorMemory::Mem`; [`convert`] then uses the CPU path.
2429    /// Passing `memory: Some(TensorMemory::DmaBuf)` with `dtype: F32` always
2430    /// returns `Error::NotSupported` — no 32-bit-float DRM fourcc exists.
2431    ///
2432    /// [`supported_render_dtypes`]: Self::supported_render_dtypes
2433    /// [`convert`]: ImageProcessorTrait::convert
2434    ///
2435    /// # Arguments
2436    ///
2437    /// * `width` - Image width in pixels
2438    /// * `height` - Image height in pixels
2439    /// * `format` - Pixel format
2440    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::F16`, `DType::F32`)
2441    /// * `memory` - Optional memory type override; when `None`, the best
2442    ///   available backend is selected automatically.
2443    /// * `access` - Declares the CPU access you plan on (`ReadWrite` is the
2444    ///   natural choice). Use [`CpuAccess::None`](edgefirst_tensor::CpuAccess)
2445    ///   for a destination that only hardware reads (an NPU input).
2446    ///   Hardware access is always implied. What this processor's own
2447    ///   convert may do to the image as a destination on the CPU is added
2448    ///   to the declaration so that convert succeeds: `Write` when it may
2449    ///   fill the whole destination on the CPU (the CPU backend, host
2450    ///   memory, a format or float dtype the GPU path does not render),
2451    ///   `ReadWrite` when a pass reads back what was written or writes only
2452    ///   part of the buffer (an `I8` destination through the CPU or G2D, a
2453    ///   `Bgra` destination through the CPU, a G2D `Rgb` letterbox border).
2454    ///   The returned tensor's [`cpu_access`](edgefirst_tensor::TensorDyn::cpu_access)
2455    ///   may therefore be wider than `access`. Any other CPU map beyond the
2456    ///   declaration fails with
2457    ///   [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied).
2458    ///   The widening covers the destination role of convert only: a
2459    ///   tensor later used as a convert source needs `Read` declared by the
2460    ///   caller, and a CPU mask draw blends into its destination and needs
2461    ///   `ReadWrite`, so declare it when the CPU may draw. A destination
2462    ///   written through views (a batch) needs `ReadWrite` for a CPU convert,
2463    ///   which [`alloc_tile_batch`](Self::alloc_tile_batch) predicts. A
2464    ///   destination left without CPU access (`None` on a GPU-rendered
2465    ///   format) is written only by the GPU, so a convert into it needs the
2466    ///   GPU to accept the source as well; when it declines, the CPU fallback
2467    ///   is refused with [`Error::CpuFallbackDenied`] carrying the GPU's
2468    ///   reason. A PBO destination is always `ReadWrite`: the GL readback
2469    ///   maps it on the CPU itself, and a narrower declaration would buy no
2470    ///   cheaper mapping.
2471    ///
2472    /// # Returns
2473    ///
2474    /// A [`TensorDyn`] backed by the highest-performance memory type
2475    /// available on this system.
2476    ///
2477    /// # Examples
2478    ///
2479    /// ```rust,no_run
2480    /// # use edgefirst_image::ImageProcessor;
2481    /// # use edgefirst_tensor::{CpuAccess, DType, PixelFormat};
2482    /// # fn main() -> Result<(), edgefirst_image::Error> {
2483    /// let processor = ImageProcessor::new()?;
2484    ///
2485    /// // Model input the NPU reads directly — no CPU mapping planned.
2486    /// let input = processor.create_image(
2487    ///     640, 640, PixelFormat::Rgb, DType::U8, None, CpuAccess::None,
2488    /// )?;
2489    /// # let _ = input;
2490    /// # Ok(())
2491    /// # }
2492    /// ```
2493    ///
2494    /// # Pitch alignment for DMA-backed allocations
2495    ///
2496    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
2497    /// specifically) require every row pitch to be a multiple of
2498    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
2499    /// method lands on `TensorMemory::DmaBuf`, the underlying allocation is
2500    /// silently padded so the row stride satisfies that requirement.
2501    ///
2502    /// **The user-requested `width` is preserved** — `tensor.width()`
2503    /// returns the same value you passed in. The padding is carried by
2504    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
2505    /// GL backend reads when importing the buffer as an EGLImage.
2506    /// Callers that compute byte offsets from the tensor must use the
2507    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
2508    /// full `stride × height` bytes.
2509    ///
2510    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
2511    /// exactly `width × bpp × height` bytes with no padding. PBO and
2512    /// Mem fallbacks never pad — they don't go through EGLImage import.
2513    ///
2514    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
2515    /// that external callers (GStreamer plugins, video pipelines) can
2516    /// use to size their own DMA-BUFs for GL compatibility.
2517    ///
2518    /// # Errors
2519    ///
2520    /// Returns an error if all allocation strategies fail.
2521    pub fn create_image(
2522        &self,
2523        width: usize,
2524        height: usize,
2525        format: PixelFormat,
2526        dtype: DType,
2527        memory: Option<TensorMemory>,
2528        access: edgefirst_tensor::CpuAccess,
2529    ) -> Result<TensorDyn> {
2530        self.create_image_with(width, height, format, dtype, memory, access, false)
2531    }
2532
2533    /// A DMA image for [`create_image_with`](Self::create_image_with), its
2534    /// packed rows padded to the GPU pitch alignment when that exceeds the
2535    /// natural pitch.
2536    ///
2537    /// Semi-planar and planar formats keep their natural layout: they are
2538    /// imported from camera capture via `from_fd` far more often than
2539    /// allocated here, and `Tensor::image_with_stride` rejects them.
2540    #[cfg(target_os = "linux")]
2541    fn create_dma_image(
2542        &self,
2543        width: usize,
2544        height: usize,
2545        format: PixelFormat,
2546        dtype: DType,
2547        access: edgefirst_tensor::CpuAccess,
2548        views: bool,
2549    ) -> Result<TensorDyn> {
2550        let dma_access = self.producer_access(access, format, dtype, TensorMemory::DmaBuf, views);
2551        let natural =
2552            primary_plane_bpp(format, dtype.size()).and_then(|bpp| width.checked_mul(bpp));
2553        let padded = natural
2554            .and_then(align_pitch_bytes_to_gpu_alignment)
2555            .filter(|&stride| {
2556                format.layout() == edgefirst_tensor::PixelLayout::Packed
2557                    && natural.is_some_and(|n| stride > n)
2558            });
2559        let memory = Some(edgefirst_tensor::TensorMemory::DmaBuf);
2560        match padded {
2561            Some(stride) => {
2562                log::debug!(
2563                    "create_image: padding row stride for {format:?} {width}x{height} \
2564                     from natural pitch to {stride} bytes for GPU alignment"
2565                );
2566                Ok(TensorDyn::image_with_stride(
2567                    width, height, format, dtype, stride, memory, dma_access,
2568                )?)
2569            }
2570            None => Ok(TensorDyn::image(
2571                width, height, format, dtype, memory, dma_access,
2572            )?),
2573        }
2574    }
2575
2576    /// A u8/i8 PBO image for [`create_image_with`](Self::create_image_with),
2577    /// or `None` when the dtype is wider or there is no PBO to be had. A PBO
2578    /// stays `ReadWrite`: the GL readback maps it itself.
2579    #[cfg(any(target_os = "linux", target_os = "windows"))]
2580    #[cfg(feature = "opengl")]
2581    fn create_byte_pbo_image(
2582        &self,
2583        width: usize,
2584        height: usize,
2585        format: PixelFormat,
2586        dtype: DType,
2587    ) -> Option<TensorDyn> {
2588        if dtype.size() != 1 {
2589            return None;
2590        }
2591        let gl = self.opengl.as_ref()?;
2592        let t = gl
2593            .create_pbo_image(width, height, format)
2594            .map_err(|e| log::debug!("PBO image creation failed, falling back to Mem: {e:?}"))
2595            .ok()?;
2596        if dtype != DType::I8 {
2597            return Some(TensorDyn::from(t));
2598        }
2599        // The int8 shader emulates i8 output via XOR 0x80 on the same u8 buffer.
2600        // Invariant: PBO tensors never have chroma (create_pbo_image → Tensor::wrap sets it None).
2601        debug_assert!(
2602            t.chroma().is_none(),
2603            "PBO i8 transmute requires chroma == None"
2604        );
2605        // SAFETY: Tensor<u8> and Tensor<i8> are layout-identical (same element
2606        // size, no T-dependent drop glue); same rationale as
2607        // gl::processor::tensor_i8_as_u8_mut.
2608        let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2609        Some(TensorDyn::from(t_i8))
2610    }
2611
2612    /// [`create_image`](Self::create_image), for a destination written
2613    /// through views when `views` is set (see `producer_access`).
2614    #[allow(clippy::too_many_arguments)]
2615    pub(crate) fn create_image_with(
2616        &self,
2617        width: usize,
2618        height: usize,
2619        format: PixelFormat,
2620        dtype: DType,
2621        memory: Option<TensorMemory>,
2622        access: edgefirst_tensor::CpuAccess,
2623        views: bool,
2624    ) -> Result<TensorDyn> {
2625        #[cfg(target_os = "linux")]
2626        let try_dma = || self.create_dma_image(width, height, format, dtype, access, views);
2627
2628        // If an explicit memory type is requested, honour it directly.
2629        // On Linux, `TensorMemory::DmaBuf` gets the padded-stride treatment;
2630        // other memory types take the user-requested width verbatim.
2631        // On macOS, `TensorMemory::DmaBuf` dispatches through `TensorDyn::image`
2632        // which selects the IOSurface allocation path (FourCC-formatted)
2633        // for image-mappable formats, or falls back to SHM/Mem otherwise.
2634        match memory {
2635            #[cfg(target_os = "linux")]
2636            Some(TensorMemory::DmaBuf) => {
2637                // F32 has no 32-bit-float DRM fourcc; callers must use PBO instead.
2638                if dtype == DType::F32 {
2639                    return Err(Error::NotSupported(
2640                        "F32 has no 32-bit-float DRM format for DMA-BUF; \
2641                         use TensorMemory::Pbo for F32"
2642                            .to_string(),
2643                    ));
2644                }
2645                return try_dma();
2646            }
2647            Some(mem) => {
2648                return Ok(TensorDyn::image(
2649                    width,
2650                    height,
2651                    format,
2652                    dtype,
2653                    Some(mem),
2654                    self.producer_access(access, format, dtype, mem, views),
2655                )?);
2656            }
2657            None => {}
2658        }
2659
2660        // macOS/Android/Windows: when the GL backend is active, prefer Dma
2661        // (IOSurface on Apple, AHardwareBuffer on Android, a D3D11 texture
2662        // on Windows) for zero-copy import. Formats without a zero-copy
2663        // mapping now ERROR under explicit Dma (the explicit-Dma
2664        // contract), so auto-select catches that error here and falls
2665        // back to host storage — loudly, via the debug log below.
2666        #[cfg(any(
2667            target_os = "macos",
2668            target_os = "ios",
2669            target_os = "android",
2670            target_os = "windows"
2671        ))]
2672        #[cfg(feature = "opengl")]
2673        if let Some(gl) = self.opengl.as_ref() {
2674            let _ = gl;
2675            // Float textures only where the display can render into them;
2676            // otherwise the float PBO step below applies. The same question
2677            // that step asks, asked through the same helper so the two
2678            // cannot answer differently.
2679            let support = self.supported_render_dtypes();
2680            let dtype_ok =
2681                !matches!(dtype, DType::F16 | DType::F32) || float_pbo_eligible(dtype, support);
2682            if dtype_ok {
2683                match TensorDyn::image(
2684                    width,
2685                    height,
2686                    format,
2687                    dtype,
2688                    Some(edgefirst_tensor::TensorMemory::DmaBuf),
2689                    self.producer_access(access, format, dtype, TensorMemory::DmaBuf, views),
2690                ) {
2691                    Ok(img) => return Ok(img),
2692                    Err(e) => {
2693                        // Falling back to a non-zero-copy destination is a real
2694                        // perf cliff — never do it silently (on-device triage
2695                        // starts from this line).
2696                        log::debug!(
2697                            "create_image: zero-copy Dma allocation declined \
2698                             ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2699                        );
2700                    }
2701                }
2702            }
2703        }
2704
2705        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
2706        // as the preferred transfer path (PBO is better than DMA in that case).
2707        #[cfg(target_os = "linux")]
2708        {
2709            #[cfg(feature = "opengl")]
2710            let gl_uses_pbo = self
2711                .opengl
2712                .as_ref()
2713                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2714            #[cfg(not(feature = "opengl"))]
2715            let gl_uses_pbo = false;
2716
2717            if !gl_uses_pbo {
2718                if let Ok(img) = try_dma() {
2719                    return Ok(img);
2720                }
2721            }
2722        }
2723
2724        #[cfg(any(target_os = "linux", target_os = "windows"))]
2725        #[cfg(feature = "opengl")]
2726        if let Some(img) = self.create_byte_pbo_image(width, height, format, dtype) {
2727            return Ok(img);
2728        }
2729
2730        // Try float PBO when the GPU backend reports support for this dtype.
2731        // Falls through to Mem on error (same policy as u8 PBO above).
2732        #[cfg(any(target_os = "linux", target_os = "windows"))]
2733        #[cfg(feature = "opengl")]
2734        if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2735            if let Some(gl) = &self.opengl {
2736                match gl.create_pbo_image_dtype(width, height, format, dtype) {
2737                    Ok(t) => return Ok(t),
2738                    Err(e) => {
2739                        log::debug!(
2740                            "Float PBO image creation failed for {dtype:?}, \
2741                             falling back to Mem: {e:?}"
2742                        );
2743                    }
2744                }
2745            }
2746        }
2747
2748        // Fallback to Mem
2749        Ok(TensorDyn::image(
2750            width,
2751            height,
2752            format,
2753            dtype,
2754            Some(edgefirst_tensor::TensorMemory::Mem),
2755            self.producer_access(access, format, dtype, TensorMemory::Mem, views),
2756        )?)
2757    }
2758
2759    /// Import an external DMA-BUF image.
2760    ///
2761    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
2762    /// consumes the descriptors and takes ownership of those fds (whether
2763    /// the call succeeds or fails).
2764    ///
2765    /// The caller must ensure the DMA-BUF allocation is large enough for the
2766    /// specified width, height, format, and any stride/offset on the plane
2767    /// descriptors. No buffer-size validation is performed; an undersized
2768    /// buffer may cause GPU faults or EGL import failure.
2769    ///
2770    /// # Arguments
2771    ///
2772    /// * `image` - Plane descriptor for the primary (or only) plane
2773    /// * `chroma` - Optional plane descriptor for the UV chroma plane
2774    ///   (required for multiplane NV12)
2775    /// * `width` - Image width in pixels
2776    /// * `height` - Image height in pixels
2777    /// * `format` - Pixel format of the buffer
2778    /// * `dtype` - Element data type (e.g. `DType::U8`)
2779    ///
2780    /// # Returns
2781    ///
2782    /// A `TensorDyn` configured as an image.
2783    ///
2784    /// # Errors
2785    ///
2786    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
2787    ///   format, or multiplane NV16 (not yet supported), or the fd is not
2788    ///   DMA-backed
2789    /// * [`Error::InvalidShape`] if NV12 height is odd
2790    ///
2791    /// # Platform
2792    ///
2793    /// Linux only.
2794    ///
2795    /// # Examples
2796    ///
2797    /// ```rust,ignore
2798    /// use edgefirst_tensor::PlaneDescriptor;
2799    ///
2800    /// // Single-plane RGBA
2801    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
2802    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8, None)?;
2803    ///
2804    /// // Multi-plane NV12 with stride
2805    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
2806    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
2807    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
2808    ///                             PixelFormat::Nv12, DType::U8, None)?;
2809    /// ```
2810    // Import inherently needs plane(s) + geometry + format + dtype + colorimetry;
2811    // a params struct would obscure more than it clarifies here.
2812    #[allow(clippy::too_many_arguments)]
2813    #[cfg(target_os = "linux")]
2814    pub fn import_image(
2815        &self,
2816        image: edgefirst_tensor::PlaneDescriptor,
2817        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2818        width: usize,
2819        height: usize,
2820        format: PixelFormat,
2821        dtype: DType,
2822        colorimetry: Option<edgefirst_tensor::Colorimetry>,
2823    ) -> Result<TensorDyn> {
2824        use edgefirst_tensor::{Tensor, TensorMemory};
2825
2826        // Capture stride/offset from descriptors before consuming them
2827        let image_stride = image.stride();
2828        let image_offset = image.offset();
2829        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2830        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2831
2832        if let Some(chroma_pd) = chroma {
2833            // ── Multiplane path ──────────────────────────────────────
2834            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
2835            // Tensor<i8>). Reject other dtypes to avoid silently returning a
2836            // tensor with the wrong element type.
2837            if dtype != DType::U8 && dtype != DType::I8 {
2838                return Err(Error::NotSupported(format!(
2839                    "multiplane import only supports U8/I8, got {dtype:?}"
2840                )));
2841            }
2842            if format.layout() != PixelLayout::SemiPlanar {
2843                return Err(Error::NotSupported(format!(
2844                    "import_image with chroma requires a semi-planar format, got {format:?}"
2845                )));
2846            }
2847
2848            let chroma_h = match format {
2849                PixelFormat::Nv12 => {
2850                    // NV12 (4:2:0): ceil(H/2) chroma rows — odd heights are valid.
2851                    height.div_ceil(2)
2852                }
2853                // NV16 multiplane will be supported in a future release;
2854                // the GL backend currently only handles NV12 plane1 attributes.
2855                PixelFormat::Nv16 => {
2856                    return Err(Error::NotSupported(
2857                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2858                    ))
2859                }
2860                _ => {
2861                    return Err(Error::NotSupported(format!(
2862                        "unsupported semi-planar format: {format:?}"
2863                    )))
2864                }
2865            };
2866
2867            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2868            if luma.memory() != TensorMemory::DmaBuf {
2869                return Err(Error::NotSupported(format!(
2870                    "luma fd must be DMA-backed, got {:?}",
2871                    luma.memory()
2872                )));
2873            }
2874
2875            let mut chroma_tensor =
2876                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2877            if chroma_tensor.memory() != TensorMemory::DmaBuf {
2878                return Err(Error::NotSupported(format!(
2879                    "chroma fd must be DMA-backed, got {:?}",
2880                    chroma_tensor.memory()
2881                )));
2882            }
2883
2884            // Geometry on the chroma plane must be set before from_planes
2885            // consumes it: the combined handle answers chroma() from the
2886            // library, and there is no chroma_mut() on the dynamic lens.
2887            if let Some(s) = chroma_stride {
2888                if s < width {
2889                    return Err(Error::InvalidShape(format!(
2890                        "chroma stride {s} < minimum {width} for {format:?}"
2891                    )));
2892                }
2893                chroma_tensor.set_row_stride_unchecked(s);
2894            }
2895            if let Some(o) = chroma_offset {
2896                chroma_tensor.set_plane_offset(o);
2897            }
2898
2899            // from_planes creates the combined tensor with format set,
2900            // preserving luma's row_stride (currently None since luma was raw).
2901            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2902
2903            // Apply stride/offset to the combined tensor (luma plane)
2904            if let Some(s) = image_stride {
2905                tensor.set_row_stride(s)?;
2906            }
2907            if let Some(o) = image_offset {
2908                tensor.set_plane_offset(o);
2909            }
2910
2911            if dtype == DType::I8 {
2912                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
2913                // the struct contains only type-erased storage (OwnedFd, shape, name),
2914                // no inline T values. This assertion catches layout drift at compile time.
2915                const {
2916                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2917                    assert!(
2918                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2919                    );
2920                }
2921                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2922                let mut dyn_tensor = TensorDyn::from(tensor_i8);
2923                dyn_tensor.set_colorimetry(colorimetry);
2924                return Ok(dyn_tensor);
2925            }
2926            let mut dyn_tensor = TensorDyn::from(tensor);
2927            dyn_tensor.set_colorimetry(colorimetry);
2928            Ok(dyn_tensor)
2929        } else {
2930            // ── Single-plane path ────────────────────────────────────
2931            // Canonical shape (Packed [H,W,C] / Planar [C,H,W] / SemiPlanar
2932            // [total_h, W]); `allocation_shape` supports NV12/NV16/NV24 (the old
2933            // hand-rolled match erroneously rejected NV24).
2934            let shape = format.allocation_shape(width, height).ok_or_else(|| {
2935                Error::NotSupported(format!(
2936                    "unsupported pixel format for import_image: {format:?}"
2937                ))
2938            })?;
2939            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2940            if tensor.memory() != TensorMemory::DmaBuf {
2941                return Err(Error::NotSupported(format!(
2942                    "import_image requires DMA-backed fd, got {:?}",
2943                    tensor.memory()
2944                )));
2945            }
2946            let mut tensor = tensor.with_format(format)?;
2947            if let Some(s) = image_stride {
2948                tensor.set_row_stride(s)?;
2949            }
2950            if let Some(o) = image_offset {
2951                tensor.set_plane_offset(o);
2952            }
2953            tensor.set_colorimetry(colorimetry);
2954            Ok(tensor)
2955        }
2956    }
2957
2958    /// Decode model outputs and draw segmentation masks onto `dst`.
2959    ///
2960    /// The processor decodes via the provided
2961    /// [`Decoder`](edgefirst_decoder::Decoder), selects the optimal rendering
2962    /// path (hybrid CPU+GL or fused GPU), and composites masks onto `dst`.
2963    ///
2964    /// Returns the detected bounding boxes.
2965    ///
2966    /// **Requires the `decode` feature**, which is off by default because it
2967    /// is the only thing in this crate that needs `edgefirst-decoder`. This
2968    /// method sequences a decode and a draw; both halves are available
2969    /// unconditionally and are equally fused, so without the feature write:
2970    ///
2971    /// ```ignore
2972    /// let mut boxes = Vec::new();
2973    /// match decoder.decode_proto(outputs, &mut boxes)? {
2974    ///     Some(protos) => proc.draw_proto_masks(dst, &boxes, &protos, overlay)?,
2975    ///     None => {
2976    ///         let mut masks = Vec::new();
2977    ///         decoder.decode(outputs, &mut boxes, &mut masks)?;
2978    ///         proc.draw_decoded_masks(dst, &boxes, &masks, overlay)?;
2979    ///     }
2980    /// }
2981    /// ```
2982    #[cfg(feature = "decode")]
2983    pub fn draw_masks(
2984        &mut self,
2985        decoder: &edgefirst_decoder::Decoder,
2986        outputs: &[&TensorDyn],
2987        dst: &mut TensorDyn,
2988        overlay: MaskOverlay<'_>,
2989    ) -> Result<Vec<DetectBox>> {
2990        let mut output_boxes = Vec::with_capacity(100);
2991
2992        // Try proto path first (fused rendering without materializing masks)
2993        let proto_result = decoder
2994            .decode_proto(outputs, &mut output_boxes)
2995            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2996
2997        if let Some(proto_data) = proto_result {
2998            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2999        } else {
3000            // Detection-only or unsupported model: full decode + render
3001            let mut output_masks = Vec::with_capacity(100);
3002            decoder
3003                .decode(outputs, &mut output_boxes, &mut output_masks)
3004                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
3005            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
3006        }
3007        Ok(output_boxes)
3008    }
3009
3010    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
3011    ///
3012    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
3013    /// maintaining object identities across frames. The tracker runs after
3014    /// NMS but before mask extraction.
3015    ///
3016    /// Returns detected boxes and track info.
3017    #[cfg(feature = "tracker")]
3018    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
3019        &mut self,
3020        decoder: &edgefirst_decoder::Decoder,
3021        tracker: &mut TR,
3022        timestamp: u64,
3023        outputs: &[&TensorDyn],
3024        dst: &mut TensorDyn,
3025        overlay: MaskOverlay<'_>,
3026    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
3027        let mut output_boxes = Vec::with_capacity(100);
3028        let mut output_tracks = Vec::new();
3029
3030        let proto_result = decoder
3031            .decode_proto_tracked(
3032                tracker,
3033                timestamp,
3034                outputs,
3035                &mut output_boxes,
3036                &mut output_tracks,
3037            )
3038            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
3039
3040        if let Some(proto_data) = proto_result {
3041            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
3042        } else {
3043            // Note: decode_proto_tracked returns None for detection-only/ModelPack
3044            // models WITHOUT calling the tracker. The else branch below is the
3045            // first (and only) tracker call for those model types.
3046            let mut output_masks = Vec::with_capacity(100);
3047            decoder
3048                .decode_tracked(
3049                    tracker,
3050                    timestamp,
3051                    outputs,
3052                    &mut output_boxes,
3053                    &mut output_masks,
3054                    &mut output_tracks,
3055                )
3056                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
3057            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
3058        }
3059        Ok((output_boxes, output_tracks))
3060    }
3061
3062    /// Materialize per-instance segmentation masks from raw prototype data.
3063    ///
3064    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
3065    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
3066    /// Mask values are continuous sigmoid confidence outputs quantized to u8
3067    /// (0 = background, 255 = full confidence), NOT binary thresholded.
3068    ///
3069    /// The returned [`Vec<Segmentation>`] can be:
3070    /// - Inspected or exported for analytics, IoU computation, etc.
3071    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
3072    ///   GPU-interpolated rendering.
3073    ///
3074    /// # Performance Note
3075    ///
3076    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
3077    /// the HAL from using its internal fused optimization path. For render-only
3078    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
3079    /// the fastest path automatically (currently 1.6×–27× faster on tested
3080    /// platforms). Use this method when you need access to the intermediate masks.
3081    ///
3082    /// # Errors
3083    ///
3084    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
3085    pub fn materialize_masks(
3086        &mut self,
3087        detect: &[DetectBox],
3088        proto_data: &ProtoData,
3089        content_region: Option<[f32; 4]>,
3090        resolution: MaskResolution,
3091    ) -> Result<Vec<Segmentation>> {
3092        let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
3093        match resolution {
3094            MaskResolution::Proto => {
3095                cpu.materialize_segmentations(detect, proto_data, content_region)
3096            }
3097            MaskResolution::Scaled { width, height } => cpu.materialize_scaled_segmentations(
3098                detect,
3099                proto_data,
3100                content_region,
3101                width,
3102                height,
3103            ),
3104        }
3105    }
3106}
3107
3108impl ImageProcessorTrait for ImageProcessor {
3109    /// Converts the source image to the destination image format and size. The
3110    /// image is cropped first, then flipped, then rotated
3111    ///
3112    /// Prefer hardware accelerators when available, falling back to CPU if
3113    /// necessary.
3114    fn convert(
3115        &mut self,
3116        src: &TensorDyn,
3117        dst: &mut TensorDyn,
3118        options: Option<ConvertOptions>,
3119    ) -> Result<()> {
3120        let options = self.resolve_interpolation(options.unwrap_or_default());
3121        let (rotation, flip) = (options.get_rotation(), options.get_flip());
3122        let start = Instant::now();
3123        let src_fmt = src.format();
3124        let dst_fmt = dst.format();
3125        let _span = tracing::trace_span!(
3126            "image.convert",
3127            ?src_fmt,
3128            ?dst_fmt,
3129            src_memory = ?src.memory(),
3130            dst_memory = ?dst.memory(),
3131            ?rotation,
3132            ?flip,
3133        )
3134        .entered();
3135        log::trace!(
3136            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
3137             rotation={rotation:?}, flip={flip:?}, backend={:?}",
3138            src.dtype(),
3139            src.memory(),
3140            dst.dtype(),
3141            dst.memory(),
3142            self.forced_backend,
3143        );
3144
3145        // ── Forced backend: no fallback chain ────────────────────────
3146        if let Some(forced) = self.forced_backend {
3147            return match forced {
3148                ForcedBackend::Cpu => {
3149                    if let Some(cpu) = self.cpu.as_mut() {
3150                        let r = cpu.convert(src, dst, Some(options));
3151                        log::trace!(
3152                            "convert: forced=cpu result={} ({:?})",
3153                            if r.is_ok() { "ok" } else { "err" },
3154                            start.elapsed()
3155                        );
3156                        return r;
3157                    }
3158                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3159                }
3160                ForcedBackend::G2d => {
3161                    #[cfg(target_os = "linux")]
3162                    if let Some(g2d) = self.g2d.as_mut() {
3163                        let r = g2d.convert(src, dst, Some(options));
3164                        log::trace!(
3165                            "convert: forced=g2d result={} ({:?})",
3166                            if r.is_ok() { "ok" } else { "err" },
3167                            start.elapsed()
3168                        );
3169                        return r;
3170                    }
3171                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3172                }
3173                ForcedBackend::OpenGl => {
3174                    #[cfg(any(
3175                        target_os = "linux",
3176                        target_os = "macos",
3177                        target_os = "ios",
3178                        target_os = "android",
3179                        target_os = "windows"
3180                    ))]
3181                    #[cfg(feature = "opengl")]
3182                    if let Some(opengl) = self.opengl.as_mut() {
3183                        let r = opengl.convert(src, dst, Some(options));
3184                        log::trace!(
3185                            "convert: forced=opengl result={} ({:?})",
3186                            if r.is_ok() { "ok" } else { "err" },
3187                            start.elapsed()
3188                        );
3189                        return r;
3190                    }
3191                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3192                }
3193            };
3194        }
3195
3196        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
3197        #[allow(unused_mut)]
3198        let mut declined: Vec<(&'static str, Error)> = Vec::new();
3199        #[cfg(any(
3200            target_os = "linux",
3201            target_os = "macos",
3202            target_os = "ios",
3203            target_os = "android",
3204            target_os = "windows"
3205        ))]
3206        #[cfg(feature = "opengl")]
3207        if let Some(opengl) = self.opengl.as_mut() {
3208            match opengl.convert(src, dst, Some(options)) {
3209                Ok(_) => {
3210                    log::trace!(
3211                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
3212                        start.elapsed()
3213                    );
3214                    return Ok(());
3215                }
3216                Err(e) => {
3217                    self.convert_fallbacks
3218                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3219                    log::debug!(
3220                        "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
3221                         falling back toward G2D/CPU: {e}",
3222                        src.memory(),
3223                        dst.memory(),
3224                    );
3225                    declined.push(("opengl", e));
3226                }
3227            }
3228        }
3229
3230        #[cfg(target_os = "linux")]
3231        if let Some(g2d) = self.g2d.as_mut() {
3232            // G2D is matrix-only (no range control, no BT.2020). For any
3233            // conversion with a YUV side, resolve that side's colorimetry and
3234            // skip G2D entirely when it cannot be expressed (full-range YUV or
3235            // BT.2020), letting the chain fall through to GL/CPU which honour
3236            // range and BT.2020. YUV→RGB uses the source colorimetry; RGB→YUV
3237            // uses the destination. RGB→RGB has no YUV side and is unaffected.
3238            let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
3239            let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
3240            let g2d_eligible = if src_is_yuv || dst_is_yuv {
3241                let cm = if src_is_yuv {
3242                    crate::colorimetry::effective_colorimetry(src)
3243                } else {
3244                    crate::colorimetry::effective_colorimetry(dst)
3245                };
3246                crate::g2d::g2d_can_handle(&cm, true)
3247            } else {
3248                true
3249            };
3250            if !g2d_eligible {
3251                log::trace!(
3252                    "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
3253                     (colorimetry not expressible: full-range/BT.2020)"
3254                );
3255            } else {
3256                match g2d.convert(src, dst, Some(options)) {
3257                    Ok(_) => {
3258                        log::trace!(
3259                            "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
3260                            start.elapsed()
3261                        );
3262                        return Ok(());
3263                    }
3264                    Err(e) => {
3265                        log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
3266                        declined.push(("g2d", e));
3267                    }
3268                }
3269            }
3270        }
3271
3272        if let Some(cpu) = self.cpu.as_mut() {
3273            match cpu.convert(src, dst, Some(options)) {
3274                Ok(_) => {
3275                    log::trace!(
3276                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
3277                        start.elapsed()
3278                    );
3279                    return Ok(());
3280                }
3281                Err(e) => {
3282                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
3283                    return Err(with_gpu_declines(e, declined));
3284                }
3285            }
3286        }
3287        Err(Error::NoConverter)
3288    }
3289
3290    fn convert_deferred(
3291        &mut self,
3292        src: &TensorDyn,
3293        dst: &mut TensorDyn,
3294        options: Option<ConvertOptions>,
3295    ) -> Result<()> {
3296        let options = self.resolve_interpolation(options.unwrap_or_default());
3297        // Deferred batching is an OpenGL optimization (shared parent EGLImage +
3298        // no per-tile glFinish). Route to the GL backend's deferred path when GL
3299        // is forced or auto-selectable; on a GL decline fall back to an eager
3300        // convert (the auto chain), which is correct everywhere — it completes
3301        // synchronously and `flush` stays a no-op for non-GL backends.
3302        #[cfg(any(
3303            target_os = "linux",
3304            target_os = "macos",
3305            target_os = "ios",
3306            target_os = "android",
3307            target_os = "windows"
3308        ))]
3309        #[cfg(feature = "opengl")]
3310        {
3311            let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
3312            if gl_forced || self.forced_backend.is_none() {
3313                if let Some(opengl) = self.opengl.as_mut() {
3314                    match opengl.convert_deferred(src, dst, Some(options)) {
3315                        Ok(()) => return Ok(()),
3316                        Err(e) => {
3317                            log::trace!("convert_deferred: gl declined: {e}; eager fallback");
3318                            // A forced-GL caller gets the GL error, matching
3319                            // `convert`'s no-fallback forced-backend contract.
3320                            if gl_forced {
3321                                return Err(e);
3322                            }
3323                        }
3324                    }
3325                }
3326            }
3327        }
3328        self.convert(src, dst, Some(options))
3329    }
3330
3331    fn flush(&mut self) -> Result<()> {
3332        let _span = tracing::trace_span!("image.flush").entered();
3333        // Only the OpenGL backend defers; flushing it issues the single GPU
3334        // sync. CPU/G2D converts already completed, so there is nothing to flush.
3335        #[cfg(any(
3336            target_os = "linux",
3337            target_os = "macos",
3338            target_os = "ios",
3339            target_os = "android",
3340            target_os = "windows"
3341        ))]
3342        #[cfg(feature = "opengl")]
3343        if let Some(opengl) = self.opengl.as_mut() {
3344            return opengl.flush();
3345        }
3346        Ok(())
3347    }
3348
3349    fn draw_decoded_masks(
3350        &mut self,
3351        dst: &mut TensorDyn,
3352        detect: &[DetectBox],
3353        segmentation: &[Segmentation],
3354        overlay: MaskOverlay<'_>,
3355    ) -> Result<()> {
3356        let _span = tracing::trace_span!(
3357            "image.draw_decoded_masks",
3358            n_detections = detect.len(),
3359            n_segmentations = segmentation.len(),
3360        )
3361        .entered();
3362        let start = Instant::now();
3363
3364        if let Some(bg) = overlay.background {
3365            if bg.aliases(dst) {
3366                return Err(Error::AliasedBuffers(
3367                    "background must not reference the same buffer as dst".to_string(),
3368                ));
3369            }
3370        }
3371
3372        // Un-letterbox detect boxes and segmentation bboxes for rendering when
3373        // a letterbox was applied to prepare the model input.
3374        let lb_boxes: Vec<DetectBox>;
3375        let lb_segs: Vec<Segmentation>;
3376        let (detect, segmentation) = if let Some(lb) = overlay.content_region {
3377            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3378            // Keep segmentation bboxes in sync with the transformed detect boxes
3379            // when we have a 1:1 correspondence (instance segmentation).
3380            if segmentation.len() == lb_boxes.len() {
3381                // Only the four bounds change; the mask bytes are copied
3382                // wholesale because `TensorDyn` is not `Clone`. The memcpy is
3383                // what `Array3<u8>::clone()` already paid; `copy_segmentation`
3384                // pins the allocation to Mem so the ALLOCATOR cost matches too
3385                // (see there). Rendering from borrowed masks plus a separate
3386                // bounds list would avoid the copy entirely.
3387                lb_segs = segmentation
3388                    .iter()
3389                    .zip(lb_boxes.iter())
3390                    .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
3391                    .collect::<Result<Vec<_>>>()?;
3392                (lb_boxes.as_slice(), lb_segs.as_slice())
3393            } else {
3394                // No 1:1 correspondence, so the segmentation bounds are left
3395                // as they are -- borrow rather than the copy `to_vec()` used
3396                // to make.
3397                (lb_boxes.as_slice(), segmentation)
3398            }
3399        } else {
3400            (detect, segmentation)
3401        };
3402        #[cfg(target_os = "linux")]
3403        let is_empty_frame = detect.is_empty() && segmentation.is_empty();
3404
3405        // ── Forced backend: no fallback chain ────────────────────────
3406        if let Some(forced) = self.forced_backend {
3407            return match forced {
3408                ForcedBackend::Cpu => {
3409                    if let Some(cpu) = self.cpu.as_mut() {
3410                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
3411                    }
3412                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3413                }
3414                ForcedBackend::G2d => {
3415                    // G2D can only produce empty frames (clear / bg blit).
3416                    // For populated frames it has no rasterizer — fail loudly.
3417                    #[cfg(target_os = "linux")]
3418                    if let Some(g2d) = self.g2d.as_mut() {
3419                        return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
3420                    }
3421                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3422                }
3423                ForcedBackend::OpenGl => {
3424                    // GL handles background natively via GPU blit, and now
3425                    // actively clears when there is no background.
3426                    #[cfg(any(
3427                        target_os = "linux",
3428                        target_os = "macos",
3429                        target_os = "ios",
3430                        target_os = "android",
3431                        target_os = "windows"
3432                    ))]
3433                    #[cfg(feature = "opengl")]
3434                    if let Some(opengl) = self.opengl.as_mut() {
3435                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3436                    }
3437                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3438                }
3439            };
3440        }
3441
3442        // ── Auto dispatch ──────────────────────────────────────────
3443        // Empty frames prefer G2D when available — a single g2d_clear or
3444        // g2d_blit is the cheapest HW path to produce the correct output
3445        // and avoids spinning up the GL pipeline every zero-detection
3446        // frame in a triple-buffered display loop.
3447        #[cfg(target_os = "linux")]
3448        if is_empty_frame {
3449            if let Some(g2d) = self.g2d.as_mut() {
3450                match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3451                    Ok(_) => {
3452                        log::trace!(
3453                            "draw_decoded_masks empty frame via g2d in {:?}",
3454                            start.elapsed()
3455                        );
3456                        return Ok(());
3457                    }
3458                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3459                }
3460            }
3461        }
3462
3463        // Populated frames (or G2D unavailable): GL first, CPU fallback.
3464        // Both backends now own their own base-layer handling (bg blit
3465        // or clear), so we hand the overlay through untouched.
3466        #[cfg(any(
3467            target_os = "linux",
3468            target_os = "macos",
3469            target_os = "ios",
3470            target_os = "android",
3471            target_os = "windows"
3472        ))]
3473        #[cfg(feature = "opengl")]
3474        if let Some(opengl) = self.opengl.as_mut() {
3475            log::trace!(
3476                "draw_decoded_masks started with opengl in {:?}",
3477                start.elapsed()
3478            );
3479            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3480                Ok(_) => {
3481                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3482                    return Ok(());
3483                }
3484                Err(e) => {
3485                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3486                }
3487            }
3488        }
3489
3490        log::trace!(
3491            "draw_decoded_masks started with cpu in {:?}",
3492            start.elapsed()
3493        );
3494        if let Some(cpu) = self.cpu.as_mut() {
3495            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3496                Ok(_) => {
3497                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3498                    return Ok(());
3499                }
3500                Err(e) => {
3501                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3502                    return Err(e);
3503                }
3504            }
3505        }
3506        Err(Error::NoConverter)
3507    }
3508
3509    fn draw_proto_masks(
3510        &mut self,
3511        dst: &mut TensorDyn,
3512        detect: &[DetectBox],
3513        proto_data: &ProtoData,
3514        overlay: MaskOverlay<'_>,
3515    ) -> Result<()> {
3516        let start = Instant::now();
3517
3518        if let Some(bg) = overlay.background {
3519            if bg.aliases(dst) {
3520                return Err(Error::AliasedBuffers(
3521                    "background must not reference the same buffer as dst".to_string(),
3522                ));
3523            }
3524        }
3525
3526        // Un-letterbox detect boxes for rendering when a letterbox was applied
3527        // to prepare the model input.  The original `detect` coords are still
3528        // passed to `materialize_segmentations` (which needs model-space coords
3529        // to correctly crop the proto tensor) alongside `overlay.content_region` so
3530        // it can emit `Segmentation` structs in output-image space.
3531        let lb_boxes: Vec<DetectBox>;
3532        let render_detect = if let Some(lb) = overlay.content_region {
3533            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3534            lb_boxes.as_slice()
3535        } else {
3536            detect
3537        };
3538        #[cfg(target_os = "linux")]
3539        let is_empty_frame = detect.is_empty();
3540
3541        // ── Forced backend: no fallback chain ────────────────────────
3542        if let Some(forced) = self.forced_backend {
3543            return match forced {
3544                ForcedBackend::Cpu => {
3545                    if let Some(cpu) = self.cpu.as_mut() {
3546                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3547                    }
3548                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3549                }
3550                ForcedBackend::G2d => {
3551                    #[cfg(target_os = "linux")]
3552                    if let Some(g2d) = self.g2d.as_mut() {
3553                        return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3554                    }
3555                    Err(Error::ForcedBackendUnavailable("g2d".into()))
3556                }
3557                ForcedBackend::OpenGl => {
3558                    #[cfg(any(
3559                        target_os = "linux",
3560                        target_os = "macos",
3561                        target_os = "ios",
3562                        target_os = "android",
3563                        target_os = "windows"
3564                    ))]
3565                    #[cfg(feature = "opengl")]
3566                    if let Some(opengl) = self.opengl.as_mut() {
3567                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3568                    }
3569                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3570                }
3571            };
3572        }
3573
3574        // ── Auto dispatch ──────────────────────────────────────────
3575        // Empty frames: prefer G2D — cheapest HW path (clear or bg blit).
3576        #[cfg(target_os = "linux")]
3577        if is_empty_frame {
3578            if let Some(g2d) = self.g2d.as_mut() {
3579                match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3580                    Ok(_) => {
3581                        log::trace!(
3582                            "draw_proto_masks empty frame via g2d in {:?}",
3583                            start.elapsed()
3584                        );
3585                        return Ok(());
3586                    }
3587                    Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3588                }
3589            }
3590        }
3591
3592        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
3593        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
3594        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
3595        // GL owns its own bg-blit / glClear — we pass the overlay through.
3596        //
3597        // CPU materialize needs `&mut` for its MaskScratch buffers; GL also
3598        // needs `&mut`. The CPU borrow is scoped to its block so the
3599        // subsequent GL borrow is free to take over `self`.
3600        #[cfg(any(
3601            target_os = "linux",
3602            target_os = "macos",
3603            target_os = "ios",
3604            target_os = "android",
3605            target_os = "windows"
3606        ))]
3607        #[cfg(feature = "opengl")]
3608        if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3609            let segmentation = match self.cpu.as_mut() {
3610                Some(cpu) => {
3611                    log::trace!(
3612                        "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3613                        start.elapsed()
3614                    );
3615                    cpu.materialize_segmentations(detect, proto_data, overlay.content_region)?
3616                }
3617                None => unreachable!("cpu presence checked above"),
3618            };
3619            if let Some(opengl) = self.opengl.as_mut() {
3620                match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3621                    Ok(_) => {
3622                        log::trace!(
3623                            "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3624                            start.elapsed()
3625                        );
3626                        return Ok(());
3627                    }
3628                    Err(e) => {
3629                        log::trace!(
3630                            "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3631                        );
3632                    }
3633                }
3634            }
3635        }
3636
3637        let Some(cpu) = self.cpu.as_mut() else {
3638            return Err(Error::Internal(
3639                "draw_proto_masks requires CPU backend for fallback path".into(),
3640            ));
3641        };
3642        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3643        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3644    }
3645
3646    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3647        let start = Instant::now();
3648
3649        // ── Forced backend: no fallback chain ────────────────────────
3650        if let Some(forced) = self.forced_backend {
3651            return match forced {
3652                ForcedBackend::Cpu => {
3653                    if let Some(cpu) = self.cpu.as_mut() {
3654                        return cpu.set_class_colors(colors);
3655                    }
3656                    Err(Error::ForcedBackendUnavailable("cpu".into()))
3657                }
3658                ForcedBackend::G2d => Err(Error::NotSupported(
3659                    "g2d does not support set_class_colors".into(),
3660                )),
3661                ForcedBackend::OpenGl => {
3662                    #[cfg(any(
3663                        target_os = "linux",
3664                        target_os = "macos",
3665                        target_os = "ios",
3666                        target_os = "android",
3667                        target_os = "windows"
3668                    ))]
3669                    #[cfg(feature = "opengl")]
3670                    if let Some(opengl) = self.opengl.as_mut() {
3671                        return opengl.set_class_colors(colors);
3672                    }
3673                    Err(Error::ForcedBackendUnavailable("opengl".into()))
3674                }
3675            };
3676        }
3677
3678        // skip G2D as it doesn't support rendering to image
3679
3680        // Every available backend, not the first that accepts. A palette is
3681        // state a later draw reads, and which backend that draw lands on is
3682        // not known here: GL declines a `view()` destination, an unsupported
3683        // format or an import failure, and the CPU backend takes over. A
3684        // backend that never received the colours would render
3685        // `DEFAULT_COLORS_U8` with nothing to show the caller why.
3686        let mut backends = 0usize;
3687        let mut first_err = None;
3688        #[cfg(any(
3689            target_os = "linux",
3690            target_os = "macos",
3691            target_os = "ios",
3692            target_os = "android",
3693            target_os = "windows"
3694        ))]
3695        #[cfg(feature = "opengl")]
3696        if let Some(opengl) = self.opengl.as_mut() {
3697            backends += 1;
3698            match opengl.set_class_colors(colors) {
3699                Ok(()) => log::trace!("colors set with opengl in {:?}", start.elapsed()),
3700                Err(e) => {
3701                    log::trace!("colors didn't set with opengl: {e:?}");
3702                    first_err.get_or_insert(e);
3703                }
3704            }
3705        }
3706        if let Some(cpu) = self.cpu.as_mut() {
3707            backends += 1;
3708            match cpu.set_class_colors(colors) {
3709                Ok(()) => log::trace!("colors set with cpu in {:?}", start.elapsed()),
3710                Err(e) => {
3711                    log::trace!("colors didn't set with cpu: {e:?}");
3712                    first_err.get_or_insert(e);
3713                }
3714            }
3715        }
3716        // The first error, so a GL failure is not hidden by the CPU backend
3717        // accepting afterwards; the colours the other backends took are kept
3718        // either way, since a partial palette beats a stale one.
3719        match first_err {
3720            Some(e) => Err(e),
3721            None if backends > 0 => Ok(()),
3722            None => Err(Error::NoConverter),
3723        }
3724    }
3725}
3726
3727// ---------------------------------------------------------------------------
3728// Image loading / saving helpers
3729// ---------------------------------------------------------------------------
3730
3731/// Test-only convenience helper that peeks the image header, allocates a
3732/// tensor sized to the image (honoring DMA pitch padding on Linux when
3733/// requested), and decodes via [`edgefirst_codec`]. Mirrors the semantics of
3734/// the removed public `load_image` API for test sites; production callers
3735/// should use the explicit peek → allocate → decode pattern directly.
3736#[cfg(all(test, feature = "codec"))]
3737pub(crate) fn load_image_test_helper(
3738    image: &[u8],
3739    format: Option<PixelFormat>,
3740    memory: Option<TensorMemory>,
3741) -> Result<TensorDyn> {
3742    use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3743
3744    // Peek the source header to get its NATIVE format and dimensions. The
3745    // codec now emits the source's native format (JPEG → Nv12/Grey, PNG →
3746    // Rgb/Rgba/Grey) and configures the destination tensor itself.
3747    let info = peek_info(image)?;
3748    let native_fmt = info.format;
3749    let w = info.width;
3750    let h = info.height;
3751
3752    let mut decoder = ImageDecoder::new();
3753
3754    // Decode into a native-format tensor. The decoder sets the tensor's
3755    // dims+format, so we allocate it sized to the native layout.
3756    #[cfg(target_os = "linux")]
3757    let native_src = {
3758        if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3759            let mut dma = Tensor::<u8>::image_with_stride(
3760                w,
3761                h,
3762                native_fmt,
3763                aligned_pitch,
3764                Some(TensorMemory::DmaBuf),
3765                edgefirst_tensor::CpuAccess::ReadWrite,
3766            )?;
3767            dma.load_image(&mut decoder, image)?;
3768            TensorDyn::from(dma)
3769        } else {
3770            let mut img = Tensor::<u8>::image(
3771                w,
3772                h,
3773                native_fmt,
3774                memory,
3775                edgefirst_tensor::CpuAccess::ReadWrite,
3776            )?;
3777            img.load_image(&mut decoder, image)?;
3778            TensorDyn::from(img)
3779        }
3780    };
3781    #[cfg(not(target_os = "linux"))]
3782    let native_src = {
3783        let mut img = Tensor::<u8>::image(
3784            w,
3785            h,
3786            native_fmt,
3787            memory,
3788            edgefirst_tensor::CpuAccess::ReadWrite,
3789        )?;
3790        img.load_image(&mut decoder, image)?;
3791        TensorDyn::from(img)
3792    };
3793
3794    // If the caller requested a different format, convert into it (same
3795    // dims) using a headless CPU-backed processor so the helper works
3796    // without GPU/G2D hardware.
3797    match format {
3798        Some(f) if f != native_fmt => {
3799            let mut dst = TensorDyn::image(
3800                w,
3801                h,
3802                f,
3803                DType::U8,
3804                memory,
3805                edgefirst_tensor::CpuAccess::ReadWrite,
3806            )?;
3807            // `ImageProcessorConfig` has platform-specific fields: on Linux it
3808            // carries extra GL/G2D options so `..Default::default()` is needed,
3809            // but on macOS `backend` is the only field, making the update
3810            // redundant (clippy::needless_update). Allow it for cross-platform
3811            // parity — the alternative (field reassign) trips
3812            // clippy::field_reassign_with_default on Linux instead.
3813            #[allow(clippy::needless_update)]
3814            let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3815                backend: ComputeBackend::Cpu,
3816                ..Default::default()
3817            })?;
3818            proc.convert(&native_src, &mut dst, None)?;
3819            Ok(dst)
3820        }
3821        _ => Ok(native_src),
3822    }
3823}
3824
3825/// Save a [`TensorDyn`] image as a JPEG file.
3826///
3827/// Only packed RGB and RGBA formats are supported.
3828pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3829    let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3830        "save_jpeg requires u8 tensor".to_string(),
3831    ))?;
3832    let fmt = t.format().ok_or(Error::NotAnImage)?;
3833    if fmt.layout() != PixelLayout::Packed {
3834        return Err(Error::NotImplemented(
3835            "Saving planar images is not supported".to_string(),
3836        ));
3837    }
3838
3839    let colour = match fmt {
3840        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3841        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3842        _ => {
3843            return Err(Error::NotImplemented(
3844                "Unsupported image format for saving".to_string(),
3845            ));
3846        }
3847    };
3848
3849    let w = t.width().ok_or(Error::NotAnImage)?;
3850    let h = t.height().ok_or(Error::NotAnImage)?;
3851    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3852    let tensor_map = t.map_read()?;
3853
3854    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3855
3856    Ok(())
3857}
3858
3859pub(crate) struct FunctionTimer<T: Display> {
3860    name: T,
3861    start: std::time::Instant,
3862}
3863
3864impl<T: Display> FunctionTimer<T> {
3865    pub fn new(name: T) -> Self {
3866        Self {
3867            name,
3868            start: std::time::Instant::now(),
3869        }
3870    }
3871}
3872
3873impl<T: Display> Drop for FunctionTimer<T> {
3874    fn drop(&mut self) {
3875        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3876    }
3877}
3878
3879const DEFAULT_COLORS: [[f32; 4]; 20] = [
3880    [0., 1., 0., 0.7],
3881    [1., 0.5568628, 0., 0.7],
3882    [0.25882353, 0.15294118, 0.13333333, 0.7],
3883    [0.8, 0.7647059, 0.78039216, 0.7],
3884    [0.3137255, 0.3137255, 0.3137255, 0.7],
3885    [0.1411765, 0.3098039, 0.1215686, 0.7],
3886    [1., 0.95686275, 0.5137255, 0.7],
3887    [0.3529412, 0.32156863, 0., 0.7],
3888    [0.4235294, 0.6235294, 0.6509804, 0.7],
3889    [0.5098039, 0.5098039, 0.7294118, 0.7],
3890    [0.00784314, 0.18823529, 0.29411765, 0.7],
3891    [0.0, 0.2706, 1.0, 0.7],
3892    [0.0, 0.0, 0.0, 0.7],
3893    [0.0, 0.5, 0.0, 0.7],
3894    [1.0, 0.0, 0.0, 0.7],
3895    [0.0, 0.0, 1.0, 0.7],
3896    [1.0, 0.5, 0.5, 0.7],
3897    [0.1333, 0.5451, 0.1333, 0.7],
3898    [0.1176, 0.4118, 0.8235, 0.7],
3899    [1., 1., 1., 0.7],
3900];
3901
3902const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3903    let mut result = [[0; M]; N];
3904    let mut i = 0;
3905    while i < N {
3906        let mut j = 0;
3907        while j < M {
3908            result[i][j] = (a[i][j] * 255.0).round() as u8;
3909            j += 1;
3910        }
3911        i += 1;
3912    }
3913    result
3914}
3915
3916const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3917
3918#[cfg(test)]
3919#[cfg_attr(coverage_nightly, coverage(off))]
3920mod alignment_tests {
3921    use super::*;
3922
3923    #[test]
3924    fn align_width_rgba8_common_widths() {
3925        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
3926        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
3927        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
3928        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
3929        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
3930                                                              // crowd.png case from the imx95 investigation:
3931        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
3932        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
3933        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
3934        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
3935    }
3936
3937    #[test]
3938    fn align_width_rgb888_packed() {
3939        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
3940        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
3941        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
3942        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
3943        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
3944                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
3945        for w in [3004usize, 1281, 100, 17] {
3946            let padded = align_width_for_gpu_pitch(w, 3);
3947            assert!(padded >= w);
3948            assert_eq!((padded * 3) % 64, 0);
3949            assert_eq!((padded * 3) % 3, 0);
3950        }
3951    }
3952
3953    #[test]
3954    fn align_width_grey_u8() {
3955        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
3956        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3957        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3958        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3959        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3960    }
3961
3962    #[test]
3963    fn align_width_zero_inputs() {
3964        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3965        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3966    }
3967
3968    #[test]
3969    fn align_width_never_returns_smaller_than_input() {
3970        // Spot-check the "returned width >= input width" contract across a
3971        // range of values that would previously have hit `width * bpp`
3972        // overflow paths.
3973        for &bpp in &[1usize, 2, 3, 4, 8] {
3974            for &w in &[
3975                1usize,
3976                17,
3977                64,
3978                65,
3979                100,
3980                1280,
3981                1281,
3982                1920,
3983                3004,
3984                3072,
3985                3840,
3986                usize::MAX / 8,
3987                usize::MAX / 4,
3988                usize::MAX / 2,
3989                usize::MAX - 1,
3990                usize::MAX,
3991            ] {
3992                let aligned = align_width_for_gpu_pitch(w, bpp);
3993                assert!(
3994                    aligned >= w,
3995                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3996                );
3997            }
3998        }
3999    }
4000
4001    #[test]
4002    fn align_width_overflow_returns_unaligned_not_smaller() {
4003        // For width values close to usize::MAX, padding up would wrap. The
4004        // function must return the original width rather than wrapping or
4005        // panicking. A pre-aligned width round-trips unchanged even at the
4006        // extreme.
4007        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
4008        assert_eq!(
4009            align_width_for_gpu_pitch(aligned_extreme, 4),
4010            aligned_extreme
4011        );
4012        // A misaligned extreme value cannot be rounded up — the function
4013        // returns the original.
4014        let misaligned_extreme = usize::MAX - 1;
4015        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
4016        assert!(
4017            result == misaligned_extreme || result >= misaligned_extreme,
4018            "extreme misaligned width must not be rounded down to {result}"
4019        );
4020    }
4021
4022    #[test]
4023    fn checked_lcm_basic_and_overflow() {
4024        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
4025        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
4026        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
4027        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
4028        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
4029        // Coprime values whose product exceeds usize::MAX must return None.
4030        assert_eq!(
4031            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
4032            None,
4033            "coprime extreme values must overflow detect, not panic"
4034        );
4035    }
4036
4037    #[test]
4038    fn primary_plane_bpp_known_formats() {
4039        // Packed formats use channels × elem_size.
4040        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
4041        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
4042        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
4043        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
4044        // Semi-planar (NV12) reports the luma plane's bpp.
4045        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
4046    }
4047}
4048
4049#[cfg(test)]
4050#[cfg_attr(coverage_nightly, coverage(off))]
4051#[allow(deprecated)]
4052mod image_tests {
4053    use super::*;
4054    #[cfg(target_os = "linux")]
4055    use crate::test_support::is_dma_available;
4056    use crate::{CPUProcessor, Rotation};
4057    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
4058    use image::buffer::ConvertBuffer;
4059
4060    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
4061    /// by going through the `TensorDyn` API.
4062    ///
4063    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
4064    /// round-trip so the caller can feed them to `compare_images` etc.
4065    fn convert_img(
4066        proc: &mut dyn ImageProcessorTrait,
4067        src: TensorDyn,
4068        dst: TensorDyn,
4069        rotation: Rotation,
4070        flip: Flip,
4071        crop: Crop,
4072    ) -> (Result<()>, TensorDyn, TensorDyn) {
4073        let src_fourcc = src.format().unwrap();
4074        let dst_fourcc = dst.format().unwrap();
4075        let src_dyn = src;
4076        let mut dst_dyn = dst;
4077        let result = proc.convert(
4078            &src_dyn,
4079            &mut dst_dyn,
4080            Some(
4081                ConvertOptions::new()
4082                    .rotation(rotation)
4083                    .flip(flip)
4084                    .crop(crop),
4085            ),
4086        );
4087        let src_back = {
4088            let mut __t = src_dyn.into_u8().unwrap();
4089            __t.set_format(src_fourcc).unwrap();
4090            TensorDyn::from(__t)
4091        };
4092        let dst_back = {
4093            let mut __t = dst_dyn.into_u8().unwrap();
4094            __t.set_format(dst_fourcc).unwrap();
4095            TensorDyn::from(__t)
4096        };
4097        (result, src_back, dst_back)
4098    }
4099
4100    #[ctor::ctor(unsafe)]
4101    fn init() {
4102        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
4103    }
4104
4105    macro_rules! function {
4106        () => {{
4107            fn f() {}
4108            fn type_name_of<T>(_: T) -> &'static str {
4109                std::any::type_name::<T>()
4110            }
4111            let name = type_name_of(f);
4112
4113            // Find and cut the rest of the path
4114            match &name[..name.len() - 3].rfind(':') {
4115                Some(pos) => &name[pos + 1..name.len() - 3],
4116                None => &name[..name.len() - 3],
4117            }
4118        }};
4119    }
4120
4121    /// Master oracle for the view/batch **destination** batch engine: render `N`
4122    /// tiles into row-bands of ONE tall destination and assert each band equals
4123    /// the same source converted standalone — proving correct band placement and
4124    /// that a later tile's letterbox clear / draw never wipes a sibling band. On
4125    /// the Linux GL backend the `N` `convert_deferred` calls share ONE parent
4126    /// EGLImage import (each tile is a `glViewport`/`glScissor` ROI) and sync
4127    /// once at `flush()`; other backends fall back to an eager per-band convert
4128    /// (CPU writes via offset + parent stride). Either way the oracle must hold.
4129    ///
4130    /// Identical source/tile size makes the convert an exact copy, so the
4131    /// assertion is backend-agnostic (no GL-vs-CPU resampling drift). Distinct
4132    /// solid colors per tile make any sibling wipe a hard failure.
4133    #[test]
4134    fn batch_view_dst_tiles_match_standalone() {
4135        let mut proc = match ImageProcessor::new() {
4136            Ok(p) => p,
4137            Err(e) => {
4138                crate::test_support::report_skip(&format!(
4139                    "{} — ImageProcessor init failed ({e:?})",
4140                    function!()
4141                ));
4142                return;
4143            }
4144        };
4145        let n = 3usize;
4146        let (w, h) = (32usize, 24usize);
4147        let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
4148        let make_src = |c: [u8; 4]| -> TensorDyn {
4149            let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
4150            load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4151        };
4152        // Tall destination: N stacked row-bands. DMA so the Linux GL band path
4153        // runs (one parent import + per-tile glViewport); skip if unavailable.
4154        let Some(parent) = crate::test_support::dma_allocated(
4155            TensorDyn::image(
4156                w,
4157                n * h,
4158                PixelFormat::Rgba,
4159                DType::U8,
4160                Some(TensorMemory::DmaBuf),
4161                edgefirst_tensor::CpuAccess::ReadWrite,
4162            ),
4163            "tall DMA destination",
4164        ) else {
4165            return;
4166        };
4167
4168        // Deferred batch: one parent import, glViewport/scissor per band, one sync.
4169        for (i, &c) in colors.iter().enumerate().take(n) {
4170            let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
4171            proc.convert_deferred(&make_src(c), &mut tile, None)
4172                .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
4173        }
4174        proc.flush().unwrap();
4175
4176        for (i, &c) in colors.iter().enumerate().take(n) {
4177            // Standalone full-buffer convert of the same source = the oracle.
4178            let mut solo = TensorDyn::image(
4179                w,
4180                h,
4181                PixelFormat::Rgba,
4182                DType::U8,
4183                Some(TensorMemory::DmaBuf),
4184                edgefirst_tensor::CpuAccess::ReadWrite,
4185            )
4186            .unwrap();
4187            proc.convert(&make_src(c), &mut solo, None).unwrap();
4188
4189            let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
4190            let band_bytes = band
4191                .as_typed::<u8>()
4192                .unwrap()
4193                .map()
4194                .unwrap()
4195                .as_slice()
4196                .to_vec();
4197            let solo_bytes = solo
4198                .as_typed::<u8>()
4199                .unwrap()
4200                .map()
4201                .unwrap()
4202                .as_slice()
4203                .to_vec();
4204            assert_eq!(
4205                band_bytes, solo_bytes,
4206                "tile {i}: band differs from standalone convert (placement or sibling wipe)"
4207            );
4208            assert!(
4209                band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
4210                "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
4211            );
4212        }
4213    }
4214
4215    /// Build a `w`×`h` RGBA frame whose pixels encode their position, so a crop
4216    /// at any origin has distinct content (catches crop-origin / band-placement
4217    /// bugs a solid color cannot).
4218    #[cfg(test)]
4219    fn gradient_frame(w: usize, h: usize) -> TensorDyn {
4220        let mut bytes = vec![0u8; w * h * 4];
4221        for y in 0..h {
4222            for x in 0..w {
4223                let i = (y * w + x) * 4;
4224                bytes[i] = x as u8;
4225                bytes[i + 1] = y as u8;
4226                bytes[i + 2] = (x ^ y) as u8;
4227                bytes[i + 3] = 255;
4228            }
4229        }
4230        load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
4231    }
4232
4233    /// `tile_into` band == standalone crop-convert of the same source region,
4234    /// on the CPU backend (runs on CI without a GPU). Distinct gradient content
4235    /// makes a wrong crop origin or a sibling-band wipe a hard failure.
4236    #[test]
4237    fn tile_into_cpu_distinct_content_parity() {
4238        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4239            backend: ComputeBackend::Cpu,
4240            ..Default::default()
4241        })
4242        .expect("the CPU backend is always available");
4243        let (fw, fh) = (96usize, 64usize);
4244        let src = gradient_frame(fw, fh);
4245        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // exact 3×2 tiling
4246        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4247        assert_eq!(n, 6);
4248
4249        let mut parent = proc
4250            .alloc_tile_batch(
4251                n,
4252                &cfg,
4253                PixelFormat::Rgba,
4254                DType::U8,
4255                Some(TensorMemory::Mem),
4256                edgefirst_tensor::CpuAccess::ReadWrite,
4257            )
4258            .unwrap();
4259        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4260        assert_eq!(placements.len(), n);
4261
4262        for p in &placements {
4263            let source = Region::new(
4264                p.origin.0 as usize,
4265                p.origin.1 as usize,
4266                p.crop_size.0 as usize,
4267                p.crop_size.1 as usize,
4268            );
4269            let mut solo = TensorDyn::image(
4270                32,
4271                32,
4272                PixelFormat::Rgba,
4273                DType::U8,
4274                Some(TensorMemory::Mem),
4275                edgefirst_tensor::CpuAccess::ReadWrite,
4276            )
4277            .unwrap();
4278            proc.convert(
4279                &src,
4280                &mut solo,
4281                Some(
4282                    crate::ConvertOptions::new().crop(
4283                        Crop::default()
4284                            .with_source(Some(source))
4285                            .with_fit(Fit::Stretch),
4286                    ),
4287                ),
4288            )
4289            .unwrap();
4290            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4291            let band_bytes = band
4292                .as_typed::<u8>()
4293                .unwrap()
4294                .map()
4295                .unwrap()
4296                .as_slice()
4297                .to_vec();
4298            let solo_bytes = solo
4299                .as_typed::<u8>()
4300                .unwrap()
4301                .map()
4302                .unwrap()
4303                .as_slice()
4304                .to_vec();
4305            assert_eq!(
4306                band_bytes, solo_bytes,
4307                "tile {} band differs from standalone crop-convert",
4308                p.index
4309            );
4310        }
4311    }
4312
4313    /// Streaming `tile_one` into a single slot == the corresponding batched
4314    /// `tile_into` band (proves the two paths agree).
4315    #[test]
4316    fn tile_one_matches_tile_into_band() {
4317        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4318            backend: ComputeBackend::Cpu,
4319            ..Default::default()
4320        })
4321        .expect("the CPU backend is always available");
4322        let (fw, fh) = (96usize, 64usize);
4323        let src = gradient_frame(fw, fh);
4324        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4325        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4326
4327        let mut parent = proc
4328            .alloc_tile_batch(
4329                n,
4330                &cfg,
4331                PixelFormat::Rgba,
4332                DType::U8,
4333                Some(TensorMemory::Mem),
4334                edgefirst_tensor::CpuAccess::ReadWrite,
4335            )
4336            .unwrap();
4337        proc.tile_into(&src, &mut parent, &cfg).unwrap();
4338
4339        let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
4340        for p in &plan {
4341            let mut slot = TensorDyn::image(
4342                32,
4343                32,
4344                PixelFormat::Rgba,
4345                DType::U8,
4346                Some(TensorMemory::Mem),
4347                edgefirst_tensor::CpuAccess::ReadWrite,
4348            )
4349            .unwrap();
4350            proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
4351            proc.flush().unwrap();
4352            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4353            let slot_bytes = slot
4354                .as_typed::<u8>()
4355                .unwrap()
4356                .map()
4357                .unwrap()
4358                .as_slice()
4359                .to_vec();
4360            let band_bytes = band
4361                .as_typed::<u8>()
4362                .unwrap()
4363                .map()
4364                .unwrap()
4365                .as_slice()
4366                .to_vec();
4367            assert_eq!(
4368                slot_bytes, band_bytes,
4369                "tile {} stream != batch band",
4370                p.index
4371            );
4372        }
4373    }
4374
4375    /// `plan_tiles` returns correct per-tile metadata for a 4K frame (pure, no
4376    /// GPU render).
4377    /// `tile_into` through the auto backend with a DMA parent — exercises the
4378    /// GL single-import band path on a GPU machine (skips when DMA/GL is
4379    /// unavailable). The source is sampled via `Crop::with_source`, never
4380    /// `src.view()`, so all tiles share one source import.
4381    #[test]
4382    fn tile_into_auto_dma_parity() {
4383        let mut proc = match ImageProcessor::new() {
4384            Ok(p) => p,
4385            Err(e) => {
4386                crate::test_support::report_skip(&format!(
4387                    "{} — ImageProcessor init failed ({e:?})",
4388                    function!()
4389                ));
4390                return;
4391            }
4392        };
4393        let (fw, fh) = (96usize, 64usize);
4394        let src = gradient_frame(fw, fh);
4395        let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
4396        let n = tile_grid(fh, fw, 32, 32, 0.0).len();
4397
4398        let Some(mut parent) = crate::test_support::dma_allocated(
4399            proc.alloc_tile_batch(
4400                n,
4401                &cfg,
4402                PixelFormat::Rgba,
4403                DType::U8,
4404                Some(TensorMemory::DmaBuf),
4405                edgefirst_tensor::CpuAccess::ReadWrite,
4406            ),
4407            "tall DMA parent",
4408        ) else {
4409            return;
4410        };
4411        let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
4412
4413        for p in &placements {
4414            let source = Region::new(
4415                p.origin.0 as usize,
4416                p.origin.1 as usize,
4417                p.crop_size.0 as usize,
4418                p.crop_size.1 as usize,
4419            );
4420            let mut solo = TensorDyn::image(
4421                32,
4422                32,
4423                PixelFormat::Rgba,
4424                DType::U8,
4425                Some(TensorMemory::DmaBuf),
4426                edgefirst_tensor::CpuAccess::ReadWrite,
4427            )
4428            .unwrap();
4429            proc.convert(
4430                &src,
4431                &mut solo,
4432                Some(
4433                    crate::ConvertOptions::new().crop(
4434                        Crop::default()
4435                            .with_source(Some(source))
4436                            .with_fit(Fit::Stretch),
4437                    ),
4438                ),
4439            )
4440            .unwrap();
4441            let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
4442            // Structural-similarity tolerance (the house GPU-parity bar) rather
4443            // than exact bytes: rendering a tile into a viewport band at a
4444            // non-zero offset vs. a standalone origin render diverges by sampling
4445            // rounding on virtualized GPUs (paravirtual Metal/ANGLE on macOS CI),
4446            // the same tolerance class as the other cross-backend parity tests.
4447            compare_images(
4448                &band,
4449                &solo,
4450                0.98,
4451                &format!("{}_tile{}", function!(), p.index),
4452            );
4453        }
4454    }
4455
4456    /// `tile_into` rejects a destination too small to hold all tile bands.
4457    #[test]
4458    fn tile_into_undersized_dst_errors() {
4459        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
4460            backend: ComputeBackend::Cpu,
4461            ..Default::default()
4462        })
4463        .expect("the CPU backend is always available");
4464        let (fw, fh) = (96usize, 64usize);
4465        let src = gradient_frame(fw, fh);
4466        let cfg = TilingConfig::new(32, 32).with_overlap(0.0); // 6 tiles
4467                                                               // Allocate a parent for only 2 bands, far short of 6.
4468        let mut small = TensorDyn::image(
4469            32,
4470            2 * 32,
4471            PixelFormat::Rgba,
4472            DType::U8,
4473            Some(TensorMemory::Mem),
4474            edgefirst_tensor::CpuAccess::ReadWrite,
4475        )
4476        .unwrap();
4477        let r = proc.tile_into(&src, &mut small, &cfg);
4478        assert!(
4479            matches!(r, Err(Error::InvalidShape(_))),
4480            "expected InvalidShape, got {r:?}"
4481        );
4482    }
4483
4484    /// `alloc_tile_batch` / `plan_tiles` reject an invalid config (zero tile).
4485    #[test]
4486    fn tiling_alloc_rejects_invalid_config() {
4487        let proc = ImageProcessor::with_config(ImageProcessorConfig {
4488            backend: ComputeBackend::Cpu,
4489            ..Default::default()
4490        })
4491        .expect("the CPU backend is always available");
4492        let bad = TilingConfig::new(0, 640);
4493        assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4494        assert!(proc
4495            .alloc_tile_batch(
4496                4,
4497                &bad,
4498                PixelFormat::Rgba,
4499                DType::U8,
4500                Some(TensorMemory::Mem),
4501                edgefirst_tensor::CpuAccess::ReadWrite,
4502            )
4503            .is_err());
4504    }
4505
4506    #[test]
4507    fn plan_tiles_metadata_4k() {
4508        let proc = ImageProcessor::with_config(ImageProcessorConfig {
4509            backend: ComputeBackend::Cpu,
4510            ..Default::default()
4511        })
4512        .expect("the CPU backend is always available");
4513        let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4514        let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4515        assert_eq!(plan.len(), 32);
4516        assert!(plan.iter().all(|p| p.count == 32));
4517        assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4518        assert!(plan.iter().all(|p| p.content_region.is_none())); // stretch fit
4519        assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4520        assert_eq!(plan[0].origin, (0.0, 0.0));
4521    }
4522
4523    #[test]
4524    fn test_invalid_crop() {
4525        let src = TensorDyn::image(
4526            100,
4527            100,
4528            PixelFormat::Rgb,
4529            DType::U8,
4530            None,
4531            edgefirst_tensor::CpuAccess::ReadWrite,
4532        )
4533        .unwrap();
4534        let dst = TensorDyn::image(
4535            100,
4536            100,
4537            PixelFormat::Rgb,
4538            DType::U8,
4539            None,
4540            edgefirst_tensor::CpuAccess::ReadWrite,
4541        )
4542        .unwrap();
4543
4544        // A source crop exceeding the source bounds is rejected.
4545        let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4546        assert!(matches!(
4547            crop.check_crop_dyn(&src, &dst),
4548            Err(Error::CropInvalid(_))
4549        ));
4550
4551        // A source crop within bounds is valid.
4552        let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4553        assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4554
4555        // Letterbox is always valid — placement is computed within the dst.
4556        assert!(Crop::letterbox([0, 0, 0, 255])
4557            .check_crop_dyn(&src, &dst)
4558            .is_ok());
4559    }
4560
4561    #[test]
4562    fn test_invalid_tensor_format() -> Result<(), Error> {
4563        // 4D tensor cannot be set to a 3-channel pixel format
4564        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4565        let result = tensor.set_format(PixelFormat::Rgb);
4566        assert!(result.is_err(), "4D tensor should reject set_format");
4567
4568        // Tensor with wrong channel count for the format
4569        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4570        let result = tensor.set_format(PixelFormat::Rgb);
4571        assert!(result.is_err(), "4-channel tensor should reject RGB format");
4572
4573        Ok(())
4574    }
4575
4576    #[test]
4577    fn test_invalid_image_file() -> Result<(), Error> {
4578        let result = crate::load_image_test_helper(&[123; 5000], None, None);
4579        assert!(
4580            matches!(result, Err(Error::Codec(_))),
4581            "unrecognised bytes should surface as Error::Codec, got {result:?}"
4582        );
4583        Ok(())
4584    }
4585
4586    #[test]
4587    fn test_invalid_jpeg_format() -> Result<(), Error> {
4588        let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4589        // YUYV is not a valid decode target; peek_info fails before the magic-
4590        // bytes check, so the precise variant depends on which error fires first.
4591        assert!(
4592            matches!(result, Err(Error::Codec(_))),
4593            "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4594        );
4595        Ok(())
4596    }
4597
4598    #[test]
4599    fn test_load_resize_save() {
4600        let file = edgefirst_bench::testdata::read("zidane.jpg");
4601        let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4602        assert_eq!(img.width(), Some(1280));
4603        assert_eq!(img.height(), Some(720));
4604
4605        let dst = TensorDyn::image(
4606            640,
4607            360,
4608            PixelFormat::Rgba,
4609            DType::U8,
4610            None,
4611            edgefirst_tensor::CpuAccess::ReadWrite,
4612        )
4613        .unwrap();
4614        let mut converter = CPUProcessor::new();
4615        let (result, _img, dst) = convert_img(
4616            &mut converter,
4617            img,
4618            dst,
4619            Rotation::None,
4620            Flip::None,
4621            Crop::no_crop(),
4622        );
4623        result.unwrap();
4624        assert_eq!(dst.width(), Some(640));
4625        assert_eq!(dst.height(), Some(360));
4626
4627        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4628
4629        let file = std::fs::read("zidane_resized.jpg").unwrap();
4630        // With `format: None` the helper returns the source's native format.
4631        // The codec now decodes colour JPEGs to NV12 (was RGB previously).
4632        let img = crate::load_image_test_helper(&file, None, None).unwrap();
4633        assert_eq!(img.width(), Some(640));
4634        assert_eq!(img.height(), Some(360));
4635        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4636    }
4637
4638    #[test]
4639    fn test_from_tensor_planar() -> Result<(), Error> {
4640        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4641        tensor
4642            .map()?
4643            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4644        let planar = {
4645            tensor
4646                .set_format(PixelFormat::PlanarRgb)
4647                .map_err(|e| crate::Error::Internal(e.to_string()))?;
4648            TensorDyn::from(tensor)
4649        };
4650
4651        let rbga = load_bytes_to_tensor(
4652            1280,
4653            720,
4654            PixelFormat::Rgba,
4655            None,
4656            &edgefirst_bench::testdata::read("camera720p.rgba"),
4657        )?;
4658        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4659
4660        Ok(())
4661    }
4662
4663    #[test]
4664    fn test_from_tensor_invalid_format() {
4665        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
4666        // Since there's no "TEST" pixel format, this validates graceful handling.
4667        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4668    }
4669
4670    #[test]
4671    #[should_panic(expected = "Failed to save planar RGB image")]
4672    fn test_save_planar() {
4673        let planar_img = load_bytes_to_tensor(
4674            1280,
4675            720,
4676            PixelFormat::PlanarRgb,
4677            None,
4678            &edgefirst_bench::testdata::read("camera720p.8bps"),
4679        )
4680        .unwrap();
4681
4682        let save_path = "/tmp/planar_rgb.jpg";
4683        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4684    }
4685
4686    #[test]
4687    #[should_panic(expected = "Failed to save YUYV image")]
4688    fn test_save_yuyv() {
4689        let planar_img = load_bytes_to_tensor(
4690            1280,
4691            720,
4692            PixelFormat::Yuyv,
4693            None,
4694            &edgefirst_bench::testdata::read("camera720p.yuyv"),
4695        )
4696        .unwrap();
4697
4698        let save_path = "/tmp/yuyv.jpg";
4699        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4700    }
4701
4702    #[test]
4703    fn test_rotation_angle() {
4704        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4705        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4706        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4707        assert_eq!(
4708            Rotation::from_degrees_clockwise(270),
4709            Rotation::CounterClockwise90
4710        );
4711        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4712        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4713        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4714        assert_eq!(
4715            Rotation::from_degrees_clockwise(630),
4716            Rotation::CounterClockwise90
4717        );
4718    }
4719
4720    #[test]
4721    #[should_panic(expected = "rotation angle is not a multiple of 90")]
4722    fn test_rotation_angle_panic() {
4723        Rotation::from_degrees_clockwise(361);
4724    }
4725
4726    #[test]
4727    fn test_disable_env_var() -> Result<(), Error> {
4728        // Acquire the env-var mutex for the entire test body so we never race
4729        // with test_force_backend_* or test_draw_proto_masks_no_cpu_returns_error.
4730        let _lock = acquire_env_lock();
4731
4732        // Snapshot ALL env vars we might touch so the RAII guard restores them
4733        // on exit (even on panic), preventing env-var poisoning of other tests.
4734        let _guard = EnvGuard::snapshot(&[
4735            "EDGEFIRST_FORCE_BACKEND",
4736            "EDGEFIRST_DISABLE_GL",
4737            "EDGEFIRST_DISABLE_G2D",
4738            "EDGEFIRST_DISABLE_CPU",
4739        ]);
4740
4741        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
4742        // so clear it for the duration of this test.
4743        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4744
4745        #[cfg(target_os = "linux")]
4746        {
4747            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4748            let converter = ImageProcessor::new()?;
4749            assert!(converter.g2d.is_none());
4750            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4751        }
4752
4753        #[cfg(any(target_os = "linux", target_os = "windows"))]
4754        #[cfg(feature = "opengl")]
4755        {
4756            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4757            let converter = ImageProcessor::new()?;
4758            assert!(converter.opengl.is_none());
4759            unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4760        }
4761
4762        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4763        let converter = ImageProcessor::new()?;
4764        assert!(converter.cpu.is_none());
4765        unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4766
4767        // Disable everything — convert must return NoConverter.
4768        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4769        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4770        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4771        let mut converter = ImageProcessor::new()?;
4772
4773        let src = TensorDyn::image(
4774            1280,
4775            720,
4776            PixelFormat::Rgba,
4777            DType::U8,
4778            None,
4779            edgefirst_tensor::CpuAccess::ReadWrite,
4780        )?;
4781        let dst = TensorDyn::image(
4782            640,
4783            360,
4784            PixelFormat::Rgba,
4785            DType::U8,
4786            None,
4787            edgefirst_tensor::CpuAccess::ReadWrite,
4788        )?;
4789        let (result, _src, _dst) = convert_img(
4790            &mut converter,
4791            src,
4792            dst,
4793            Rotation::None,
4794            Flip::None,
4795            Crop::no_crop(),
4796        );
4797        assert!(matches!(result, Err(Error::NoConverter)));
4798        // _guard restores all env vars on drop.
4799        Ok(())
4800    }
4801
4802    #[test]
4803    fn test_unsupported_conversion() {
4804        let src = TensorDyn::image(
4805            1280,
4806            720,
4807            PixelFormat::Nv12,
4808            DType::U8,
4809            None,
4810            edgefirst_tensor::CpuAccess::ReadWrite,
4811        )
4812        .unwrap();
4813        let dst = TensorDyn::image(
4814            640,
4815            360,
4816            PixelFormat::Nv12,
4817            DType::U8,
4818            None,
4819            edgefirst_tensor::CpuAccess::ReadWrite,
4820        )
4821        .unwrap();
4822        let mut converter = ImageProcessor::new().unwrap();
4823        let (result, _src, _dst) = convert_img(
4824            &mut converter,
4825            src,
4826            dst,
4827            Rotation::None,
4828            Flip::None,
4829            Crop::no_crop(),
4830        );
4831        log::debug!("result: {:?}", result);
4832        assert!(matches!(
4833            result,
4834            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4835        ));
4836    }
4837
4838    #[test]
4839    fn test_load_grey() {
4840        // A single-component (greyscale) JPEG decodes to its native GREY
4841        // format, which has no even-dimension constraint, so the 1024×681
4842        // `grey.jpg` loads and converts to RGBA successfully.
4843        let grey_img = crate::load_image_test_helper(
4844            &edgefirst_bench::testdata::read("grey.jpg"),
4845            Some(PixelFormat::Rgba),
4846            None,
4847        )
4848        .unwrap();
4849        assert_eq!(grey_img.width(), Some(1024));
4850        assert_eq!(grey_img.height(), Some(681));
4851
4852        // `grey-rgb.jpg` holds the same grey content but is encoded as a
4853        // 3-component (colour) JPEG, so the codec decodes it to native NV12.
4854        // Its 1024×681 dimensions have an odd height; NV12 now represents odd
4855        // dimensions via the `H + ceil(H/2)` combined-plane height, so the
4856        // decode succeeds and converts to RGBA at the true dimensions.
4857        let grey_but_rgb = crate::load_image_test_helper(
4858            &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4859            Some(PixelFormat::Rgba),
4860            None,
4861        )
4862        .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4863        assert_eq!(grey_but_rgb.width(), Some(1024));
4864        assert_eq!(grey_but_rgb.height(), Some(681));
4865    }
4866
4867    #[test]
4868    fn test_new_nv12() {
4869        let nv12 = TensorDyn::image(
4870            1280,
4871            720,
4872            PixelFormat::Nv12,
4873            DType::U8,
4874            None,
4875            edgefirst_tensor::CpuAccess::ReadWrite,
4876        )
4877        .unwrap();
4878        assert_eq!(nv12.height(), Some(720));
4879        assert_eq!(nv12.width(), Some(1280));
4880        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4881        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
4882        assert_eq!(nv12.format().unwrap().channels(), 1);
4883        assert!(nv12.format().is_some_and(
4884            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4885        ))
4886    }
4887
4888    #[test]
4889    #[cfg(any(target_os = "linux", target_os = "windows"))]
4890    fn test_new_image_converter() {
4891        let dst_width = 640;
4892        let dst_height = 360;
4893        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4894        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4895
4896        let mut converter = ImageProcessor::new().unwrap();
4897        let converter_dst = converter
4898            .create_image(
4899                dst_width,
4900                dst_height,
4901                PixelFormat::Rgba,
4902                DType::U8,
4903                None,
4904                edgefirst_tensor::CpuAccess::ReadWrite,
4905            )
4906            .unwrap();
4907        let (result, src, converter_dst) = convert_img(
4908            &mut converter,
4909            src,
4910            converter_dst,
4911            Rotation::None,
4912            Flip::None,
4913            Crop::no_crop(),
4914        );
4915        result.unwrap();
4916
4917        let cpu_dst = TensorDyn::image(
4918            dst_width,
4919            dst_height,
4920            PixelFormat::Rgba,
4921            DType::U8,
4922            None,
4923            edgefirst_tensor::CpuAccess::ReadWrite,
4924        )
4925        .unwrap();
4926        let mut cpu_converter = CPUProcessor::new();
4927        let (result, _src, cpu_dst) = convert_img(
4928            &mut cpu_converter,
4929            src,
4930            cpu_dst,
4931            Rotation::None,
4932            Flip::None,
4933            Crop::no_crop(),
4934        );
4935        result.unwrap();
4936
4937        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4938    }
4939
4940    #[test]
4941    #[cfg(any(target_os = "linux", target_os = "windows"))]
4942    fn test_create_image_dtype_i8() {
4943        let mut converter = ImageProcessor::new().unwrap();
4944
4945        // I8 image should allocate successfully via create_image
4946        let dst = converter
4947            .create_image(
4948                320,
4949                240,
4950                PixelFormat::Rgb,
4951                DType::I8,
4952                None,
4953                edgefirst_tensor::CpuAccess::ReadWrite,
4954            )
4955            .unwrap();
4956        assert_eq!(dst.dtype(), DType::I8);
4957        assert!(dst.width() == Some(320));
4958        assert!(dst.height() == Some(240));
4959        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4960
4961        // U8 for comparison
4962        let dst_u8 = converter
4963            .create_image(
4964                320,
4965                240,
4966                PixelFormat::Rgb,
4967                DType::U8,
4968                None,
4969                edgefirst_tensor::CpuAccess::ReadWrite,
4970            )
4971            .unwrap();
4972        assert_eq!(dst_u8.dtype(), DType::U8);
4973
4974        // Convert into I8 dst should succeed
4975        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4976        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4977        let mut dst_i8 = converter
4978            .create_image(
4979                320,
4980                240,
4981                PixelFormat::Rgb,
4982                DType::I8,
4983                None,
4984                edgefirst_tensor::CpuAccess::ReadWrite,
4985            )
4986            .unwrap();
4987        converter.convert(&src, &mut dst_i8, None).unwrap();
4988    }
4989
4990    #[test]
4991    #[cfg(target_os = "linux")]
4992    fn test_create_image_nv12_dma_non_aligned_width() {
4993        // create_image is fully stride-aware: a non-64-aligned NV12 DMA tensor
4994        // may legitimately carry a GPU-pitch-padded row stride — that is the
4995        // intended behaviour, not a bug. Verify the logical geometry is preserved
4996        // for any width and that a reported stride is a valid (>= logical)
4997        // padding, rather than asserting the absence of a stride.
4998        let converter = ImageProcessor::new().unwrap();
4999
5000        // 100 is intentionally not a multiple of 64 (the GPU pitch alignment).
5001        let Some(img) = crate::test_support::dma_allocated(
5002            converter.create_image(
5003                100,
5004                64,
5005                PixelFormat::Nv12,
5006                DType::U8,
5007                Some(TensorMemory::DmaBuf),
5008                edgefirst_tensor::CpuAccess::ReadWrite,
5009            ),
5010            "create_image NV12 DMA non-aligned width",
5011        ) else {
5012            return;
5013        };
5014        assert_eq!(img.width(), Some(100));
5015        assert_eq!(img.height(), Some(64));
5016        assert_eq!(img.format(), Some(PixelFormat::Nv12));
5017        if let Some(stride) = img.row_stride() {
5018            assert!(
5019                stride >= 100,
5020                "NV12 row_stride {stride} must be >= the logical width (100)",
5021            );
5022        }
5023    }
5024
5025    #[test]
5026    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
5027              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
5028              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
5029    fn test_crop_skip() {
5030        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5031        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5032
5033        let mut converter = ImageProcessor::new().unwrap();
5034        let converter_dst = converter
5035            .create_image(
5036                1280,
5037                720,
5038                PixelFormat::Rgba,
5039                DType::U8,
5040                None,
5041                edgefirst_tensor::CpuAccess::ReadWrite,
5042            )
5043            .unwrap();
5044        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
5045        let (result, src, converter_dst) = convert_img(
5046            &mut converter,
5047            src,
5048            converter_dst,
5049            Rotation::None,
5050            Flip::None,
5051            crop,
5052        );
5053        result.unwrap();
5054
5055        let cpu_dst = TensorDyn::image(
5056            1280,
5057            720,
5058            PixelFormat::Rgba,
5059            DType::U8,
5060            None,
5061            edgefirst_tensor::CpuAccess::ReadWrite,
5062        )
5063        .unwrap();
5064        let mut cpu_converter = CPUProcessor::new();
5065        let (result, _src, cpu_dst) = convert_img(
5066            &mut cpu_converter,
5067            src,
5068            cpu_dst,
5069            Rotation::None,
5070            Flip::None,
5071            crop,
5072        );
5073        result.unwrap();
5074
5075        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
5076    }
5077
5078    #[test]
5079    fn test_invalid_pixel_format() {
5080        // PixelFormat::from_fourcc returns None for unknown formats,
5081        // so TensorDyn::image cannot be called with an invalid format.
5082        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
5083    }
5084
5085    // Helper function to check if G2D library is available (Linux/i.MX8 only)
5086    #[cfg(target_os = "linux")]
5087    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5088
5089    #[cfg(target_os = "linux")]
5090    fn is_g2d_available() -> bool {
5091        crate::test_support::g2d_required_or(
5092            *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok()),
5093        )
5094    }
5095
5096    // Every GL platform: Linux EGL, ANGLE/Metal (macOS, iOS), Android EGL,
5097    // ANGLE/D3D11 (Windows). `GLProcessorThreaded::new` fails cleanly where
5098    // the driver or ANGLE is missing, so the `test_opengl_*` callers below
5099    // self-skip there (and `HAL_TEST_REQUIRE_GL=1` lanes catch that via
5100    // `gl_backend_available_canary`).
5101    #[cfg(any(
5102        target_os = "linux",
5103        target_os = "macos",
5104        target_os = "ios",
5105        target_os = "android",
5106        target_os = "windows"
5107    ))]
5108    #[cfg(feature = "opengl")]
5109    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5110
5111    #[cfg(any(
5112        target_os = "linux",
5113        target_os = "macos",
5114        target_os = "ios",
5115        target_os = "android",
5116        target_os = "windows"
5117    ))]
5118    #[cfg(feature = "opengl")]
5119    // Helper function to check if OpenGL is available
5120    fn is_opengl_available() -> bool {
5121        *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
5122    }
5123
5124    /// CI canary: fails the lane when the GL backend cannot initialize.
5125    ///
5126    /// Every GL test in this suite self-skips when the backend is
5127    /// unavailable — correct for developer machines, but it means a broken
5128    /// CI GL stack (e.g. the macOS ANGLE re-sign step regressing, the exact
5129    /// failure mode documented in the workflow) ships an untested GL
5130    /// backend behind a green lane. Gated on `HAL_TEST_REQUIRE_GL=1`, set
5131    /// only by CI jobs that install a working GL stack; local runs without
5132    /// one pass trivially. On macOS it additionally requires
5133    /// `HAL_TEST_ALLOW_DLOPEN_ANGLE`, so coverage pass 1 (unsigned
5134    /// binaries, dlopen gate closed) skips it and pass 2 (signed) enforces.
5135    #[test]
5136    #[cfg(feature = "opengl")]
5137    fn gl_backend_available_canary() {
5138        let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
5139        if !require_gl {
5140            crate::test_support::report_skip(&format!(
5141                "{} — HAL_TEST_REQUIRE_GL is not set to 1",
5142                function!()
5143            ));
5144            return;
5145        }
5146        #[cfg(target_os = "macos")]
5147        if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
5148            crate::test_support::report_skip(&format!(
5149                "{} — ANGLE dlopen gate closed (coverage pass 1)",
5150                function!()
5151            ));
5152            return;
5153        }
5154        GLProcessorThreaded::new(None).expect(
5155            "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
5156             check the ANGLE install/re-sign step and binary entitlements \
5157             (macOS) or the EGL stack (Linux)",
5158        );
5159    }
5160
5161    #[test]
5162    fn test_load_jpeg_with_exif() {
5163        use edgefirst_codec::peek_info;
5164
5165        // The migrated codec NEVER applies EXIF orientation: it decodes to the
5166        // source's native (un-rotated) dimensions and reports the rotation via
5167        // ImageInfo. `zidane_rotated_exif.jpg` carries EXIF orientation 6
5168        // (90° clockwise) over a 1280×720 frame.
5169        let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
5170        let info = peek_info(&file).unwrap();
5171        assert_eq!(info.rotation_degrees, 90);
5172        assert!(!info.flip_horizontal);
5173
5174        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5175        // Native (un-rotated) dimensions — the decode does not rotate.
5176        assert_eq!(loaded.width(), Some(1280));
5177        assert_eq!(loaded.height(), Some(720));
5178
5179        // Applying the reported rotation downstream reproduces the upright
5180        // image: it matches `zidane.jpg` rotated by the same 90° clockwise.
5181        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5182        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5183
5184        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5185        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
5186
5187        let cpu_dst = TensorDyn::image(
5188            dst_width,
5189            dst_height,
5190            PixelFormat::Rgba,
5191            DType::U8,
5192            None,
5193            edgefirst_tensor::CpuAccess::ReadWrite,
5194        )
5195        .unwrap();
5196        let mut cpu_converter = CPUProcessor::new();
5197
5198        // Rotate the native-orientation `loaded` frame and the native `zidane`
5199        // frame by the same reported rotation; the results must agree.
5200        let loaded_rotated = TensorDyn::image(
5201            dst_width,
5202            dst_height,
5203            PixelFormat::Rgba,
5204            DType::U8,
5205            None,
5206            edgefirst_tensor::CpuAccess::ReadWrite,
5207        )
5208        .unwrap();
5209        let (r0, _loaded, loaded_rotated) = convert_img(
5210            &mut cpu_converter,
5211            loaded,
5212            loaded_rotated,
5213            rotation,
5214            Flip::None,
5215            Crop::no_crop(),
5216        );
5217        r0.unwrap();
5218
5219        let (result, _cpu_src, cpu_dst) = convert_img(
5220            &mut cpu_converter,
5221            cpu_src,
5222            cpu_dst,
5223            rotation,
5224            Flip::None,
5225            Crop::no_crop(),
5226        );
5227        result.unwrap();
5228
5229        compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
5230    }
5231
5232    #[test]
5233    fn test_load_png_with_exif() {
5234        use edgefirst_codec::peek_info;
5235
5236        // PNGs also report EXIF orientation without applying it.
5237        // `zidane_rotated_exif_180.png` carries EXIF orientation 3 (180°).
5238        let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
5239        let info = peek_info(&file).unwrap();
5240        assert_eq!(info.rotation_degrees, 180);
5241        assert!(!info.flip_horizontal);
5242
5243        let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5244        // Native (un-rotated) dimensions — PNG decodes upright as authored.
5245        assert_eq!(loaded.height(), Some(720));
5246        assert_eq!(loaded.width(), Some(1280));
5247
5248        // The PNG fixture stores upright `zidane` pixels tagged with a 180°
5249        // EXIF orientation. Because the codec no longer applies the rotation,
5250        // the decoded pixels match `zidane.jpg` directly (no convert needed).
5251        // Re-applying the reported rotation to both must still agree.
5252        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5253        let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5254
5255        let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
5256        let cpu_dst = TensorDyn::image(
5257            1280,
5258            720,
5259            PixelFormat::Rgba,
5260            DType::U8,
5261            None,
5262            edgefirst_tensor::CpuAccess::ReadWrite,
5263        )
5264        .unwrap();
5265        let mut cpu_converter = CPUProcessor::new();
5266
5267        let (result, _cpu_src, cpu_dst) = convert_img(
5268            &mut cpu_converter,
5269            cpu_src,
5270            cpu_dst,
5271            rotation,
5272            Flip::None,
5273            Crop::no_crop(),
5274        );
5275        result.unwrap();
5276
5277        // Rotate the decoded PNG by the same reported angle so both frames are
5278        // in the same (180°-rotated) orientation before comparing.
5279        let loaded_rotated = TensorDyn::image(
5280            1280,
5281            720,
5282            PixelFormat::Rgba,
5283            DType::U8,
5284            None,
5285            edgefirst_tensor::CpuAccess::ReadWrite,
5286        )
5287        .unwrap();
5288        let (r0, _loaded, loaded_rotated) = convert_img(
5289            &mut cpu_converter,
5290            loaded,
5291            loaded_rotated,
5292            rotation,
5293            Flip::None,
5294            Crop::no_crop(),
5295        );
5296        r0.unwrap();
5297
5298        // Threshold 0.95 (was 0.98): `loaded` comes from a lossless PNG decode
5299        // while `cpu_src` (zidane.jpg) now decodes through native NV12 (chroma
5300        // subsampling) before the RGBA conversion, so the two paths differ by a
5301        // couple of percent versus the old direct-RGB JPEG decode.
5302        compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
5303    }
5304
5305    /// Synthesise an RGB JPEG with a deterministic pattern at `(width, height)`
5306    /// using the workspace's `jpeg-encoder` crate (the `image` crate is
5307    /// compiled without its JPEG feature). Used to exercise the decoder /
5308    /// pitch-padding paths for arbitrary dimensions without having to bundle
5309    /// a fixture file per test size.
5310    #[cfg(target_os = "linux")]
5311    fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
5312        let mut bytes = Vec::with_capacity((width * height * 3) as usize);
5313        for y in 0..height {
5314            for x in 0..width {
5315                bytes.push(((x + y) & 0xFF) as u8);
5316                bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
5317                bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
5318            }
5319        }
5320        let mut out = Vec::new();
5321        let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
5322        encoder
5323            .encode(
5324                &bytes,
5325                width as u16,
5326                height as u16,
5327                jpeg_encoder::ColorType::Rgb,
5328            )
5329            .expect("jpeg-encoder must succeed on trivial input");
5330        out
5331    }
5332
5333    /// End-to-end: a 375×333 RGBA JPEG (width NOT divisible by 4) loaded
5334    /// via the pitch-padded DMA path and letterboxed through the GL
5335    /// backend must produce correct output. Before the Rgba/Bgra
5336    /// width%4 relaxation in `DmaImportAttrs::from_tensor`, this case
5337    /// failed the pre-check and forced a CPU texture upload fallback;
5338    /// with the relaxation, EGL import succeeds at the driver level and
5339    /// the GL fast path runs. Output correctness is checked against a
5340    /// CPU reference (convert ran with `EDGEFIRST_FORCE_BACKEND=cpu`).
5341    #[test]
5342    #[cfg(target_os = "linux")]
5343    #[cfg(feature = "opengl")]
5344    fn test_convert_rgba_non_4_aligned_width_end_to_end() {
5345        use crate::test_support::is_dma_available;
5346        if !is_dma_available() {
5347            crate::test_support::report_skip(
5348                "test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available",
5349            );
5350            return;
5351        }
5352        // 375 is the canonical failure width from dataset loaders —
5353        // 375 * 4 = 1500 bytes/row, pitch-padded to 1536. Width%4 = 3,
5354        // so the old pre-check rejected it; new code accepts it.
5355        let jpeg = make_rgb_jpeg(375, 333);
5356        let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5357        assert_eq!(src_gl.width(), Some(375));
5358        // Row stride must still be pitch-padded (separate concern from width).
5359        let stride = src_gl.row_stride().unwrap();
5360        assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
5361
5362        // GL-backed convert into a pitch-aligned 640×640 Rgba dest.
5363        let mut gl_proc = ImageProcessor::new().unwrap();
5364        let gl_dst = gl_proc
5365            .create_image(
5366                640,
5367                640,
5368                PixelFormat::Rgba,
5369                DType::U8,
5370                None,
5371                edgefirst_tensor::CpuAccess::ReadWrite,
5372            )
5373            .unwrap();
5374        let (r_gl, _src_gl, gl_dst) = convert_img(
5375            &mut gl_proc,
5376            src_gl,
5377            gl_dst,
5378            Rotation::None,
5379            Flip::None,
5380            Crop::no_crop(),
5381        );
5382        r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
5383
5384        // CPU reference via a fresh load so the two paths start from
5385        // byte-identical inputs. `with_config(backend=Cpu)` forces the
5386        // CPU-only processor regardless of which backends the host has
5387        // available.
5388        let src_cpu =
5389            crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
5390                .unwrap();
5391        let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
5392            backend: ComputeBackend::Cpu,
5393            ..Default::default()
5394        })
5395        .unwrap();
5396        let cpu_dst = TensorDyn::image(
5397            640,
5398            640,
5399            PixelFormat::Rgba,
5400            DType::U8,
5401            Some(TensorMemory::Mem),
5402            edgefirst_tensor::CpuAccess::ReadWrite,
5403        )
5404        .unwrap();
5405        let (r_cpu, _src_cpu, cpu_dst) = convert_img(
5406            &mut cpu_proc,
5407            src_cpu,
5408            cpu_dst,
5409            Rotation::None,
5410            Flip::None,
5411            Crop::no_crop(),
5412        );
5413        r_cpu.unwrap();
5414
5415        // Structural similarity: the GL path may have gone through EGL
5416        // import OR fallen back to CPU texture upload — either way, the
5417        // output must match the CPU reference closely.
5418        compare_images(&gl_dst, &cpu_dst, 0.95, function!());
5419    }
5420
5421    /// Regression lock: loading a JPEG at a non-64-aligned RGBA pitch (e.g.
5422    /// 500×333 → natural pitch 2000, needs to be padded to 2048) must go
5423    /// through `image_with_stride` and set `row_stride()` / `effective_row_stride()`
5424    /// to the padded value. The earlier pitch-padding commit fixed this in
5425    /// `load_jpeg`; a regression would surface as `row_stride == None` or
5426    /// `effective_row_stride == 2000`.
5427    #[test]
5428    #[cfg(target_os = "linux")]
5429    fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5430        use crate::test_support::is_dma_available;
5431        if !is_dma_available() {
5432            crate::test_support::report_skip(
5433                "test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available",
5434            );
5435            return;
5436        }
5437        // Widths that force a non-64-aligned natural RGBA pitch. All three
5438        // are divisible by 4 so the EGL width-alignment pre-check passes.
5439        // The pitch-padding fix is what makes these importable at all.
5440        for &w in &[500u32, 612, 428] {
5441            let jpeg = make_rgb_jpeg(w, 333);
5442            let loaded =
5443                crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5444            let natural = (w as usize) * 4;
5445            let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5446            assert!(
5447                aligned > natural,
5448                "test sanity: width {w} should be unaligned"
5449            );
5450            let stride = loaded
5451                .row_stride()
5452                .expect("padded DMA path must set an explicit row_stride — regression if None");
5453            assert_eq!(
5454                stride, aligned,
5455                "width {w}: expected padded stride {aligned}, got {stride} \
5456                 (regression: pitch-padding branch skipped?)"
5457            );
5458            let eff = loaded.effective_row_stride().unwrap();
5459            assert_eq!(
5460                eff, aligned,
5461                "effective_row_stride must match stored stride"
5462            );
5463            assert_eq!(loaded.width(), Some(w as usize));
5464            assert_eq!(loaded.height(), Some(333));
5465        }
5466    }
5467
5468    /// `padded_dma_pitch_for` must respect the caller's memory choice and
5469    /// must NOT route into the pitch-padded DMA path when the caller left
5470    /// the choice to the allocator (`None`) but DMA is unavailable on the
5471    /// host. The padded path requires `image_with_stride`, which always
5472    /// allocates DMA — taking it on a system without `/dev/dma_heap`
5473    /// would convert a normally-working image load into a hard failure
5474    /// (since `Tensor::image(..., None)` would have fallen back to
5475    /// SHM/Mem).
5476    #[test]
5477    #[cfg(target_os = "linux")]
5478    fn test_padded_dma_pitch_for_respects_memory_choice() {
5479        use crate::test_support::is_dma_available;
5480        use edgefirst_tensor::TensorMemory;
5481
5482        // 500×4 = 2000 → padded to 2048 by GPU alignment. Use it for
5483        // every case so any "no padding" answer is unambiguous.
5484        let unaligned_w = 500;
5485
5486        // Caller asks for Mem / Shm: never pad, regardless of DMA.
5487        assert_eq!(
5488            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5489            None,
5490            "Mem must never trigger DMA padding"
5491        );
5492        assert_eq!(
5493            crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5494            None,
5495            "Shm must never trigger DMA padding"
5496        );
5497
5498        // Caller explicitly asks for DMA: always pad if width needs it.
5499        // Even if the runtime can't actually allocate DMA, the caller
5500        // owns that decision and the resulting allocation error is
5501        // their problem, not ours.
5502        assert_eq!(
5503            crate::padded_dma_pitch_for(
5504                PixelFormat::Rgba,
5505                unaligned_w,
5506                &Some(TensorMemory::DmaBuf),
5507            ),
5508            Some(2048),
5509            "explicit Dma must pad regardless of runtime DMA availability"
5510        );
5511
5512        // Caller leaves it to the allocator: behaviour depends on
5513        // host-runtime DMA availability. This is the case the fix
5514        // guards against.
5515        let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5516        if is_dma_available() {
5517            assert_eq!(
5518                none_result,
5519                Some(2048),
5520                "memory=None + DMA available → pad (will route through DMA)"
5521            );
5522        } else {
5523            assert_eq!(
5524                none_result, None,
5525                "memory=None + DMA unavailable → must NOT pad (would force \
5526                 image_with_stride into a DMA-only allocation that fails). \
5527                 Regression: padded_dma_pitch_for ignored is_dma_available()."
5528            );
5529        }
5530    }
5531
5532    // Synthesise a small greyscale PNG in memory at `(width, height)` with a
5533    // deterministic ramp pattern so multiple tests can cross-check output
5534    // without bundling an extra fixture file.
5535    fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5536        let mut bytes = Vec::with_capacity((width * height) as usize);
5537        for y in 0..height {
5538            for x in 0..width {
5539                bytes.push(((x + y) & 0xFF) as u8);
5540            }
5541        }
5542        let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5543        let mut buf = Vec::new();
5544        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5545            .unwrap();
5546        buf
5547    }
5548
5549    /// Greyscale PNG with a width that forces a pitch-misaligned natural
5550    /// row stride (612 bytes is not a multiple of the 64-byte GPU pitch
5551    /// alignment) must still load via the pitch-padded DMA path. Gated on
5552    /// DMA availability because `image_with_stride` is DMA-only.
5553    #[test]
5554    #[cfg(target_os = "linux")]
5555    fn test_load_png_grey_misaligned_width_dma() {
5556        use crate::test_support::is_dma_available;
5557        if !is_dma_available() {
5558            crate::test_support::report_skip(
5559                "test_load_png_grey_misaligned_width_dma — DMA not available",
5560            );
5561            return;
5562        }
5563        let png = make_grey_png(612, 388);
5564        let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5565        assert_eq!(loaded.width(), Some(612));
5566        assert_eq!(loaded.height(), Some(388));
5567        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5568
5569        // Round-trip pixels — natural-pitch DMA-BUFs pad the stride so we
5570        // must indirect through row_stride() rather than assume width.
5571        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5572        let stride = loaded.row_stride().unwrap_or(612);
5573        assert!(stride >= 612);
5574        let bytes: &[u8] = &map;
5575        for y in 0..388usize {
5576            for x in 0..612usize {
5577                let expected = ((x + y) & 0xFF) as u8;
5578                let got = bytes[y * stride + x];
5579                assert_eq!(
5580                    got, expected,
5581                    "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5582                );
5583            }
5584        }
5585    }
5586
5587    /// Greyscale PNG loaded with explicit Mem backing — runs on any
5588    /// platform (no DMA permission requirement) and covers the
5589    /// decoder-native Luma → Grey no-conversion path.
5590    #[test]
5591    fn test_load_png_grey_mem() {
5592        use edgefirst_tensor::TensorMemory;
5593        let png = make_grey_png(612, 100);
5594        let loaded =
5595            crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5596                .unwrap();
5597        assert_eq!(loaded.width(), Some(612));
5598        assert_eq!(loaded.height(), Some(100));
5599        assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5600        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5601        let bytes: &[u8] = &map;
5602        // Mem allocation uses the natural pitch — 612 bytes per row, exact.
5603        assert_eq!(bytes.len(), 612 * 100);
5604        for y in 0..100 {
5605            for x in 0..612 {
5606                assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5607            }
5608        }
5609    }
5610
5611    /// Greyscale PNG decoded into RGB — exercises the decoder-colorspace
5612    /// mismatch path (Luma → Rgb via CPU converter). Uses Mem memory to
5613    /// stay portable to host-side test environments.
5614    #[test]
5615    fn test_load_png_grey_to_rgb_mem() {
5616        use edgefirst_tensor::TensorMemory;
5617        let png = make_grey_png(620, 240);
5618        let loaded =
5619            crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5620                .unwrap();
5621        assert_eq!(loaded.width(), Some(620));
5622        assert_eq!(loaded.height(), Some(240));
5623        assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5624
5625        // Greyscale promoted to RGB replicates luma into each channel.
5626        let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5627        let bytes: &[u8] = &map;
5628        for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5629            let expected = ((x + y) & 0xFF) as u8;
5630            let off = (y * 620 + x) * 3;
5631            assert_eq!(bytes[off], expected, "R@{x},{y}");
5632            assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5633            assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5634        }
5635    }
5636
5637    #[test]
5638    #[cfg(target_os = "linux")]
5639    fn test_g2d_resize() {
5640        if !is_g2d_available() {
5641            crate::test_support::report_skip(
5642                "test_g2d_resize - G2D library (libg2d.so.2) not available",
5643            );
5644            return;
5645        }
5646        if !is_dma_available() {
5647            crate::test_support::report_skip("test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5648            return;
5649        }
5650
5651        let dst_width = 640;
5652        let dst_height = 360;
5653        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5654        let src = crate::load_image_test_helper(
5655            &file,
5656            Some(PixelFormat::Rgba),
5657            Some(TensorMemory::DmaBuf),
5658        )
5659        .unwrap();
5660
5661        let g2d_dst = TensorDyn::image(
5662            dst_width,
5663            dst_height,
5664            PixelFormat::Rgba,
5665            DType::U8,
5666            Some(TensorMemory::DmaBuf),
5667            edgefirst_tensor::CpuAccess::ReadWrite,
5668        )
5669        .unwrap();
5670        let mut g2d_converter = G2DProcessor::new().unwrap();
5671        let (result, src, g2d_dst) = convert_img(
5672            &mut g2d_converter,
5673            src,
5674            g2d_dst,
5675            Rotation::None,
5676            Flip::None,
5677            Crop::no_crop(),
5678        );
5679        result.unwrap();
5680
5681        let cpu_dst = TensorDyn::image(
5682            dst_width,
5683            dst_height,
5684            PixelFormat::Rgba,
5685            DType::U8,
5686            None,
5687            edgefirst_tensor::CpuAccess::ReadWrite,
5688        )
5689        .unwrap();
5690        let mut cpu_converter = CPUProcessor::new();
5691        let (result, _src, cpu_dst) = convert_img(
5692            &mut cpu_converter,
5693            src,
5694            cpu_dst,
5695            Rotation::None,
5696            Flip::None,
5697            Crop::no_crop(),
5698        );
5699        result.unwrap();
5700
5701        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5702        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5703        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5704        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5705        // structural gap not exercised by these limited-range fixtures.
5706        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5707    }
5708
5709    #[test]
5710    #[cfg(any(
5711        target_os = "linux",
5712        target_os = "macos",
5713        target_os = "ios",
5714        target_os = "android",
5715        target_os = "windows"
5716    ))]
5717    #[cfg(feature = "opengl")]
5718    fn test_opengl_resize() {
5719        if !is_opengl_available() {
5720            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5721            return;
5722        }
5723
5724        let dst_width = 640;
5725        let dst_height = 360;
5726        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5727        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5728
5729        let cpu_dst = TensorDyn::image(
5730            dst_width,
5731            dst_height,
5732            PixelFormat::Rgba,
5733            DType::U8,
5734            None,
5735            edgefirst_tensor::CpuAccess::ReadWrite,
5736        )
5737        .unwrap();
5738        let mut cpu_converter = CPUProcessor::new();
5739        let (result, src, cpu_dst) = convert_img(
5740            &mut cpu_converter,
5741            src,
5742            cpu_dst,
5743            Rotation::None,
5744            Flip::None,
5745            Crop::no_crop(),
5746        );
5747        result.unwrap();
5748
5749        let mut src = src;
5750        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5751
5752        for _ in 0..5 {
5753            let gl_dst = TensorDyn::image(
5754                dst_width,
5755                dst_height,
5756                PixelFormat::Rgba,
5757                DType::U8,
5758                None,
5759                edgefirst_tensor::CpuAccess::ReadWrite,
5760            )
5761            .unwrap();
5762            let (result, src_back, gl_dst) = convert_img(
5763                &mut gl_converter,
5764                src,
5765                gl_dst,
5766                Rotation::None,
5767                Flip::None,
5768                Crop::no_crop(),
5769            );
5770            result.unwrap();
5771            src = src_back;
5772
5773            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5774        }
5775    }
5776
5777    #[test]
5778    #[cfg(any(
5779        target_os = "linux",
5780        target_os = "macos",
5781        target_os = "ios",
5782        target_os = "android",
5783        target_os = "windows"
5784    ))]
5785    #[cfg(feature = "opengl")]
5786    fn test_opengl_10_threads() {
5787        if !is_opengl_available() {
5788            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5789            return;
5790        }
5791
5792        let handles: Vec<_> = (0..10)
5793            .map(|i| {
5794                std::thread::Builder::new()
5795                    .name(format!("Thread {i}"))
5796                    .spawn(test_opengl_resize)
5797                    .unwrap()
5798            })
5799            .collect();
5800        handles.into_iter().for_each(|h| {
5801            if let Err(e) = h.join() {
5802                std::panic::resume_unwind(e)
5803            }
5804        });
5805    }
5806
5807    #[test]
5808    #[cfg(any(
5809        target_os = "linux",
5810        target_os = "macos",
5811        target_os = "ios",
5812        target_os = "android",
5813        target_os = "windows"
5814    ))]
5815    #[cfg(feature = "opengl")]
5816    fn test_opengl_grey() {
5817        if !is_opengl_available() {
5818            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
5819            return;
5820        }
5821
5822        let img = crate::load_image_test_helper(
5823            &edgefirst_bench::testdata::read("grey.jpg"),
5824            Some(PixelFormat::Grey),
5825            None,
5826        )
5827        .unwrap();
5828
5829        let gl_dst = TensorDyn::image(
5830            640,
5831            640,
5832            PixelFormat::Grey,
5833            DType::U8,
5834            None,
5835            edgefirst_tensor::CpuAccess::ReadWrite,
5836        )
5837        .unwrap();
5838        let cpu_dst = TensorDyn::image(
5839            640,
5840            640,
5841            PixelFormat::Grey,
5842            DType::U8,
5843            None,
5844            edgefirst_tensor::CpuAccess::ReadWrite,
5845        )
5846        .unwrap();
5847
5848        let mut converter = CPUProcessor::new();
5849
5850        let (result, img, cpu_dst) = convert_img(
5851            &mut converter,
5852            img,
5853            cpu_dst,
5854            Rotation::None,
5855            Flip::None,
5856            Crop::no_crop(),
5857        );
5858        result.unwrap();
5859
5860        let mut gl = GLProcessorThreaded::new(None).unwrap();
5861        let (result, _img, gl_dst) = convert_img(
5862            &mut gl,
5863            img,
5864            gl_dst,
5865            Rotation::None,
5866            Flip::None,
5867            Crop::no_crop(),
5868        );
5869        result.unwrap();
5870
5871        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5872    }
5873
5874    #[test]
5875    #[cfg(target_os = "linux")]
5876    fn test_g2d_src_crop() {
5877        if !is_g2d_available() {
5878            crate::test_support::report_skip(
5879                "test_g2d_src_crop - G2D library (libg2d.so.2) not available",
5880            );
5881            return;
5882        }
5883        if !is_dma_available() {
5884            crate::test_support::report_skip("test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5885            return;
5886        }
5887
5888        let dst_width = 640;
5889        let dst_height = 640;
5890        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5891        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5892
5893        let cpu_dst = TensorDyn::image(
5894            dst_width,
5895            dst_height,
5896            PixelFormat::Rgba,
5897            DType::U8,
5898            None,
5899            edgefirst_tensor::CpuAccess::ReadWrite,
5900        )
5901        .unwrap();
5902        let mut cpu_converter = CPUProcessor::new();
5903        let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5904        let (result, src, cpu_dst) = convert_img(
5905            &mut cpu_converter,
5906            src,
5907            cpu_dst,
5908            Rotation::None,
5909            Flip::None,
5910            crop,
5911        );
5912        result.unwrap();
5913
5914        let g2d_dst = TensorDyn::image(
5915            dst_width,
5916            dst_height,
5917            PixelFormat::Rgba,
5918            DType::U8,
5919            None,
5920            edgefirst_tensor::CpuAccess::ReadWrite,
5921        )
5922        .unwrap();
5923        let mut g2d_converter = G2DProcessor::new().unwrap();
5924        let (result, _src, g2d_dst) = convert_img(
5925            &mut g2d_converter,
5926            src,
5927            g2d_dst,
5928            Rotation::None,
5929            Flip::None,
5930            crop,
5931        );
5932        result.unwrap();
5933
5934        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
5935        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
5936        // the YUV-matrix delta that forced 0.95 has closed; tightened to
5937        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
5938        // structural gap not exercised by these limited-range fixtures.
5939        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5940    }
5941
5942    #[test]
5943    #[cfg(target_os = "linux")]
5944    fn test_g2d_dst_crop() {
5945        if !is_g2d_available() {
5946            crate::test_support::report_skip(
5947                "test_g2d_dst_crop - G2D library (libg2d.so.2) not available",
5948            );
5949            return;
5950        }
5951        if !is_dma_available() {
5952            crate::test_support::report_skip("test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)");
5953            return;
5954        }
5955
5956        let dst_width = 640;
5957        let dst_height = 640;
5958        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5959        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5960
5961        let cpu_dst = TensorDyn::image(
5962            dst_width,
5963            dst_height,
5964            PixelFormat::Rgba,
5965            DType::U8,
5966            None,
5967            edgefirst_tensor::CpuAccess::ReadWrite,
5968        )
5969        .unwrap();
5970        let mut cpu_converter = CPUProcessor::new();
5971        let crop = Crop::new();
5972        let (result, src, cpu_dst) = convert_img(
5973            &mut cpu_converter,
5974            src,
5975            cpu_dst,
5976            Rotation::None,
5977            Flip::None,
5978            crop,
5979        );
5980        result.unwrap();
5981
5982        let g2d_dst = TensorDyn::image(
5983            dst_width,
5984            dst_height,
5985            PixelFormat::Rgba,
5986            DType::U8,
5987            None,
5988            edgefirst_tensor::CpuAccess::ReadWrite,
5989        )
5990        .unwrap();
5991        let mut g2d_converter = G2DProcessor::new().unwrap();
5992        let (result, _src, g2d_dst) = convert_img(
5993            &mut g2d_converter,
5994            src,
5995            g2d_dst,
5996            Rotation::None,
5997            Flip::None,
5998            crop,
5999        );
6000        result.unwrap();
6001
6002        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
6003        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
6004        // the YUV-matrix delta that forced 0.95 has closed; tightened to
6005        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
6006        // structural gap not exercised by these limited-range fixtures.
6007        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6008    }
6009
6010    #[test]
6011    #[cfg(target_os = "linux")]
6012    fn test_g2d_all_rgba() {
6013        if !is_g2d_available() {
6014            crate::test_support::report_skip(
6015                "test_g2d_all_rgba - G2D library (libg2d.so.2) not available",
6016            );
6017            return;
6018        }
6019        if !is_dma_available() {
6020            crate::test_support::report_skip("test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)");
6021            return;
6022        }
6023
6024        let dst_width = 640;
6025        let dst_height = 640;
6026        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6027        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6028        let src_dyn = src;
6029
6030        let mut cpu_dst = TensorDyn::image(
6031            dst_width,
6032            dst_height,
6033            PixelFormat::Rgba,
6034            DType::U8,
6035            None,
6036            edgefirst_tensor::CpuAccess::ReadWrite,
6037        )
6038        .unwrap();
6039        let mut cpu_converter = CPUProcessor::new();
6040        let mut g2d_dst = TensorDyn::image(
6041            dst_width,
6042            dst_height,
6043            PixelFormat::Rgba,
6044            DType::U8,
6045            None,
6046            edgefirst_tensor::CpuAccess::ReadWrite,
6047        )
6048        .unwrap();
6049        let mut g2d_converter = G2DProcessor::new().unwrap();
6050
6051        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
6052
6053        for rot in [
6054            Rotation::None,
6055            Rotation::Clockwise90,
6056            Rotation::Rotate180,
6057            Rotation::CounterClockwise90,
6058        ] {
6059            cpu_dst
6060                .as_typed::<u8>()
6061                .unwrap()
6062                .map()
6063                .unwrap()
6064                .as_mut_slice()
6065                .fill(114);
6066            g2d_dst
6067                .as_typed::<u8>()
6068                .unwrap()
6069                .map()
6070                .unwrap()
6071                .as_mut_slice()
6072                .fill(114);
6073            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
6074                let mut cpu_dst_dyn = cpu_dst;
6075                cpu_converter
6076                    .convert(
6077                        &src_dyn,
6078                        &mut cpu_dst_dyn,
6079                        Some(crate::ConvertOptions::new().crop(crop)),
6080                    )
6081                    .unwrap();
6082                cpu_dst = {
6083                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
6084                    __t.set_format(PixelFormat::Rgba).unwrap();
6085                    TensorDyn::from(__t)
6086                };
6087
6088                let mut g2d_dst_dyn = g2d_dst;
6089                g2d_converter
6090                    .convert(
6091                        &src_dyn,
6092                        &mut g2d_dst_dyn,
6093                        Some(crate::ConvertOptions::new().crop(crop)),
6094                    )
6095                    .unwrap();
6096                g2d_dst = {
6097                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
6098                    __t.set_format(PixelFormat::Rgba).unwrap();
6099                    TensorDyn::from(__t)
6100                };
6101
6102                compare_images(
6103                    &g2d_dst,
6104                    &cpu_dst,
6105                    0.98,
6106                    &format!("{} {:?} {:?}", function!(), rot, flip),
6107                );
6108            }
6109        }
6110    }
6111
6112    #[test]
6113    #[cfg(any(
6114        target_os = "linux",
6115        target_os = "macos",
6116        target_os = "ios",
6117        target_os = "android",
6118        target_os = "windows"
6119    ))]
6120    #[cfg(feature = "opengl")]
6121    fn test_opengl_src_crop() {
6122        if !is_opengl_available() {
6123            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6124            return;
6125        }
6126
6127        let dst_width = 640;
6128        let dst_height = 360;
6129        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6130        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6131        let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
6132
6133        let cpu_dst = TensorDyn::image(
6134            dst_width,
6135            dst_height,
6136            PixelFormat::Rgba,
6137            DType::U8,
6138            None,
6139            edgefirst_tensor::CpuAccess::ReadWrite,
6140        )
6141        .unwrap();
6142        let mut cpu_converter = CPUProcessor::new();
6143        let (result, src, cpu_dst) = convert_img(
6144            &mut cpu_converter,
6145            src,
6146            cpu_dst,
6147            Rotation::None,
6148            Flip::None,
6149            crop,
6150        );
6151        result.unwrap();
6152
6153        let gl_dst = TensorDyn::image(
6154            dst_width,
6155            dst_height,
6156            PixelFormat::Rgba,
6157            DType::U8,
6158            None,
6159            edgefirst_tensor::CpuAccess::ReadWrite,
6160        )
6161        .unwrap();
6162        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6163        let (result, _src, gl_dst) = convert_img(
6164            &mut gl_converter,
6165            src,
6166            gl_dst,
6167            Rotation::None,
6168            Flip::None,
6169            crop,
6170        );
6171        result.unwrap();
6172
6173        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6174    }
6175
6176    #[test]
6177    #[cfg(any(
6178        target_os = "linux",
6179        target_os = "macos",
6180        target_os = "ios",
6181        target_os = "android",
6182        target_os = "windows"
6183    ))]
6184    #[cfg(feature = "opengl")]
6185    fn test_opengl_dst_crop() {
6186        if !is_opengl_available() {
6187            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6188            return;
6189        }
6190
6191        let dst_width = 640;
6192        let dst_height = 640;
6193        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6194        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
6195
6196        let cpu_dst = TensorDyn::image(
6197            dst_width,
6198            dst_height,
6199            PixelFormat::Rgba,
6200            DType::U8,
6201            None,
6202            edgefirst_tensor::CpuAccess::ReadWrite,
6203        )
6204        .unwrap();
6205        let mut cpu_converter = CPUProcessor::new();
6206        let crop = Crop::new();
6207        let (result, src, cpu_dst) = convert_img(
6208            &mut cpu_converter,
6209            src,
6210            cpu_dst,
6211            Rotation::None,
6212            Flip::None,
6213            crop,
6214        );
6215        result.unwrap();
6216
6217        let gl_dst = TensorDyn::image(
6218            dst_width,
6219            dst_height,
6220            PixelFormat::Rgba,
6221            DType::U8,
6222            None,
6223            edgefirst_tensor::CpuAccess::ReadWrite,
6224        )
6225        .unwrap();
6226        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6227        let (result, _src, gl_dst) = convert_img(
6228            &mut gl_converter,
6229            src,
6230            gl_dst,
6231            Rotation::None,
6232            Flip::None,
6233            crop,
6234        );
6235        result.unwrap();
6236
6237        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6238    }
6239    /// A deterministic RGBA8 `Mem` source: `R = x`, `G = y`, `B = x + y`,
6240    /// `A = 255`, each `& 255`. Written at the tensor's own row stride so a
6241    /// padded allocation still gets the pattern at the right columns.
6242    #[cfg(all(target_os = "windows", feature = "opengl"))]
6243    fn synthetic_rgba(w: usize, h: usize) -> TensorDyn {
6244        let t = TensorDyn::image(
6245            w,
6246            h,
6247            PixelFormat::Rgba,
6248            DType::U8,
6249            Some(TensorMemory::Mem),
6250            edgefirst_tensor::CpuAccess::ReadWrite,
6251        )
6252        .unwrap();
6253        fill_synthetic_rgba(&t);
6254        t
6255    }
6256
6257    /// Every pixel of `actual` equals `expected`, compared through the
6258    /// tensors' own row compaction so a padded texture and a tight `Mem`
6259    /// image compare as images. `compare_images` scores similarity and
6260    /// accepts anything at a 0.0 threshold; this is the exact check.
6261    #[cfg(all(target_os = "windows", feature = "opengl"))]
6262    fn assert_pixels_identical(actual: &TensorDyn, expected: &TensorDyn, name: &str) {
6263        assert_eq!(actual.shape(), expected.shape(), "{name}: shapes differ");
6264        let flat = |t: &TensorDyn| {
6265            let typed = t.as_typed::<u8>().expect("u8 image");
6266            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6267            typed.copy_to_flat(&mut out).expect("compact the rows");
6268            out
6269        };
6270        let (a, e) = (flat(actual), flat(expected));
6271        let mismatches = a.iter().zip(&e).filter(|(x, y)| x != y).count();
6272        assert_eq!(
6273            mismatches,
6274            0,
6275            "{name}: {mismatches} of {} bytes differ (first rows: {:?} vs {:?})",
6276            a.len(),
6277            &a[..16.min(a.len())],
6278            &e[..16.min(e.len())]
6279        );
6280    }
6281
6282    /// Write the `synthetic_rgba` pattern into any writable RGBA8 tensor,
6283    /// a texture included, at its own row stride.
6284    #[cfg(all(target_os = "windows", feature = "opengl"))]
6285    fn fill_synthetic_rgba(t: &TensorDyn) {
6286        let w = t.width().unwrap();
6287        let h = t.height().unwrap();
6288        let bound = t.as_typed::<u8>().unwrap();
6289        let stride = bound.effective_row_stride().unwrap_or(w * 4);
6290        let mut m = bound.map().unwrap();
6291        let px = m.as_mut_slice();
6292        for y in 0..h {
6293            for x in 0..w {
6294                let o = y * stride + x * 4;
6295                px[o] = x as u8;
6296                px[o + 1] = y as u8;
6297                px[o + 2] = (x + y) as u8;
6298                px[o + 3] = 255;
6299            }
6300        }
6301    }
6302
6303    /// Element-wise comparison of two float images of the same format and
6304    /// dtype.
6305    ///
6306    /// Both sides are read through `copy_to_flat`, the tensor's own row
6307    /// compaction, for the reason `compare_images` does: a Windows texture
6308    /// destination pads its rows, so reading the head of the mapping as a
6309    /// tight image would read padding as values. F16 values are widened with
6310    /// `half::f16::to_f64` before the comparison.
6311    #[cfg(all(target_os = "windows", feature = "opengl"))]
6312    fn compare_float_images(a: &TensorDyn, b: &TensorDyn, tol: f64, what: &str) {
6313        assert_eq!(a.format(), b.format(), "{what}: pixel formats differ");
6314        assert_eq!(a.dtype(), b.dtype(), "{what}: dtypes differ");
6315        assert_eq!(a.shape(), b.shape(), "{what}: shapes differ");
6316        let values = |t: &TensorDyn| -> Vec<f64> {
6317            let elems: usize = t.shape().iter().product();
6318            match t.dtype() {
6319                DType::F32 => {
6320                    let typed = t.as_typed::<f32>().expect("F32 image");
6321                    let mut bytes = vec![0u8; elems * 4];
6322                    typed
6323                        .copy_to_flat(&mut bytes)
6324                        .expect("compact the padded rows");
6325                    bytes
6326                        .as_chunks::<4>()
6327                        .0
6328                        .iter()
6329                        .map(|c| f32::from_le_bytes(*c) as f64)
6330                        .collect()
6331                }
6332                DType::F16 => {
6333                    let typed = t.as_typed::<half::f16>().expect("F16 image");
6334                    let mut bytes = vec![0u8; elems * 2];
6335                    typed
6336                        .copy_to_flat(&mut bytes)
6337                        .expect("compact the padded rows");
6338                    bytes
6339                        .as_chunks::<2>()
6340                        .0
6341                        .iter()
6342                        .map(|c| half::f16::from_le_bytes(*c).to_f64())
6343                        .collect()
6344                }
6345                other => panic!("{what}: {other:?} is not a float dtype"),
6346            }
6347        };
6348        let (av, bv) = (values(a), values(b));
6349        assert_eq!(av.len(), bv.len(), "{what}: element counts differ");
6350        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
6351            assert!(
6352                (x - y).abs() <= tol,
6353                "{what}: element {i} differs by {} (gpu {x}, cpu {y}, tol {tol})",
6354                (x - y).abs()
6355            );
6356        }
6357    }
6358
6359    /// The float PBO readback has to land each destination row at the
6360    /// destination's own pitch, exactly as the u8 PBO readback does.
6361    ///
6362    /// The packed render target's rows ARE the destination's rows -- `(W/4,
6363    /// 3H)` RGBA16F texels, one plane row per surface row, for
6364    /// `PlanarRgb`/F16; `(W*3, H)` R32F texels, one image row per surface
6365    /// row, for `Rgb`/F32 -- and the read was tight whatever pitch the
6366    /// destination declared. A pool tensor narrowed with `configure_image`
6367    /// that keeps the pool's pitch is exactly that destination, so every row
6368    /// after the first landed short.
6369    ///
6370    /// Both routes the pitch can take are covered. `POOL_W`'s pitch is a
6371    /// whole number of texels for both formats, which `GL_PACK_ROW_LENGTH`
6372    /// expresses; the F16 case adds a 4-byte pad, which an 8-byte texel does
6373    /// not divide, so those rows are read tight and spread afterwards. The
6374    /// tight stride is the control: it must still take the read it always
6375    /// did.
6376    ///
6377    /// The oracle is the same convert into a tight PBO of the same geometry.
6378    /// `compare_float_images` reads both sides through `copy_to_flat`, the
6379    /// tensor's own row compaction, so it compares logical elements and not
6380    /// mapping offsets.
6381    #[test]
6382    #[cfg(all(target_os = "windows", feature = "opengl"))]
6383    fn windows_padded_float_pbo_rows_land_at_the_declared_stride() {
6384        if !is_opengl_available() {
6385            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6386            return;
6387        }
6388        const W: usize = 64;
6389        const H: usize = 32;
6390        const POOL_W: usize = 96;
6391
6392        let mut gpu = ImageProcessor::new().unwrap();
6393        let support = gpu.supported_render_dtypes();
6394        let src = synthetic_rgba(W, H);
6395
6396        // (format, dtype, supported, the pitches to declare)
6397        let cases: [(PixelFormat, DType, bool, &[usize]); 2] = [
6398            (
6399                PixelFormat::PlanarRgb,
6400                DType::F16,
6401                support.f16,
6402                // Tight plane row, the pool's pitch (a whole texel count),
6403                // and a 4-byte pad that no 8-byte texel divides.
6404                &[W * 2, POOL_W * 2, W * 2 + 4],
6405            ),
6406            (
6407                PixelFormat::Rgb,
6408                DType::F32,
6409                support.f32,
6410                &[W * 3 * 4, POOL_W * 3 * 4],
6411            ),
6412        ];
6413
6414        for (fmt, dtype, supported, strides) in cases {
6415            if !supported {
6416                crate::test_support::report_skip(&format!(
6417                    "{} - {fmt:?}/{dtype:?} not reported",
6418                    function!()
6419                ));
6420                continue;
6421            }
6422            let Some(gl) = gpu.opengl.as_ref() else {
6423                crate::test_support::report_skip(&format!("{} - no GL processor", function!()));
6424                return;
6425            };
6426            // The oracle: a tight PBO of the target geometry, same route.
6427            let mut reference = gl
6428                .create_pbo_image_dtype(W, H, fmt, dtype)
6429                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight reference PBO: {e:?}"));
6430            assert_eq!(
6431                reference.memory(),
6432                TensorMemory::Pbo,
6433                "{fmt:?}/{dtype:?}: the destination must be a PBO to reach this readback"
6434            );
6435            gpu.convert(&src, &mut reference, None)
6436                .unwrap_or_else(|e| panic!("{fmt:?}/{dtype:?}: tight convert failed: {e}"));
6437
6438            for &stride in strides {
6439                let label = format!("{fmt:?}/{dtype:?} at a {stride} B pitch");
6440                // Allocate at the pool width, then narrow the geometry and
6441                // declare the pitch, as the u8 stride tests do.
6442                let mut padded = gpu
6443                    .opengl
6444                    .as_ref()
6445                    .expect("GL processor")
6446                    .create_pbo_image_dtype(POOL_W, H, fmt, dtype)
6447                    .unwrap_or_else(|e| panic!("{label}: pool PBO: {e:?}"));
6448                padded
6449                    .configure_image(W, H, fmt)
6450                    .unwrap_or_else(|e| panic!("{label}: narrow the pool tensor: {e}"));
6451                padded
6452                    .set_row_stride(stride)
6453                    .unwrap_or_else(|e| panic!("{label}: declare the pitch: {e}"));
6454                assert_eq!(padded.effective_row_stride(), Some(stride), "{label}");
6455
6456                let before = gpu.convert_fallback_count();
6457                gpu.convert(&src, &mut padded, None)
6458                    .unwrap_or_else(|e| panic!("{label}: padded convert failed: {e}"));
6459                // A CPU fallback writes the destination with the stride-aware
6460                // CPU writers and would pass without the GL readback running.
6461                assert_eq!(
6462                    gpu.convert_fallback_count(),
6463                    before,
6464                    "{label}: fell back to the CPU backend, so the float PBO readback under test never ran"
6465                );
6466
6467                let tol = if dtype == DType::F16 {
6468                    1.0 / 1024.0
6469                } else {
6470                    1e-6
6471                };
6472                compare_float_images(&padded, &reference, tol, &label);
6473            }
6474        }
6475    }
6476
6477    /// Every float destination layout the GL backend reports it can render
6478    /// must render zero-copy into the texture and match the CPU converter
6479    /// element for element: F16 and F32 planar (three and four planes),
6480    /// interleaved float RGB, and float RGBA.
6481    ///
6482    /// The destination memory is requested explicitly so each layout is
6483    /// exercised as a texture whatever `create_image`'s own allocation
6484    /// policy would pick for it; the policy itself is covered by
6485    /// `windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event`.
6486    #[test]
6487    #[cfg(all(target_os = "windows", feature = "opengl"))]
6488    fn windows_float_destinations_match_the_cpu_converter_for_every_layout() {
6489        if !is_opengl_available() {
6490            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
6491            return;
6492        }
6493        if !crate::test_support::is_gpu_buffer_available() {
6494            crate::test_support::report_skip(&format!(
6495                "{} - no zero-copy GPU buffers",
6496                function!()
6497            ));
6498            return;
6499        }
6500        let mut gpu = ImageProcessor::new().unwrap();
6501        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6502            backend: ComputeBackend::Cpu,
6503            ..Default::default()
6504        })
6505        .unwrap();
6506        let support = gpu.supported_render_dtypes();
6507        eprintln!("{}: supported_render_dtypes = {support:?}", function!());
6508        let src = synthetic_rgba(640, 480);
6509        for (fmt, dtype, need) in [
6510            (PixelFormat::PlanarRgb, DType::F16, support.f16),
6511            (PixelFormat::PlanarRgb, DType::F32, support.f32),
6512            (PixelFormat::PlanarRgba, DType::F16, support.f16),
6513            (PixelFormat::PlanarRgba, DType::F32, support.f32),
6514            (PixelFormat::Rgb, DType::F16, support.f16),
6515            (PixelFormat::Rgb, DType::F32, support.f32),
6516            (PixelFormat::Rgba, DType::F16, support.f16),
6517            (PixelFormat::Rgba, DType::F32, support.f32),
6518        ] {
6519            if !need {
6520                crate::test_support::report_skip(&format!(
6521                    "{} - {fmt:?}/{dtype:?} not reported",
6522                    function!()
6523                ));
6524                continue;
6525            }
6526            let mut dst_gpu = gpu
6527                .create_image(
6528                    640,
6529                    480,
6530                    fmt,
6531                    dtype,
6532                    Some(TensorMemory::DmaBuf),
6533                    edgefirst_tensor::CpuAccess::ReadWrite,
6534                )
6535                .unwrap();
6536            assert_eq!(
6537                dst_gpu.memory(),
6538                TensorMemory::DmaBuf,
6539                "{fmt:?}/{dtype:?} must be a texture"
6540            );
6541            let mut dst_cpu = TensorDyn::image(
6542                640,
6543                480,
6544                fmt,
6545                dtype,
6546                Some(TensorMemory::Mem),
6547                edgefirst_tensor::CpuAccess::ReadWrite,
6548            )
6549            .unwrap();
6550            let before = gpu.convert_fallback_count();
6551            gpu.convert(&src, &mut dst_gpu, None).unwrap();
6552            cpu.convert(&src, &mut dst_cpu, None).unwrap();
6553            assert_eq!(
6554                gpu.convert_fallback_count(),
6555                before,
6556                "{fmt:?}/{dtype:?} fell back to CPU"
6557            );
6558            let tol = if dtype == DType::F16 {
6559                1.0 / 1024.0
6560            } else {
6561                1e-6
6562            };
6563            compare_float_images(&dst_gpu, &dst_cpu, tol, &format!("{fmt:?}/{dtype:?}"));
6564        }
6565    }
6566
6567    // The tests below wait on a Windows completion fence directly, so they
6568    // need the raw wait call — no crate in this workspace wraps it.
6569    #[cfg(target_os = "windows")]
6570    #[link(name = "kernel32")]
6571    extern "system" {
6572        fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32;
6573    }
6574
6575    /// A batch of deferred converts records one distinct fence value per
6576    /// tile and submits once, at `flush`.
6577    ///
6578    /// The values are what a GPU consumer waits on, so they have to stay
6579    /// per-destination and strictly increasing; the submission is what
6580    /// batching exists to spare, so there must be exactly one of it. The
6581    /// destinations are compared against the same convert done standalone,
6582    /// which is what proves the deferred submission did not lose a tile.
6583    #[cfg(all(target_os = "windows", feature = "opengl"))]
6584    #[test]
6585    fn windows_deferred_batch_records_one_value_per_tile_and_submits_once() {
6586        use edgefirst_tensor::CpuAccess;
6587
6588        if !crate::test_support::is_gpu_buffer_available() {
6589            crate::test_support::report_skip(&format!(
6590                "{} - no zero-copy GPU buffers",
6591                function!()
6592            ));
6593            return;
6594        }
6595        let mut p = match ImageProcessor::new() {
6596            Ok(p) => p,
6597            Err(e) => {
6598                crate::test_support::report_skip(&format!(
6599                    "{} - ImageProcessor init failed ({e:?})",
6600                    function!()
6601                ));
6602                return;
6603            }
6604        };
6605        let (w, h, tiles) = (640usize, 480usize, 4usize);
6606        let src = synthetic_rgba(w, h);
6607        let mut dsts: Vec<TensorDyn> = (0..tiles)
6608            .map(|_| {
6609                p.create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6610                    .unwrap()
6611            })
6612            .collect();
6613        if dsts[0].memory() != TensorMemory::DmaBuf {
6614            crate::test_support::report_skip(&format!(
6615                "{} - destinations are not textures",
6616                function!()
6617            ));
6618            return;
6619        }
6620        for dst in &mut dsts {
6621            p.convert_deferred(&src, dst, None).unwrap();
6622        }
6623        let values: Vec<u64> = dsts
6624            .iter()
6625            .map(|d| {
6626                d.gpu_completion()
6627                    .unwrap()
6628                    .expect("each deferred convert records its own completion")
6629                    .value
6630            })
6631            .collect();
6632        for pair in values.windows(2) {
6633            assert!(
6634                pair[1] > pair[0],
6635                "the batch allocated {values:?}, which is not one increasing value per tile"
6636            );
6637        }
6638        p.flush().unwrap();
6639        // The one submission the batch owed has happened, so every recorded
6640        // value is reachable.
6641        let dev = edgefirst_tensor::d3d11::device().unwrap();
6642        let last = *values.last().unwrap();
6643        let ev = dev.event_for(last).unwrap();
6644        // SAFETY: `ev` owns a live Win32 event handle for the duration of
6645        // this call.
6646        assert_eq!(
6647            unsafe {
6648                WaitForSingleObject(
6649                    std::os::windows::io::AsRawHandle::as_raw_handle(&ev) as _,
6650                    5000,
6651                )
6652            },
6653            0,
6654            "the batch's newest value {last} never arrived after flush"
6655        );
6656
6657        let mut solo = p
6658            .create_image(w, h, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6659            .unwrap();
6660        p.convert(&src, &mut solo, None).unwrap();
6661        // Read through the tensor's own compaction: a Windows texture's
6662        // staging rows are pitch-padded, so a flat read of the mapping would
6663        // compare padding as pixels.
6664        let flat = |img: &TensorDyn| -> Vec<u8> {
6665            let t = img.as_typed::<u8>().expect("u8 image");
6666            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
6667            t.copy_to_flat(&mut out).expect("compact the padded rows");
6668            out
6669        };
6670        let want = flat(&solo);
6671        for (i, dst) in dsts.iter().enumerate() {
6672            assert_eq!(
6673                flat(dst),
6674                want,
6675                "tile {i} does not match a standalone convert"
6676            );
6677        }
6678    }
6679
6680    /// A processor dropped with a batch still open submits what that batch
6681    /// recorded.
6682    ///
6683    /// The values are already published on destinations that outlive the
6684    /// processor, so a consumer may be waiting on one; a deferred signal that
6685    /// is never submitted is a wait nothing can satisfy. `flush` is what
6686    /// normally pays it, and the whole point here is that the caller never
6687    /// calls it.
6688    #[cfg(all(target_os = "windows", feature = "opengl"))]
6689    #[test]
6690    fn windows_dropping_a_processor_submits_an_open_batch() {
6691        use edgefirst_tensor::CpuAccess;
6692
6693        if !crate::test_support::is_gpu_buffer_available() {
6694            crate::test_support::report_skip(&format!(
6695                "{} - no zero-copy GPU buffers",
6696                function!()
6697            ));
6698            return;
6699        }
6700        let src = synthetic_rgba(320, 240);
6701        let recorded = {
6702            let mut p = match ImageProcessor::new() {
6703                Ok(p) => p,
6704                Err(e) => {
6705                    crate::test_support::report_skip(&format!(
6706                        "{} - init failed ({e:?})",
6707                        function!()
6708                    ));
6709                    return;
6710                }
6711            };
6712            let mut dst = p
6713                .create_image(
6714                    320,
6715                    240,
6716                    PixelFormat::Rgba,
6717                    DType::U8,
6718                    None,
6719                    CpuAccess::Read,
6720                )
6721                .unwrap();
6722            if dst.memory() != TensorMemory::DmaBuf {
6723                crate::test_support::report_skip(&format!(
6724                    "{} - destination is not a texture",
6725                    function!()
6726                ));
6727                return;
6728            }
6729            p.convert_deferred(&src, &mut dst, None).unwrap();
6730            // No `flush`: the drop below is what has to settle the batch.
6731            dst.gpu_completion()
6732                .unwrap()
6733                .expect("the deferred convert recorded a completion")
6734                .value
6735        };
6736
6737        let dev = edgefirst_tensor::d3d11::device().unwrap();
6738        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6739        while dev.completed_value() < recorded {
6740            assert!(
6741                std::time::Instant::now() < deadline,
6742                "the dropped processor never submitted its batch: fence at {}, recorded {recorded}",
6743                dev.completed_value()
6744            );
6745            std::thread::yield_now();
6746        }
6747    }
6748
6749    /// After Task B5, `create_image(.., None, ..)` reaches the Windows
6750    /// Dma-first arm (a D3D11 texture, not a PBO or `Mem` tensor), and
6751    /// `convert_with_fence` is available on every platform: on Windows it
6752    /// returns a manual-reset event set when the GPU work completes.
6753    #[cfg(all(target_os = "windows", feature = "opengl"))]
6754    #[test]
6755    fn windows_create_image_prefers_textures_and_convert_with_fence_returns_a_set_event() {
6756        use edgefirst_tensor::CpuAccess;
6757        use std::os::windows::io::AsRawHandle;
6758
6759        if !crate::test_support::is_gpu_buffer_available() {
6760            crate::test_support::report_skip(&format!(
6761                "{} - no zero-copy GPU buffers",
6762                function!()
6763            ));
6764            return;
6765        }
6766        let mut p = ImageProcessor::new().unwrap();
6767        let src = synthetic_rgba(320, 240);
6768        let mut dst = p
6769            .create_image(
6770                320,
6771                240,
6772                PixelFormat::Rgba,
6773                DType::U8,
6774                None,
6775                CpuAccess::Read,
6776            )
6777            .unwrap();
6778        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
6779        // answers host memory and there is no fence to wait on; the lanes
6780        // that set HAL_TEST_REQUIRE_GL=1 still hold the texture-first claim.
6781        if dst.memory() != TensorMemory::DmaBuf
6782            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
6783        {
6784            crate::test_support::report_skip(&format!(
6785                "{} - create_image gave {:?}, not a texture (no GL backend)",
6786                function!(),
6787                dst.memory()
6788            ));
6789            return;
6790        }
6791        assert_eq!(
6792            dst.memory(),
6793            TensorMemory::DmaBuf,
6794            "create_image(None) must prefer a texture on Windows"
6795        );
6796        // Read before the wait: the recorded value must be the one the fence
6797        // names, not an older point the destination happened to carry.
6798        let before = dst.gpu_completion().unwrap().map(|c| c.value);
6799        let fence = p
6800            .convert_with_fence(&src, &mut dst, None)
6801            .unwrap()
6802            .expect("Windows exports a fence");
6803        let recorded = dst
6804            .gpu_completion()
6805            .unwrap()
6806            .expect("the convert recorded a completion")
6807            .value;
6808        assert!(
6809            before.is_none_or(|b| recorded > b),
6810            "the fenced convert recorded a newer value than {before:?}"
6811        );
6812        // One flush and one signal for the whole call: the value the
6813        // destination carries is the newest the device has allocated, so
6814        // nothing was signalled after `record_completion`. Two signals (the
6815        // shape this replaced) would leave `last_signalled` one past it.
6816        assert_eq!(
6817            edgefirst_tensor::d3d11::device().unwrap().last_signalled(),
6818            recorded,
6819            "a fenced convert must signal exactly once"
6820        );
6821        // SAFETY: `fence` owns a live Win32 event handle returned by
6822        // `convert_with_fence`; WaitForSingleObject only reads the handle,
6823        // which stays valid for the duration of this call because `fence`
6824        // is not dropped until after it.
6825        assert_eq!(
6826            unsafe { WaitForSingleObject(fence.as_raw_handle() as _, 5000) },
6827            0,
6828            "fence was not set within 5 seconds"
6829        );
6830        // The event and the destination's own completion name the same
6831        // value, so a consumer may wait on either: the event fired, and the
6832        // device's completed value has reached the value `dst` reports.
6833        assert!(
6834            edgefirst_tensor::d3d11::device().unwrap().completed_value() >= recorded,
6835            "the event fired, so the recorded value {recorded} is complete"
6836        );
6837        assert_eq!(
6838            dst.gpu_completion().unwrap().map(|c| c.value),
6839            Some(recorded),
6840            "the destination still reports the value the event waited on"
6841        );
6842        let mut expected = TensorDyn::image(
6843            320,
6844            240,
6845            PixelFormat::Rgba,
6846            DType::U8,
6847            Some(TensorMemory::Mem),
6848            CpuAccess::ReadWrite,
6849        )
6850        .unwrap();
6851        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6852            backend: ComputeBackend::Cpu,
6853            ..Default::default()
6854        })
6855        .unwrap();
6856        cpu.convert(&src, &mut expected, None).unwrap();
6857        compare_images(&dst, &expected, 0.0, "fenced convert");
6858    }
6859
6860    /// A Windows GL test's destination must be a texture; without a GL backend
6861    /// (CI's no-ANGLE coverage pass) `create_image` answers host memory and the
6862    /// test skips, unless the lane set HAL_TEST_REQUIRE_GL=1, which keeps the
6863    /// texture-first claim a hard assertion.
6864    #[cfg(all(target_os = "windows", feature = "opengl"))]
6865    fn texture_or_skip(t: &TensorDyn, test: &str) -> bool {
6866        if t.memory() == TensorMemory::DmaBuf {
6867            return true;
6868        }
6869        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") {
6870            panic!(
6871                "{test}: create_image(None) must prefer a texture on Windows, got {:?}",
6872                t.memory()
6873            );
6874        }
6875        crate::test_support::report_skip(&format!(
6876            "{test} - create_image gave {:?}, not a texture (no GL backend)",
6877            t.memory()
6878        ));
6879        false
6880    }
6881
6882    /// A draw into a texture destination records a completion on it the way
6883    /// a convert does, so a device consumer can wait on the drawn frame. The
6884    /// destination is `CpuAccess::Read`: only the GL backend can write it,
6885    /// so a CPU fallback fails the draw outright instead of leaving the
6886    /// completion empty.
6887    #[cfg(all(target_os = "windows", feature = "opengl"))]
6888    #[test]
6889    fn windows_draw_decoded_masks_into_a_texture_records_a_completion() {
6890        use edgefirst_tensor::{CpuAccess, Segmentation};
6891        use ndarray::Array3;
6892
6893        if !crate::test_support::is_gpu_buffer_available() {
6894            crate::test_support::report_skip(&format!(
6895                "{} - no zero-copy GPU buffers",
6896                function!()
6897            ));
6898            return;
6899        }
6900        let mut p = ImageProcessor::new().unwrap();
6901        let mut dst = p
6902            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6903            .unwrap();
6904        if !texture_or_skip(&dst, function!()) {
6905            return;
6906        }
6907        assert!(
6908            dst.gpu_completion().unwrap().is_none(),
6909            "nothing has written this texture yet"
6910        );
6911
6912        let detect = DetectBox {
6913            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6914            score: 0.99,
6915            label: 0,
6916        };
6917        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
6918        let seg = Segmentation {
6919            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
6920                .unwrap()
6921                .into(),
6922            xmin: 0.25,
6923            ymin: 0.25,
6924            xmax: 0.75,
6925            ymax: 0.75,
6926        };
6927        p.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
6928            .expect("draw into a read-only texture");
6929
6930        let completion = dst
6931            .gpu_completion()
6932            .unwrap()
6933            .expect("the draw recorded no completion on dst");
6934        assert!(completion.value > 0, "completion value must be non-zero");
6935
6936        // The drawn frame is read back through the texture's staging copy;
6937        // `copy_to_flat` compacts its padded rows into tight RGBA pixels and
6938        // needs only a read mapping, which the read-only texture allows.
6939        // The corner is cleared and the box centre carries the mask colour.
6940        let mut flat = vec![0u8; 64 * 64 * 4];
6941        dst.as_typed::<u8>()
6942            .unwrap()
6943            .copy_to_flat(&mut flat)
6944            .expect("read the drawn texture");
6945        let px = |x: usize, y: usize| -> [u8; 4] {
6946            let o = (y * 64 + x) * 4;
6947            [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]]
6948        };
6949        assert_eq!(px(2, 2), [0, 0, 0, 0], "corner (2,2) was not cleared");
6950        assert_ne!(px(32, 32), [0, 0, 0, 0], "centre (32,32) was not coloured");
6951    }
6952
6953    /// The full-GPU proto draw (forced GL, no CPU materialize) records a
6954    /// completion on a texture destination too.
6955    #[cfg(all(target_os = "windows", feature = "opengl"))]
6956    #[test]
6957    fn windows_draw_proto_masks_into_a_texture_records_a_completion() {
6958        use edgefirst_tensor::{CpuAccess, Tensor};
6959
6960        if !crate::test_support::is_gpu_buffer_available() {
6961            crate::test_support::report_skip(&format!(
6962                "{} - no zero-copy GPU buffers",
6963                function!()
6964            ));
6965            return;
6966        }
6967        // Forcing OpenGL fails outright without a GL backend (CI's no-ANGLE
6968        // coverage pass); that is the same skip the texture guard below gives,
6969        // unless the lane set HAL_TEST_REQUIRE_GL=1.
6970        let mut p = match with_force_backend(Some("opengl"), ImageProcessor::new) {
6971            Ok(p) => p,
6972            Err(e) if !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1") => {
6973                crate::test_support::report_skip(&format!(
6974                    "{} - no OpenGL backend: {e}",
6975                    function!()
6976                ));
6977                return;
6978            }
6979            Err(e) => panic!("forced OpenGL backend unavailable: {e}"),
6980        };
6981        let mut dst = p
6982            .create_image(64, 64, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
6983            .unwrap();
6984        if !texture_or_skip(&dst, function!()) {
6985            return;
6986        }
6987
6988        let detect = DetectBox {
6989            bbox: [0.25, 0.25, 0.75, 0.75].into(),
6990            score: 0.99,
6991            label: 0,
6992        };
6993        // One prototype channel of ones and a positive coefficient: every
6994        // pixel of the box is foreground.
6995        let proto = ProtoData {
6996            mask_coefficients: TensorDyn::F32(
6997                Tensor::<f32>::from_slice(&[1.0_f32], &[1, 1]).unwrap(),
6998            ),
6999            protos: TensorDyn::F32(
7000                Tensor::<f32>::from_slice(&vec![1.0_f32; 8 * 8], &[8, 8, 1]).unwrap(),
7001            ),
7002            layout: ProtoLayout::Nhwc,
7003        };
7004        p.draw_proto_masks(&mut dst, &[detect], &proto, MaskOverlay::default())
7005            .expect("forced-GL proto draw into a read-only texture");
7006
7007        let completion = dst
7008            .gpu_completion()
7009            .unwrap()
7010            .expect("the proto draw recorded no completion on dst");
7011        assert!(completion.value > 0, "completion value must be non-zero");
7012    }
7013
7014    /// An empty draw with a host-memory background onto a texture
7015    /// destination leaves the background in the texture, rows in order.
7016    /// The gradient pattern makes a missing or vertically flipped base
7017    /// layer differ from the expected image.
7018    #[cfg(all(target_os = "windows", feature = "opengl"))]
7019    #[test]
7020    fn windows_draw_decoded_masks_composites_a_host_background_onto_a_texture() {
7021        use edgefirst_tensor::CpuAccess;
7022
7023        if !crate::test_support::is_gpu_buffer_available() {
7024            crate::test_support::report_skip(&format!(
7025                "{} - no zero-copy GPU buffers",
7026                function!()
7027            ));
7028            return;
7029        }
7030        let mut p = ImageProcessor::new().unwrap();
7031        let bg = synthetic_rgba(64, 48);
7032        let mut dst = p
7033            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7034            .unwrap();
7035        if !texture_or_skip(&dst, function!()) {
7036            return;
7037        }
7038        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7039            .expect("empty draw with a host background onto a texture");
7040        assert_pixels_identical(&dst, &bg, "host background onto a texture");
7041        assert!(dst.gpu_completion().unwrap().is_some());
7042    }
7043
7044    /// The same draw with the background in a texture of its own: the
7045    /// base layer is sampled from the background texture directly.
7046    #[cfg(all(target_os = "windows", feature = "opengl"))]
7047    #[test]
7048    fn windows_draw_decoded_masks_composites_a_texture_background_onto_a_texture() {
7049        use edgefirst_tensor::CpuAccess;
7050
7051        if !crate::test_support::is_gpu_buffer_available() {
7052            crate::test_support::report_skip(&format!(
7053                "{} - no zero-copy GPU buffers",
7054                function!()
7055            ));
7056            return;
7057        }
7058        let mut p = ImageProcessor::new().unwrap();
7059        let expected = synthetic_rgba(64, 48);
7060        let bg = p
7061            .create_image(
7062                64,
7063                48,
7064                PixelFormat::Rgba,
7065                DType::U8,
7066                None,
7067                CpuAccess::ReadWrite,
7068            )
7069            .unwrap();
7070        if !texture_or_skip(&bg, function!()) {
7071            return;
7072        }
7073        fill_synthetic_rgba(&bg);
7074        let mut dst = p
7075            .create_image(64, 48, PixelFormat::Rgba, DType::U8, None, CpuAccess::Read)
7076            .unwrap();
7077        assert_eq!(dst.memory(), TensorMemory::DmaBuf);
7078        p.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
7079            .expect("empty draw with a texture background onto a texture");
7080        assert_pixels_identical(&dst, &expected, "texture background onto a texture");
7081        assert!(dst.gpu_completion().unwrap().is_some());
7082    }
7083
7084    /// A draw into a `view()` of a texture fills that band and leaves its
7085    /// sibling alone. The GL draw declines a sub-region destination
7086    /// (`check_draw_dst_placement`) and the CPU backend, which offsets into
7087    /// the parent, takes it; the zero-copy arm would otherwise have cleared
7088    /// the parent's first rows instead of the view's.
7089    #[cfg(all(target_os = "windows", feature = "opengl"))]
7090    #[test]
7091    fn windows_draw_decoded_masks_into_a_texture_view_fills_only_its_band() {
7092        use edgefirst_tensor::CpuAccess;
7093
7094        if !crate::test_support::is_gpu_buffer_available() {
7095            crate::test_support::report_skip(&format!(
7096                "{} - no zero-copy GPU buffers",
7097                function!()
7098            ));
7099            return;
7100        }
7101        let (w, h) = (32usize, 64usize);
7102        let mut p = ImageProcessor::new().unwrap();
7103        let parent = p
7104            .create_image(
7105                w,
7106                h,
7107                PixelFormat::Rgba,
7108                DType::U8,
7109                None,
7110                CpuAccess::ReadWrite,
7111            )
7112            .unwrap();
7113        if !texture_or_skip(&parent, function!()) {
7114            return;
7115        }
7116        fill_synthetic_rgba(&parent);
7117
7118        // The lower band only. An empty draw with no background clears its
7119        // destination, so a write at the parent's origin would show up as
7120        // cleared pixels in the upper band.
7121        let mut band = parent
7122            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7123            .unwrap();
7124        p.draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7125            .expect("empty draw into a texture view");
7126        drop(band);
7127
7128        let mut flat = vec![0u8; w * h * 4];
7129        parent
7130            .as_typed::<u8>()
7131            .unwrap()
7132            .copy_to_flat(&mut flat)
7133            .expect("read the parent texture");
7134        for y in 0..h {
7135            for x in 0..w {
7136                let o = (y * w + x) * 4;
7137                let got = [flat[o], flat[o + 1], flat[o + 2], flat[o + 3]];
7138                let want = if y < h / 2 {
7139                    [x as u8, y as u8, (x + y) as u8, 255]
7140                } else {
7141                    [0, 0, 0, 0]
7142                };
7143                assert_eq!(got, want, "pixel ({x},{y})");
7144            }
7145        }
7146
7147        // The GL decline is what sent the draw above to the CPU: with GL
7148        // forced there is no CPU arm to fall through to, so it surfaces.
7149        let mut forced = with_force_backend(Some("opengl"), ImageProcessor::new).unwrap();
7150        let mut band = parent
7151            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7152            .unwrap();
7153        let err = forced
7154            .draw_decoded_masks(&mut band, &[], &[], MaskOverlay::default())
7155            .expect_err("forced GL must decline a view destination");
7156        assert!(
7157            matches!(err, Error::NotSupported(_)),
7158            "expected NotSupported, got {err:?}"
7159        );
7160    }
7161
7162    /// `set_class_colors` reaches the CPU backend even when GL accepts it, so
7163    /// a draw GL declines still renders the caller's palette. The view
7164    /// destination is the reachable decline: `check_draw_dst_placement`
7165    /// refuses it and the CPU backend draws the mask.
7166    #[cfg(all(target_os = "windows", feature = "opengl"))]
7167    #[test]
7168    fn windows_class_colors_reach_the_cpu_backend_behind_a_declined_gl_draw() {
7169        use edgefirst_tensor::{CpuAccess, Segmentation};
7170        use ndarray::Array3;
7171
7172        if !crate::test_support::is_gpu_buffer_available() {
7173            crate::test_support::report_skip(&format!(
7174                "{} - no zero-copy GPU buffers",
7175                function!()
7176            ));
7177            return;
7178        }
7179        const CUSTOM: [u8; 4] = [7, 200, 13, 255];
7180        assert_ne!(
7181            CUSTOM[..3],
7182            DEFAULT_COLORS_U8[0][..3],
7183            "the custom colour must differ from the default palette's, or \
7184             this test cannot tell them apart"
7185        );
7186
7187        let (w, h) = (32usize, 64usize);
7188        let mut p = ImageProcessor::new().unwrap();
7189        let parent = p
7190            .create_image(
7191                w,
7192                h,
7193                PixelFormat::Rgba,
7194                DType::U8,
7195                None,
7196                CpuAccess::ReadWrite,
7197            )
7198            .unwrap();
7199        // Without a GL backend (CI's no-ANGLE coverage pass) `create_image`
7200        // answers host memory and nothing declines the draw; the test needs
7201        // the texture-backed parent so the view is the reachable decline. The
7202        // lanes that set HAL_TEST_REQUIRE_GL=1 still require the texture.
7203        if parent.memory() != TensorMemory::DmaBuf
7204            && !std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
7205        {
7206            crate::test_support::report_skip(&format!(
7207                "{} - create_image gave {:?}, not a texture (no GL backend)",
7208                function!(),
7209                parent.memory()
7210            ));
7211            return;
7212        }
7213        p.set_class_colors(&[CUSTOM]).unwrap();
7214
7215        let detect = DetectBox {
7216            bbox: [0.25, 0.25, 0.75, 0.75].into(),
7217            score: 0.99,
7218            label: 0,
7219        };
7220        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
7221        let seg = Segmentation {
7222            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
7223                .unwrap()
7224                .into(),
7225            xmin: 0.25,
7226            ymin: 0.25,
7227            xmax: 0.75,
7228            ymax: 0.75,
7229        };
7230        let mut band = parent
7231            .view(edgefirst_tensor::Region::new(0, h / 2, w, h / 2))
7232            .unwrap();
7233        p.draw_decoded_masks(&mut band, &[detect], &[seg], MaskOverlay::default())
7234            .expect("draw into a texture view");
7235        drop(band);
7236
7237        let mut flat = vec![0u8; w * h * 4];
7238        parent
7239            .as_typed::<u8>()
7240            .unwrap()
7241            .copy_to_flat(&mut flat)
7242            .expect("read the parent texture");
7243        // The band's centre, inside the box. The mask blend writes RGB only;
7244        // alpha is whatever the base layer left, which is the clear.
7245        let o = ((h / 2 + h / 4) * w + w / 2) * 4;
7246        assert_eq!(
7247            &flat[o..o + 3],
7248            &CUSTOM[..3],
7249            "the CPU backend drew the default palette, so it never received \
7250             set_class_colors"
7251        );
7252    }
7253
7254    /// Two independently-created `ImageProcessor`s, each converting into its
7255    /// own texture destination in lockstep, must not bleed GL state into
7256    /// each other across 200 iterations (the destination created by
7257    /// `create_image(None)` is the texture arm this task adds).
7258    #[cfg(all(target_os = "windows", feature = "opengl"))]
7259    #[test]
7260    fn two_processors_alternate_on_texture_destinations_without_state_bleed() {
7261        use edgefirst_tensor::CpuAccess;
7262
7263        if !crate::test_support::is_gpu_buffer_available() {
7264            crate::test_support::report_skip(&format!(
7265                "{} - no zero-copy GPU buffers",
7266                function!()
7267            ));
7268            return;
7269        }
7270        let mut a = ImageProcessor::new().unwrap();
7271        let mut b = ImageProcessor::new().unwrap();
7272        let src_a = synthetic_rgba(512, 512);
7273        let src_b = synthetic_rgba(256, 256);
7274        let mut dst_a = a
7275            .create_image(
7276                512,
7277                512,
7278                PixelFormat::Rgba,
7279                DType::U8,
7280                None,
7281                CpuAccess::Read,
7282            )
7283            .unwrap();
7284        let mut dst_b = b
7285            .create_image(
7286                256,
7287                256,
7288                PixelFormat::Rgba,
7289                DType::U8,
7290                None,
7291                CpuAccess::Read,
7292            )
7293            .unwrap();
7294        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
7295            backend: ComputeBackend::Cpu,
7296            ..Default::default()
7297        })
7298        .unwrap();
7299        let mut ref_a = TensorDyn::image(
7300            512,
7301            512,
7302            PixelFormat::Rgba,
7303            DType::U8,
7304            Some(TensorMemory::Mem),
7305            CpuAccess::ReadWrite,
7306        )
7307        .unwrap();
7308        let mut ref_b = TensorDyn::image(
7309            256,
7310            256,
7311            PixelFormat::Rgba,
7312            DType::U8,
7313            Some(TensorMemory::Mem),
7314            CpuAccess::ReadWrite,
7315        )
7316        .unwrap();
7317        cpu.convert(&src_a, &mut ref_a, None).unwrap();
7318        cpu.convert(&src_b, &mut ref_b, None).unwrap();
7319        for _ in 0..200 {
7320            a.convert(&src_a, &mut dst_a, None).unwrap();
7321            b.convert(&src_b, &mut dst_b, None).unwrap();
7322            compare_images(&dst_a, &ref_a, 0.0, "processor A");
7323            compare_images(&dst_b, &ref_b, 0.0, "processor B");
7324        }
7325    }
7326
7327    #[test]
7328    #[cfg(any(
7329        target_os = "linux",
7330        target_os = "macos",
7331        target_os = "ios",
7332        target_os = "android",
7333        target_os = "windows"
7334    ))]
7335    #[cfg(feature = "opengl")]
7336    fn test_opengl_all_rgba() {
7337        if !is_opengl_available() {
7338            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7339            return;
7340        }
7341
7342        let dst_width = 640;
7343        let dst_height = 640;
7344        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7345
7346        let mut cpu_converter = CPUProcessor::new();
7347
7348        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7349
7350        // Every backing this host can allocate: Shm is Unix-only, and the
7351        // zero-copy slot is DMA-BUF on Linux / IOSurface on macOS (nothing on
7352        // Windows yet).
7353        let mut mem = vec![None, Some(TensorMemory::Mem)];
7354        if edgefirst_tensor::is_shm_available() {
7355            mem.push(Some(TensorMemory::Shm));
7356        }
7357        if crate::test_support::is_gpu_buffer_available() {
7358            mem.push(Some(TensorMemory::DmaBuf));
7359        } else {
7360            crate::test_support::report_skip(&format!(
7361                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7362                function!()
7363            ));
7364        }
7365        let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
7366        for m in mem {
7367            let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
7368            let src_dyn = src;
7369
7370            for rot in [
7371                Rotation::None,
7372                Rotation::Clockwise90,
7373                Rotation::Rotate180,
7374                Rotation::CounterClockwise90,
7375            ] {
7376                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
7377                    let cpu_dst = TensorDyn::image(
7378                        dst_width,
7379                        dst_height,
7380                        PixelFormat::Rgba,
7381                        DType::U8,
7382                        m,
7383                        edgefirst_tensor::CpuAccess::ReadWrite,
7384                    )
7385                    .unwrap();
7386                    let gl_dst = TensorDyn::image(
7387                        dst_width,
7388                        dst_height,
7389                        PixelFormat::Rgba,
7390                        DType::U8,
7391                        m,
7392                        edgefirst_tensor::CpuAccess::ReadWrite,
7393                    )
7394                    .unwrap();
7395                    cpu_dst
7396                        .as_typed::<u8>()
7397                        .unwrap()
7398                        .map()
7399                        .unwrap()
7400                        .as_mut_slice()
7401                        .fill(114);
7402                    gl_dst
7403                        .as_typed::<u8>()
7404                        .unwrap()
7405                        .map()
7406                        .unwrap()
7407                        .as_mut_slice()
7408                        .fill(114);
7409
7410                    let mut cpu_dst_dyn = cpu_dst;
7411                    cpu_converter
7412                        .convert(
7413                            &src_dyn,
7414                            &mut cpu_dst_dyn,
7415                            Some(crate::ConvertOptions::new().crop(crop)),
7416                        )
7417                        .unwrap();
7418                    let cpu_dst = {
7419                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
7420                        __t.set_format(PixelFormat::Rgba).unwrap();
7421                        TensorDyn::from(__t)
7422                    };
7423
7424                    let mut gl_dst_dyn = gl_dst;
7425                    gl_converter
7426                        .convert(
7427                            &src_dyn,
7428                            &mut gl_dst_dyn,
7429                            Some(crate::ConvertOptions::new().crop(crop)),
7430                        )
7431                        .map_err(|e| {
7432                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
7433                            e
7434                        })
7435                        .unwrap();
7436                    let gl_dst = {
7437                        let mut __t = gl_dst_dyn.into_u8().unwrap();
7438                        __t.set_format(PixelFormat::Rgba).unwrap();
7439                        TensorDyn::from(__t)
7440                    };
7441
7442                    compare_images(
7443                        &gl_dst,
7444                        &cpu_dst,
7445                        0.98,
7446                        &format!("{} {:?} {:?}", function!(), rot, flip),
7447                    );
7448                }
7449            }
7450        }
7451    }
7452
7453    #[test]
7454    #[cfg(target_os = "linux")]
7455    fn test_cpu_rotate() {
7456        for rot in [
7457            Rotation::Clockwise90,
7458            Rotation::Rotate180,
7459            Rotation::CounterClockwise90,
7460        ] {
7461            test_cpu_rotate_(rot);
7462        }
7463    }
7464
7465    #[cfg(target_os = "linux")]
7466    fn test_cpu_rotate_(rot: Rotation) {
7467        // This test rotates the image 4 times and checks that the image was returned to
7468        // be the same Currently doesn't check if rotations actually rotated in
7469        // right direction
7470        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7471
7472        let unchanged_src =
7473            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7474        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7475
7476        let (dst_width, dst_height) = match rot {
7477            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
7478            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
7479                (src.height().unwrap(), src.width().unwrap())
7480            }
7481        };
7482
7483        let cpu_dst = TensorDyn::image(
7484            dst_width,
7485            dst_height,
7486            PixelFormat::Rgba,
7487            DType::U8,
7488            None,
7489            edgefirst_tensor::CpuAccess::ReadWrite,
7490        )
7491        .unwrap();
7492        let mut cpu_converter = CPUProcessor::new();
7493
7494        // After rotating 4 times, the image should be the same as the original
7495
7496        let (result, src, cpu_dst) = convert_img(
7497            &mut cpu_converter,
7498            src,
7499            cpu_dst,
7500            rot,
7501            Flip::None,
7502            Crop::no_crop(),
7503        );
7504        result.unwrap();
7505
7506        let (result, cpu_dst, src) = convert_img(
7507            &mut cpu_converter,
7508            cpu_dst,
7509            src,
7510            rot,
7511            Flip::None,
7512            Crop::no_crop(),
7513        );
7514        result.unwrap();
7515
7516        let (result, src, cpu_dst) = convert_img(
7517            &mut cpu_converter,
7518            src,
7519            cpu_dst,
7520            rot,
7521            Flip::None,
7522            Crop::no_crop(),
7523        );
7524        result.unwrap();
7525
7526        let (result, _cpu_dst, src) = convert_img(
7527            &mut cpu_converter,
7528            cpu_dst,
7529            src,
7530            rot,
7531            Flip::None,
7532            Crop::no_crop(),
7533        );
7534        result.unwrap();
7535
7536        compare_images(&src, &unchanged_src, 0.98, function!());
7537    }
7538
7539    #[test]
7540    #[cfg(any(
7541        target_os = "linux",
7542        target_os = "macos",
7543        target_os = "ios",
7544        target_os = "android",
7545        target_os = "windows"
7546    ))]
7547    #[cfg(feature = "opengl")]
7548    fn test_opengl_rotate() {
7549        if !is_opengl_available() {
7550            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7551            return;
7552        }
7553
7554        let size = (1280, 720);
7555        // Same backing list as `test_opengl_all_rgba`.
7556        let mut mem = vec![None, Some(TensorMemory::Mem)];
7557        if edgefirst_tensor::is_shm_available() {
7558            mem.push(Some(TensorMemory::Shm));
7559        }
7560        if crate::test_support::is_gpu_buffer_available() {
7561            mem.push(Some(TensorMemory::DmaBuf));
7562        } else {
7563            crate::test_support::report_skip(&format!(
7564                "{} - DmaBuf backing not exercised: no zero-copy GPU buffers",
7565                function!()
7566            ));
7567        }
7568        for m in mem {
7569            for rot in [
7570                Rotation::Clockwise90,
7571                Rotation::Rotate180,
7572                Rotation::CounterClockwise90,
7573            ] {
7574                test_opengl_rotate_(size, rot, m);
7575            }
7576        }
7577    }
7578
7579    #[cfg(any(
7580        target_os = "linux",
7581        target_os = "macos",
7582        target_os = "ios",
7583        target_os = "android",
7584        target_os = "windows"
7585    ))]
7586    #[cfg(feature = "opengl")]
7587    fn test_opengl_rotate_(
7588        size: (usize, usize),
7589        rot: Rotation,
7590        tensor_memory: Option<TensorMemory>,
7591    ) {
7592        let (dst_width, dst_height) = match rot {
7593            Rotation::None | Rotation::Rotate180 => size,
7594            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7595        };
7596
7597        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7598        let src =
7599            crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
7600
7601        let cpu_dst = TensorDyn::image(
7602            dst_width,
7603            dst_height,
7604            PixelFormat::Rgba,
7605            DType::U8,
7606            None,
7607            edgefirst_tensor::CpuAccess::ReadWrite,
7608        )
7609        .unwrap();
7610        let mut cpu_converter = CPUProcessor::new();
7611
7612        let (result, mut src, cpu_dst) = convert_img(
7613            &mut cpu_converter,
7614            src,
7615            cpu_dst,
7616            rot,
7617            Flip::None,
7618            Crop::no_crop(),
7619        );
7620        result.unwrap();
7621
7622        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7623
7624        for _ in 0..5 {
7625            let gl_dst = TensorDyn::image(
7626                dst_width,
7627                dst_height,
7628                PixelFormat::Rgba,
7629                DType::U8,
7630                tensor_memory,
7631                edgefirst_tensor::CpuAccess::ReadWrite,
7632            )
7633            .unwrap();
7634            let (result, src_back, gl_dst) = convert_img(
7635                &mut gl_converter,
7636                src,
7637                gl_dst,
7638                rot,
7639                Flip::None,
7640                Crop::no_crop(),
7641            );
7642            result.unwrap();
7643            src = src_back;
7644            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
7645        }
7646    }
7647
7648    #[test]
7649    #[cfg(target_os = "linux")]
7650    fn test_g2d_rotate() {
7651        if !is_g2d_available() {
7652            crate::test_support::report_skip(
7653                "test_g2d_rotate - G2D library (libg2d.so.2) not available",
7654            );
7655            return;
7656        }
7657        if !is_dma_available() {
7658            crate::test_support::report_skip("test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7659            return;
7660        }
7661
7662        let size = (1280, 720);
7663        for rot in [
7664            Rotation::Clockwise90,
7665            Rotation::Rotate180,
7666            Rotation::CounterClockwise90,
7667        ] {
7668            test_g2d_rotate_(size, rot);
7669        }
7670    }
7671
7672    #[cfg(target_os = "linux")]
7673    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
7674        let (dst_width, dst_height) = match rot {
7675            Rotation::None | Rotation::Rotate180 => size,
7676            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
7677        };
7678
7679        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7680        let src = crate::load_image_test_helper(
7681            &file,
7682            Some(PixelFormat::Rgba),
7683            Some(TensorMemory::DmaBuf),
7684        )
7685        .unwrap();
7686
7687        let cpu_dst = TensorDyn::image(
7688            dst_width,
7689            dst_height,
7690            PixelFormat::Rgba,
7691            DType::U8,
7692            None,
7693            edgefirst_tensor::CpuAccess::ReadWrite,
7694        )
7695        .unwrap();
7696        let mut cpu_converter = CPUProcessor::new();
7697
7698        let (result, src, cpu_dst) = convert_img(
7699            &mut cpu_converter,
7700            src,
7701            cpu_dst,
7702            rot,
7703            Flip::None,
7704            Crop::no_crop(),
7705        );
7706        result.unwrap();
7707
7708        let g2d_dst = TensorDyn::image(
7709            dst_width,
7710            dst_height,
7711            PixelFormat::Rgba,
7712            DType::U8,
7713            Some(TensorMemory::DmaBuf),
7714            edgefirst_tensor::CpuAccess::ReadWrite,
7715        )
7716        .unwrap();
7717        let mut g2d_converter = G2DProcessor::new().unwrap();
7718
7719        let (result, _src, g2d_dst) = convert_img(
7720            &mut g2d_converter,
7721            src,
7722            g2d_dst,
7723            rot,
7724            Flip::None,
7725            Crop::no_crop(),
7726        );
7727        result.unwrap();
7728
7729        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
7730        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
7731        // the YUV-matrix delta that forced 0.95 has closed; tightened to
7732        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
7733        // structural gap not exercised by these limited-range fixtures.
7734        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7735    }
7736
7737    #[test]
7738    fn test_rgba_to_yuyv_resize_cpu() {
7739        let src = load_bytes_to_tensor(
7740            1280,
7741            720,
7742            PixelFormat::Rgba,
7743            None,
7744            &edgefirst_bench::testdata::read("camera720p.rgba"),
7745        )
7746        .unwrap();
7747
7748        let (dst_width, dst_height) = (640, 360);
7749
7750        let dst = TensorDyn::image(
7751            dst_width,
7752            dst_height,
7753            PixelFormat::Yuyv,
7754            DType::U8,
7755            None,
7756            edgefirst_tensor::CpuAccess::ReadWrite,
7757        )
7758        .unwrap();
7759
7760        let dst_through_yuyv = TensorDyn::image(
7761            dst_width,
7762            dst_height,
7763            PixelFormat::Rgba,
7764            DType::U8,
7765            None,
7766            edgefirst_tensor::CpuAccess::ReadWrite,
7767        )
7768        .unwrap();
7769        let dst_direct = TensorDyn::image(
7770            dst_width,
7771            dst_height,
7772            PixelFormat::Rgba,
7773            DType::U8,
7774            None,
7775            edgefirst_tensor::CpuAccess::ReadWrite,
7776        )
7777        .unwrap();
7778
7779        let mut cpu_converter = CPUProcessor::new();
7780
7781        let (result, src, dst) = convert_img(
7782            &mut cpu_converter,
7783            src,
7784            dst,
7785            Rotation::None,
7786            Flip::None,
7787            Crop::no_crop(),
7788        );
7789        result.unwrap();
7790
7791        let (result, _dst, dst_through_yuyv) = convert_img(
7792            &mut cpu_converter,
7793            dst,
7794            dst_through_yuyv,
7795            Rotation::None,
7796            Flip::None,
7797            Crop::no_crop(),
7798        );
7799        result.unwrap();
7800
7801        let (result, _src, dst_direct) = convert_img(
7802            &mut cpu_converter,
7803            src,
7804            dst_direct,
7805            Rotation::None,
7806            Flip::None,
7807            Crop::no_crop(),
7808        );
7809        result.unwrap();
7810
7811        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
7812    }
7813
7814    #[test]
7815    #[cfg(any(
7816        target_os = "linux",
7817        target_os = "macos",
7818        target_os = "ios",
7819        target_os = "android",
7820        target_os = "windows"
7821    ))]
7822    #[cfg(feature = "opengl")]
7823    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
7824    fn test_rgba_to_yuyv_resize_opengl() {
7825        if !is_opengl_available() {
7826            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
7827            return;
7828        }
7829
7830        if !crate::test_support::is_gpu_buffer_available() {
7831            crate::test_support::report_skip(&format!(
7832                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
7833                function!()
7834            ));
7835            return;
7836        }
7837
7838        let src = load_bytes_to_tensor(
7839            1280,
7840            720,
7841            PixelFormat::Rgba,
7842            None,
7843            &edgefirst_bench::testdata::read("camera720p.rgba"),
7844        )
7845        .unwrap();
7846
7847        let (dst_width, dst_height) = (640, 360);
7848
7849        let dst = TensorDyn::image(
7850            dst_width,
7851            dst_height,
7852            PixelFormat::Yuyv,
7853            DType::U8,
7854            Some(TensorMemory::DmaBuf),
7855            edgefirst_tensor::CpuAccess::ReadWrite,
7856        )
7857        .unwrap();
7858
7859        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7860
7861        let (result, src, dst) = convert_img(
7862            &mut gl_converter,
7863            src,
7864            dst,
7865            Rotation::None,
7866            Flip::None,
7867            Crop::letterbox([255, 255, 255, 255]),
7868        );
7869        result.unwrap();
7870
7871        std::fs::write(
7872            "rgba_to_yuyv_opengl.yuyv",
7873            dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
7874        )
7875        .unwrap();
7876        let cpu_dst = TensorDyn::image(
7877            dst_width,
7878            dst_height,
7879            PixelFormat::Yuyv,
7880            DType::U8,
7881            Some(TensorMemory::DmaBuf),
7882            edgefirst_tensor::CpuAccess::ReadWrite,
7883        )
7884        .unwrap();
7885        let (result, _src, cpu_dst) = convert_img(
7886            &mut CPUProcessor::new(),
7887            src,
7888            cpu_dst,
7889            Rotation::None,
7890            Flip::None,
7891            Crop::no_crop(),
7892        );
7893        result.unwrap();
7894
7895        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
7896    }
7897
7898    #[test]
7899    #[cfg(target_os = "linux")]
7900    fn test_rgba_to_yuyv_resize_g2d() {
7901        if !is_g2d_available() {
7902            crate::test_support::report_skip(
7903                "test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
7904            );
7905            return;
7906        }
7907        if !is_dma_available() {
7908            crate::test_support::report_skip("test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
7909            return;
7910        }
7911
7912        let src = load_bytes_to_tensor(
7913            1280,
7914            720,
7915            PixelFormat::Rgba,
7916            Some(TensorMemory::DmaBuf),
7917            &edgefirst_bench::testdata::read("camera720p.rgba"),
7918        )
7919        .unwrap();
7920
7921        let (dst_width, dst_height) = (1280, 720);
7922
7923        let cpu_dst = TensorDyn::image(
7924            dst_width,
7925            dst_height,
7926            PixelFormat::Yuyv,
7927            DType::U8,
7928            Some(TensorMemory::DmaBuf),
7929            edgefirst_tensor::CpuAccess::ReadWrite,
7930        )
7931        .unwrap();
7932
7933        let g2d_dst = TensorDyn::image(
7934            dst_width,
7935            dst_height,
7936            PixelFormat::Yuyv,
7937            DType::U8,
7938            Some(TensorMemory::DmaBuf),
7939            edgefirst_tensor::CpuAccess::ReadWrite,
7940        )
7941        .unwrap();
7942
7943        let mut g2d_converter = G2DProcessor::new().unwrap();
7944        let crop = Crop::new();
7945
7946        g2d_dst
7947            .as_typed::<u8>()
7948            .unwrap()
7949            .map()
7950            .unwrap()
7951            .as_mut_slice()
7952            .fill(128);
7953        let (result, src, g2d_dst) = convert_img(
7954            &mut g2d_converter,
7955            src,
7956            g2d_dst,
7957            Rotation::None,
7958            Flip::None,
7959            crop,
7960        );
7961        result.unwrap();
7962
7963        let cpu_dst_img = cpu_dst;
7964        cpu_dst_img
7965            .as_typed::<u8>()
7966            .unwrap()
7967            .map()
7968            .unwrap()
7969            .as_mut_slice()
7970            .fill(128);
7971        let (result, _src, cpu_dst) = convert_img(
7972            &mut CPUProcessor::new(),
7973            src,
7974            cpu_dst_img,
7975            Rotation::None,
7976            Flip::None,
7977            crop,
7978        );
7979        result.unwrap();
7980
7981        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
7982    }
7983
7984    #[test]
7985    fn test_yuyv_to_rgba_cpu() {
7986        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
7987        let src = TensorDyn::image(
7988            1280,
7989            720,
7990            PixelFormat::Yuyv,
7991            DType::U8,
7992            None,
7993            edgefirst_tensor::CpuAccess::ReadWrite,
7994        )
7995        .unwrap();
7996        src.as_typed::<u8>()
7997            .unwrap()
7998            .map()
7999            .unwrap()
8000            .as_mut_slice()
8001            .copy_from_slice(&file);
8002
8003        let dst = TensorDyn::image(
8004            1280,
8005            720,
8006            PixelFormat::Rgba,
8007            DType::U8,
8008            None,
8009            edgefirst_tensor::CpuAccess::ReadWrite,
8010        )
8011        .unwrap();
8012        let mut cpu_converter = CPUProcessor::new();
8013
8014        let (result, _src, dst) = convert_img(
8015            &mut cpu_converter,
8016            src,
8017            dst,
8018            Rotation::None,
8019            Flip::None,
8020            Crop::no_crop(),
8021        );
8022        result.unwrap();
8023
8024        let target_image = TensorDyn::image(
8025            1280,
8026            720,
8027            PixelFormat::Rgba,
8028            DType::U8,
8029            None,
8030            edgefirst_tensor::CpuAccess::ReadWrite,
8031        )
8032        .unwrap();
8033        target_image
8034            .as_typed::<u8>()
8035            .unwrap()
8036            .map()
8037            .unwrap()
8038            .as_mut_slice()
8039            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8040
8041        // CPU path resolves the untagged 720p source to BT.709 limited (height
8042        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8043        compare_images(&dst, &target_image, 0.98, function!());
8044    }
8045
8046    #[test]
8047    fn test_yuyv_to_rgb_cpu() {
8048        let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
8049        let src = TensorDyn::image(
8050            1280,
8051            720,
8052            PixelFormat::Yuyv,
8053            DType::U8,
8054            None,
8055            edgefirst_tensor::CpuAccess::ReadWrite,
8056        )
8057        .unwrap();
8058        src.as_typed::<u8>()
8059            .unwrap()
8060            .map()
8061            .unwrap()
8062            .as_mut_slice()
8063            .copy_from_slice(&file);
8064
8065        let dst = TensorDyn::image(
8066            1280,
8067            720,
8068            PixelFormat::Rgb,
8069            DType::U8,
8070            None,
8071            edgefirst_tensor::CpuAccess::ReadWrite,
8072        )
8073        .unwrap();
8074        let mut cpu_converter = CPUProcessor::new();
8075
8076        let (result, _src, dst) = convert_img(
8077            &mut cpu_converter,
8078            src,
8079            dst,
8080            Rotation::None,
8081            Flip::None,
8082            Crop::no_crop(),
8083        );
8084        result.unwrap();
8085
8086        let target_image = TensorDyn::image(
8087            1280,
8088            720,
8089            PixelFormat::Rgb,
8090            DType::U8,
8091            None,
8092            edgefirst_tensor::CpuAccess::ReadWrite,
8093        )
8094        .unwrap();
8095        target_image
8096            .as_typed::<u8>()
8097            .unwrap()
8098            .map()
8099            .unwrap()
8100            .as_mut_slice()
8101            .as_chunks_mut::<3>()
8102            .0
8103            .iter_mut()
8104            .zip(
8105                edgefirst_bench::testdata::read("camera720p.rgba")
8106                    .as_chunks::<4>()
8107                    .0,
8108            )
8109            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8110
8111        // CPU path resolves the untagged 720p source to BT.709 limited (height
8112        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
8113        compare_images(&dst, &target_image, 0.98, function!());
8114    }
8115
8116    #[test]
8117    #[cfg(target_os = "linux")]
8118    fn test_yuyv_to_rgba_g2d() {
8119        if !is_g2d_available() {
8120            crate::test_support::report_skip(
8121                "test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available",
8122            );
8123            return;
8124        }
8125        if !is_dma_available() {
8126            crate::test_support::report_skip("test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
8127            return;
8128        }
8129
8130        let src = load_bytes_to_tensor(
8131            1280,
8132            720,
8133            PixelFormat::Yuyv,
8134            None,
8135            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8136        )
8137        .unwrap();
8138
8139        let dst = TensorDyn::image(
8140            1280,
8141            720,
8142            PixelFormat::Rgba,
8143            DType::U8,
8144            Some(TensorMemory::DmaBuf),
8145            edgefirst_tensor::CpuAccess::ReadWrite,
8146        )
8147        .unwrap();
8148        let mut g2d_converter = G2DProcessor::new().unwrap();
8149
8150        let (result, _src, dst) = convert_img(
8151            &mut g2d_converter,
8152            src,
8153            dst,
8154            Rotation::None,
8155            Flip::None,
8156            Crop::no_crop(),
8157        );
8158        result.unwrap();
8159
8160        let target_image = TensorDyn::image(
8161            1280,
8162            720,
8163            PixelFormat::Rgba,
8164            DType::U8,
8165            None,
8166            edgefirst_tensor::CpuAccess::ReadWrite,
8167        )
8168        .unwrap();
8169        target_image
8170            .as_typed::<u8>()
8171            .unwrap()
8172            .map()
8173            .unwrap()
8174            .as_mut_slice()
8175            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8176
8177        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8178        // so the matrix delta vs the reference that forced 0.95 has closed;
8179        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8180        compare_images(&dst, &target_image, 0.98, function!());
8181    }
8182
8183    #[test]
8184    #[cfg(any(
8185        target_os = "linux",
8186        target_os = "macos",
8187        target_os = "ios",
8188        target_os = "android",
8189        target_os = "windows"
8190    ))]
8191    #[cfg(feature = "opengl")]
8192    fn test_yuyv_to_rgba_opengl() {
8193        if !is_opengl_available() {
8194            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
8195            return;
8196        }
8197        // Zero-copy YUYV source: DMA-BUF on Linux, IOSurface on macOS, a
8198        // D3D11 texture on Windows; skips where no such backing exists.
8199        if !crate::test_support::is_gpu_buffer_available() {
8200            crate::test_support::report_skip(&format!(
8201                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8202                function!()
8203            ));
8204            return;
8205        }
8206
8207        let src = load_bytes_to_tensor(
8208            1280,
8209            720,
8210            PixelFormat::Yuyv,
8211            Some(TensorMemory::DmaBuf),
8212            &edgefirst_bench::testdata::read("camera720p.yuyv"),
8213        )
8214        .unwrap();
8215
8216        let dst = TensorDyn::image(
8217            1280,
8218            720,
8219            PixelFormat::Rgba,
8220            DType::U8,
8221            Some(TensorMemory::DmaBuf),
8222            edgefirst_tensor::CpuAccess::ReadWrite,
8223        )
8224        .unwrap();
8225        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8226
8227        let (result, _src, dst) = convert_img(
8228            &mut gl_converter,
8229            src,
8230            dst,
8231            Rotation::None,
8232            Flip::None,
8233            Crop::no_crop(),
8234        );
8235        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
8236        // Production ImageProcessor falls back to CPU; this oracle skips.
8237        if let Err(crate::Error::NotSupported(msg)) = &result {
8238            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
8239            return;
8240        }
8241        result.unwrap();
8242
8243        let target_image = TensorDyn::image(
8244            1280,
8245            720,
8246            PixelFormat::Rgba,
8247            DType::U8,
8248            None,
8249            edgefirst_tensor::CpuAccess::ReadWrite,
8250        )
8251        .unwrap();
8252        target_image
8253            .as_typed::<u8>()
8254            .unwrap()
8255            .map()
8256            .unwrap()
8257            .as_mut_slice()
8258            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8259
8260        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
8261        // so the matrix delta vs the reference that forced 0.95 has closed;
8262        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
8263        compare_images(&dst, &target_image, 0.98, function!());
8264    }
8265
8266    /// macOS analog of `test_yuyv_to_rgba_opengl` — drives the ANGLE +
8267    /// IOSurface backend end-to-end and compares against the same
8268    /// reference image. Skips silently if ANGLE isn't installed so the
8269    /// test suite still passes on CI hosts without the Homebrew tap.
8270    /// Step-1 probe: proves ANGLE's Metal IOSurface-client-buffer path accepts
8271    /// an `L008`→`GL_RED` (R8) binding — the foundation for sampling the
8272    /// contiguous semi-planar YUV buffer as a single R8 texture. Renders a
8273    /// GREY (R8 IOSurface) source through the GL backend to RGBA and checks the
8274    /// luma round-trips to R=G=B (identity GREY→RGB).
8275    #[test]
8276    #[cfg(target_os = "macos")]
8277    #[cfg(feature = "opengl")]
8278    fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
8279        let mut proc = match GLProcessorThreaded::new(None) {
8280            Ok(p) => p,
8281            Err(e) => {
8282                crate::test_support::report_skip(&format!(
8283                    "{} — GL engine init failed ({e:?})",
8284                    function!()
8285                ));
8286                return;
8287            }
8288        };
8289
8290        let (w, h) = (16usize, 16usize);
8291        let src = TensorDyn::image(
8292            w,
8293            h,
8294            PixelFormat::Grey,
8295            DType::U8,
8296            Some(TensorMemory::DmaBuf),
8297            edgefirst_tensor::CpuAccess::ReadWrite,
8298        )
8299        .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
8300        // Known luma ramp: value = (x * 13 + y * 7) & 0xff.
8301        {
8302            let su8 = src.as_typed::<u8>().unwrap();
8303            let stride = src
8304                .as_typed::<u8>()
8305                .unwrap()
8306                .effective_row_stride()
8307                .unwrap();
8308            let mut m = su8.map().unwrap();
8309            let buf = m.as_mut_slice();
8310            for y in 0..h {
8311                for x in 0..w {
8312                    buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
8313                }
8314            }
8315        }
8316
8317        let dst = TensorDyn::image(
8318            w,
8319            h,
8320            PixelFormat::Rgba,
8321            DType::U8,
8322            Some(TensorMemory::DmaBuf),
8323            edgefirst_tensor::CpuAccess::ReadWrite,
8324        )
8325        .unwrap();
8326        let (result, src_back, dst) = convert_img(
8327            &mut proc,
8328            src,
8329            dst,
8330            Rotation::None,
8331            Flip::None,
8332            Crop::no_crop(),
8333        );
8334        result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
8335
8336        let src_stride = src_back
8337            .as_typed::<u8>()
8338            .unwrap()
8339            .effective_row_stride()
8340            .unwrap();
8341        let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
8342        let sbytes = src_map.as_slice();
8343        let dst_stride = dst
8344            .as_typed::<u8>()
8345            .unwrap()
8346            .effective_row_stride()
8347            .unwrap();
8348        let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
8349        let dbytes = dst_map.as_slice();
8350        for y in 0..h {
8351            for x in 0..w {
8352                let yv = sbytes[y * src_stride + x] as i16;
8353                let p = y * dst_stride + x * 4;
8354                for c in 0..3 {
8355                    assert!(
8356                        (dbytes[p + c] as i16 - yv).abs() <= 2,
8357                        "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
8358                        dbytes[p + c]
8359                    );
8360                }
8361            }
8362        }
8363    }
8364
8365    /// Two-pass GPU chain: NV12 (R8 IOSurface) → PlanarRgb F16, the profiler's
8366    /// preprocess. Verifies the chained `convert_nv_to_planar_float`
8367    /// (NV12→RGBA8 then the verified RGBA8→PlanarRgb F16) executes on ANGLE and
8368    /// produces a sane F16 planar result: a neutral-grey NV12 input (Y=U=V=128,
8369    /// BT.601 full ⇒ RGB≈0.5) must yield all three planes ≈0.5 (half-float).
8370    #[test]
8371    #[cfg(target_os = "macos")]
8372    #[cfg(feature = "opengl")]
8373    fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
8374        let mut gpu = match GLProcessorThreaded::new(None) {
8375            Ok(p) => p,
8376            Err(e) => {
8377                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8378                return;
8379            }
8380        };
8381        let (w, h) = (64usize, 64usize);
8382        let Some(src) = crate::test_support::gpu_buffer_allocated(
8383            TensorDyn::image(
8384                w,
8385                h,
8386                PixelFormat::Nv12,
8387                DType::U8,
8388                Some(TensorMemory::DmaBuf),
8389                edgefirst_tensor::CpuAccess::ReadWrite,
8390            ),
8391            "NV12 IOSurface",
8392        ) else {
8393            return;
8394        };
8395        src.as_typed::<u8>()
8396            .unwrap()
8397            .map()
8398            .unwrap()
8399            .as_mut_slice()
8400            .fill(128); // Y=U=V=128
8401
8402        let Some(dst) = crate::test_support::gpu_buffer_allocated(
8403            TensorDyn::image(
8404                w,
8405                h,
8406                PixelFormat::PlanarRgb,
8407                DType::F16,
8408                Some(TensorMemory::DmaBuf),
8409                edgefirst_tensor::CpuAccess::ReadWrite,
8410            ),
8411            "F16 PlanarRgb IOSurface",
8412        ) else {
8413            return;
8414        };
8415        let mut dst = dst;
8416        // Call convert directly (the convert_img helper restores u8 only).
8417        if let Err(e) = ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, None) {
8418            // GL_EXT_color_buffer_half_float may be absent on some configs; the
8419            // RGBA8 pass-1 + F16 pass-2 path then can't render. Skip rather than
8420            // fail on a capability gap (the same policy as the F16 path tests).
8421            crate::test_support::report_skip(&format!(
8422                "{} — NV12→PlanarRgb F16 not available ({e:?})",
8423                function!()
8424            ));
8425            return;
8426        }
8427        let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
8428        let map = dt.map().unwrap();
8429        let vals = map.as_slice();
8430        // Neutral grey → ~0.5 in every plane. Allow generous tolerance for the
8431        // mediump YUV math + half-float rounding.
8432        let mut checked = 0usize;
8433        for &v in vals.iter() {
8434            let f = f32::from(v);
8435            assert!(
8436                (0.40..=0.60).contains(&f),
8437                "planar F16 value {f} not ~0.5 for neutral-grey NV12"
8438            );
8439            checked += 1;
8440        }
8441        assert!(
8442            checked >= w * h * 3,
8443            "expected >= 3 planes of samples, got {checked}"
8444        );
8445    }
8446
8447    /// Profiler-shaped two-pass: a reused **R8/Grey pool** (allocated larger
8448    /// than the frame, the NV24 worst case `3·H`) is reconfigured to an NV12
8449    /// frame, filled at the preserved physical stride, and converted with a
8450    /// letterbox `src_rect` crop into a model-sized PlanarRgb F16 destination —
8451    /// exactly the orchestrator's preprocess. Guards against the pooled
8452    /// two-pass NV→PlanarRgb F16 path hanging/erroring (the exact-size
8453    /// `test_nv12_to_planar_f16_two_pass` never exercised the larger pool).
8454    #[test]
8455    #[cfg(target_os = "macos")]
8456    #[cfg(feature = "opengl")]
8457    fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
8458        let mut gpu = match GLProcessorThreaded::new(None) {
8459            Ok(p) => p,
8460            Err(e) => {
8461                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8462                return;
8463            }
8464        };
8465        // Frame 96×64 in a 256×768 R8 pool (3·256 height; bpr padded past 96).
8466        let (fw, fh) = (96usize, 64usize);
8467        let (pool_w, pool_h) = (256usize, 768usize);
8468        let (model_w, model_h) = (128usize, 128usize);
8469
8470        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8471            TensorDyn::image(
8472                pool_w,
8473                pool_h,
8474                PixelFormat::Grey,
8475                DType::U8,
8476                Some(TensorMemory::DmaBuf),
8477                edgefirst_tensor::CpuAccess::ReadWrite,
8478            ),
8479            "R8 pool",
8480        ) else {
8481            return;
8482        };
8483        src.configure_image(fw, fh, PixelFormat::Nv12)
8484            .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
8485        let stride = src
8486            .as_typed::<u8>()
8487            .unwrap()
8488            .effective_row_stride()
8489            .unwrap();
8490        src.as_typed::<u8>()
8491            .unwrap()
8492            .map()
8493            .unwrap()
8494            .as_mut_slice()
8495            .fill(128); // neutral grey
8496
8497        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8498            TensorDyn::image(
8499                model_w,
8500                model_h,
8501                PixelFormat::PlanarRgb,
8502                DType::F16,
8503                Some(TensorMemory::DmaBuf),
8504                edgefirst_tensor::CpuAccess::ReadWrite,
8505            ),
8506            "F16 PlanarRgb dst",
8507        ) else {
8508            return;
8509        };
8510
8511        // Letterbox crop like the profiler: the backend computes the band.
8512        let _ = model_w;
8513        let crop = Crop::new()
8514            .with_source(Some(Region::new(0, 0, fw, fh)))
8515            .with_fit(Fit::Letterbox {
8516                pad: [0, 0, 0, 255],
8517            });
8518        if let Err(e) = ImageProcessorTrait::convert(
8519            &mut gpu,
8520            &src,
8521            &mut dst,
8522            Some(ConvertOptions::new().crop(crop)),
8523        ) {
8524            crate::test_support::report_skip(&format!(
8525                "{} — NV12→PlanarRgb F16 unavailable ({e:?})",
8526                function!()
8527            ));
8528            return;
8529        }
8530        let _ = stride;
8531        // Neutral grey → ~0.5 inside the letterbox band; just assert the convert
8532        // completed and produced finite values (no hang, no NaN garbage).
8533        let dt = dst.as_typed::<half::f16>().expect("dst F16");
8534        let map = dt.map().unwrap();
8535        let any_half = map.as_slice().iter().any(|&v| {
8536            let f = f32::from(v);
8537            (0.40..=0.60).contains(&f)
8538        });
8539        assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
8540    }
8541
8542    /// Mirrors the orchestrator: the GL processor is created on one thread
8543    /// and `convert()` is called from a *different* thread (the profiler's
8544    /// Pre-processing worker). Reproduces (or rules out) the GL-context /
8545    /// `glFinish` cross-thread hang seen in the live pipeline. A 20 s watchdog
8546    /// fails loudly rather than hanging the whole test binary.
8547    #[test]
8548    #[cfg(target_os = "macos")]
8549    #[cfg(feature = "opengl")]
8550    fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
8551        use std::sync::mpsc;
8552        // Public ImageProcessor (Send) created HERE (the main test thread),
8553        // exactly like the orchestrator builds `config.processor` during setup.
8554        let mut proc = match ImageProcessor::new() {
8555            Ok(p) => p,
8556            Err(e) => {
8557                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8558                return;
8559            }
8560        };
8561        let (fw, fh) = (96usize, 64usize);
8562        let Some(mut src) = crate::test_support::gpu_buffer_allocated(
8563            TensorDyn::image(
8564                256,
8565                768,
8566                PixelFormat::Grey,
8567                DType::U8,
8568                Some(TensorMemory::DmaBuf),
8569                edgefirst_tensor::CpuAccess::ReadWrite,
8570            ),
8571            "NV12 R8 pool",
8572        ) else {
8573            return;
8574        };
8575        src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
8576        src.as_typed::<u8>()
8577            .unwrap()
8578            .map()
8579            .unwrap()
8580            .as_mut_slice()
8581            .fill(128);
8582        let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
8583            TensorDyn::image(
8584                128,
8585                128,
8586                PixelFormat::PlanarRgb,
8587                DType::F16,
8588                Some(TensorMemory::DmaBuf),
8589                edgefirst_tensor::CpuAccess::ReadWrite,
8590            ),
8591            "F16 PlanarRgb dst",
8592        ) else {
8593            return;
8594        };
8595        let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8596
8597        // ...then MOVED to a worker thread where convert() runs — exactly the
8598        // orchestrator's create-on-setup / convert-on-Pre-processing split.
8599        let (tx, rx) = mpsc::channel::<bool>();
8600        let worker = std::thread::spawn(move || {
8601            let _ = ImageProcessorTrait::convert(
8602                &mut proc,
8603                &src,
8604                &mut dst,
8605                Some(ConvertOptions::new().crop(crop)),
8606            );
8607            let _ = tx.send(true);
8608        });
8609        match rx.recv_timeout(std::time::Duration::from_secs(20)) {
8610            Ok(_) => { let _ = worker.join(); }
8611            Err(_) => panic!(
8612                "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
8613            ),
8614        }
8615    }
8616
8617    /// Reproduces the profiler's progressive-slowdown/hang: one processor
8618    /// converting many **varying-size** NV frames (like a COCO dataset) from a
8619    /// reused R8 pool into a fixed PlanarRgb F16 model input. The two-pass path
8620    /// reallocated its RGBA intermediate per frame-size, churning/leaking
8621    /// pbuffers until the GPU stalled. Asserts per-convert latency stays bounded
8622    /// (no runaway) over many iterations.
8623    #[test]
8624    #[cfg(target_os = "macos")]
8625    #[cfg(feature = "opengl")]
8626    fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
8627        let mut gpu = match GLProcessorThreaded::new(None) {
8628            Ok(p) => p,
8629            Err(e) => {
8630                crate::test_support::report_skip(&format!("{} — init failed ({e:?})", function!()));
8631                return;
8632            }
8633        };
8634        // Mirror the orchestrator's ring buffers at depth 4: a pool of source R8
8635        // tensors AND a pool of PlanarRgb F16 dst slots, both cycled per frame.
8636        let (max_w, max_h) = (640usize, 640usize);
8637        let depth = 4usize;
8638        let mut srcs = Vec::new();
8639        let mut dsts = Vec::new();
8640        for _ in 0..depth {
8641            let Some(src) = crate::test_support::gpu_buffer_allocated(
8642                TensorDyn::image(
8643                    max_w,
8644                    max_h * 3,
8645                    PixelFormat::Grey,
8646                    DType::U8,
8647                    Some(TensorMemory::DmaBuf),
8648                    edgefirst_tensor::CpuAccess::ReadWrite,
8649                ),
8650                "R8 pool",
8651            ) else {
8652                return;
8653            };
8654            srcs.push(src);
8655            let Some(dst) = crate::test_support::gpu_buffer_allocated(
8656                TensorDyn::image(
8657                    640,
8658                    640,
8659                    PixelFormat::PlanarRgb,
8660                    DType::F16,
8661                    Some(TensorMemory::DmaBuf),
8662                    edgefirst_tensor::CpuAccess::ReadWrite,
8663                ),
8664                "F16 PlanarRgb dst",
8665            ) else {
8666                return;
8667            };
8668            dsts.push(dst);
8669        }
8670        // COCO-like assorted frame sizes (all ≤ max), cycled.
8671        let sizes = [
8672            (640, 480),
8673            (500, 375),
8674            (640, 427),
8675            (333, 500),
8676            (480, 640),
8677            (612, 612),
8678            (428, 640),
8679            (576, 432),
8680        ];
8681        let mut first_ms = 0f64;
8682        let mut last_ms = 0f64;
8683        let iters = 40usize;
8684        for i in 0..iters {
8685            let (fw, fh) = sizes[i % sizes.len()];
8686            let src = &mut srcs[i % depth];
8687            let dst = &mut dsts[i % depth];
8688            src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
8689            src.as_typed::<u8>()
8690                .unwrap()
8691                .map()
8692                .unwrap()
8693                .as_mut_slice()
8694                .fill(128);
8695            let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
8696            let t0 = std::time::Instant::now();
8697            ImageProcessorTrait::convert(
8698                &mut gpu,
8699                src,
8700                dst,
8701                Some(ConvertOptions::new().crop(crop)),
8702            )
8703            .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
8704            let ms = t0.elapsed().as_secs_f64() * 1e3;
8705            if i == 2 {
8706                first_ms = ms;
8707            }
8708            if i == iters - 1 {
8709                last_ms = ms;
8710            }
8711        }
8712        eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
8713        assert!(
8714            last_ms < first_ms * 5.0 + 5.0,
8715            "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
8716        );
8717    }
8718
8719    /// Step-2 verification: NV12/NV16/NV24 (R8 IOSurface) → RGBA on the GPU
8720    /// must match the CPU `yuv` kernels within shader rounding. Fills an
8721    /// IOSurface source and a Mem source from the same logical YUV pattern
8722    /// (each at its own row stride), converts the IOSurface on the GPU and the
8723    /// Mem one on the CPU, and compares. Exercises the in-shader semi-planar
8724    /// addressing for all three subsamplings (incl. NV24's 2×-wide UV rows).
8725    #[test]
8726    #[cfg(target_os = "macos")]
8727    #[cfg(feature = "opengl")]
8728    fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
8729        let mut gpu = match GLProcessorThreaded::new(None) {
8730            Ok(p) => p,
8731            Err(e) => {
8732                crate::test_support::report_skip(&format!(
8733                    "{} — GL engine init failed ({e:?})",
8734                    function!()
8735                ));
8736                return;
8737            }
8738        };
8739        let mut cpu = CPUProcessor::new();
8740
8741        // Fill Y plus the interleaved UV plane in the canonical semi-planar
8742        // layout — exactly what the codec writes and the CPU `yuv`-crate reader
8743        // expects: the Y plane is `h` rows at the buffer's row stride; the UV
8744        // plane starts at `h * stride`; each chroma row advances
8745        // `uv_grid_rows * stride` bytes (NV24 carries a full-resolution `2*W`
8746        // byte line == two grid rows, NV12/NV16 one row of `W/2` pairs); each
8747        // (Cb,Cr) pair is two consecutive bytes at column `cx * 2`. This is
8748        // stride-correct for both the tight Mem buffer and the padded IOSurface.
8749        //
8750        // Takes explicit w/h so the closure can be reused across multiple frame
8751        // sizes (even and odd) without capturing a fixed outer variable.
8752        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8753            for y in 0..h {
8754                for x in 0..w {
8755                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8756                }
8757            }
8758            let (cw, ch, uv_grid_rows) = match fmt {
8759                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8760                PixelFormat::Nv16 => (w / 2, h, 1usize),
8761                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8762            };
8763            let uv_plane = h * stride;
8764            for cy in 0..ch {
8765                for cx in 0..cw {
8766                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8767                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8768                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8769                }
8770            }
8771        };
8772
8773        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8774            for (w, h) in [
8775                (16usize, 16usize), // original even-dim case
8776                (15, 16),           // odd-W
8777                (16, 15),           // odd-H
8778            ] {
8779                let mem = TensorDyn::image(
8780                    w,
8781                    h,
8782                    fmt,
8783                    DType::U8,
8784                    None,
8785                    edgefirst_tensor::CpuAccess::ReadWrite,
8786                )
8787                .unwrap();
8788                let mem_stride = mem
8789                    .as_typed::<u8>()
8790                    .unwrap()
8791                    .effective_row_stride()
8792                    .unwrap();
8793                fill(
8794                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8795                    mem_stride,
8796                    fmt,
8797                    w,
8798                    h,
8799                );
8800                let cpu_dst = TensorDyn::image(
8801                    w,
8802                    h,
8803                    PixelFormat::Rgba,
8804                    DType::U8,
8805                    None,
8806                    edgefirst_tensor::CpuAccess::ReadWrite,
8807                )
8808                .unwrap();
8809                let (r, _s, cpu_dst) = convert_img(
8810                    &mut cpu,
8811                    mem,
8812                    cpu_dst,
8813                    Rotation::None,
8814                    Flip::None,
8815                    Crop::no_crop(),
8816                );
8817                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8818
8819                let ios = TensorDyn::image(
8820                    w,
8821                    h,
8822                    fmt,
8823                    DType::U8,
8824                    Some(TensorMemory::DmaBuf),
8825                    edgefirst_tensor::CpuAccess::ReadWrite,
8826                )
8827                .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
8828                let ios_stride = ios
8829                    .as_typed::<u8>()
8830                    .unwrap()
8831                    .effective_row_stride()
8832                    .unwrap();
8833                fill(
8834                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8835                    ios_stride,
8836                    fmt,
8837                    w,
8838                    h,
8839                );
8840                let gpu_dst = TensorDyn::image(
8841                    w,
8842                    h,
8843                    PixelFormat::Rgba,
8844                    DType::U8,
8845                    Some(TensorMemory::DmaBuf),
8846                    edgefirst_tensor::CpuAccess::ReadWrite,
8847                )
8848                .unwrap();
8849                let (r, _s, gpu_dst) = convert_img(
8850                    &mut gpu,
8851                    ios,
8852                    gpu_dst,
8853                    Rotation::None,
8854                    Flip::None,
8855                    Crop::no_crop(),
8856                );
8857                r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
8858
8859                let cs = cpu_dst
8860                    .as_typed::<u8>()
8861                    .unwrap()
8862                    .effective_row_stride()
8863                    .unwrap();
8864                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8865                let cb = cmap.as_slice();
8866                let gs = gpu_dst
8867                    .as_typed::<u8>()
8868                    .unwrap()
8869                    .effective_row_stride()
8870                    .unwrap();
8871                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
8872                let gb = gmap.as_slice();
8873                let mut max_d = 0i16;
8874                for y in 0..h {
8875                    for x in 0..w {
8876                        for c in 0..3 {
8877                            let cv = cb[y * cs + x * 4 + c] as i16;
8878                            let gv = gb[y * gs + x * 4 + c] as i16;
8879                            max_d = max_d.max((cv - gv).abs());
8880                        }
8881                    }
8882                }
8883                assert!(
8884                    max_d <= 3,
8885                    "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
8886                );
8887            }
8888        }
8889    }
8890
8891    /// Phase-0 gate: the reused-pool / larger-surface case. A single R8 pool
8892    /// IOSurface (allocated bigger than the frame, so its `bytesPerRow` exceeds
8893    /// the frame's even width) is reconfigured to each NV frame, filled at the
8894    /// preserved physical stride, and converted on the GPU. This proves ANGLE
8895    /// binds the *whole* physical surface as a pbuffer and that `texelFetch`
8896    /// resolves the frame's Y/UV texels through the surface's real `bytesPerRow`
8897    /// (the physical-grid / logical-ROI decoupling). GPU must match CPU ≤3 LSB.
8898    #[test]
8899    #[cfg(target_os = "macos")]
8900    #[cfg(feature = "opengl")]
8901    fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
8902        let mut gpu = match GLProcessorThreaded::new(None) {
8903            Ok(p) => p,
8904            Err(e) => {
8905                crate::test_support::report_skip(&format!(
8906                    "{} — GL engine init failed ({e:?})",
8907                    function!()
8908                ));
8909                return;
8910            }
8911        };
8912        let mut cpu = CPUProcessor::new();
8913        // The pool is generously oversized (256-wide → bpr 256, well beyond any
8914        // frame's even width) and tall enough for NV24's 3·H at the test frames.
8915        let (pool_w, pool_h) = (256usize, 256usize);
8916
8917        // Canonical semi-planar fill: Y plane at the row stride, then the UV
8918        // plane at `h * stride` with each chroma row advancing `uv_grid_rows *
8919        // stride` bytes. Stride-correct for both tight Mem and padded IOSurface.
8920        let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
8921            for y in 0..h {
8922                for x in 0..w {
8923                    buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
8924                }
8925            }
8926            let (cw, ch, uv_grid_rows) = match fmt {
8927                PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
8928                PixelFormat::Nv16 => (w / 2, h, 1usize),
8929                _ => (w, h, 2usize), // Nv24: full-res chroma, 2W bytes/row
8930            };
8931            let uv_plane = h * stride;
8932            for cy in 0..ch {
8933                for cx in 0..cw {
8934                    let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
8935                    buf[off] = ((cx * 11 + 30) & 0xff) as u8;
8936                    buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
8937                }
8938            }
8939        };
8940
8941        for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
8942            for (w, h) in [
8943                (40usize, 24usize), // original even-dim case
8944                (15, 16),           // odd-W
8945                (16, 15),           // odd-H
8946            ] {
8947                // `ew` is the minimum even extent; the pool stride must exceed it
8948                // to actually exercise the physical-stride shader decoupling.
8949                let ew = w.next_multiple_of(2);
8950
8951                // CPU reference from a tightly-packed Mem tensor at the frame size.
8952                let mem = TensorDyn::image(
8953                    w,
8954                    h,
8955                    fmt,
8956                    DType::U8,
8957                    None,
8958                    edgefirst_tensor::CpuAccess::ReadWrite,
8959                )
8960                .unwrap();
8961                let mem_stride = mem
8962                    .as_typed::<u8>()
8963                    .unwrap()
8964                    .effective_row_stride()
8965                    .unwrap();
8966                fill(
8967                    mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
8968                    mem_stride,
8969                    fmt,
8970                    w,
8971                    h,
8972                );
8973                let cpu_dst = TensorDyn::image(
8974                    w,
8975                    h,
8976                    PixelFormat::Rgba,
8977                    DType::U8,
8978                    None,
8979                    edgefirst_tensor::CpuAccess::ReadWrite,
8980                )
8981                .unwrap();
8982                let (r, _s, cpu_dst) = convert_img(
8983                    &mut cpu,
8984                    mem,
8985                    cpu_dst,
8986                    Rotation::None,
8987                    Flip::None,
8988                    Crop::no_crop(),
8989                );
8990                r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
8991
8992                // GPU source: a LARGER R8 pool surface, reconfigured down to the
8993                // frame. Phase 1 preserves the pool's padded `bytesPerRow` as the
8994                // tensor's row stride; the fill writes the frame at that stride.
8995                let Some(mut ios) = crate::test_support::gpu_buffer_allocated(
8996                    TensorDyn::image(
8997                        pool_w,
8998                        pool_h,
8999                        PixelFormat::Grey,
9000                        DType::U8,
9001                        Some(TensorMemory::DmaBuf),
9002                        edgefirst_tensor::CpuAccess::ReadWrite,
9003                    ),
9004                    "R8 pool IOSurface",
9005                ) else {
9006                    return;
9007                };
9008                ios.configure_image(w, h, fmt)
9009                    .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
9010                let ios_stride = ios
9011                    .as_typed::<u8>()
9012                    .unwrap()
9013                    .effective_row_stride()
9014                    .unwrap();
9015                assert!(
9016                    ios_stride > ew,
9017                    "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
9018                     (test must exercise padding)"
9019                );
9020                fill(
9021                    ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
9022                    ios_stride,
9023                    fmt,
9024                    w,
9025                    h,
9026                );
9027
9028                let gpu_dst = TensorDyn::image(
9029                    w,
9030                    h,
9031                    PixelFormat::Rgba,
9032                    DType::U8,
9033                    Some(TensorMemory::DmaBuf),
9034                    edgefirst_tensor::CpuAccess::ReadWrite,
9035                )
9036                .unwrap();
9037                let (r, _s, gpu_dst) = convert_img(
9038                    &mut gpu,
9039                    ios,
9040                    gpu_dst,
9041                    Rotation::None,
9042                    Flip::None,
9043                    Crop::no_crop(),
9044                );
9045                r.unwrap_or_else(|e| {
9046                    panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
9047                });
9048
9049                let cs = cpu_dst
9050                    .as_typed::<u8>()
9051                    .unwrap()
9052                    .effective_row_stride()
9053                    .unwrap();
9054                let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9055                let cb = cmap.as_slice();
9056                let gs = gpu_dst
9057                    .as_typed::<u8>()
9058                    .unwrap()
9059                    .effective_row_stride()
9060                    .unwrap();
9061                let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
9062                let gb = gmap.as_slice();
9063                let mut max_d = 0i16;
9064                for y in 0..h {
9065                    for x in 0..w {
9066                        for c in 0..3 {
9067                            let cv = cb[y * cs + x * 4 + c] as i16;
9068                            let gv = gb[y * gs + x * 4 + c] as i16;
9069                            max_d = max_d.max((cv - gv).abs());
9070                        }
9071                    }
9072                }
9073                assert!(
9074                    max_d <= 3,
9075                    "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
9076                );
9077            }
9078        }
9079    }
9080
9081    #[test]
9082    #[cfg(target_os = "macos")]
9083    #[cfg(feature = "opengl")]
9084    fn test_yuyv_to_rgba_opengl_macos() {
9085        let mut proc = match GLProcessorThreaded::new(None) {
9086            Ok(p) => p,
9087            Err(e) => {
9088                crate::test_support::report_skip(&format!(
9089                    "{} — GL engine init failed ({e:?}). \
9090                     Install ANGLE via `brew install startergo/angle/angle` \
9091                     and re-sign per README.md § macOS GPU Acceleration to \
9092                     run this test.",
9093                    function!()
9094                ));
9095                return;
9096            }
9097        };
9098
9099        let src = load_bytes_to_tensor(
9100            1280,
9101            720,
9102            PixelFormat::Yuyv,
9103            Some(TensorMemory::DmaBuf),
9104            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9105        )
9106        .unwrap();
9107
9108        let dst = TensorDyn::image(
9109            1280,
9110            720,
9111            PixelFormat::Rgba,
9112            DType::U8,
9113            Some(TensorMemory::DmaBuf),
9114            edgefirst_tensor::CpuAccess::ReadWrite,
9115        )
9116        .unwrap();
9117
9118        let (result, _src, dst) = convert_img(
9119            &mut proc,
9120            src,
9121            dst,
9122            Rotation::None,
9123            Flip::None,
9124            Crop::no_crop(),
9125        );
9126        result.unwrap();
9127
9128        let target_image = TensorDyn::image(
9129            1280,
9130            720,
9131            PixelFormat::Rgba,
9132            DType::U8,
9133            None,
9134            edgefirst_tensor::CpuAccess::ReadWrite,
9135        )
9136        .unwrap();
9137        target_image
9138            .as_typed::<u8>()
9139            .unwrap()
9140            .map()
9141            .unwrap()
9142            .as_mut_slice()
9143            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
9144
9145        // macOS YUYV shader now threads per-tensor colorimetry: the untagged
9146        // camera720p source resolves to BT.709 limited (matching the BT.709
9147        // reference), measured 0.9973 on ANGLE — up from 0.9733 under the old
9148        // BT.601-full stop-gap. 0.98 leaves headroom for cross-GPU variance.
9149        compare_images(&dst, &target_image, 0.98, function!());
9150    }
9151
9152    /// Multi-resolution smoke test: convert YUYV→RGBA via the GL
9153    /// backend at a small (64×32) frame and a 4K (3840×2160) frame,
9154    /// both filled with a synthetic mid-grey pattern. Validates the
9155    /// shader math at the chroma-pairing boundary on small textures
9156    /// and exercises the IOSurface bytes-per-row alignment path at 4K
9157    /// (3840 pixels × 2 bytes/pixel = 7680 bytes, naturally 64-aligned).
9158    ///
9159    /// Resolutions below 32 pixels wide aren't tested because the
9160    /// IOSurface allocator pads bpr to 64 bytes — for a 4-px-wide
9161    /// YUYV surface that's 8 bytes data + 56 bytes padding per row,
9162    /// which exercises a sampling pattern that's ANGLE-version
9163    /// dependent rather than HAL-correctness dependent.
9164    ///
9165    /// This complements `test_yuyv_to_rgba_opengl_macos` (which checks
9166    /// pixel-exact correctness against a reference image at 720p) by
9167    /// ensuring the pipeline does not crash or produce gross errors at
9168    /// resolution extremes. Pixel-exact validation at 4K would require
9169    /// a 30 MB reference file we don't want to bundle.
9170    #[test]
9171    #[cfg(target_os = "macos")]
9172    #[cfg(feature = "opengl")]
9173    fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
9174        let mut proc = match GLProcessorThreaded::new(None) {
9175            Ok(p) => p,
9176            Err(e) => {
9177                crate::test_support::report_skip(&format!(
9178                    "{} — GL engine init failed ({e:?})",
9179                    function!()
9180                ));
9181                return;
9182            }
9183        };
9184
9185        for (w, h) in [(64usize, 32usize), (3840, 2160)] {
9186            // Synthetic YUYV: Y=128 (mid-grey luma), U=V=128 (neutral
9187            // chroma) → RGB grey at the output.
9188            let bytes_per_row = w * 2;
9189            let mut yuyv = vec![0u8; bytes_per_row * h];
9190            for chunk in yuyv.as_chunks_mut::<4>().0 {
9191                chunk[0] = 128; // Y0
9192                chunk[1] = 128; // U
9193                chunk[2] = 128; // Y1
9194                chunk[3] = 128; // V
9195            }
9196
9197            let src =
9198                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9199                    .unwrap();
9200
9201            let dst = TensorDyn::image(
9202                w,
9203                h,
9204                PixelFormat::Rgba,
9205                DType::U8,
9206                Some(TensorMemory::DmaBuf),
9207                edgefirst_tensor::CpuAccess::ReadWrite,
9208            )
9209            .unwrap();
9210
9211            let (result, _src, dst) = convert_img(
9212                &mut proc,
9213                src,
9214                dst,
9215                Rotation::None,
9216                Flip::None,
9217                Crop::no_crop(),
9218            );
9219            result.expect("GL convert should succeed at this resolution");
9220
9221            // The neutral-chroma input must produce a near-grey output;
9222            // BT.709 limited-range maps Y=128/UV=128 → roughly
9223            // (130, 130, 130). Allow ±4 LSB for `mediump float` shader
9224            // rounding.
9225            let dst_u8 = dst.as_typed::<u8>().unwrap();
9226            let dst_map = dst_u8.map().unwrap();
9227            let dst_bytes = dst_map.as_slice();
9228            assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
9229            for px in dst_bytes.as_chunks::<4>().0 {
9230                for (i, &c) in px[..3].iter().enumerate() {
9231                    assert!(
9232                        (120..=140).contains(&c),
9233                        "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
9234                        function!(),
9235                    );
9236                }
9237                assert_eq!(px[3], 255, "alpha must be 1.0");
9238            }
9239        }
9240    }
9241
9242    /// Verify that two consecutive convert() calls on the same source
9243    /// tensor reuse the cached EGL pbuffer. Tests the cache hit path
9244    /// added with the macOS GL backend hardening — without it, each
9245    /// frame would pay `eglCreatePbufferFromClientBuffer` + destroy.
9246    ///
9247    /// This is a behaviour test rather than a perf test (the timing
9248    /// difference is 100-200µs which is too noisy to assert on); we
9249    /// check that the second call succeeds and produces a result
9250    /// identical to the first.
9251    #[test]
9252    #[cfg(target_os = "macos")]
9253    #[cfg(feature = "opengl")]
9254    fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
9255        let mut proc = match GLProcessorThreaded::new(None) {
9256            Ok(p) => p,
9257            Err(e) => {
9258                crate::test_support::report_skip(&format!(
9259                    "{} — GL engine init failed ({e:?})",
9260                    function!()
9261                ));
9262                return;
9263            }
9264        };
9265
9266        // Allocate one source + one destination, run convert twice.
9267        let mut yuyv = vec![0u8; 64 * 32 * 2];
9268        for chunk in yuyv.as_chunks_mut::<4>().0 {
9269            chunk[0] = 200;
9270            chunk[1] = 100;
9271            chunk[2] = 200;
9272            chunk[3] = 156;
9273        }
9274        let src =
9275            load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9276                .unwrap();
9277        let dst = TensorDyn::image(
9278            64,
9279            32,
9280            PixelFormat::Rgba,
9281            DType::U8,
9282            Some(TensorMemory::DmaBuf),
9283            edgefirst_tensor::CpuAccess::ReadWrite,
9284        )
9285        .unwrap();
9286
9287        let (r1, src, dst) = convert_img(
9288            &mut proc,
9289            src,
9290            dst,
9291            Rotation::None,
9292            Flip::None,
9293            Crop::no_crop(),
9294        );
9295        r1.unwrap();
9296        let first: Vec<u8> = dst
9297            .as_typed::<u8>()
9298            .unwrap()
9299            .map()
9300            .unwrap()
9301            .as_slice()
9302            .to_vec();
9303
9304        let (r2, _src, dst) = convert_img(
9305            &mut proc,
9306            src,
9307            dst,
9308            Rotation::None,
9309            Flip::None,
9310            Crop::no_crop(),
9311        );
9312        r2.unwrap();
9313        let second: Vec<u8> = dst
9314            .as_typed::<u8>()
9315            .unwrap()
9316            .map()
9317            .unwrap()
9318            .as_slice()
9319            .to_vec();
9320
9321        assert_eq!(first, second, "cache-hit conversion must be deterministic");
9322    }
9323
9324    /// Steady-state import gate (macOS half of the Linux
9325    /// `dma_pool_steady_state_zero_imports` test): an N-frame convert loop
9326    /// over a fixed pool of IOSurface tensors must create ZERO new EGL
9327    /// pbuffers after the pool has been seen once — pbuffer-cache misses
9328    /// stay flat while hits grow. Counter-based hardening of
9329    /// `test_macos_gl_pbuffer_cache_reuses_surfaces` above: a refactor that
9330    /// re-imports per frame passes the pixel-equality test but fails this.
9331    #[test]
9332    #[cfg(target_os = "macos")]
9333    #[cfg(feature = "opengl")]
9334    fn test_macos_gl_pbuffer_cache_steady_state() {
9335        let mut proc = match GLProcessorThreaded::new(None) {
9336            Ok(p) => p,
9337            Err(e) => {
9338                crate::test_support::report_skip(&format!(
9339                    "{} — GL engine init failed ({e:?})",
9340                    function!()
9341                ));
9342                return;
9343            }
9344        };
9345
9346        let (w, h) = (64usize, 32usize);
9347        const POOL: usize = 3;
9348        const FRAMES: usize = 100;
9349
9350        let yuyv = vec![128u8; w * h * 2];
9351        let pool: Vec<TensorDyn> = (0..POOL)
9352            .map(|_| {
9353                load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
9354                    .unwrap()
9355            })
9356            .collect();
9357        let mut dst = TensorDyn::image(
9358            w,
9359            h,
9360            PixelFormat::Rgba,
9361            DType::U8,
9362            Some(TensorMemory::DmaBuf),
9363            edgefirst_tensor::CpuAccess::ReadWrite,
9364        )
9365        .unwrap();
9366
9367        // Warmup: two passes over the pool import every surface once.
9368        for src in pool.iter().cycle().take(POOL * 2) {
9369            proc.convert(src, &mut dst, None).unwrap();
9370        }
9371        let warm = proc.egl_cache_stats().unwrap();
9372
9373        for src in pool.iter().cycle().take(FRAMES) {
9374            proc.convert(src, &mut dst, None).unwrap();
9375        }
9376        let steady = proc.egl_cache_stats().unwrap();
9377
9378        assert_eq!(
9379            warm.total_misses(),
9380            steady.total_misses(),
9381            "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
9382        );
9383        let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits + s.nv_uv.hits;
9384        assert!(
9385            hits(&steady) - hits(&warm) >= FRAMES as u64,
9386            "expected at least {FRAMES} import-cache hits over the loop, got {}",
9387            hits(&steady) - hits(&warm)
9388        );
9389    }
9390
9391    /// Backend assertion for the F16 zero-copy path: when the GL backend
9392    /// initialized (ANGLE on macOS) and reports F16 color-buffer support,
9393    /// the NV12→PlanarRgb-F16 IOSurface convert MUST be handled by the GL
9394    /// engine — proven by the engine's import-cache counters moving, not
9395    /// just by output correctness. This is the guard against the
9396    /// silent-CPU-fallback failure mode: a misclassified IOSurface F16
9397    /// destination keeps every output-correctness test green while
9398    /// quietly running ~10× slower on CPU; only a backend observable
9399    /// catches it. Skips ONLY when GL itself is unavailable or the
9400    /// configuration lacks F16 — a convert error or a CPU-routed convert
9401    /// with the capability present is a FAILURE.
9402    #[test]
9403    #[cfg(target_os = "macos")]
9404    #[cfg(feature = "opengl")]
9405    fn test_macos_gl_f16_planar_is_gl_backed() {
9406        let mut proc = ImageProcessor::new().expect("ImageProcessor");
9407        let Some(ref gl) = proc.opengl else {
9408            crate::test_support::report_skip(&format!("{} — GL backend unavailable", function!()));
9409            return;
9410        };
9411        if !gl.supported_render_dtypes().f16 {
9412            crate::test_support::report_skip(&format!(
9413                "{} — configuration lacks F16 color-buffer support",
9414                function!()
9415            ));
9416            return;
9417        }
9418        let stats_before = gl.egl_cache_stats().expect("cache stats");
9419
9420        let src = TensorDyn::image(
9421            1280,
9422            720,
9423            PixelFormat::Nv12,
9424            DType::U8,
9425            Some(TensorMemory::DmaBuf),
9426            edgefirst_tensor::CpuAccess::ReadWrite,
9427        )
9428        .unwrap();
9429        {
9430            let t = src.as_typed::<u8>().unwrap();
9431            let mut m = t.map().unwrap();
9432            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9433                *b = ((i * 31) % 211) as u8;
9434            }
9435        }
9436        let mut dst = TensorDyn::image(
9437            640,
9438            640,
9439            PixelFormat::PlanarRgb,
9440            DType::F16,
9441            Some(TensorMemory::DmaBuf),
9442            edgefirst_tensor::CpuAccess::ReadWrite,
9443        )
9444        .unwrap();
9445
9446        proc.convert(
9447            &src,
9448            &mut dst,
9449            Some(crate::ConvertOptions::new().crop(Crop::letterbox([114, 114, 114, 255]))),
9450        )
9451        .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
9452        let stats_after = proc
9453            .opengl
9454            .as_ref()
9455            .expect("GL backend present")
9456            .egl_cache_stats()
9457            .expect("cache stats");
9458        // ≥ 2 misses: the fused convert imports the zero-copy NV source
9459        // (pass 1) AND the F16 destination (pass 2). Requiring both means a
9460        // convert that imported its source, failed mid-engine, and fell back
9461        // to CPU (1 miss) cannot satisfy this gate.
9462        assert!(
9463            stats_after.total_misses() >= stats_before.total_misses() + 2,
9464            "convert succeeded but the GL engine did not import both the \
9465             source and the F16 destination — the work did not (fully) run \
9466             on the GL backend (silent CPU fallback); misses before={} after={}",
9467            stats_before.total_misses(),
9468            stats_after.total_misses()
9469        );
9470    }
9471
9472    /// Portable oracle for the fused NV12→PlanarRgb-F16 engine convert
9473    /// (two GL passes: NV→RGBA intermediate, then the packed RGBA16F
9474    /// render). New on every platform with F16 render support — macOS
9475    /// IOSurface and Linux DMA-BUF alike. Compares against the CPU
9476    /// backend's reference within the float-path tolerance.
9477    #[test]
9478    #[cfg(feature = "opengl")]
9479    fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
9480        let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
9481            backend: ComputeBackend::OpenGl,
9482            ..Default::default()
9483        }) {
9484            Ok(p) if p.opengl.is_some() => p,
9485            _ => {
9486                crate::test_support::report_skip(&format!(
9487                    "{} — GL backend unavailable",
9488                    function!()
9489                ));
9490                return;
9491            }
9492        };
9493        if !gl
9494            .opengl
9495            .as_ref()
9496            .map(|g| g.supported_render_dtypes().f16)
9497            .unwrap_or(false)
9498        {
9499            crate::test_support::report_skip(&format!("{} — no F16 render support", function!()));
9500            return;
9501        }
9502        let mem = if crate::test_support::is_gpu_buffer_available() {
9503            TensorMemory::DmaBuf
9504        } else {
9505            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9506            return;
9507        };
9508
9509        let src = TensorDyn::image(
9510            1280,
9511            720,
9512            PixelFormat::Nv12,
9513            DType::U8,
9514            Some(mem),
9515            edgefirst_tensor::CpuAccess::ReadWrite,
9516        )
9517        .unwrap();
9518        {
9519            // Smooth gradients, NOT noise: the GL and CPU paths upsample
9520            // chroma with different kernels (nearest vs bilinear), which
9521            // legitimately diverges on per-texel chroma noise. Gradients
9522            // keep that kernel difference sub-LSB while still exercising
9523            // the full matrix math and the letterbox geometry.
9524            let t = src.as_typed::<u8>().unwrap();
9525            let mut m = t.map().unwrap();
9526            let buf = m.as_mut_slice();
9527            let (w, h) = (1280usize, 720usize);
9528            for y in 0..h {
9529                for x in 0..w {
9530                    buf[y * w + x] = ((x * 255) / w) as u8; // luma ramp
9531                }
9532            }
9533            for y in 0..(h / 2) {
9534                for x in 0..(w / 2) {
9535                    let o = h * w + y * w + 2 * x;
9536                    buf[o] = ((y * 255) / (h / 2)) as u8; // U vertical ramp
9537                    buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; // V diagonal
9538                }
9539            }
9540        }
9541        let crop = Crop::letterbox([114, 114, 114, 255]);
9542        let mut gl_dst = TensorDyn::image(
9543            640,
9544            640,
9545            PixelFormat::PlanarRgb,
9546            DType::F16,
9547            Some(mem),
9548            edgefirst_tensor::CpuAccess::ReadWrite,
9549        )
9550        .unwrap();
9551        // Drive the GL backend DIRECTLY: a convert through `ImageProcessor`
9552        // silently falls back to CPU on a GL error, turning this oracle into
9553        // a CPU-vs-CPU tautology. A direct call surfaces the engine error.
9554        // GPUs that allocate DMA-BUF but cannot complete an RGBA16F FBO
9555        // (desktop NVIDIA: GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8cd6)
9556        // take the production CPU fallback; this oracle cannot run there.
9557        match gl.opengl.as_mut().expect("GL backend present").convert(
9558            &src,
9559            &mut gl_dst,
9560            Some(crate::ConvertOptions::new().crop(crop)),
9561        ) {
9562            Ok(()) => {}
9563            Err(crate::Error::NotSupported(msg)) => {
9564                crate::test_support::report_skip(&format!("{} — {msg}", function!()));
9565                return;
9566            }
9567            Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
9568        }
9569
9570        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9571            backend: ComputeBackend::Cpu,
9572            ..Default::default()
9573        })
9574        .unwrap();
9575        let mut cpu_dst = TensorDyn::image(
9576            640,
9577            640,
9578            PixelFormat::PlanarRgb,
9579            DType::F16,
9580            Some(TensorMemory::Mem),
9581            edgefirst_tensor::CpuAccess::ReadWrite,
9582        )
9583        .unwrap();
9584        cpu.convert(
9585            &src,
9586            &mut cpu_dst,
9587            Some(crate::ConvertOptions::new().crop(crop)),
9588        )
9589        .expect("CPU reference convert");
9590
9591        let g = gl_dst
9592            .as_typed::<half::f16>()
9593            .unwrap()
9594            .map()
9595            .unwrap()
9596            .as_slice()
9597            .to_vec();
9598        let c = cpu_dst
9599            .as_typed::<half::f16>()
9600            .unwrap()
9601            .map()
9602            .unwrap()
9603            .as_slice()
9604            .to_vec();
9605        assert_eq!(g.len(), c.len());
9606        let mut max_diff = 0.0f32;
9607        let mut max_at = 0usize;
9608        for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
9609            let d = (a.to_f32() - b.to_f32()).abs();
9610            if d > max_diff {
9611                max_diff = d;
9612                max_at = i;
9613            }
9614        }
9615        // Localize: plane (R/G/B), row, col of the worst element.
9616        let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
9617        let (row, col) = (rem / 640, rem % 640);
9618        eprintln!(
9619            "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
9620             gl={} cpu={}",
9621            g[max_at].to_f32(),
9622            c[max_at].to_f32()
9623        );
9624        // Two GPU passes (8-bit intermediate + linear filtering) vs the
9625        // CPU's direct path: allow a few 8-bit steps of divergence.
9626        assert!(
9627            max_diff <= 4.0 / 255.0 + 1e-3,
9628            "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
9629        );
9630    }
9631
9632    /// Zero-copy source → heap destination through the GL engine,
9633    /// driven on the GL backend DIRECTLY so a GL error cannot hide
9634    /// behind the `ImageProcessor` CPU fallback (the shape that exposed
9635    /// the macOS `glReadnPixels` failure: imported source, rendered,
9636    /// then errored at the heap readback). RGBA→BGRA so the convert is
9637    /// a pure byte shuffle: no chroma kernel or colorimetry ambiguity
9638    /// (Vivante's NV fast path legitimately diverges from the CPU
9639    /// reference), and the readback stays GL_RGBA (V3D rejects RGB
9640    /// readbacks).
9641    #[test]
9642    #[cfg(feature = "opengl")]
9643    fn test_zero_copy_src_to_mem_dst_gl_direct() {
9644        let mut proc = match ImageProcessor::new() {
9645            Ok(p) if p.opengl.is_some() => p,
9646            _ => {
9647                crate::test_support::report_skip(&format!(
9648                    "{} — GL backend unavailable",
9649                    function!()
9650                ));
9651                return;
9652            }
9653        };
9654        if !crate::test_support::is_gpu_buffer_available() {
9655            crate::test_support::report_skip(&format!("{} — no zero-copy buffers", function!()));
9656            return;
9657        }
9658
9659        let src = TensorDyn::image(
9660            1280,
9661            720,
9662            PixelFormat::Rgba,
9663            DType::U8,
9664            Some(TensorMemory::DmaBuf),
9665            edgefirst_tensor::CpuAccess::ReadWrite,
9666        )
9667        .unwrap();
9668        {
9669            let t = src.as_typed::<u8>().unwrap();
9670            let mut m = t.map().unwrap();
9671            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
9672                *b = ((i * 31) % 211) as u8;
9673            }
9674        }
9675        let mut gl_dst = TensorDyn::image(
9676            1280,
9677            720,
9678            PixelFormat::Bgra,
9679            DType::U8,
9680            Some(TensorMemory::Mem),
9681            edgefirst_tensor::CpuAccess::ReadWrite,
9682        )
9683        .unwrap();
9684        proc.opengl
9685            .as_mut()
9686            .expect("GL backend present")
9687            .convert(&src, &mut gl_dst, None)
9688            .expect("zero-copy src → heap dst GL convert");
9689
9690        let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
9691            backend: ComputeBackend::Cpu,
9692            ..Default::default()
9693        })
9694        .unwrap();
9695        let mut cpu_dst = TensorDyn::image(
9696            1280,
9697            720,
9698            PixelFormat::Bgra,
9699            DType::U8,
9700            Some(TensorMemory::Mem),
9701            edgefirst_tensor::CpuAccess::ReadWrite,
9702        )
9703        .unwrap();
9704        cpu.convert(&src, &mut cpu_dst, None)
9705            .expect("CPU reference convert");
9706
9707        let g = gl_dst
9708            .as_typed::<u8>()
9709            .unwrap()
9710            .map()
9711            .unwrap()
9712            .as_slice()
9713            .to_vec();
9714        let c = cpu_dst
9715            .as_typed::<u8>()
9716            .unwrap()
9717            .map()
9718            .unwrap()
9719            .as_slice()
9720            .to_vec();
9721        assert_eq!(g.len(), c.len());
9722        let max_diff = g
9723            .iter()
9724            .zip(c.iter())
9725            .map(|(a, b)| a.abs_diff(*b))
9726            .max()
9727            .unwrap();
9728        // Same-size byte shuffle: GL samples texel centers 1:1, so allow
9729        // only rounding slack.
9730        assert!(
9731            max_diff <= 2,
9732            "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
9733        );
9734    }
9735
9736    #[test]
9737    #[cfg(target_os = "linux")]
9738    fn test_yuyv_to_rgb_g2d() {
9739        if !is_g2d_available() {
9740            crate::test_support::report_skip(
9741                "test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available",
9742            );
9743            return;
9744        }
9745        if !is_dma_available() {
9746            crate::test_support::report_skip("test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9747            return;
9748        }
9749
9750        let src = load_bytes_to_tensor(
9751            1280,
9752            720,
9753            PixelFormat::Yuyv,
9754            None,
9755            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9756        )
9757        .unwrap();
9758
9759        let g2d_dst = TensorDyn::image(
9760            1280,
9761            720,
9762            PixelFormat::Rgb,
9763            DType::U8,
9764            Some(TensorMemory::DmaBuf),
9765            edgefirst_tensor::CpuAccess::ReadWrite,
9766        )
9767        .unwrap();
9768        let mut g2d_converter = G2DProcessor::new().unwrap();
9769
9770        let (result, src, g2d_dst) = convert_img(
9771            &mut g2d_converter,
9772            src,
9773            g2d_dst,
9774            Rotation::None,
9775            Flip::None,
9776            Crop::no_crop(),
9777        );
9778        result.unwrap();
9779
9780        let cpu_dst = TensorDyn::image(
9781            1280,
9782            720,
9783            PixelFormat::Rgb,
9784            DType::U8,
9785            None,
9786            edgefirst_tensor::CpuAccess::ReadWrite,
9787        )
9788        .unwrap();
9789        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9790
9791        let (result, _src, cpu_dst) = convert_img(
9792            &mut cpu_converter,
9793            src,
9794            cpu_dst,
9795            Rotation::None,
9796            Flip::None,
9797            Crop::no_crop(),
9798        );
9799        result.unwrap();
9800
9801        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
9802        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
9803        // the YUV-matrix delta that forced 0.95 has closed; tightened to
9804        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
9805        // structural gap not exercised by these limited-range fixtures.
9806        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
9807    }
9808
9809    #[test]
9810    #[cfg(target_os = "linux")]
9811    fn test_yuyv_to_yuyv_resize_g2d() {
9812        if !is_g2d_available() {
9813            crate::test_support::report_skip(
9814                "test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available",
9815            );
9816            return;
9817        }
9818        if !is_dma_available() {
9819            crate::test_support::report_skip("test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9820            return;
9821        }
9822
9823        let src = load_bytes_to_tensor(
9824            1280,
9825            720,
9826            PixelFormat::Yuyv,
9827            None,
9828            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9829        )
9830        .unwrap();
9831
9832        let g2d_dst = TensorDyn::image(
9833            600,
9834            400,
9835            PixelFormat::Yuyv,
9836            DType::U8,
9837            Some(TensorMemory::DmaBuf),
9838            edgefirst_tensor::CpuAccess::ReadWrite,
9839        )
9840        .unwrap();
9841        let mut g2d_converter = G2DProcessor::new().unwrap();
9842
9843        let (result, src, g2d_dst) = convert_img(
9844            &mut g2d_converter,
9845            src,
9846            g2d_dst,
9847            Rotation::None,
9848            Flip::None,
9849            Crop::no_crop(),
9850        );
9851        result.unwrap();
9852
9853        let cpu_dst = TensorDyn::image(
9854            600,
9855            400,
9856            PixelFormat::Yuyv,
9857            DType::U8,
9858            None,
9859            edgefirst_tensor::CpuAccess::ReadWrite,
9860        )
9861        .unwrap();
9862        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
9863
9864        let (result, _src, cpu_dst) = convert_img(
9865            &mut cpu_converter,
9866            src,
9867            cpu_dst,
9868            Rotation::None,
9869            Flip::None,
9870            Crop::no_crop(),
9871        );
9872        result.unwrap();
9873
9874        // G2D has poor colorimetry support: its hardware YUYV resize/sampling
9875        // diverges from the CPU reference by enough that the similarity score
9876        // sits around 0.85 on every measured G2D core (i.MX 8M Plus, i.MX 95).
9877        // The threshold is held at 0.85 so the test guards against gross
9878        // regressions while tolerating the driver's inherent colorimetry error.
9879        // TODO: compare YUYV↔YUYV directly without a YUYV→RGB convert.
9880        eprintln!(
9881            "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
9882             CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
9883        );
9884        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
9885    }
9886
9887    #[test]
9888    fn test_yuyv_to_rgba_resize_cpu() {
9889        let src = load_bytes_to_tensor(
9890            1280,
9891            720,
9892            PixelFormat::Yuyv,
9893            None,
9894            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9895        )
9896        .unwrap();
9897
9898        let (dst_width, dst_height) = (960, 540);
9899
9900        let dst = TensorDyn::image(
9901            dst_width,
9902            dst_height,
9903            PixelFormat::Rgba,
9904            DType::U8,
9905            None,
9906            edgefirst_tensor::CpuAccess::ReadWrite,
9907        )
9908        .unwrap();
9909        let mut cpu_converter = CPUProcessor::new();
9910
9911        let (result, _src, dst) = convert_img(
9912            &mut cpu_converter,
9913            src,
9914            dst,
9915            Rotation::None,
9916            Flip::None,
9917            Crop::no_crop(),
9918        );
9919        result.unwrap();
9920
9921        let dst_target = TensorDyn::image(
9922            dst_width,
9923            dst_height,
9924            PixelFormat::Rgba,
9925            DType::U8,
9926            None,
9927            edgefirst_tensor::CpuAccess::ReadWrite,
9928        )
9929        .unwrap();
9930        let src_target = load_bytes_to_tensor(
9931            1280,
9932            720,
9933            PixelFormat::Rgba,
9934            None,
9935            &edgefirst_bench::testdata::read("camera720p.rgba"),
9936        )
9937        .unwrap();
9938        let (result, _src_target, dst_target) = convert_img(
9939            &mut cpu_converter,
9940            src_target,
9941            dst_target,
9942            Rotation::None,
9943            Flip::None,
9944            Crop::no_crop(),
9945        );
9946        result.unwrap();
9947
9948        // CPU path resolves the untagged 720p source to BT.709 limited (height
9949        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
9950        compare_images(&dst, &dst_target, 0.98, function!());
9951    }
9952
9953    #[test]
9954    #[cfg(target_os = "linux")]
9955    fn test_yuyv_to_rgba_crop_flip_g2d() {
9956        if !is_g2d_available() {
9957            crate::test_support::report_skip(
9958                "test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available",
9959            );
9960            return;
9961        }
9962        if !is_dma_available() {
9963            crate::test_support::report_skip("test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
9964            return;
9965        }
9966
9967        let src = load_bytes_to_tensor(
9968            1280,
9969            720,
9970            PixelFormat::Yuyv,
9971            Some(TensorMemory::DmaBuf),
9972            &edgefirst_bench::testdata::read("camera720p.yuyv"),
9973        )
9974        .unwrap();
9975
9976        let (dst_width, dst_height) = (640, 640);
9977
9978        let dst_g2d = TensorDyn::image(
9979            dst_width,
9980            dst_height,
9981            PixelFormat::Rgba,
9982            DType::U8,
9983            Some(TensorMemory::DmaBuf),
9984            edgefirst_tensor::CpuAccess::ReadWrite,
9985        )
9986        .unwrap();
9987        let mut g2d_converter = G2DProcessor::new().unwrap();
9988        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
9989
9990        let (result, src, dst_g2d) = convert_img(
9991            &mut g2d_converter,
9992            src,
9993            dst_g2d,
9994            Rotation::None,
9995            Flip::Horizontal,
9996            crop,
9997        );
9998        result.unwrap();
9999
10000        let dst_cpu = TensorDyn::image(
10001            dst_width,
10002            dst_height,
10003            PixelFormat::Rgba,
10004            DType::U8,
10005            Some(TensorMemory::DmaBuf),
10006            edgefirst_tensor::CpuAccess::ReadWrite,
10007        )
10008        .unwrap();
10009        let mut cpu_converter = CPUProcessor::new();
10010
10011        let (result, _src, dst_cpu) = convert_img(
10012            &mut cpu_converter,
10013            src,
10014            dst_cpu,
10015            Rotation::None,
10016            Flip::Horizontal,
10017            crop,
10018        );
10019        result.unwrap();
10020        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10021        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10022        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10023        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10024        // structural gap not exercised by these limited-range fixtures.
10025        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
10026    }
10027
10028    #[test]
10029    #[cfg(any(
10030        target_os = "linux",
10031        target_os = "macos",
10032        target_os = "ios",
10033        target_os = "android",
10034        target_os = "windows"
10035    ))]
10036    #[cfg(feature = "opengl")]
10037    fn test_yuyv_to_rgba_crop_flip_opengl() {
10038        if !is_opengl_available() {
10039            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10040            return;
10041        }
10042
10043        if !crate::test_support::is_gpu_buffer_available() {
10044            crate::test_support::report_skip(&format!(
10045                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10046                function!()
10047            ));
10048            return;
10049        }
10050
10051        let src = load_bytes_to_tensor(
10052            1280,
10053            720,
10054            PixelFormat::Yuyv,
10055            Some(TensorMemory::DmaBuf),
10056            &edgefirst_bench::testdata::read("camera720p.yuyv"),
10057        )
10058        .unwrap();
10059
10060        let (dst_width, dst_height) = (640, 640);
10061
10062        let dst_gl = TensorDyn::image(
10063            dst_width,
10064            dst_height,
10065            PixelFormat::Rgba,
10066            DType::U8,
10067            Some(TensorMemory::DmaBuf),
10068            edgefirst_tensor::CpuAccess::ReadWrite,
10069        )
10070        .unwrap();
10071        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10072        let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
10073
10074        let (result, src, dst_gl) = convert_img(
10075            &mut gl_converter,
10076            src,
10077            dst_gl,
10078            Rotation::None,
10079            Flip::Horizontal,
10080            crop,
10081        );
10082        // Desktop NVIDIA cannot sample YUYV as a GL texture; i.MX/Vivante can.
10083        if let Err(crate::Error::NotSupported(msg)) = &result {
10084            crate::test_support::report_skip(&format!("{} — {msg}", function!()));
10085            return;
10086        }
10087        result.unwrap();
10088
10089        let dst_cpu = TensorDyn::image(
10090            dst_width,
10091            dst_height,
10092            PixelFormat::Rgba,
10093            DType::U8,
10094            Some(TensorMemory::DmaBuf),
10095            edgefirst_tensor::CpuAccess::ReadWrite,
10096        )
10097        .unwrap();
10098        let mut cpu_converter = CPUProcessor::new();
10099
10100        let (result, _src, dst_cpu) = convert_img(
10101            &mut cpu_converter,
10102            src,
10103            dst_cpu,
10104            Rotation::None,
10105            Flip::Horizontal,
10106            crop,
10107        );
10108        result.unwrap();
10109        // Post-WS1 the GL path applies the resolved colorimetry via the EGL
10110        // YUV color-space/sample-range hints, so the matrix delta that forced
10111        // 0.95 has closed; tightened to 0.98 (driver-matrix rounding confirmed
10112        // on the GPU lanes).
10113        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
10114    }
10115
10116    #[test]
10117    fn test_vyuy_to_rgba_cpu() {
10118        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10119        let src = TensorDyn::image(
10120            1280,
10121            720,
10122            PixelFormat::Vyuy,
10123            DType::U8,
10124            None,
10125            edgefirst_tensor::CpuAccess::ReadWrite,
10126        )
10127        .unwrap();
10128        src.as_typed::<u8>()
10129            .unwrap()
10130            .map()
10131            .unwrap()
10132            .as_mut_slice()
10133            .copy_from_slice(&file);
10134
10135        let dst = TensorDyn::image(
10136            1280,
10137            720,
10138            PixelFormat::Rgba,
10139            DType::U8,
10140            None,
10141            edgefirst_tensor::CpuAccess::ReadWrite,
10142        )
10143        .unwrap();
10144        let mut cpu_converter = CPUProcessor::new();
10145
10146        let (result, _src, dst) = convert_img(
10147            &mut cpu_converter,
10148            src,
10149            dst,
10150            Rotation::None,
10151            Flip::None,
10152            Crop::no_crop(),
10153        );
10154        result.unwrap();
10155
10156        let target_image = TensorDyn::image(
10157            1280,
10158            720,
10159            PixelFormat::Rgba,
10160            DType::U8,
10161            None,
10162            edgefirst_tensor::CpuAccess::ReadWrite,
10163        )
10164        .unwrap();
10165        target_image
10166            .as_typed::<u8>()
10167            .unwrap()
10168            .map()
10169            .unwrap()
10170            .as_mut_slice()
10171            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10172
10173        // CPU path resolves the untagged 720p source to BT.709 limited (height
10174        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10175        compare_images(&dst, &target_image, 0.98, function!());
10176    }
10177
10178    #[test]
10179    fn test_vyuy_to_rgb_cpu() {
10180        let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
10181        let src = TensorDyn::image(
10182            1280,
10183            720,
10184            PixelFormat::Vyuy,
10185            DType::U8,
10186            None,
10187            edgefirst_tensor::CpuAccess::ReadWrite,
10188        )
10189        .unwrap();
10190        src.as_typed::<u8>()
10191            .unwrap()
10192            .map()
10193            .unwrap()
10194            .as_mut_slice()
10195            .copy_from_slice(&file);
10196
10197        let dst = TensorDyn::image(
10198            1280,
10199            720,
10200            PixelFormat::Rgb,
10201            DType::U8,
10202            None,
10203            edgefirst_tensor::CpuAccess::ReadWrite,
10204        )
10205        .unwrap();
10206        let mut cpu_converter = CPUProcessor::new();
10207
10208        let (result, _src, dst) = convert_img(
10209            &mut cpu_converter,
10210            src,
10211            dst,
10212            Rotation::None,
10213            Flip::None,
10214            Crop::no_crop(),
10215        );
10216        result.unwrap();
10217
10218        let target_image = TensorDyn::image(
10219            1280,
10220            720,
10221            PixelFormat::Rgb,
10222            DType::U8,
10223            None,
10224            edgefirst_tensor::CpuAccess::ReadWrite,
10225        )
10226        .unwrap();
10227        target_image
10228            .as_typed::<u8>()
10229            .unwrap()
10230            .map()
10231            .unwrap()
10232            .as_mut_slice()
10233            .as_chunks_mut::<3>()
10234            .0
10235            .iter_mut()
10236            .zip(
10237                edgefirst_bench::testdata::read("camera720p.rgba")
10238                    .as_chunks::<4>()
10239                    .0,
10240            )
10241            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
10242
10243        // CPU path resolves the untagged 720p source to BT.709 limited (height
10244        // heuristic), matching the BT.709 camera fixture; measured 0.9995.
10245        compare_images(&dst, &target_image, 0.98, function!());
10246    }
10247
10248    #[test]
10249    #[cfg(target_os = "linux")]
10250    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10251    fn test_vyuy_to_rgba_g2d() {
10252        if !is_g2d_available() {
10253            crate::test_support::report_skip(
10254                "test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available",
10255            );
10256            return;
10257        }
10258        if !is_dma_available() {
10259            crate::test_support::report_skip("test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10260            return;
10261        }
10262
10263        let src = load_bytes_to_tensor(
10264            1280,
10265            720,
10266            PixelFormat::Vyuy,
10267            None,
10268            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10269        )
10270        .unwrap();
10271
10272        let dst = TensorDyn::image(
10273            1280,
10274            720,
10275            PixelFormat::Rgba,
10276            DType::U8,
10277            Some(TensorMemory::DmaBuf),
10278            edgefirst_tensor::CpuAccess::ReadWrite,
10279        )
10280        .unwrap();
10281        let mut g2d_converter = G2DProcessor::new().unwrap();
10282
10283        let (result, _src, dst) = convert_img(
10284            &mut g2d_converter,
10285            src,
10286            dst,
10287            Rotation::None,
10288            Flip::None,
10289            Crop::no_crop(),
10290        );
10291        match result {
10292            Err(Error::G2D(_)) => {
10293                crate::test_support::report_skip(
10294                    "test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format",
10295                );
10296                return;
10297            }
10298            r => r.unwrap(),
10299        }
10300
10301        let target_image = TensorDyn::image(
10302            1280,
10303            720,
10304            PixelFormat::Rgba,
10305            DType::U8,
10306            None,
10307            edgefirst_tensor::CpuAccess::ReadWrite,
10308        )
10309        .unwrap();
10310        target_image
10311            .as_typed::<u8>()
10312            .unwrap()
10313            .map()
10314            .unwrap()
10315            .as_mut_slice()
10316            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10317
10318        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10319        // so the matrix delta vs the reference that forced 0.95 has closed;
10320        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10321        compare_images(&dst, &target_image, 0.98, function!());
10322    }
10323
10324    #[test]
10325    #[cfg(target_os = "linux")]
10326    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
10327    fn test_vyuy_to_rgb_g2d() {
10328        if !is_g2d_available() {
10329            crate::test_support::report_skip(
10330                "test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available",
10331            );
10332            return;
10333        }
10334        if !is_dma_available() {
10335            crate::test_support::report_skip("test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)");
10336            return;
10337        }
10338
10339        let src = load_bytes_to_tensor(
10340            1280,
10341            720,
10342            PixelFormat::Vyuy,
10343            None,
10344            &edgefirst_bench::testdata::read("camera720p.vyuy"),
10345        )
10346        .unwrap();
10347
10348        let g2d_dst = TensorDyn::image(
10349            1280,
10350            720,
10351            PixelFormat::Rgb,
10352            DType::U8,
10353            Some(TensorMemory::DmaBuf),
10354            edgefirst_tensor::CpuAccess::ReadWrite,
10355        )
10356        .unwrap();
10357        let mut g2d_converter = G2DProcessor::new().unwrap();
10358
10359        let (result, src, g2d_dst) = convert_img(
10360            &mut g2d_converter,
10361            src,
10362            g2d_dst,
10363            Rotation::None,
10364            Flip::None,
10365            Crop::no_crop(),
10366        );
10367        match result {
10368            Err(Error::G2D(_)) => {
10369                crate::test_support::report_skip(
10370                    "test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format",
10371                );
10372                return;
10373            }
10374            r => r.unwrap(),
10375        }
10376
10377        let cpu_dst = TensorDyn::image(
10378            1280,
10379            720,
10380            PixelFormat::Rgb,
10381            DType::U8,
10382            None,
10383            edgefirst_tensor::CpuAccess::ReadWrite,
10384        )
10385        .unwrap();
10386        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
10387
10388        let (result, _src, cpu_dst) = convert_img(
10389            &mut cpu_converter,
10390            src,
10391            cpu_dst,
10392            Rotation::None,
10393            Flip::None,
10394            Crop::no_crop(),
10395        );
10396        result.unwrap();
10397
10398        // Post-WS1 both CPU and G2D resolve untagged sources to limited-
10399        // range BT.601/709 (G2D is limited-range matrix-only hardware), so
10400        // the YUV-matrix delta that forced 0.95 has closed; tightened to
10401        // 0.98. G2D declines full-range and BT.2020 (handled by GL/CPU) — a
10402        // structural gap not exercised by these limited-range fixtures.
10403        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
10404    }
10405
10406    #[test]
10407    #[cfg(any(
10408        target_os = "linux",
10409        target_os = "macos",
10410        target_os = "ios",
10411        target_os = "android",
10412        target_os = "windows"
10413    ))]
10414    #[cfg(feature = "opengl")]
10415    fn test_vyuy_to_rgba_opengl() {
10416        if !is_opengl_available() {
10417            crate::test_support::report_skip(&format!("{} - OpenGL not available", function!()));
10418            return;
10419        }
10420        if !crate::test_support::is_gpu_buffer_available() {
10421            crate::test_support::report_skip(&format!(
10422                "{} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
10423                function!()
10424            ));
10425            return;
10426        }
10427
10428        // VYUY has a DMA-BUF FourCC on Linux but no IOSurface mapping on macOS/iOS.
10429        let expect = if cfg!(target_os = "linux") {
10430            crate::test_support::Expect::Succeeds
10431        } else {
10432            crate::test_support::Expect::MayFail("no zero-copy VYUY mapping on this platform")
10433        };
10434        let Some(src) = crate::test_support::allocated(
10435            load_bytes_to_tensor(
10436                1280,
10437                720,
10438                PixelFormat::Vyuy,
10439                Some(TensorMemory::DmaBuf),
10440                &edgefirst_bench::testdata::read("camera720p.vyuy"),
10441            ),
10442            "zero-copy VYUY source",
10443            expect,
10444        ) else {
10445            return;
10446        };
10447
10448        let dst = TensorDyn::image(
10449            1280,
10450            720,
10451            PixelFormat::Rgba,
10452            DType::U8,
10453            Some(TensorMemory::DmaBuf),
10454            edgefirst_tensor::CpuAccess::ReadWrite,
10455        )
10456        .unwrap();
10457        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
10458
10459        let (result, _src, dst) = convert_img(
10460            &mut gl_converter,
10461            src,
10462            dst,
10463            Rotation::None,
10464            Flip::None,
10465            Crop::no_crop(),
10466        );
10467        match result {
10468            Err(Error::NotSupported(_)) => {
10469                crate::test_support::report_skip(&format!(
10470                    "{} - OpenGL does not support PixelFormat::Vyuy DMA format",
10471                    function!()
10472                ));
10473                return;
10474            }
10475            r => r.unwrap(),
10476        }
10477
10478        let target_image = TensorDyn::image(
10479            1280,
10480            720,
10481            PixelFormat::Rgba,
10482            DType::U8,
10483            None,
10484            edgefirst_tensor::CpuAccess::ReadWrite,
10485        )
10486        .unwrap();
10487        target_image
10488            .as_typed::<u8>()
10489            .unwrap()
10490            .map()
10491            .unwrap()
10492            .as_mut_slice()
10493            .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
10494
10495        // Post-WS1 the GPU path applies the resolved per-tensor colorimetry,
10496        // so the matrix delta vs the reference that forced 0.95 has closed;
10497        // tightened to 0.98 (confirmed on the GPU/G2D lanes).
10498        compare_images(&dst, &target_image, 0.98, function!());
10499    }
10500
10501    #[test]
10502    fn test_nv12_to_rgba_cpu() {
10503        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10504        let src = TensorDyn::image(
10505            1280,
10506            720,
10507            PixelFormat::Nv12,
10508            DType::U8,
10509            None,
10510            edgefirst_tensor::CpuAccess::ReadWrite,
10511        )
10512        .unwrap();
10513        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10514            .copy_from_slice(&file);
10515
10516        let dst = TensorDyn::image(
10517            1280,
10518            720,
10519            PixelFormat::Rgba,
10520            DType::U8,
10521            None,
10522            edgefirst_tensor::CpuAccess::ReadWrite,
10523        )
10524        .unwrap();
10525        let mut cpu_converter = CPUProcessor::new();
10526
10527        let (result, _src, dst) = convert_img(
10528            &mut cpu_converter,
10529            src,
10530            dst,
10531            Rotation::None,
10532            Flip::None,
10533            Crop::no_crop(),
10534        );
10535        result.unwrap();
10536
10537        let target_image = crate::load_image_test_helper(
10538            &edgefirst_bench::testdata::read("zidane.jpg"),
10539            Some(PixelFormat::Rgba),
10540            None,
10541        )
10542        .unwrap();
10543
10544        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10545        // to native NV12 and then converts to RGBA (was a direct JPEG → RGBA
10546        // decode), so it differs slightly from the RGBA derived from the
10547        // separate `zidane.nv12` fixture.
10548        compare_images(&dst, &target_image, 0.95, function!());
10549    }
10550
10551    #[test]
10552    fn test_nv12_odd_height_to_rgb_cpu() {
10553        // Odd height (even width) — the logical-odd case, e.g. 640×483. The
10554        // contiguous NV12 buffer is `[5 + ceil(5/2), 8]` = `[8, 8]` (5 luma rows
10555        // + 3 chroma rows). A neutral-grey fill (Y=U=V=128, BT.601 full-range)
10556        // must convert to a uniform grey RGB, exercising the odd-height
10557        // chroma-row count and the logical-height derivation in convert.
10558        // (Odd *width* is rounded to an even buffer at allocation, so it is
10559        // covered by the decode integration tests rather than here.)
10560        // CPU-only test: pin to tight host memory (None auto-selects pitch-padded
10561        // DMA on i.MX, which would leave the dst's row padding unconverted and
10562        // break the flat byte scan below).
10563        let mut src = TensorDyn::image(
10564            8,
10565            5,
10566            PixelFormat::Nv12,
10567            DType::U8,
10568            Some(TensorMemory::Mem),
10569            edgefirst_tensor::CpuAccess::ReadWrite,
10570        )
10571        .unwrap();
10572        assert_eq!(src.shape(), &[8, 8]);
10573        assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
10574        src.as_typed::<u8>()
10575            .unwrap()
10576            .map()
10577            .unwrap()
10578            .as_mut_slice()
10579            .fill(128);
10580        // Tag BT.601 full-range so Y=128 decodes to grey 128 (the neutral-grey
10581        // identity this test asserts). Without a tag, the colorimetry heuristic
10582        // resolves an SD tensor to BT.601 *limited*, expanding Y=128 → ~131.
10583        src.set_colorimetry(Some(
10584            edgefirst_tensor::Colorimetry::default()
10585                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10586                .with_range(edgefirst_tensor::ColorRange::Full),
10587        ));
10588
10589        let dst = TensorDyn::image(
10590            8,
10591            5,
10592            PixelFormat::Rgb,
10593            DType::U8,
10594            Some(TensorMemory::Mem),
10595            edgefirst_tensor::CpuAccess::ReadWrite,
10596        )
10597        .unwrap();
10598        let mut cpu_converter = CPUProcessor::new();
10599        let (result, _src, dst) = convert_img(
10600            &mut cpu_converter,
10601            src,
10602            dst,
10603            Rotation::None,
10604            Flip::None,
10605            Crop::no_crop(),
10606        );
10607        result.unwrap();
10608
10609        assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
10610        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10611        for (i, &b) in map.as_slice().iter().enumerate() {
10612            assert!(
10613                (b as i16 - 128).abs() <= 2,
10614                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
10615            );
10616        }
10617    }
10618
10619    #[test]
10620    fn test_nv24_to_rgb_cpu() {
10621        // NV24 (4:4:4) at 8×4: contiguous buffer is [4*3, 8] = [12, 8] — Y plane
10622        // (4 rows) + full-res interleaved UV plane (8 rows = 2H, 2W bytes per
10623        // chroma row). Neutral-grey fill (Y=U=V=128) must convert to uniform
10624        // grey RGB, exercising the 2× UV stride and shape[0]/3 height recovery.
10625        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10626        let mut src = TensorDyn::image(
10627            8,
10628            4,
10629            PixelFormat::Nv24,
10630            DType::U8,
10631            Some(TensorMemory::Mem),
10632            edgefirst_tensor::CpuAccess::ReadWrite,
10633        )
10634        .unwrap();
10635        assert_eq!(src.shape(), &[12, 8]);
10636        assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
10637        src.as_typed::<u8>()
10638            .unwrap()
10639            .map()
10640            .unwrap()
10641            .as_mut_slice()
10642            .fill(128);
10643        // Tag BT.601 full-range (see test_nv12_odd_height_to_rgb_cpu): without it
10644        // the heuristic picks limited range and Y=128 expands to ~131.
10645        src.set_colorimetry(Some(
10646            edgefirst_tensor::Colorimetry::default()
10647                .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
10648                .with_range(edgefirst_tensor::ColorRange::Full),
10649        ));
10650
10651        let dst = TensorDyn::image(
10652            8,
10653            4,
10654            PixelFormat::Rgb,
10655            DType::U8,
10656            Some(TensorMemory::Mem),
10657            edgefirst_tensor::CpuAccess::ReadWrite,
10658        )
10659        .unwrap();
10660        let mut cpu_converter = CPUProcessor::new();
10661        let (result, _src, dst) = convert_img(
10662            &mut cpu_converter,
10663            src,
10664            dst,
10665            Rotation::None,
10666            Flip::None,
10667            Crop::no_crop(),
10668        );
10669        result.unwrap();
10670
10671        assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
10672        let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10673        for (i, &b) in map.as_slice().iter().enumerate() {
10674            assert!(
10675                (b as i16 - 128).abs() <= 2,
10676                "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
10677            );
10678        }
10679    }
10680
10681    #[test]
10682    fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
10683        // A uniform but *saturated* chroma sample (U/V far from neutral) so the
10684        // YUV→RGB matrix — not just the range — drives the result. Decoding the
10685        // same NV12 bytes under BT.601 / BT.709 / BT.2020 must yield three
10686        // distinct RGB triples, proving the CPU path honours the source's tagged
10687        // ColorEncoding instead of a hardcoded matrix. (G2D declines BT.2020 and
10688        // falls through to this CPU path; QA F9.)
10689        // CPU-only test: pin to tight host memory (see test_nv12_odd_height_to_rgb_cpu).
10690        fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
10691            let mut src = TensorDyn::image(
10692                8,
10693                4,
10694                PixelFormat::Nv12,
10695                DType::U8,
10696                Some(TensorMemory::Mem),
10697                edgefirst_tensor::CpuAccess::ReadWrite,
10698            )
10699            .unwrap();
10700            // NV12 8×4: 32-byte Y plane + 16-byte interleaved UV plane (4:2:0).
10701            assert_eq!(src.shape(), &[6, 8]);
10702            {
10703                let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
10704                let buf = map.as_mut_slice();
10705                buf[..32].fill(120); // Y
10706                for px in buf[32..].as_chunks_mut::<2>().0 {
10707                    px[0] = 180; // U / Cb
10708                    px[1] = 64; // V / Cr
10709                }
10710            }
10711            // Range held constant (Limited) across all three so only the encoding
10712            // matrix varies between runs.
10713            src.set_colorimetry(Some(
10714                edgefirst_tensor::Colorimetry::default()
10715                    .with_encoding(enc)
10716                    .with_range(edgefirst_tensor::ColorRange::Limited),
10717            ));
10718            let dst = TensorDyn::image(
10719                8,
10720                4,
10721                PixelFormat::Rgb,
10722                DType::U8,
10723                Some(TensorMemory::Mem),
10724                edgefirst_tensor::CpuAccess::ReadWrite,
10725            )
10726            .unwrap();
10727            let mut cpu = CPUProcessor::new();
10728            let (result, _src, dst) = convert_img(
10729                &mut cpu,
10730                src,
10731                dst,
10732                Rotation::None,
10733                Flip::None,
10734                Crop::no_crop(),
10735            );
10736            result.unwrap();
10737            let map = dst.as_typed::<u8>().unwrap().map().unwrap();
10738            let s = map.as_slice();
10739            [s[0], s[1], s[2]]
10740        }
10741
10742        let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
10743        let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
10744        let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
10745
10746        assert_ne!(
10747            bt2020, bt601,
10748            "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
10749        );
10750        assert_ne!(
10751            bt2020, bt709,
10752            "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
10753        );
10754        assert_ne!(
10755            bt601, bt709,
10756            "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
10757        );
10758    }
10759
10760    #[test]
10761    fn test_nv12_to_rgb_cpu() {
10762        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10763        let src = TensorDyn::image(
10764            1280,
10765            720,
10766            PixelFormat::Nv12,
10767            DType::U8,
10768            None,
10769            edgefirst_tensor::CpuAccess::ReadWrite,
10770        )
10771        .unwrap();
10772        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10773            .copy_from_slice(&file);
10774
10775        let dst = TensorDyn::image(
10776            1280,
10777            720,
10778            PixelFormat::Rgb,
10779            DType::U8,
10780            None,
10781            edgefirst_tensor::CpuAccess::ReadWrite,
10782        )
10783        .unwrap();
10784        let mut cpu_converter = CPUProcessor::new();
10785
10786        let (result, _src, dst) = convert_img(
10787            &mut cpu_converter,
10788            src,
10789            dst,
10790            Rotation::None,
10791            Flip::None,
10792            Crop::no_crop(),
10793        );
10794        result.unwrap();
10795
10796        let target_image = crate::load_image_test_helper(
10797            &edgefirst_bench::testdata::read("zidane.jpg"),
10798            Some(PixelFormat::Rgb),
10799            None,
10800        )
10801        .unwrap();
10802
10803        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10804        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10805        // decode), so it differs slightly from the RGB derived from the
10806        // separate `zidane.nv12` fixture.
10807        compare_images(&dst, &target_image, 0.95, function!());
10808    }
10809
10810    #[test]
10811    fn test_nv12_to_grey_cpu() {
10812        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10813        let src = TensorDyn::image(
10814            1280,
10815            720,
10816            PixelFormat::Nv12,
10817            DType::U8,
10818            None,
10819            edgefirst_tensor::CpuAccess::ReadWrite,
10820        )
10821        .unwrap();
10822        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10823            .copy_from_slice(&file);
10824
10825        let dst = TensorDyn::image(
10826            1280,
10827            720,
10828            PixelFormat::Grey,
10829            DType::U8,
10830            None,
10831            edgefirst_tensor::CpuAccess::ReadWrite,
10832        )
10833        .unwrap();
10834        let mut cpu_converter = CPUProcessor::new();
10835
10836        let (result, _src, dst) = convert_img(
10837            &mut cpu_converter,
10838            src,
10839            dst,
10840            Rotation::None,
10841            Flip::None,
10842            Crop::no_crop(),
10843        );
10844        result.unwrap();
10845
10846        let target_image = crate::load_image_test_helper(
10847            &edgefirst_bench::testdata::read("zidane.jpg"),
10848            Some(PixelFormat::Grey),
10849            None,
10850        )
10851        .unwrap();
10852
10853        // Threshold 0.95 (was 0.98): the reference grey frame now comes from
10854        // the colour JPEG decoded to native NV12 and then converted to GREY
10855        // (was a direct JPEG → GREY decode), so it differs slightly from the
10856        // grey derived from the `zidane.nv12` fixture.
10857        compare_images(&dst, &target_image, 0.95, function!());
10858    }
10859
10860    #[test]
10861    fn test_nv12_to_yuyv_cpu() {
10862        let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
10863        let src = TensorDyn::image(
10864            1280,
10865            720,
10866            PixelFormat::Nv12,
10867            DType::U8,
10868            None,
10869            edgefirst_tensor::CpuAccess::ReadWrite,
10870        )
10871        .unwrap();
10872        src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
10873            .copy_from_slice(&file);
10874
10875        let dst = TensorDyn::image(
10876            1280,
10877            720,
10878            PixelFormat::Yuyv,
10879            DType::U8,
10880            None,
10881            edgefirst_tensor::CpuAccess::ReadWrite,
10882        )
10883        .unwrap();
10884        let mut cpu_converter = CPUProcessor::new();
10885
10886        let (result, _src, dst) = convert_img(
10887            &mut cpu_converter,
10888            src,
10889            dst,
10890            Rotation::None,
10891            Flip::None,
10892            Crop::no_crop(),
10893        );
10894        result.unwrap();
10895
10896        let target_image = crate::load_image_test_helper(
10897            &edgefirst_bench::testdata::read("zidane.jpg"),
10898            Some(PixelFormat::Rgb),
10899            None,
10900        )
10901        .unwrap();
10902
10903        // Threshold 0.95 (was 0.98): the reference now decodes the colour JPEG
10904        // to native NV12 and then converts to RGB (was a direct JPEG → RGB
10905        // decode), so it differs slightly from the YUYV-sourced frame derived
10906        // from the separate `zidane.nv12` fixture.
10907        compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
10908    }
10909
10910    #[test]
10911    fn test_cpu_resize_nv16() {
10912        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
10913        let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
10914
10915        let cpu_nv16_dst = TensorDyn::image(
10916            640,
10917            640,
10918            PixelFormat::Nv16,
10919            DType::U8,
10920            None,
10921            edgefirst_tensor::CpuAccess::ReadWrite,
10922        )
10923        .unwrap();
10924        let cpu_rgb_dst = TensorDyn::image(
10925            640,
10926            640,
10927            PixelFormat::Rgb,
10928            DType::U8,
10929            None,
10930            edgefirst_tensor::CpuAccess::ReadWrite,
10931        )
10932        .unwrap();
10933        let mut cpu_converter = CPUProcessor::new();
10934        let crop = Crop::letterbox([255, 128, 0, 255]);
10935
10936        let (result, src, cpu_nv16_dst) = convert_img(
10937            &mut cpu_converter,
10938            src,
10939            cpu_nv16_dst,
10940            Rotation::None,
10941            Flip::None,
10942            crop,
10943        );
10944        result.unwrap();
10945
10946        let (result, _src, cpu_rgb_dst) = convert_img(
10947            &mut cpu_converter,
10948            src,
10949            cpu_rgb_dst,
10950            Rotation::None,
10951            Flip::None,
10952            crop,
10953        );
10954        result.unwrap();
10955        compare_images_convert_to_rgb(
10956            &cpu_nv16_dst,
10957            &cpu_rgb_dst,
10958            yuv_threshold(0.99),
10959            function!(),
10960        );
10961    }
10962
10963    /// Similarity threshold for a comparison whose path runs a YUV↔RGB
10964    /// conversion.
10965    ///
10966    /// [`crate::cpu::convert`]'s `yuv_mode()` deliberately selects
10967    /// `YuvConversionMode::Fast` on cores without Armv8.1 `rdm`
10968    /// (Cortex-A53/A35 class, e.g. the i.MX 8M Plus), because `Balanced`
10969    /// emulates `SQRDMLAH` there and costs roughly 2×. `Fast` is a
10970    /// lower-precision integer approximation with a documented error of up to
10971    /// ~2/255, so a threshold calibrated on an `rdm`-capable core is
10972    /// unreachable on those cores **by design**: NV16 measures 0.9885 against
10973    /// 0.99 on an A53, while every `rdm` core passes.
10974    ///
10975    /// No CI runner is A53-class, so this only reproduces on target.
10976    ///
10977    /// Relaxing per-core keeps the assertion tight where the precise path
10978    /// runs, instead of loosening it everywhere to accommodate one core.
10979    fn yuv_threshold(balanced: f64) -> f64 {
10980        #[cfg(target_arch = "aarch64")]
10981        {
10982            if !std::arch::is_aarch64_feature_detected!("rdm") {
10983                return balanced - 0.01;
10984            }
10985        }
10986        balanced
10987    }
10988
10989    fn load_bytes_to_tensor(
10990        width: usize,
10991        height: usize,
10992        format: PixelFormat,
10993        memory: Option<TensorMemory>,
10994        bytes: &[u8],
10995    ) -> Result<TensorDyn, Error> {
10996        let src = TensorDyn::image(
10997            width,
10998            height,
10999            format,
11000            DType::U8,
11001            memory,
11002            edgefirst_tensor::CpuAccess::ReadWrite,
11003        )?;
11004        src.as_typed::<u8>()
11005            .unwrap()
11006            .map()?
11007            .as_mut_slice()
11008            .copy_from_slice(bytes);
11009        Ok(src)
11010    }
11011
11012    // DEDUP: this function is also defined verbatim in
11013    // `crates/image/src/gl/tests.rs` (inside `mod gl_tests`). Both copies
11014    // must be kept in sync. Cross-module sharing would require either a
11015    // `pub(crate)` test-helper module (which pollutes the non-test API) or a
11016    // separate test-utils crate — both are disproportionate for a single
11017    // helper. If the implementation ever diverges, extract to a shared
11018    // `test_helpers` module in this crate.
11019    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
11020        assert_eq!(img1.height(), img2.height(), "Heights differ");
11021        assert_eq!(img1.width(), img2.width(), "Widths differ");
11022        assert_eq!(
11023            img1.format().unwrap(),
11024            img2.format().unwrap(),
11025            "PixelFormat differ"
11026        );
11027        assert!(
11028            matches!(
11029                img1.format().unwrap(),
11030                PixelFormat::Rgb
11031                    | PixelFormat::Rgba
11032                    | PixelFormat::Grey
11033                    | PixelFormat::PlanarRgb
11034                    | PixelFormat::PlanarRgba
11035            ),
11036            "format must be Rgb or Rgba for comparison"
11037        );
11038
11039        // A pitch-padded destination -- the D3D11 staging texture behind a
11040        // Windows `TensorMemory::DmaBuf` tensor pads an RGBA row to 128 bytes
11041        // -- spaces its rows at `effective_row_stride()`, so reading the head
11042        // of the mapping as a tight image would read padding as pixels.
11043        // `copy_to_flat` is the tensor's own compaction and degenerates to one
11044        // memcpy when the tensor is tight.
11045        fn flat(img: &TensorDyn) -> Vec<u8> {
11046            let t = img.as_typed::<u8>().expect("u8 image");
11047            let mut out = vec![0u8; t.shape().iter().product::<usize>()];
11048            t.copy_to_flat(&mut out).expect("compact the padded rows");
11049            out
11050        }
11051
11052        let image1 = match img1.format().unwrap() {
11053            PixelFormat::Rgb => image::RgbImage::from_vec(
11054                img1.width().unwrap() as u32,
11055                img1.height().unwrap() as u32,
11056                flat(img1),
11057            )
11058            .unwrap(),
11059            PixelFormat::Rgba => image::RgbaImage::from_vec(
11060                img1.width().unwrap() as u32,
11061                img1.height().unwrap() as u32,
11062                flat(img1),
11063            )
11064            .unwrap()
11065            .convert(),
11066            PixelFormat::Grey => image::GrayImage::from_vec(
11067                img1.width().unwrap() as u32,
11068                img1.height().unwrap() as u32,
11069                flat(img1),
11070            )
11071            .unwrap()
11072            .convert(),
11073            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11074                img1.width().unwrap() as u32,
11075                (img1.height().unwrap() * img1.format().unwrap().channels()) as u32,
11076                flat(img1),
11077            )
11078            .unwrap()
11079            .convert(),
11080            _ => return,
11081        };
11082
11083        let image2 = match img2.format().unwrap() {
11084            PixelFormat::Rgb => image::RgbImage::from_vec(
11085                img2.width().unwrap() as u32,
11086                img2.height().unwrap() as u32,
11087                flat(img2),
11088            )
11089            .unwrap(),
11090            PixelFormat::Rgba => image::RgbaImage::from_vec(
11091                img2.width().unwrap() as u32,
11092                img2.height().unwrap() as u32,
11093                flat(img2),
11094            )
11095            .unwrap()
11096            .convert(),
11097            PixelFormat::Grey => image::GrayImage::from_vec(
11098                img2.width().unwrap() as u32,
11099                img2.height().unwrap() as u32,
11100                flat(img2),
11101            )
11102            .unwrap()
11103            .convert(),
11104            PixelFormat::PlanarRgb | PixelFormat::PlanarRgba => image::GrayImage::from_vec(
11105                img2.width().unwrap() as u32,
11106                (img2.height().unwrap() * img2.format().unwrap().channels()) as u32,
11107                flat(img2),
11108            )
11109            .unwrap()
11110            .convert(),
11111            _ => return,
11112        };
11113
11114        let similarity = image_compare::rgb_similarity_structure(
11115            &image_compare::Algorithm::RootMeanSquared,
11116            &image1,
11117            &image2,
11118        )
11119        .expect("Image Comparison failed");
11120        if similarity.score < threshold {
11121            // image1.save(format!("{name}_1.png"));
11122            // image2.save(format!("{name}_2.png"));
11123            similarity
11124                .image
11125                .to_color_map()
11126                .save(format!("{name}.png"))
11127                .unwrap();
11128            panic!(
11129                "{name}: converted image and target image have similarity score too low: {} < {}",
11130                similarity.score, threshold
11131            )
11132        }
11133    }
11134
11135    fn compare_images_convert_to_rgb(
11136        img1: &TensorDyn,
11137        img2: &TensorDyn,
11138        threshold: f64,
11139        name: &str,
11140    ) {
11141        assert_eq!(img1.height(), img2.height(), "Heights differ");
11142        assert_eq!(img1.width(), img2.width(), "Widths differ");
11143
11144        let mut img_rgb1 = TensorDyn::image(
11145            img1.width().unwrap(),
11146            img1.height().unwrap(),
11147            PixelFormat::Rgb,
11148            DType::U8,
11149            Some(TensorMemory::Mem),
11150            edgefirst_tensor::CpuAccess::ReadWrite,
11151        )
11152        .unwrap();
11153        let mut img_rgb2 = TensorDyn::image(
11154            img1.width().unwrap(),
11155            img1.height().unwrap(),
11156            PixelFormat::Rgb,
11157            DType::U8,
11158            Some(TensorMemory::Mem),
11159            edgefirst_tensor::CpuAccess::ReadWrite,
11160        )
11161        .unwrap();
11162        let mut __cv = CPUProcessor::default();
11163        let r1 = __cv.convert(img1, &mut img_rgb1, None);
11164        let r2 = __cv.convert(img2, &mut img_rgb2, None);
11165        if r1.is_err() || r2.is_err() {
11166            // Fallback: compare raw bytes as greyscale strip
11167            let w = img1.width().unwrap() as u32;
11168            let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11169            let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
11170            let h1 = (data1.len() as u32) / w;
11171            let h2 = (data2.len() as u32) / w;
11172            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
11173            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
11174            let similarity = image_compare::gray_similarity_structure(
11175                &image_compare::Algorithm::RootMeanSquared,
11176                &g1,
11177                &g2,
11178            )
11179            .expect("Image Comparison failed");
11180            if similarity.score < threshold {
11181                panic!(
11182                    "{name}: converted image and target image have similarity score too low: {} < {}",
11183                    similarity.score, threshold
11184                )
11185            }
11186            return;
11187        }
11188
11189        let image1 = image::RgbImage::from_vec(
11190            img_rgb1.width().unwrap() as u32,
11191            img_rgb1.height().unwrap() as u32,
11192            img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11193        )
11194        .unwrap();
11195
11196        let image2 = image::RgbImage::from_vec(
11197            img_rgb2.width().unwrap() as u32,
11198            img_rgb2.height().unwrap() as u32,
11199            img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
11200        )
11201        .unwrap();
11202
11203        let similarity = image_compare::rgb_similarity_structure(
11204            &image_compare::Algorithm::RootMeanSquared,
11205            &image1,
11206            &image2,
11207        )
11208        .expect("Image Comparison failed");
11209        if similarity.score < threshold {
11210            // image1.save(format!("{name}_1.png"));
11211            // image2.save(format!("{name}_2.png"));
11212            similarity
11213                .image
11214                .to_color_map()
11215                .save(format!("{name}.png"))
11216                .unwrap();
11217            panic!(
11218                "{name}: converted image and target image have similarity score too low: {} < {}",
11219                similarity.score, threshold
11220            )
11221        }
11222    }
11223
11224    // =========================================================================
11225    // PixelFormat::Nv12 Format Tests
11226    // =========================================================================
11227
11228    #[test]
11229    fn test_nv12_image_creation() {
11230        let width = 640;
11231        let height = 480;
11232        let img = TensorDyn::image(
11233            width,
11234            height,
11235            PixelFormat::Nv12,
11236            DType::U8,
11237            None,
11238            edgefirst_tensor::CpuAccess::ReadWrite,
11239        )
11240        .unwrap();
11241
11242        assert_eq!(img.width(), Some(width));
11243        assert_eq!(img.height(), Some(height));
11244        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
11245        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
11246        assert_eq!(
11247            img.as_typed::<u8>().unwrap().shape(),
11248            &[height * 3 / 2, width]
11249        );
11250    }
11251
11252    #[test]
11253    fn test_nv12_channels() {
11254        let img = TensorDyn::image(
11255            640,
11256            480,
11257            PixelFormat::Nv12,
11258            DType::U8,
11259            None,
11260            edgefirst_tensor::CpuAccess::ReadWrite,
11261        )
11262        .unwrap();
11263        // PixelFormat::Nv12.channels() returns 1 (luma plane)
11264        assert_eq!(img.format().unwrap().channels(), 1);
11265    }
11266
11267    // =========================================================================
11268    // Tensor Format Metadata Tests
11269    // =========================================================================
11270
11271    #[test]
11272    fn test_tensor_set_format_planar() {
11273        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
11274        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11275        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
11276        assert_eq!(tensor.width(), Some(640));
11277        assert_eq!(tensor.height(), Some(480));
11278    }
11279
11280    #[test]
11281    fn test_tensor_set_format_interleaved() {
11282        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
11283        tensor.set_format(PixelFormat::Rgba).unwrap();
11284        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
11285        assert_eq!(tensor.width(), Some(640));
11286        assert_eq!(tensor.height(), Some(480));
11287    }
11288
11289    #[test]
11290    fn test_tensordyn_image_rgb() {
11291        let img = TensorDyn::image(
11292            640,
11293            480,
11294            PixelFormat::Rgb,
11295            DType::U8,
11296            None,
11297            edgefirst_tensor::CpuAccess::ReadWrite,
11298        )
11299        .unwrap();
11300        assert_eq!(img.width(), Some(640));
11301        assert_eq!(img.height(), Some(480));
11302        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11303    }
11304
11305    #[test]
11306    fn test_tensordyn_image_planar_rgb() {
11307        let img = TensorDyn::image(
11308            640,
11309            480,
11310            PixelFormat::PlanarRgb,
11311            DType::U8,
11312            None,
11313            edgefirst_tensor::CpuAccess::ReadWrite,
11314        )
11315        .unwrap();
11316        assert_eq!(img.width(), Some(640));
11317        assert_eq!(img.height(), Some(480));
11318        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11319    }
11320
11321    #[test]
11322    fn test_rgb_int8_format() {
11323        // Int8 variant: same PixelFormat::Rgb but with DType::I8
11324        let img = TensorDyn::image(
11325            1280,
11326            720,
11327            PixelFormat::Rgb,
11328            DType::I8,
11329            Some(TensorMemory::Mem),
11330            edgefirst_tensor::CpuAccess::ReadWrite,
11331        )
11332        .unwrap();
11333        assert_eq!(img.width(), Some(1280));
11334        assert_eq!(img.height(), Some(720));
11335        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11336        assert_eq!(img.dtype(), DType::I8);
11337    }
11338
11339    #[test]
11340    fn test_planar_rgb_int8_format() {
11341        let img = TensorDyn::image(
11342            1280,
11343            720,
11344            PixelFormat::PlanarRgb,
11345            DType::I8,
11346            Some(TensorMemory::Mem),
11347            edgefirst_tensor::CpuAccess::ReadWrite,
11348        )
11349        .unwrap();
11350        assert_eq!(img.width(), Some(1280));
11351        assert_eq!(img.height(), Some(720));
11352        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11353        assert_eq!(img.dtype(), DType::I8);
11354    }
11355
11356    #[test]
11357    fn test_rgb_from_tensor() {
11358        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
11359        tensor.set_format(PixelFormat::Rgb).unwrap();
11360        let img = TensorDyn::from(tensor);
11361        assert_eq!(img.width(), Some(1280));
11362        assert_eq!(img.height(), Some(720));
11363        assert_eq!(img.format(), Some(PixelFormat::Rgb));
11364    }
11365
11366    #[test]
11367    fn test_planar_rgb_from_tensor() {
11368        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
11369        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
11370        let img = TensorDyn::from(tensor);
11371        assert_eq!(img.width(), Some(1280));
11372        assert_eq!(img.height(), Some(720));
11373        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
11374    }
11375
11376    #[test]
11377    fn test_dtype_determines_int8() {
11378        // DType::I8 indicates int8 data
11379        let u8_img = TensorDyn::image(
11380            64,
11381            64,
11382            PixelFormat::Rgb,
11383            DType::U8,
11384            None,
11385            edgefirst_tensor::CpuAccess::ReadWrite,
11386        )
11387        .unwrap();
11388        let i8_img = TensorDyn::image(
11389            64,
11390            64,
11391            PixelFormat::Rgb,
11392            DType::I8,
11393            None,
11394            edgefirst_tensor::CpuAccess::ReadWrite,
11395        )
11396        .unwrap();
11397        assert_eq!(u8_img.dtype(), DType::U8);
11398        assert_eq!(i8_img.dtype(), DType::I8);
11399    }
11400
11401    #[test]
11402    fn test_pixel_layout_packed_vs_planar() {
11403        // Packed vs planar layout classification
11404        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
11405        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
11406        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
11407        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
11408    }
11409
11410    /// Integration test that exercises the PBO-to-PBO convert path.
11411    /// A PBO cannot hand out a host address that outlives its map guard, and
11412    /// the error has to say why rather than reading as unfinished work.
11413    ///
11414    /// `glMapBufferRange`'s pointer is valid only until `glUnmapBuffer`, and
11415    /// this backend maps with `MAP_READ_BIT | MAP_WRITE_BIT` — there is no
11416    /// `GL_MAP_PERSISTENT_BIT` and no `glBufferStorage` in the tree. Pinning
11417    /// would mean holding the buffer mapped across GL work, which is the
11418    /// opposite of what a PBO is for. `EXT_buffer_storage` would change the
11419    /// answer, but that is a buffer-allocation feature, not wiring.
11420    #[cfg(target_os = "linux")]
11421    #[cfg(feature = "opengl")]
11422    #[test]
11423    fn pbo_reports_why_it_cannot_pin() {
11424        use edgefirst_tensor::{Error, TensorTrait};
11425
11426        let converter = ImageProcessor::new().unwrap();
11427        let is_pbo = converter
11428            .opengl
11429            .as_ref()
11430            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11431        if !is_pbo {
11432            crate::test_support::report_skip(
11433                "pbo_reports_why_it_cannot_pin - transfer backend is not PBO",
11434            );
11435            return;
11436        }
11437
11438        let t = converter
11439            .create_image(
11440                64,
11441                64,
11442                PixelFormat::Rgba,
11443                DType::U8,
11444                None,
11445                edgefirst_tensor::CpuAccess::ReadWrite,
11446            )
11447            .unwrap();
11448        assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
11449        let t = t.as_typed::<u8>().unwrap();
11450
11451        match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
11452            Err(Error::NotImplemented(m)) => {
11453                assert!(
11454                    m.contains("glMapBufferRange"),
11455                    "must name the constraint: {m}"
11456                );
11457                assert!(m.contains("map()"), "must point at the supported API: {m}");
11458            }
11459            other => panic!("expected an explained NotImplemented, got {other:?}"),
11460        }
11461        // Sync is unavailable for the same reason, and must not silently succeed.
11462        assert!(matches!(
11463            t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
11464            Err(Error::NotImplemented(_))
11465        ));
11466
11467        // The supported path still works, which is what makes the refusal safe.
11468        let g = t.map().expect("map must work where pin cannot");
11469        assert!(!g.as_slice().is_empty());
11470    }
11471
11472    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
11473    /// then converts between them. Skipped when GL is unavailable or the
11474    /// backend is not PBO (e.g. DMA-buf systems).
11475    #[cfg(target_os = "linux")]
11476    #[cfg(feature = "opengl")]
11477    #[test]
11478    fn test_convert_pbo_to_pbo() {
11479        let mut converter = ImageProcessor::new().unwrap();
11480
11481        // Skip if GL is not available or backend is not PBO
11482        let is_pbo = converter
11483            .opengl
11484            .as_ref()
11485            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
11486        if !is_pbo {
11487            crate::test_support::report_skip(
11488                "test_convert_pbo_to_pbo - transfer backend is not PBO",
11489            );
11490            return;
11491        }
11492
11493        let src_w = 640;
11494        let src_h = 480;
11495        let dst_w = 320;
11496        let dst_h = 240;
11497
11498        // Create PBO-backed source image
11499        let pbo_src = converter
11500            .create_image(
11501                src_w,
11502                src_h,
11503                PixelFormat::Rgba,
11504                DType::U8,
11505                None,
11506                edgefirst_tensor::CpuAccess::ReadWrite,
11507            )
11508            .unwrap();
11509        assert_eq!(
11510            pbo_src.as_typed::<u8>().unwrap().memory(),
11511            TensorMemory::Pbo,
11512            "create_image should produce a PBO tensor"
11513        );
11514
11515        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
11516        let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
11517        let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
11518
11519        // Resize JPEG into a Mem temp of the right size, then copy into PBO
11520        let mem_src = TensorDyn::image(
11521            src_w,
11522            src_h,
11523            PixelFormat::Rgba,
11524            DType::U8,
11525            Some(TensorMemory::Mem),
11526            edgefirst_tensor::CpuAccess::ReadWrite,
11527        )
11528        .unwrap();
11529        let (result, _jpeg_src, mem_src) = convert_img(
11530            &mut CPUProcessor::new(),
11531            jpeg_src,
11532            mem_src,
11533            Rotation::None,
11534            Flip::None,
11535            Crop::no_crop(),
11536        );
11537        result.unwrap();
11538
11539        // Copy pixel data into the PBO source by mapping it
11540        {
11541            let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
11542            let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
11543            pbo_map.copy_from_slice(&src_data);
11544        }
11545
11546        // Create PBO-backed destination image
11547        let pbo_dst = converter
11548            .create_image(
11549                dst_w,
11550                dst_h,
11551                PixelFormat::Rgba,
11552                DType::U8,
11553                None,
11554                edgefirst_tensor::CpuAccess::ReadWrite,
11555            )
11556            .unwrap();
11557        assert_eq!(
11558            pbo_dst.as_typed::<u8>().unwrap().memory(),
11559            TensorMemory::Pbo
11560        );
11561
11562        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
11563        let mut pbo_dst = pbo_dst;
11564        let result = converter.convert(&pbo_src, &mut pbo_dst, None);
11565        result.unwrap();
11566
11567        // Verify: compare with CPU-only conversion of the same input
11568        let cpu_dst = TensorDyn::image(
11569            dst_w,
11570            dst_h,
11571            PixelFormat::Rgba,
11572            DType::U8,
11573            Some(TensorMemory::Mem),
11574            edgefirst_tensor::CpuAccess::ReadWrite,
11575        )
11576        .unwrap();
11577        let (result, _mem_src, cpu_dst) = convert_img(
11578            &mut CPUProcessor::new(),
11579            mem_src,
11580            cpu_dst,
11581            Rotation::None,
11582            Flip::None,
11583            Crop::no_crop(),
11584        );
11585        result.unwrap();
11586
11587        let pbo_dst_img = {
11588            let mut __t = pbo_dst.into_u8().unwrap();
11589            __t.set_format(PixelFormat::Rgba).unwrap();
11590            TensorDyn::from(__t)
11591        };
11592        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
11593        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
11594    }
11595
11596    #[test]
11597    fn test_image_bgra() {
11598        let img = TensorDyn::image(
11599            640,
11600            480,
11601            PixelFormat::Bgra,
11602            DType::U8,
11603            Some(edgefirst_tensor::TensorMemory::Mem),
11604            edgefirst_tensor::CpuAccess::ReadWrite,
11605        )
11606        .unwrap();
11607        assert_eq!(img.width(), Some(640));
11608        assert_eq!(img.height(), Some(480));
11609        assert_eq!(img.format().unwrap().channels(), 4);
11610        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
11611    }
11612
11613    // ========================================================================
11614    // Tests for EDGEFIRST_FORCE_BACKEND env var
11615    // ========================================================================
11616
11617    #[test]
11618    fn test_force_backend_cpu() {
11619        let _lock = acquire_env_lock();
11620        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11621        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11622        let converter = ImageProcessor::new().unwrap();
11623        assert!(converter.cpu.is_some());
11624        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
11625    }
11626
11627    #[test]
11628    fn test_force_backend_invalid() {
11629        let _lock = acquire_env_lock();
11630        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11631        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
11632        let result = ImageProcessor::new();
11633        assert!(
11634            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
11635            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
11636        );
11637    }
11638
11639    #[test]
11640    fn test_force_backend_unset() {
11641        let _lock = acquire_env_lock();
11642        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11643        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
11644        let converter = ImageProcessor::new().unwrap();
11645        assert!(converter.forced_backend.is_none());
11646    }
11647
11648    // ========================================================================
11649    // Tests for hybrid mask path error handling
11650    // ========================================================================
11651
11652    #[test]
11653    fn test_draw_proto_masks_no_cpu_returns_error() {
11654        // Serialize against all other env-var-mutating tests.
11655        let _lock = acquire_env_lock();
11656        let _guard = EnvGuard::snapshot(&[
11657            "EDGEFIRST_FORCE_BACKEND",
11658            "EDGEFIRST_DISABLE_GL",
11659            "EDGEFIRST_DISABLE_G2D",
11660            "EDGEFIRST_DISABLE_CPU",
11661        ]);
11662
11663        // Disable all backends so cpu.is_none() after construction.
11664        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
11665        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
11666        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
11667
11668        let mut converter = ImageProcessor::new().unwrap();
11669        assert!(converter.cpu.is_none(), "CPU should be disabled");
11670
11671        let dst = TensorDyn::image(
11672            640,
11673            480,
11674            PixelFormat::Rgba,
11675            DType::U8,
11676            Some(TensorMemory::Mem),
11677            edgefirst_tensor::CpuAccess::ReadWrite,
11678        )
11679        .unwrap();
11680        let mut dst_dyn = dst;
11681        let det = [DetectBox {
11682            bbox: edgefirst_tensor::BoundingBox {
11683                xmin: 0.1,
11684                ymin: 0.1,
11685                xmax: 0.5,
11686                ymax: 0.5,
11687            },
11688            score: 0.9,
11689            label: 0,
11690        }];
11691        let proto_data = {
11692            use edgefirst_tensor::{Tensor, TensorDyn};
11693            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11694            let protos_t =
11695                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11696            ProtoData {
11697                mask_coefficients: TensorDyn::F32(coeff_t),
11698                protos: TensorDyn::F32(protos_t),
11699                layout: ProtoLayout::Nhwc,
11700            }
11701        };
11702        let result =
11703            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11704        assert!(
11705            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
11706            "draw_proto_masks without CPU should return Internal error: {result:?}"
11707        );
11708    }
11709
11710    #[test]
11711    fn test_draw_proto_masks_cpu_fallback_works() {
11712        // Force CPU-only backend to ensure the CPU fallback path executes.
11713        // Serialized under ENV_MUTEX so we don't race with disable-var tests.
11714        let _lock = acquire_env_lock();
11715        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
11716        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
11717        let mut converter = ImageProcessor::new().unwrap();
11718        assert!(converter.cpu.is_some());
11719
11720        let dst = TensorDyn::image(
11721            64,
11722            64,
11723            PixelFormat::Rgba,
11724            DType::U8,
11725            Some(TensorMemory::Mem),
11726            edgefirst_tensor::CpuAccess::ReadWrite,
11727        )
11728        .unwrap();
11729        let mut dst_dyn = dst;
11730        let det = [DetectBox {
11731            bbox: edgefirst_tensor::BoundingBox {
11732                xmin: 0.1,
11733                ymin: 0.1,
11734                xmax: 0.5,
11735                ymax: 0.5,
11736            },
11737            score: 0.9,
11738            label: 0,
11739        }];
11740        let proto_data = {
11741            use edgefirst_tensor::{Tensor, TensorDyn};
11742            let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
11743            let protos_t =
11744                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
11745            ProtoData {
11746                mask_coefficients: TensorDyn::F32(coeff_t),
11747                protos: TensorDyn::F32(protos_t),
11748                layout: ProtoLayout::Nhwc,
11749            }
11750        };
11751        let result =
11752            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
11753        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
11754    }
11755
11756    // ============================================================
11757    // draw_decoded_masks / draw_proto_masks — 4-scenario pixel-
11758    // verified tests. Exercises each backend against the full
11759    // output-contract matrix:
11760    //
11761    //   | detections | background | expected dst             |
11762    //   |------------|------------|--------------------------|
11763    //   | empty      | none       | fully cleared (0x00)     |
11764    //   | empty      | set        | fully equal to bg        |
11765    //   | set        | none       | cleared outside box +    |
11766    //   |            |            | mask-coloured inside     |
11767    //   | set        | set        | bg outside box + mask    |
11768    //   |            |            | blended inside           |
11769    //
11770    // Every test pre-fills dst with a non-zero "dirty" pattern so
11771    // that any silent `return Ok(())` leaks the pattern into the
11772    // asserted output and fails loudly.
11773    // ============================================================
11774
11775    // =========================================================================
11776    // Env-var serialisation helpers
11777    //
11778    // ALL tests that mutate any EDGEFIRST_* backend env var must hold
11779    // ENV_MUTEX for their full duration.  This single mutex serialises
11780    // test_disable_env_var, test_draw_proto_masks_no_cpu_returns_error,
11781    // test_force_backend_*, with_force_backend, and with_env — preventing
11782    // any two of them from racing in a parallel `cargo test` run.
11783    // =========================================================================
11784
11785    /// Acquire the process-wide env-var mutex.  Returns a guard that must be
11786    /// kept alive for the entire duration of the test body.
11787    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
11788        use std::sync::{Mutex, OnceLock};
11789        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
11790        ENV_MUTEX
11791            .get_or_init(|| Mutex::new(()))
11792            .lock()
11793            .unwrap_or_else(|e| e.into_inner())
11794    }
11795
11796    /// RAII guard that snapshots a set of env vars on construction and
11797    /// restores them on `Drop`, even if the test panics.
11798    struct EnvGuard {
11799        vars: Vec<(&'static str, Option<String>)>,
11800    }
11801
11802    impl EnvGuard {
11803        /// Snapshot the current values of `names`.  Call this while holding
11804        /// the env lock (the lock is not taken here — that is the caller's
11805        /// responsibility so the lock scope can be wider than the guard).
11806        fn snapshot(names: &[&'static str]) -> Self {
11807            Self {
11808                vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
11809            }
11810        }
11811    }
11812
11813    impl Drop for EnvGuard {
11814        fn drop(&mut self) {
11815            for (k, v) in &self.vars {
11816                match v {
11817                    Some(s) => unsafe { std::env::set_var(k, s) },
11818                    None => unsafe { std::env::remove_var(k) },
11819                }
11820            }
11821        }
11822    }
11823
11824    /// `EDGEFIRST_INTERPOLATION` accepts `nearest` and `bilinear` in any
11825    /// case and ignores anything else.
11826    #[test]
11827    fn test_interpolation_env_parse() {
11828        let _lock = acquire_env_lock();
11829        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11830        for (value, want) in [
11831            (Some("nearest"), Some(Interpolation::Nearest)),
11832            (Some(" BILINEAR "), Some(Interpolation::Bilinear)),
11833            (Some("lanczos"), None),
11834            (Some(""), None),
11835            (None, None),
11836        ] {
11837            match value {
11838                Some(v) => unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", v) },
11839                None => unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") },
11840            }
11841            assert_eq!(
11842                interpolation_from_env(),
11843                want,
11844                "EDGEFIRST_INTERPOLATION={value:?}"
11845            );
11846        }
11847    }
11848
11849    /// `ImageProcessorConfig::interpolation` reaches the CPU backend, and
11850    /// `EDGEFIRST_INTERPOLATION` overrides it.
11851    #[test]
11852    fn test_interpolation_config_and_env_reach_cpu() {
11853        let _lock = acquire_env_lock();
11854        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11855        let mut checker = Vec::with_capacity(8 * 8 * 4);
11856        for y in 0..8 {
11857            for x in 0..8 {
11858                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
11859                checker.extend_from_slice(&[v, v, v, 255]);
11860            }
11861        }
11862        let resize = |processor: &mut dyn ImageProcessorTrait| {
11863            let mut src = TensorDyn::image(
11864                8,
11865                8,
11866                PixelFormat::Rgba,
11867                DType::U8,
11868                Some(TensorMemory::Mem),
11869                edgefirst_tensor::CpuAccess::ReadWrite,
11870            )
11871            .unwrap();
11872            src.as_u8_mut()
11873                .unwrap()
11874                .map_mut()
11875                .unwrap()
11876                .as_mut_slice()
11877                .copy_from_slice(&checker);
11878            let mut dst = TensorDyn::image(
11879                12,
11880                12,
11881                PixelFormat::Rgba,
11882                DType::U8,
11883                Some(TensorMemory::Mem),
11884                edgefirst_tensor::CpuAccess::ReadWrite,
11885            )
11886            .unwrap();
11887            processor.convert(&src, &mut dst, None).unwrap();
11888            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
11889        };
11890        let nearest = resize(&mut CPUProcessor::new_nearest());
11891        let bilinear = resize(&mut CPUProcessor::new());
11892        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
11893        let config = |interpolation| ImageProcessorConfig {
11894            backend: ComputeBackend::Cpu,
11895            interpolation,
11896            ..Default::default()
11897        };
11898
11899        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
11900        let mut p = ImageProcessor::with_config(config(Interpolation::Nearest)).unwrap();
11901        assert_eq!(resize(&mut p), nearest, "config Nearest");
11902        p.set_interpolation(Interpolation::Bilinear).unwrap();
11903        assert_eq!(resize(&mut p), bilinear, "set_interpolation(Bilinear)");
11904
11905        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "nearest") };
11906        let mut p = ImageProcessor::with_config(config(Interpolation::Bilinear)).unwrap();
11907        assert_eq!(
11908            resize(&mut p),
11909            nearest,
11910            "EDGEFIRST_INTERPOLATION pins Nearest"
11911        );
11912        p.set_interpolation(Interpolation::Bilinear).unwrap();
11913        assert_eq!(
11914            resize(&mut p),
11915            nearest,
11916            "the env pin outlives set_interpolation"
11917        );
11918    }
11919
11920    /// A per-convert interpolation overrides the processor's for that
11921    /// convert only, so two option sets on one processor do not interfere;
11922    /// `EDGEFIRST_INTERPOLATION` pins both.
11923    #[test]
11924    fn test_per_convert_interpolation_overrides_processor() {
11925        let _lock = acquire_env_lock();
11926        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
11927        let mut checker = Vec::with_capacity(8 * 8 * 4);
11928        for y in 0..8 {
11929            for x in 0..8 {
11930                let v = if (x + y) % 2 == 0 { 0u8 } else { 255 };
11931                checker.extend_from_slice(&[v, v, v, 255]);
11932            }
11933        }
11934        let resize = |processor: &mut dyn ImageProcessorTrait, options: Option<ConvertOptions>| {
11935            let mut src = TensorDyn::image(
11936                8,
11937                8,
11938                PixelFormat::Rgba,
11939                DType::U8,
11940                Some(TensorMemory::Mem),
11941                edgefirst_tensor::CpuAccess::ReadWrite,
11942            )
11943            .unwrap();
11944            src.as_u8_mut()
11945                .unwrap()
11946                .map_mut()
11947                .unwrap()
11948                .as_mut_slice()
11949                .copy_from_slice(&checker);
11950            let mut dst = TensorDyn::image(
11951                12,
11952                12,
11953                PixelFormat::Rgba,
11954                DType::U8,
11955                Some(TensorMemory::Mem),
11956                edgefirst_tensor::CpuAccess::ReadWrite,
11957            )
11958            .unwrap();
11959            processor.convert(&src, &mut dst, options).unwrap();
11960            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
11961        };
11962        let nearest = resize(&mut CPUProcessor::new_nearest(), None);
11963        let bilinear = resize(&mut CPUProcessor::new(), None);
11964        assert_ne!(nearest, bilinear, "the checker must tell the filters apart");
11965        let cpu_only = || {
11966            ImageProcessor::with_config(ImageProcessorConfig {
11967                backend: ComputeBackend::Cpu,
11968                ..Default::default()
11969            })
11970            .unwrap()
11971        };
11972        let model_a = ConvertOptions::new().interpolation(Interpolation::Nearest);
11973        let model_b = ConvertOptions::new().interpolation(Interpolation::Bicubic);
11974
11975        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
11976        let mut p = cpu_only();
11977        assert_eq!(p.interpolation(), Interpolation::Bilinear);
11978        for _ in 0..2 {
11979            assert_eq!(resize(&mut p, Some(model_a)), nearest, "model A: nearest");
11980            assert_eq!(resize(&mut p, Some(model_b)), bilinear, "model B: bicubic");
11981            assert_eq!(resize(&mut p, None), bilinear, "processor setting kept");
11982        }
11983        p.set_interpolation(Interpolation::Nearest).unwrap();
11984        assert_eq!(resize(&mut p, Some(model_b)), bilinear, "options win");
11985        assert_eq!(resize(&mut p, None), nearest, "processor fallback");
11986
11987        let mut spec = PreprocessSpec::new();
11988        spec.interpolation = Interpolation::Nearest;
11989        let from_spec = ConvertOptions::from_spec(&spec).unwrap();
11990        assert_eq!(from_spec.get_interpolation(), Some(Interpolation::Nearest));
11991        let mut p = cpu_only();
11992        let stretch = from_spec.crop(Crop::default());
11993        assert_eq!(
11994            resize(&mut p, Some(stretch)),
11995            nearest,
11996            "from_spec carries it"
11997        );
11998
11999        let cpu = p.cpu.as_mut().unwrap();
12000        cpu.set_interpolation(Interpolation::Nearest);
12001        p.set_interpolation(Interpolation::Bilinear).unwrap();
12002        assert_eq!(
12003            resize(p.cpu.as_mut().unwrap(), None),
12004            nearest,
12005            "an unchanged mode does not reach the backends"
12006        );
12007
12008        unsafe { std::env::set_var("EDGEFIRST_INTERPOLATION", "bilinear") };
12009        let mut p = cpu_only();
12010        assert_eq!(resize(&mut p, Some(model_a)), bilinear, "the env pin wins");
12011    }
12012
12013    /// The threaded GL worker honours a per-convert interpolation and keeps
12014    /// the processor's for converts without one.
12015    #[test]
12016    #[cfg(feature = "opengl")]
12017    fn test_per_convert_interpolation_reaches_gl_worker() {
12018        let _lock = acquire_env_lock();
12019        let _guard = EnvGuard::snapshot(&["EDGEFIRST_INTERPOLATION"]);
12020        unsafe { std::env::remove_var("EDGEFIRST_INTERPOLATION") };
12021        let mut p = match ImageProcessor::with_config(ImageProcessorConfig {
12022            backend: ComputeBackend::OpenGl,
12023            ..Default::default()
12024        }) {
12025            Ok(p) if p.opengl.is_some() => p,
12026            _ => {
12027                crate::test_support::report_skip(&format!(
12028                    "{} — GL backend unavailable",
12029                    function!()
12030                ));
12031                return;
12032            }
12033        };
12034        let (sw, sh, dw, dh) = (40usize, 30usize, 56usize, 42usize);
12035        let mut rgba = Vec::with_capacity(sw * sh * 4);
12036        for y in 0..sh {
12037            for x in 0..sw {
12038                let v = if (x + y) % 2 == 0 { 16u8 } else { 235 };
12039                rgba.extend_from_slice(&[v, 128, 255 - v, 255]);
12040            }
12041        }
12042        let mut src = TensorDyn::image(
12043            sw,
12044            sh,
12045            PixelFormat::Rgba,
12046            DType::U8,
12047            Some(TensorMemory::Mem),
12048            edgefirst_tensor::CpuAccess::ReadWrite,
12049        )
12050        .unwrap();
12051        src.as_u8_mut()
12052            .unwrap()
12053            .map_mut()
12054            .unwrap()
12055            .as_mut_slice()
12056            .copy_from_slice(&rgba);
12057        let gl_convert = |p: &mut ImageProcessor, options: Option<ConvertOptions>| {
12058            let mut dst = TensorDyn::image(
12059                dw,
12060                dh,
12061                PixelFormat::Rgba,
12062                DType::U8,
12063                Some(TensorMemory::Mem),
12064                edgefirst_tensor::CpuAccess::ReadWrite,
12065            )
12066            .unwrap();
12067            let options = p.resolve_interpolation(options.unwrap_or_default());
12068            p.opengl
12069                .as_mut()
12070                .unwrap()
12071                .convert(&src, &mut dst, Some(options))
12072                .unwrap();
12073            dst.as_u8().unwrap().map().unwrap().as_slice().to_vec()
12074        };
12075        let nearest = ConvertOptions::new().interpolation(Interpolation::Nearest);
12076        let per_convert = gl_convert(&mut p, Some(nearest));
12077        let processor_bilinear = gl_convert(&mut p, None);
12078        p.set_interpolation(Interpolation::Nearest).unwrap();
12079        let processor_nearest = gl_convert(&mut p, None);
12080        assert_ne!(processor_bilinear, processor_nearest);
12081        assert_eq!(per_convert, processor_nearest, "per-convert Nearest");
12082        let bilinear = ConvertOptions::new().interpolation(Interpolation::Bilinear);
12083        assert_eq!(gl_convert(&mut p, Some(bilinear)), processor_bilinear);
12084    }
12085
12086    /// Each unsupported interpolation warns at most once per process.
12087    #[test]
12088    fn test_interpolation_fallback_warns_once() {
12089        runnable_interpolation(Interpolation::Area);
12090        assert!(!warn_first_fallback(Interpolation::Area));
12091        assert_eq!(
12092            runnable_interpolation(Interpolation::Area),
12093            Interpolation::Bilinear
12094        );
12095        assert!(!warn_first_fallback(Interpolation::Area));
12096    }
12097
12098    /// Run `body` with `EDGEFIRST_FORCE_BACKEND` temporarily set (or
12099    /// removed), restoring the prior value afterward. Tests are env-
12100    /// serialized via the process-wide `ENV_MUTEX`.
12101    fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
12102        let _lock = acquire_env_lock();
12103        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12104        match value {
12105            Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
12106            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
12107        }
12108        body()
12109    }
12110
12111    /// Allocate an RGBA image tensor and pre-fill every byte with a
12112    /// distinctive non-zero pattern. Any test that relies on the old
12113    /// "dst is already cleared" assumption will see this pattern leak
12114    /// through to the output and fail.
12115    fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
12116        let dst = TensorDyn::image(
12117            w,
12118            h,
12119            PixelFormat::Rgba,
12120            DType::U8,
12121            mem,
12122            edgefirst_tensor::CpuAccess::ReadWrite,
12123        )
12124        .unwrap();
12125        {
12126            use edgefirst_tensor::TensorMapTrait;
12127            let u8t = dst.as_typed::<u8>().unwrap();
12128            let mut map = u8t.map().unwrap();
12129            for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
12130                *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
12131            }
12132        }
12133        dst
12134    }
12135
12136    /// Allocate an RGBA background filled with a constant colour.
12137    fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
12138        let bg = TensorDyn::image(
12139            w,
12140            h,
12141            PixelFormat::Rgba,
12142            DType::U8,
12143            mem,
12144            edgefirst_tensor::CpuAccess::ReadWrite,
12145        )
12146        .unwrap();
12147        {
12148            use edgefirst_tensor::TensorMapTrait;
12149            let u8t = bg.as_typed::<u8>().unwrap();
12150            let mut map = u8t.map().unwrap();
12151            for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12152                chunk.copy_from_slice(&rgba);
12153            }
12154        }
12155        bg
12156    }
12157
12158    fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
12159        use edgefirst_tensor::TensorMapTrait;
12160        let w = dst.width().unwrap();
12161        let off = (y * w + x) * 4;
12162        let u8t = dst.as_typed::<u8>().unwrap();
12163        let map = u8t.map().unwrap();
12164        let s = map.as_slice();
12165        [s[off], s[off + 1], s[off + 2], s[off + 3]]
12166    }
12167
12168    fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
12169        use edgefirst_tensor::TensorMapTrait;
12170        let u8t = dst.as_typed::<u8>().unwrap();
12171        let map = u8t.map().unwrap();
12172        for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
12173            assert_eq!(
12174                chunk, &expected,
12175                "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
12176            );
12177        }
12178    }
12179
12180    /// Scenario 1: empty detections, empty segmentation, no background
12181    /// → dst must be fully cleared to 0x00000000.
12182    fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
12183        let mut dst = make_dirty_dst(64, 64, None);
12184        processor
12185            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12186            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
12187        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
12188
12189        let mut dst = make_dirty_dst(64, 64, None);
12190        let proto = {
12191            use edgefirst_tensor::{Tensor, TensorDyn};
12192            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12193            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12194            let protos_t =
12195                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12196            ProtoData {
12197                mask_coefficients: TensorDyn::F32(coeff_t),
12198                protos: TensorDyn::F32(protos_t),
12199                layout: ProtoLayout::Nhwc,
12200            }
12201        };
12202        processor
12203            .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
12204            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
12205        assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
12206    }
12207
12208    /// Scenario 2: empty detections, empty segmentation, background set
12209    /// → dst must be fully equal to bg.
12210    fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
12211        let bg_color = [42, 99, 200, 255];
12212        let bg = make_bg(64, 64, None, bg_color);
12213        let overlay = MaskOverlay::new().with_background(&bg);
12214
12215        let mut dst = make_dirty_dst(64, 64, None);
12216        processor
12217            .draw_decoded_masks(&mut dst, &[], &[], overlay)
12218            .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
12219        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
12220
12221        let mut dst = make_dirty_dst(64, 64, None);
12222        let proto = {
12223            use edgefirst_tensor::{Tensor, TensorDyn};
12224            // Placeholder (no detections); shape [1, 4] to keep the tensor well-formed.
12225            let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
12226            let protos_t =
12227                Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
12228            ProtoData {
12229                mask_coefficients: TensorDyn::F32(coeff_t),
12230                protos: TensorDyn::F32(protos_t),
12231                layout: ProtoLayout::Nhwc,
12232            }
12233        };
12234        processor
12235            .draw_proto_masks(&mut dst, &[], &proto, overlay)
12236            .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
12237        assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
12238    }
12239
12240    /// Scenario 3: one detection with a fully-opaque segmentation fill,
12241    /// no background → outside the box dst must be 0x00, inside it must
12242    /// be a non-zero mask colour (the render_segmentation output).
12243    fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
12244        use edgefirst_tensor::Segmentation;
12245        use ndarray::Array3;
12246        processor
12247            .set_class_colors(&[[200, 80, 40, 255]])
12248            .expect("set_class_colors");
12249
12250        let detect = DetectBox {
12251            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12252            score: 0.99,
12253            label: 0,
12254        };
12255        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12256        let seg = Segmentation {
12257            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12258                .unwrap()
12259                .into(),
12260            xmin: 0.25,
12261            ymin: 0.25,
12262            xmax: 0.75,
12263            ymax: 0.75,
12264        };
12265
12266        let mut dst = make_dirty_dst(64, 64, None);
12267        processor
12268            .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
12269            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
12270
12271        // Outside the bbox (corner): must be cleared black.
12272        let corner = pixel_at(&dst, 2, 2);
12273        assert_eq!(
12274            corner,
12275            [0, 0, 0, 0],
12276            "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
12277        );
12278        // Inside the bbox (center): the mask colour must be visible.
12279        // Any non-zero pixel is acceptable — exact rendering varies
12280        // between backends (GL smoothstep, CPU nearest).
12281        let center = pixel_at(&dst, 32, 32);
12282        assert!(
12283            center != [0, 0, 0, 0],
12284            "{case}/decoded: center (32,32) was not coloured: {center:?}"
12285        );
12286    }
12287
12288    /// Scenario 4: detection + background. Outside the box must match
12289    /// bg; inside the box must NOT match bg (mask blended on top).
12290    fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
12291        use edgefirst_tensor::Segmentation;
12292        use ndarray::Array3;
12293        processor
12294            .set_class_colors(&[[200, 80, 40, 255]])
12295            .expect("set_class_colors");
12296        let bg_color = [10, 20, 30, 255];
12297        let bg = make_bg(64, 64, None, bg_color);
12298
12299        let detect = DetectBox {
12300            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12301            score: 0.99,
12302            label: 0,
12303        };
12304        let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
12305        let seg = Segmentation {
12306            segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
12307                .unwrap()
12308                .into(),
12309            xmin: 0.25,
12310            ymin: 0.25,
12311            xmax: 0.75,
12312            ymax: 0.75,
12313        };
12314
12315        let overlay = MaskOverlay::new().with_background(&bg);
12316        let mut dst = make_dirty_dst(64, 64, None);
12317        processor
12318            .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
12319            .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
12320
12321        // Outside the bbox (corner): bg colour.
12322        let corner = pixel_at(&dst, 2, 2);
12323        assert_eq!(
12324            corner, bg_color,
12325            "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
12326        );
12327        // Inside the bbox (center): mask blended on bg, must differ from
12328        // pure bg (alpha-blend with mask colour produces a distinct shade).
12329        let center = pixel_at(&dst, 32, 32);
12330        assert!(
12331            center != bg_color,
12332            "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
12333        );
12334    }
12335
12336    /// Run all 4 scenarios against the processor. Skip gracefully if
12337    /// construction fails (backend unavailable on this host).
12338    fn run_all_scenarios(
12339        force_backend: Option<&'static str>,
12340        case: &'static str,
12341        require_dma_for_bg: bool,
12342    ) {
12343        if require_dma_for_bg && !crate::test_support::is_dma_available() {
12344            crate::test_support::report_skip(&format!("{case} — DMA not available on this host"));
12345            return;
12346        }
12347        let processor_result = with_force_backend(force_backend, ImageProcessor::new);
12348        let mut processor = match processor_result {
12349            Ok(p) => p,
12350            Err(e) => {
12351                crate::test_support::report_skip(&format!("{case} — backend init failed: {e:?}"));
12352                return;
12353            }
12354        };
12355        scenario_empty_no_bg(&mut processor, case);
12356        scenario_empty_with_bg(&mut processor, case);
12357        scenario_detect_no_bg(&mut processor, case);
12358        scenario_detect_with_bg(&mut processor, case);
12359    }
12360
12361    #[test]
12362    fn test_draw_masks_4_scenarios_cpu() {
12363        run_all_scenarios(Some("cpu"), "cpu", false);
12364    }
12365
12366    #[test]
12367    fn test_draw_masks_4_scenarios_auto() {
12368        run_all_scenarios(None, "auto", false);
12369    }
12370
12371    #[cfg(any(
12372        target_os = "linux",
12373        target_os = "macos",
12374        target_os = "ios",
12375        target_os = "android",
12376        target_os = "windows"
12377    ))]
12378    #[cfg(feature = "opengl")]
12379    #[test]
12380    fn test_draw_masks_4_scenarios_opengl() {
12381        run_all_scenarios(Some("opengl"), "opengl", false);
12382    }
12383
12384    /// G2D forced backend: exercises the zero-detection empty-frame
12385    /// paths via `g2d_clear` and `g2d_blit`. Scenarios 3 and 4 (with
12386    /// detections) expect `NotImplemented` since G2D has no rasterizer
12387    /// for boxes / masks.
12388    #[cfg(target_os = "linux")]
12389    #[test]
12390    fn test_draw_masks_zero_detection_g2d_forced() {
12391        if !crate::test_support::is_dma_available() {
12392            crate::test_support::report_skip("g2d forced — DMA not available on this host");
12393            return;
12394        }
12395        let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
12396        let mut processor = match processor_result {
12397            Ok(p) => p,
12398            Err(e) => {
12399                crate::test_support::report_skip(&format!("g2d forced — init failed: {e:?}"));
12400                return;
12401            }
12402        };
12403
12404        // Case 1: empty + no bg. G2D requires DMA-backed dst.
12405        let mut dst = TensorDyn::image(
12406            64,
12407            64,
12408            PixelFormat::Rgba,
12409            DType::U8,
12410            Some(TensorMemory::DmaBuf),
12411            edgefirst_tensor::CpuAccess::ReadWrite,
12412        )
12413        .unwrap();
12414        {
12415            use edgefirst_tensor::TensorMapTrait;
12416            let u8t = dst.as_typed_mut::<u8>().unwrap();
12417            let mut map = u8t.map().unwrap();
12418            map.as_mut_slice().fill(0xBB);
12419        }
12420        processor
12421            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
12422            .expect("g2d empty+no-bg");
12423        assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
12424
12425        // Case 2: empty + bg. Both surfaces DMA-backed for g2d_blit.
12426        let bg_color = [7, 11, 13, 255];
12427        let bg = {
12428            let t = TensorDyn::image(
12429                64,
12430                64,
12431                PixelFormat::Rgba,
12432                DType::U8,
12433                Some(TensorMemory::DmaBuf),
12434                edgefirst_tensor::CpuAccess::ReadWrite,
12435            )
12436            .unwrap();
12437            {
12438                use edgefirst_tensor::TensorMapTrait;
12439                let u8t = t.as_typed::<u8>().unwrap();
12440                let mut map = u8t.map().unwrap();
12441                for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
12442                    chunk.copy_from_slice(&bg_color);
12443                }
12444            }
12445            t
12446        };
12447        let mut dst = TensorDyn::image(
12448            64,
12449            64,
12450            PixelFormat::Rgba,
12451            DType::U8,
12452            Some(TensorMemory::DmaBuf),
12453            edgefirst_tensor::CpuAccess::ReadWrite,
12454        )
12455        .unwrap();
12456        {
12457            use edgefirst_tensor::TensorMapTrait;
12458            let u8t = dst.as_typed_mut::<u8>().unwrap();
12459            let mut map = u8t.map().unwrap();
12460            map.as_mut_slice().fill(0x55);
12461        }
12462        processor
12463            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
12464            .expect("g2d empty+bg");
12465        assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
12466
12467        // Case 3 and 4: detect present — must return NotImplemented.
12468        let detect = DetectBox {
12469            bbox: [0.25, 0.25, 0.75, 0.75].into(),
12470            score: 0.9,
12471            label: 0,
12472        };
12473        let mut dst = TensorDyn::image(
12474            64,
12475            64,
12476            PixelFormat::Rgba,
12477            DType::U8,
12478            Some(TensorMemory::DmaBuf),
12479            edgefirst_tensor::CpuAccess::ReadWrite,
12480        )
12481        .unwrap();
12482        let err = processor
12483            .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
12484            .expect_err("g2d must reject detect-present draw_decoded_masks");
12485        assert!(
12486            matches!(err, Error::NotImplemented(_)),
12487            "g2d case3 wrong error: {err:?}"
12488        );
12489    }
12490
12491    #[test]
12492    fn test_set_format_then_cpu_convert() {
12493        // Force CPU backend; serialized under ENV_MUTEX to avoid racing with
12494        // test_force_backend_* and test_disable_env_var.
12495        let _lock = acquire_env_lock();
12496        let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
12497        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
12498        let mut processor = ImageProcessor::new().unwrap();
12499
12500        // Load a source image
12501        let image = edgefirst_bench::testdata::read("zidane.jpg");
12502        let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
12503
12504        // Create a raw tensor, then attach format — simulating the from_fd workflow
12505        let mut dst =
12506            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
12507        dst.set_format(PixelFormat::Rgb).unwrap();
12508
12509        // Convert should work with the set_format-annotated tensor
12510        processor.convert(&src, &mut dst, None).unwrap();
12511
12512        // Verify format survived conversion
12513        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
12514        assert_eq!(dst.width(), Some(640));
12515        assert_eq!(dst.height(), Some(640));
12516    }
12517
12518    /// Verify that creating multiple ImageProcessors on the same thread and
12519    /// performing a resize on each does not deadlock or error.
12520    ///
12521    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12522    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12523    #[test]
12524    fn test_multiple_image_processors_same_thread() {
12525        // Hold the env mutex so env-var-mutating tests can't corrupt the state
12526        // seen by ImageProcessor::new() calls during this test.
12527        let _lock = acquire_env_lock();
12528        let mut processors: Vec<ImageProcessor> = (0..4)
12529            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
12530            .collect();
12531
12532        for proc in &mut processors {
12533            let src = proc
12534                .create_image(
12535                    128,
12536                    128,
12537                    PixelFormat::Rgb,
12538                    DType::U8,
12539                    None,
12540                    edgefirst_tensor::CpuAccess::ReadWrite,
12541                )
12542                .expect("create src failed");
12543            let mut dst = proc
12544                .create_image(
12545                    64,
12546                    64,
12547                    PixelFormat::Rgb,
12548                    DType::U8,
12549                    None,
12550                    edgefirst_tensor::CpuAccess::ReadWrite,
12551                )
12552                .expect("create dst failed");
12553            proc.convert(&src, &mut dst, None).expect("convert failed");
12554            assert_eq!(dst.width(), Some(64));
12555            assert_eq!(dst.height(), Some(64));
12556        }
12557    }
12558
12559    /// Verify that creating ImageProcessors on separate threads and performing
12560    /// a resize on each does not deadlock or error.
12561    ///
12562    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
12563    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
12564    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12565    #[test]
12566    fn test_multiple_image_processors_separate_threads() {
12567        use std::sync::mpsc;
12568        use std::time::Duration;
12569
12570        // The Vivante GC7000UL driver (i.MX 8M Plus) double-frees on concurrent
12571        // EGL context teardown — four processors spun up on four threads here
12572        // trips it and aborts the whole test binary (SIGABRT, not a catchable
12573        // panic). The bug is the driver's, not the HAL's; this test is kept so
12574        // it still exercises the multi-context path on every other GPU. The
12575        // board lane and scripts/on-target-test.sh set
12576        // EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS on any board with /dev/galcore
12577        // to skip just this case there, while the run stays red anywhere else
12578        // a regression appears. (Skip, not #[ignore]: the platform is decided
12579        // at runtime, not compile time.)
12580        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12581            crate::test_support::report_skip(
12582                "test_multiple_image_processors_separate_threads — known Vivante \
12583                 GC7000UL concurrent-EGL-teardown double-free \
12584                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12585            );
12586            return;
12587        }
12588
12589        const TIMEOUT: Duration = Duration::from_secs(60);
12590
12591        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12592        // calls made inside the spawned threads during this test.
12593        let _lock = acquire_env_lock();
12594
12595        let (tx, rx) = mpsc::channel::<()>();
12596
12597        std::thread::spawn(move || {
12598            let handles: Vec<_> = (0..4)
12599                .map(|i| {
12600                    std::thread::spawn(move || {
12601                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12602                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12603                        });
12604                        let src = proc
12605                            .create_image(
12606                                128,
12607                                128,
12608                                PixelFormat::Rgb,
12609                                DType::U8,
12610                                None,
12611                                edgefirst_tensor::CpuAccess::ReadWrite,
12612                            )
12613                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
12614                        let mut dst = proc
12615                            .create_image(
12616                                64,
12617                                64,
12618                                PixelFormat::Rgb,
12619                                DType::U8,
12620                                None,
12621                                edgefirst_tensor::CpuAccess::ReadWrite,
12622                            )
12623                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
12624                        proc.convert(&src, &mut dst, None)
12625                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12626                        assert_eq!(dst.width(), Some(64));
12627                        assert_eq!(dst.height(), Some(64));
12628                    })
12629                })
12630                .collect();
12631
12632            for (i, h) in handles.into_iter().enumerate() {
12633                h.join()
12634                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12635            }
12636
12637            let _ = tx.send(());
12638        });
12639
12640        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12641            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
12642        });
12643    }
12644
12645    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
12646    /// all operate concurrently without deadlocking each other.
12647    ///
12648    /// All processors are created first, then a barrier synchronizes them so
12649    /// they all start converting at the same instant — maximizing contention.
12650    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
12651    #[test]
12652    fn test_image_processors_concurrent_operations() {
12653        use std::sync::{mpsc, Arc, Barrier};
12654        use std::time::Duration;
12655
12656        const N: usize = 4;
12657        const ROUNDS: usize = 10;
12658        const TIMEOUT: Duration = Duration::from_secs(60);
12659
12660        // Hold the env mutex so env-var-mutating tests can't corrupt ImageProcessor::new()
12661        // calls made inside the spawned threads during this test.
12662        let _lock = acquire_env_lock();
12663
12664        let (tx, rx) = mpsc::channel::<()>();
12665
12666        std::thread::spawn(move || {
12667            let barrier = Arc::new(Barrier::new(N));
12668
12669            let handles: Vec<_> = (0..N)
12670                .map(|i| {
12671                    let barrier = Arc::clone(&barrier);
12672                    std::thread::spawn(move || {
12673                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12674                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12675                        });
12676
12677                        // All threads wait here until every processor is initialized.
12678                        barrier.wait();
12679
12680                        // Now all 4 hammer the GPU concurrently.
12681                        for round in 0..ROUNDS {
12682                            let src = proc
12683                                .create_image(
12684                                    128,
12685                                    128,
12686                                    PixelFormat::Rgb,
12687                                    DType::U8,
12688                                    None,
12689                                    edgefirst_tensor::CpuAccess::ReadWrite,
12690                                )
12691                                .unwrap_or_else(|e| {
12692                                    panic!("create src failed on thread {i} round {round}: {e}")
12693                                });
12694                            let mut dst = proc
12695                                .create_image(
12696                                    64,
12697                                    64,
12698                                    PixelFormat::Rgb,
12699                                    DType::U8,
12700                                    None,
12701                                    edgefirst_tensor::CpuAccess::ReadWrite,
12702                                )
12703                                .unwrap_or_else(|e| {
12704                                    panic!("create dst failed on thread {i} round {round}: {e}")
12705                                });
12706                            proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
12707                                panic!("convert failed on thread {i} round {round}: {e}")
12708                            });
12709                            assert_eq!(dst.width(), Some(64));
12710                            assert_eq!(dst.height(), Some(64));
12711                        }
12712                    })
12713                })
12714                .collect();
12715
12716            for (i, h) in handles.into_iter().enumerate() {
12717                h.join()
12718                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
12719            }
12720
12721            let _ = tx.send(());
12722        });
12723
12724        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12725            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
12726        });
12727    }
12728
12729    /// THE parallel-processors demonstration test: 4 ImageProcessors on 4
12730    /// threads, each with its own GL context and worker, converting
12731    /// per-thread-DISTINCT synthetic inputs concurrently (barrier-released);
12732    /// every output must byte-match the thread's own pre-barrier sequential
12733    /// oracle from the same processor. On LifecycleOnly platforms (Mali,
12734    /// V3D, Tegra, llvmpipe, macOS) the converts genuinely overlap on the
12735    /// GPU; on Vivante they serialize via the Full policy and the test still
12736    /// must pass. Distinct inputs make any cross-processor state leakage
12737    /// (wrong texture, wrong context, clobbered upload) visible as a byte
12738    /// diff rather than a coincidental match — proven by a scratch
12739    /// cross-wire run (neighbor's input post-oracle) failing on every
12740    /// thread with ~53% of bytes diverged.
12741    ///
12742    /// Skipped under EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS like
12743    /// `test_multiple_image_processors_separate_threads`: the galcore driver
12744    /// can abort intermittently on concurrent multi-processor lifecycles
12745    /// regardless of locking (P0 spike: reproduces fully serialized).
12746    #[test]
12747    fn test_parallel_processors_unique_outputs() {
12748        use std::sync::{mpsc, Arc, Barrier};
12749        use std::time::Duration;
12750
12751        const N: usize = 4;
12752        const ROUNDS: usize = 25;
12753        const TIMEOUT: Duration = Duration::from_secs(60);
12754
12755        if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
12756            crate::test_support::report_skip(
12757                "test_parallel_processors_unique_outputs — known Vivante \
12758                 GC7000UL concurrent-multi-processor driver abort \
12759                 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)",
12760            );
12761            return;
12762        }
12763
12764        let _lock = acquire_env_lock();
12765        // Probed here, where the policy can name the test; worker threads are unnamed.
12766        let use_dma = crate::test_support::is_dma_available();
12767        if !use_dma {
12768            crate::test_support::report_skip(&format!(
12769                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
12770                function!()
12771            ));
12772        }
12773        let (tx, rx) = mpsc::channel::<()>();
12774
12775        std::thread::spawn(move || {
12776            let barrier = Arc::new(Barrier::new(N));
12777            let handles: Vec<_> = (0..N)
12778                .map(|i| {
12779                    let barrier = Arc::clone(&barrier);
12780                    std::thread::spawn(move || {
12781                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12782                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12783                        });
12784                        // CI-weight geometry (llvmpipe renders on the CPU).
12785                        let (w, h) = (640usize, 480usize);
12786                        let mem = if use_dma {
12787                            Some(TensorMemory::DmaBuf)
12788                        } else {
12789                            Some(TensorMemory::Mem)
12790                        };
12791                        let src = proc
12792                            .create_image(
12793                                w,
12794                                h,
12795                                PixelFormat::Nv12,
12796                                DType::U8,
12797                                mem,
12798                                edgefirst_tensor::CpuAccess::ReadWrite,
12799                            )
12800                            .unwrap();
12801                        {
12802                            let t = src.as_typed::<u8>().unwrap();
12803                            let mut m = t.map().unwrap();
12804                            let s = m.as_mut_slice();
12805                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12806                                *b = ((i * 53 + j) % 200 + 16) as u8;
12807                            }
12808                            for b in &mut s[w * h..] {
12809                                *b = (80 + i * 24) as u8;
12810                            }
12811                        }
12812                        let lb = Crop::letterbox([114, 114, 114, 255]);
12813                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12814                            let mut dst = proc
12815                                .create_image(
12816                                    320,
12817                                    320,
12818                                    PixelFormat::Rgba,
12819                                    DType::U8,
12820                                    mem,
12821                                    edgefirst_tensor::CpuAccess::ReadWrite,
12822                                )
12823                                .unwrap();
12824                            proc.convert(
12825                                &src,
12826                                &mut dst,
12827                                Some(crate::ConvertOptions::new().crop(lb)),
12828                            )
12829                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12830                            let t = dst.as_typed::<u8>().unwrap();
12831                            let m = t.map().unwrap();
12832                            m.as_slice().to_vec()
12833                        };
12834
12835                        let oracle = convert_once(&mut proc);
12836                        barrier.wait();
12837                        for round in 0..ROUNDS {
12838                            let out = convert_once(&mut proc);
12839                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12840                            assert!(
12841                                diffs == 0,
12842                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12843                                 from this processor's own oracle — cross-processor \
12844                                 GL state leakage under parallel execution",
12845                                oracle.len()
12846                            );
12847                        }
12848                    })
12849                })
12850                .collect();
12851
12852            for (i, h) in handles.into_iter().enumerate() {
12853                h.join()
12854                    .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
12855            }
12856            let _ = tx.send(());
12857        });
12858
12859        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12860            panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
12861        });
12862    }
12863
12864    /// Heavy on-demand stressor for the GL serialization policy: 4
12865    /// processors × 4 threads × barrier × 200 NV12 720p → RGB 640 letterbox
12866    /// converts (DMA where available); every output must byte-match the
12867    /// thread's own pre-barrier sequential oracle from the same processor.
12868    /// Ignored by default (heavy; board tool — the CI-weight version is
12869    /// `test_parallel_processors_unique_outputs`). Run explicitly, optionally
12870    /// pinning the policy via EDGEFIRST_GL_SERIALIZE=full|lifecycle:
12871    ///   <test binary> stress_parallel_processors_oracle --ignored
12872    #[test]
12873    #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
12874    fn stress_parallel_processors_oracle() {
12875        use std::sync::{mpsc, Arc, Barrier};
12876        use std::time::Duration;
12877
12878        const N: usize = 4;
12879        const ROUNDS: usize = 200;
12880        const TIMEOUT: Duration = Duration::from_secs(600);
12881
12882        let _lock = acquire_env_lock();
12883        // Probed here, where the policy can name the test; worker threads are unnamed.
12884        let use_dma = crate::test_support::is_dma_available();
12885        if !use_dma {
12886            crate::test_support::report_skip(&format!(
12887                "{} - DmaBuf sources not exercised, running on Mem: no usable DMA heap",
12888                function!()
12889            ));
12890        }
12891        let (tx, rx) = mpsc::channel::<()>();
12892
12893        std::thread::spawn(move || {
12894            let barrier = Arc::new(Barrier::new(N));
12895            let handles: Vec<_> = (0..N)
12896                .map(|i| {
12897                    let barrier = Arc::clone(&barrier);
12898                    std::thread::spawn(move || {
12899                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
12900                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
12901                        });
12902                        let (w, h) = (1280usize, 720usize);
12903                        let mem = if use_dma {
12904                            Some(TensorMemory::DmaBuf)
12905                        } else {
12906                            Some(TensorMemory::Mem)
12907                        };
12908
12909                        // Per-thread-distinct synthetic NV12 so cross-wired
12910                        // GL state between processors shows up as a byte diff.
12911                        let src = proc
12912                            .create_image(
12913                                w,
12914                                h,
12915                                PixelFormat::Nv12,
12916                                DType::U8,
12917                                mem,
12918                                edgefirst_tensor::CpuAccess::ReadWrite,
12919                            )
12920                            .unwrap();
12921                        {
12922                            let t = src.as_typed::<u8>().unwrap();
12923                            let mut m = t.map().unwrap();
12924                            let s = m.as_mut_slice();
12925                            for (j, b) in s[..w * h].iter_mut().enumerate() {
12926                                *b = ((i * 37 + j) % 200 + 16) as u8;
12927                            }
12928                            for b in &mut s[w * h..] {
12929                                *b = (96 + i * 16) as u8;
12930                            }
12931                        }
12932                        let lb = Crop::letterbox([114, 114, 114, 255]);
12933
12934                        let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
12935                            let mut dst = proc
12936                                .create_image(
12937                                    640,
12938                                    640,
12939                                    PixelFormat::Rgb,
12940                                    DType::U8,
12941                                    mem,
12942                                    edgefirst_tensor::CpuAccess::ReadWrite,
12943                                )
12944                                .unwrap();
12945                            proc.convert(
12946                                &src,
12947                                &mut dst,
12948                                Some(crate::ConvertOptions::new().crop(lb)),
12949                            )
12950                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
12951                            let t = dst.as_typed::<u8>().unwrap();
12952                            let m = t.map().unwrap();
12953                            m.as_slice().to_vec()
12954                        };
12955
12956                        let oracle = convert_once(&mut proc);
12957                        barrier.wait();
12958                        for round in 0..ROUNDS {
12959                            let out = convert_once(&mut proc);
12960                            let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
12961                            assert!(
12962                                diffs == 0,
12963                                "thread {i} round {round}: {diffs}/{} bytes diverged \
12964                                 from the pre-barrier oracle",
12965                                oracle.len()
12966                            );
12967                        }
12968                    })
12969                })
12970                .collect();
12971
12972            for (i, h) in handles.into_iter().enumerate() {
12973                h.join()
12974                    .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
12975            }
12976            let _ = tx.send(());
12977        });
12978
12979        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
12980            panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
12981        });
12982    }
12983
12984    // =========================================================================
12985    // F16 / F32 auto-chain fallback integration tests
12986    // =========================================================================
12987
12988    /// Proves the auto-chain (OpenGL → G2D → CPU) NEVER errors for a float
12989    /// combo the GL path does NOT cover.
12990    ///
12991    /// `Yuyv → Rgb F32` is not handled by the GL float render path (which
12992    /// only covers `Rgba → PlanarRgb F16` and `Rgba → Rgb F32`), so the
12993    /// chain falls through to the CPU float path. Before commit 868a7649
12994    /// added CPU U8→F32/F16 support this would have returned `Err`; now it
12995    /// must return `Ok` with output in `[0, 1]` and all values finite.
12996    #[test]
12997    fn convert_f32_auto_never_errors_non_gl_combo() {
12998        const W: usize = 64;
12999        const H: usize = 64;
13000
13001        // Build a small synthetic YUYV source (Y=128, U=128, V=128 → near-grey).
13002        // YUYV packs two pixels into 4 bytes: [Y0, U, Y1, V] per macropixel.
13003        let src = TensorDyn::image(
13004            W,
13005            H,
13006            PixelFormat::Yuyv,
13007            DType::U8,
13008            Some(TensorMemory::Mem),
13009            edgefirst_tensor::CpuAccess::ReadWrite,
13010        )
13011        .unwrap();
13012        {
13013            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13014            let data = map.as_mut_slice();
13015            for chunk in data.as_chunks_mut::<4>().0 {
13016                chunk[0] = 128; // Y0
13017                chunk[1] = 128; // U
13018                chunk[2] = 160; // Y1 — distinct so a layout bug is visible
13019                chunk[3] = 128; // V
13020            }
13021        }
13022
13023        let mut dst = TensorDyn::image(
13024            W,
13025            H,
13026            PixelFormat::Rgb,
13027            DType::F32,
13028            Some(TensorMemory::Mem),
13029            edgefirst_tensor::CpuAccess::ReadWrite,
13030        )
13031        .unwrap();
13032
13033        let mut proc = ImageProcessor::new().unwrap();
13034        let result = proc.convert(&src, &mut dst, None);
13035        assert!(
13036            result.is_ok(),
13037            "auto-chain Yuyv→Rgb F32 must not error: {:?}",
13038            result.err()
13039        );
13040
13041        // Verify all output values are finite and in [0, 1].
13042        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13043        let floats = map.as_slice();
13044        assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
13045        for (i, &v) in floats.iter().enumerate() {
13046            assert!(
13047                v.is_finite() && (0.0..=1.0).contains(&v),
13048                "output[{i}]={v} is not finite or not in [0,1]"
13049            );
13050        }
13051
13052        // WEAK-1: Anti-all-zero spot-check.  A Y=128 YUYV source normalises to
13053        // ≈0.502 on the luma channel.  If the buffer is all-zero (e.g. the CPU
13054        // path never wrote to it) this assertion catches the regression.
13055        let first_non_zero = floats.iter().find(|&&v| v > 0.01);
13056        assert!(
13057            first_non_zero.is_some(),
13058            "all-zero output detected — CPU path likely did not write to the destination buffer"
13059        );
13060        // Y=128 → luma ≈ 0.502.  Spot-check the first pixel's R channel
13061        // (which carries luma for a near-grey YUV source).
13062        let r0 = floats[0];
13063        assert!(
13064            (r0 - 0.502_f32).abs() < 0.05,
13065            "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
13066        );
13067    }
13068
13069    /// Proves CPU-forced `Rgba → PlanarRgb F16` correctness.
13070    ///
13071    /// Uses a source with clearly distinct per-channel values so a
13072    /// plane-swap or layout bug surfaces immediately. Tolerance is 2^-9
13073    /// (one F16 ULP at 0.5, i.e. roughly 1/512).
13074    #[test]
13075    // `ImageProcessorConfig` carries a Linux-only `egl_display` field, so
13076    // `{ backend, ..Default::default() }` is a genuine update on Linux but
13077    // covers no remaining fields on macOS, where `clippy::needless_update`
13078    // then fires. `allow` (not `expect`) because the lint is platform-
13079    // conditional — it does not fire on Linux.
13080    #[allow(clippy::needless_update)]
13081    fn convert_f16_forced_cpu_correct() {
13082        const W: usize = 16;
13083        const H: usize = 16;
13084        const TOL: f32 = 1.0 / 512.0; // 2^-9
13085
13086        // pixel (y,x): R = 50+x, G = 100+y*8, B = 200
13087        let src = TensorDyn::image(
13088            W,
13089            H,
13090            PixelFormat::Rgba,
13091            DType::U8,
13092            Some(TensorMemory::Mem),
13093            edgefirst_tensor::CpuAccess::ReadWrite,
13094        )
13095        .unwrap();
13096        {
13097            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13098            let data = map.as_mut_slice();
13099            for y in 0..H {
13100                for x in 0..W {
13101                    let i = y * W + x;
13102                    data[i * 4] = (50 + x) as u8; // R: 50..65
13103                    data[i * 4 + 1] = (100 + y * 8) as u8; // G: 100..220
13104                    data[i * 4 + 2] = 200; // B: constant
13105                    data[i * 4 + 3] = 255;
13106                }
13107            }
13108        }
13109
13110        let mut dst = TensorDyn::image(
13111            W,
13112            H,
13113            PixelFormat::PlanarRgb,
13114            DType::F16,
13115            Some(TensorMemory::Mem),
13116            edgefirst_tensor::CpuAccess::ReadWrite,
13117        )
13118        .unwrap();
13119
13120        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13121            backend: ComputeBackend::Cpu,
13122            ..Default::default()
13123        })
13124        .unwrap();
13125        proc.convert(&src, &mut dst, None)
13126            .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13127
13128        let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
13129        let src_bytes = src_map.as_slice();
13130        let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13131        let dst_halfs = dst_map.as_slice();
13132
13133        let plane = W * H;
13134        assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
13135
13136        for y in 0..H {
13137            for x in 0..W {
13138                let i = y * W + x;
13139                let r_exp = src_bytes[i * 4] as f32 / 255.0;
13140                let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
13141                let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
13142
13143                let r_got = dst_halfs[i].to_f32();
13144                let g_got = dst_halfs[plane + i].to_f32();
13145                let b_got = dst_halfs[2 * plane + i].to_f32();
13146
13147                assert!(
13148                    (r_got - r_exp).abs() <= TOL,
13149                    "R plane ({x},{y}): got {r_got}, expected {r_exp}"
13150                );
13151                assert!(
13152                    (g_got - g_exp).abs() <= TOL,
13153                    "G plane ({x},{y}): got {g_got}, expected {g_exp}"
13154                );
13155                assert!(
13156                    (b_got - b_exp).abs() <= TOL,
13157                    "B plane ({x},{y}): got {b_got}, expected {b_exp}"
13158                );
13159
13160                // Catch plane-swap: R and G must differ (they have different formulas).
13161                if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
13162                    assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
13163                }
13164            }
13165        }
13166    }
13167
13168    /// Proves the auto-chain falls through to CPU for `Rgba → Rgb F32` with
13169    /// rotation set.
13170    ///
13171    /// The GL float render path rejects any call where rotation ≠ None,
13172    /// returning an error that causes the chain to continue. Before the CPU
13173    /// float fallback this would have produced an error at the end of the
13174    /// chain; now it must reach CPU and return `Ok` with finite `[0, 1]` output.
13175    #[test]
13176    fn convert_f32_with_rotation_falls_back() {
13177        const W: usize = 16;
13178        const H: usize = 16;
13179
13180        // RGBA8 source with a known gradient (distinct per-channel values).
13181        let src = TensorDyn::image(
13182            W,
13183            H,
13184            PixelFormat::Rgba,
13185            DType::U8,
13186            Some(TensorMemory::Mem),
13187            edgefirst_tensor::CpuAccess::ReadWrite,
13188        )
13189        .unwrap();
13190        {
13191            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13192            let data = map.as_mut_slice();
13193            for y in 0..H {
13194                for x in 0..W {
13195                    let i = y * W + x;
13196                    data[i * 4] = (x * 16) as u8; // R
13197                    data[i * 4 + 1] = (y * 16) as u8; // G
13198                    data[i * 4 + 2] = 128; // B
13199                    data[i * 4 + 3] = 255;
13200                }
13201            }
13202        }
13203
13204        // Rotation swaps W and H, so dst is [W, H] (H×W output).
13205        let mut dst = TensorDyn::image(
13206            H, // dst W = src H after 90° rotation
13207            W, // dst H = src W after 90° rotation
13208            PixelFormat::Rgb,
13209            DType::F32,
13210            Some(TensorMemory::Mem),
13211            edgefirst_tensor::CpuAccess::ReadWrite,
13212        )
13213        .unwrap();
13214
13215        let mut proc = ImageProcessor::new().unwrap();
13216        let result = proc.convert(
13217            &src,
13218            &mut dst,
13219            Some(crate::ConvertOptions::new().rotation(Rotation::Clockwise90)),
13220        );
13221        assert!(
13222            result.is_ok(),
13223            "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
13224            result.err()
13225        );
13226
13227        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13228        let floats = map.as_slice();
13229        assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
13230        for (i, &v) in floats.iter().enumerate() {
13231            assert!(
13232                v.is_finite() && (0.0..=1.0).contains(&v),
13233                "output[{i}]={v} is not finite or not in [0,1]"
13234            );
13235        }
13236    }
13237
13238    /// GL-vs-CPU identity parity for `Rgba → PlanarRgb F16`.
13239    ///
13240    /// Converts the same RGBA8 source via the `OpenGl` backend and the
13241    /// forced `Cpu` backend, then verifies the two F16 output tensors agree
13242    /// element-wise within 2^-8 (two F16 ULPs at 0.5). Skipped when OpenGL
13243    /// or F16 render is unavailable, and when the host cannot hold the
13244    /// zero-copy F16 destination the GL float route needs.
13245    ///
13246    /// **The GL route is asserted, not assumed** (issue #179).
13247    /// `ComputeBackend::OpenGl` is not a forced backend: it leaves
13248    /// `forced_backend` at `None`, so a GL decline logs at debug and the CPU
13249    /// serves the same convert. Both sides of the comparison would then be
13250    /// the CPU's answer and the parity claim would be a tautology — which is
13251    /// exactly how a host whose RGBA16F FBO is incomplete (desktop NVIDIA:
13252    /// `GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT`) passed while proving nothing,
13253    /// `supported_render_dtypes().f16` above notwithstanding: that flag is
13254    /// an extension query, not a completed render. `convert_fallback_count()`
13255    /// staying flat across the GL convert is the proof, and a decline is
13256    /// reported as a skip rather than a failure because it is a property of
13257    /// the host's driver, not of this code — except under
13258    /// `HAL_TEST_REQUIRE_GL=1`, which turns it into a named failure, the
13259    /// same opt-in `crates/image/tests/dst_view_stride.rs` and
13260    /// `reconstructed_view_draw.rs` carry for a GL backend that fails to
13261    /// come up. A lane that has asserted it has GL should not be quietly
13262    /// losing this test's coverage.
13263    #[test]
13264    #[cfg(all(
13265        any(
13266            target_os = "linux",
13267            target_os = "macos",
13268            target_os = "ios",
13269            target_os = "android",
13270            target_os = "windows"
13271        ),
13272        feature = "opengl"
13273    ))]
13274    fn convert_f16_gl_cpu_parity_identity() {
13275        // The macOS coverage lane sets `HAL_TEST_REQUIRE_GL=1` for both
13276        // passes but deliberately hobbles pass 1 (unsigned binaries, the
13277        // ANGLE dlopen gate closed). Excluded there the same way
13278        // `gl_backend_available_canary` excludes it, so the gate below
13279        // enforces in pass 2 only.
13280        #[cfg(target_os = "macos")]
13281        if std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1")
13282            && std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none()
13283        {
13284            crate::test_support::report_skip(
13285                "convert_f16_gl_cpu_parity_identity - ANGLE dlopen gate closed (coverage pass 1)",
13286            );
13287            return;
13288        }
13289        if !is_opengl_available() {
13290            crate::test_support::report_skip(
13291                "convert_f16_gl_cpu_parity_identity - OpenGL not available",
13292            );
13293            return;
13294        }
13295
13296        // 32 F16 pixels make a 64-byte row, the pitch IOSurface requires for a planar destination.
13297        const W: usize = 32;
13298        const H: usize = 16;
13299        const TOL: f32 = 1.0 / 256.0; // 2^-8
13300
13301        // pixel (y,x): R = 40+x, G = 80+y*10, B = 180
13302        let src = TensorDyn::image(
13303            W,
13304            H,
13305            PixelFormat::Rgba,
13306            DType::U8,
13307            Some(TensorMemory::Mem),
13308            edgefirst_tensor::CpuAccess::ReadWrite,
13309        )
13310        .unwrap();
13311        {
13312            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13313            let data = map.as_mut_slice();
13314            for y in 0..H {
13315                for x in 0..W {
13316                    let i = y * W + x;
13317                    data[i * 4] = (40 + x) as u8; // R
13318                    data[i * 4 + 1] = (80 + y * 10) as u8; // G
13319                    data[i * 4 + 2] = 180; // B
13320                    data[i * 4 + 3] = 255;
13321                }
13322            }
13323        }
13324
13325        // GL path.
13326        let gl_result = {
13327            let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
13328                backend: ComputeBackend::OpenGl,
13329                ..Default::default()
13330            }) {
13331                Ok(p) => p,
13332                Err(e) => {
13333                    crate::test_support::report_skip(&format!(
13334                        "convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
13335                    ));
13336                    return;
13337                }
13338            };
13339
13340            if !gl_proc.supported_render_dtypes().f16 {
13341                crate::test_support::report_skip(
13342                    "convert_f16_gl_cpu_parity_identity - F16 render not supported",
13343                );
13344                return;
13345            }
13346
13347            // A destination the GL float route actually serves. A
13348            // `TensorMemory::Mem` float destination has no arm in
13349            // `float_dispatch::classify_float_render` at all: it classifies as
13350            // `FloatRenderPath::None`, the u8 route rejects the dtype, and the
13351            // CPU serves every convert. With a host destination the GL half
13352            // below was therefore unreachable and this test could only ever
13353            // skip. `(Rgba, PlanarRgb, F16, DmaBuf)` -- `ZeroCopyF16Nchw` --
13354            // is the zero-copy float tuple EVERY platform's capability set
13355            // serves: it is unconditional in `classify_float_render`, so it
13356            // holds for the `PlanarF16` set Linux, Android and ANGLE/macOS
13357            // declare as well as the `All` set Windows declares.
13358            //
13359            // One spelling covers all of them. `TensorMemory::DmaBuf` is the
13360            // portable name for the platform-native zero-copy GPU buffer --
13361            // a DMA-BUF on Linux, an IOSurface on macOS/iOS, an
13362            // AHardwareBuffer on Android, an `ID3D11Texture2D` on Windows --
13363            // and the classifier matches on that one variant for all of them,
13364            // so this needs no `cfg`. Where the platform cannot hold one the
13365            // allocation fails or falls back, and both are reported below.
13366            // Checked before the HAL_TEST_REQUIRE_GL gate below, which covers a declined destination.
13367            let Some(mut dst) = crate::test_support::gpu_buffer_allocated(
13368                TensorDyn::image(
13369                    W,
13370                    H,
13371                    PixelFormat::PlanarRgb,
13372                    DType::F16,
13373                    Some(TensorMemory::DmaBuf),
13374                    edgefirst_tensor::CpuAccess::ReadWrite,
13375                ),
13376                "zero-copy F16 PlanarRgb destination",
13377            ) else {
13378                return;
13379            };
13380            if dst.memory() != TensorMemory::DmaBuf {
13381                crate::test_support::report_skip(&format!(
13382                    "convert_f16_gl_cpu_parity_identity - the zero-copy F16 \
13383                     destination fell back to {:?}",
13384                    dst.memory()
13385                ));
13386                return;
13387            }
13388            let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
13389            let fallbacks_before = gl_proc.convert_fallback_count();
13390            match gl_proc.convert(&src, &mut dst, None) {
13391                Ok(()) => {
13392                    if gl_proc.convert_fallback_count() != fallbacks_before {
13393                        assert!(
13394                            !require_gl,
13395                            "HAL_TEST_REQUIRE_GL=1 but \
13396                             convert_f16_gl_cpu_parity_identity would have skipped \
13397                             while reporting success: the GL F16 path declined and \
13398                             the CPU served the convert"
13399                        );
13400                        crate::test_support::report_skip(
13401                            "convert_f16_gl_cpu_parity_identity - the GL F16 path \
13402                             declined and the CPU served the convert; comparing that \
13403                             against the forced-CPU result would prove nothing",
13404                        );
13405                        return;
13406                    }
13407                    dst
13408                }
13409                Err(e) => {
13410                    crate::test_support::report_skip(&format!(
13411                        "convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
13412                    ));
13413                    return;
13414                }
13415            }
13416        };
13417
13418        // CPU path.
13419        let cpu_result = {
13420            let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
13421                backend: ComputeBackend::Cpu,
13422                ..Default::default()
13423            })
13424            .unwrap();
13425            let mut dst = TensorDyn::image(
13426                W,
13427                H,
13428                PixelFormat::PlanarRgb,
13429                DType::F16,
13430                Some(TensorMemory::Mem),
13431                edgefirst_tensor::CpuAccess::ReadWrite,
13432            )
13433            .unwrap();
13434            cpu_proc
13435                .convert(&src, &mut dst, None)
13436                .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
13437            dst
13438        };
13439
13440        // Compare element-wise. Both sides are read through `copy_to_flat`:
13441        // the GL destination is a packed zero-copy surface whose rows can be
13442        // spaced at a driver pitch, so its mapping is not a flat run of the
13443        // logical elements.
13444        let flat_f16 = |t: &TensorDyn| -> Vec<half::f16> {
13445            let typed = t.as_typed::<half::f16>().expect("F16 image");
13446            let mut bytes = vec![0u8; typed.shape().iter().product::<usize>() * 2];
13447            typed
13448                .copy_to_flat(&mut bytes)
13449                .expect("compact any padded rows");
13450            bytes
13451                .as_chunks::<2>()
13452                .0
13453                .iter()
13454                .map(|&c| half::f16::from_le_bytes(c))
13455                .collect()
13456        };
13457        let gl_halfs = flat_f16(&gl_result);
13458        let cpu_halfs = flat_f16(&cpu_result);
13459
13460        assert_eq!(
13461            gl_halfs.len(),
13462            cpu_halfs.len(),
13463            "GL and CPU output sizes differ"
13464        );
13465
13466        let plane = W * H;
13467        let channel_names = ["R", "G", "B"];
13468        for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
13469            let gl_v = gl_h.to_f32();
13470            let cpu_v = cpu_h.to_f32();
13471            let err = (gl_v - cpu_v).abs();
13472            let ch = channel_names[idx / plane];
13473            let pixel = idx % plane;
13474            assert!(
13475                err <= TOL,
13476                "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
13477            );
13478        }
13479    }
13480
13481    // =========================================================================
13482    // GAP-1: supported_render_dtypes() Linux smoke test
13483    // =========================================================================
13484
13485    /// Exercises the real Linux/Windows GL path that reads
13486    /// `gl.supported_render_dtypes()`. Skipped when no GL backend is
13487    /// available (CI/host without a GPU).
13488    #[test]
13489    #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
13490    fn supported_render_dtypes_linux_smoke() {
13491        let proc = match ImageProcessor::new() {
13492            Ok(p) => p,
13493            Err(e) => {
13494                crate::test_support::report_skip(&format!(
13495                    "supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}"
13496                ));
13497                return;
13498            }
13499        };
13500        if proc.opengl.is_none() {
13501            crate::test_support::report_skip(
13502                "supported_render_dtypes_linux_smoke — no GL backend on this host",
13503            );
13504            return;
13505        }
13506        // The call must complete without panicking or deadlocking.
13507        let support = proc.supported_render_dtypes();
13508        eprintln!(
13509            "supported_render_dtypes_linux_smoke: f16={} f32={}",
13510            support.f16, support.f32
13511        );
13512        // No assertion on the specific values — they are hardware-dependent.
13513    }
13514
13515    // =========================================================================
13516    // GAP-2: F16 PlanarRgb with width NOT divisible by 4 falls back to CPU
13517    // =========================================================================
13518
13519    /// The GL float render path rejects PlanarRgb F16 destinations whose width
13520    /// is not a multiple of 4 (the packed RGBA16F swizzle trick requires W%4==0).
13521    /// The auto-chain must transparently fall through to the CPU path, which has
13522    /// no such restriction.  W=18, H=16 is chosen so W%4 == 2.
13523    #[test]
13524    fn convert_f16_pbo_non_4_aligned_width_falls_back() {
13525        const W: usize = 18; // 18 % 4 == 2 — NOT divisible by 4
13526        const H: usize = 16;
13527
13528        // RGBA8 source filled with a flat mid-grey.
13529        let src = TensorDyn::image(
13530            W,
13531            H,
13532            PixelFormat::Rgba,
13533            DType::U8,
13534            Some(TensorMemory::Mem),
13535            edgefirst_tensor::CpuAccess::ReadWrite,
13536        )
13537        .unwrap();
13538        {
13539            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13540            let data = map.as_mut_slice();
13541            for chunk in data.as_chunks_mut::<4>().0 {
13542                chunk[0] = 128;
13543                chunk[1] = 64;
13544                chunk[2] = 200;
13545                chunk[3] = 255;
13546            }
13547        }
13548
13549        // F16 PlanarRgb destination in Mem (GL would use PBO, but we want
13550        // to exercise the fallback chain without hardware dependency).
13551        let mut dst = TensorDyn::image(
13552            W,
13553            H,
13554            PixelFormat::PlanarRgb,
13555            DType::F16,
13556            Some(TensorMemory::Mem),
13557            edgefirst_tensor::CpuAccess::ReadWrite,
13558        )
13559        .unwrap();
13560
13561        // Use the default auto-chain so the GL path can attempt and reject,
13562        // then the CPU path succeeds.
13563        let mut proc = ImageProcessor::new().unwrap();
13564        let result = proc.convert(&src, &mut dst, None);
13565        assert!(
13566            result.is_ok(),
13567            "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
13568            result.err()
13569        );
13570
13571        // All output values must be finite and in [0, 1].
13572        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13573        let halfs = map.as_slice();
13574        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13575        for (i, h) in halfs.iter().enumerate() {
13576            let v = h.to_f32();
13577            assert!(
13578                v.is_finite() && (0.0..=1.0).contains(&v),
13579                "output[{i}]={v} is not finite or not in [0,1]"
13580            );
13581        }
13582    }
13583
13584    // =========================================================================
13585    // GAP-4: NV12 → Rgb F32 and NV12 → PlanarRgb F16, forced CPU
13586    // =========================================================================
13587
13588    /// CPU widen-composition: NV12 (non-RGBA source) → Rgb F32.
13589    ///
13590    /// NV12 requires a two-stage CPU conversion (NV12→Rgba then Rgba→F32)
13591    /// which was untested. A wrong intermediate format selection silently
13592    /// produces garbage. Y=128, U=V=128 → near-neutral grey → R≈G≈B≈0.5.
13593    #[test]
13594    // Linux-only `egl_display` field makes `..Default::default()` needless
13595    // on macOS only; see `convert_f16_forced_cpu_correct`.
13596    #[allow(clippy::needless_update)]
13597    fn convert_nv12_to_rgb_f32_cpu() {
13598        const W: usize = 16;
13599        const H: usize = 16; // must be even for NV12
13600
13601        // Build a valid NV12 tensor: shape [H*3/2, W], luma=128, chroma=128.
13602        let src = TensorDyn::image(
13603            W,
13604            H,
13605            PixelFormat::Nv12,
13606            DType::U8,
13607            Some(TensorMemory::Mem),
13608            edgefirst_tensor::CpuAccess::ReadWrite,
13609        )
13610        .unwrap();
13611        {
13612            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13613            map.as_mut_slice().fill(128); // Y=128, U=V=128 → neutral grey
13614        }
13615
13616        let mut dst = TensorDyn::image(
13617            W,
13618            H,
13619            PixelFormat::Rgb,
13620            DType::F32,
13621            Some(TensorMemory::Mem),
13622            edgefirst_tensor::CpuAccess::ReadWrite,
13623        )
13624        .unwrap();
13625
13626        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13627            backend: ComputeBackend::Cpu,
13628            ..Default::default()
13629        })
13630        .unwrap();
13631
13632        let result = proc.convert(&src, &mut dst, None);
13633        assert!(
13634            result.is_ok(),
13635            "forced-CPU NV12→Rgb F32 must not error: {:?}",
13636            result.err()
13637        );
13638
13639        let map = dst.as_typed::<f32>().unwrap().map().unwrap();
13640        let floats = map.as_slice();
13641        assert_eq!(floats.len(), W * H * 3, "unexpected element count");
13642        for (i, &v) in floats.iter().enumerate() {
13643            assert!(
13644                v.is_finite() && (0.0..=1.0).contains(&v),
13645                "output[{i}]={v} is not finite or not in [0,1]"
13646            );
13647        }
13648        // Anti-all-zero: Y=128 → luma channel ≈ 0.5 after YUV→RGB.
13649        let non_zero = floats.iter().any(|&v| v > 0.01);
13650        assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
13651    }
13652
13653    /// CPU widen-composition: NV12 (non-RGBA source) → PlanarRgb F16.
13654    ///
13655    /// Same rationale as `convert_nv12_to_rgb_f32_cpu` but for F16 output.
13656    #[test]
13657    // Linux-only `egl_display` field makes `..Default::default()` needless
13658    // on macOS only; see `convert_f16_forced_cpu_correct`.
13659    #[allow(clippy::needless_update)]
13660    fn convert_nv12_to_planar_rgb_f16_cpu() {
13661        const W: usize = 16;
13662        const H: usize = 16;
13663
13664        let src = TensorDyn::image(
13665            W,
13666            H,
13667            PixelFormat::Nv12,
13668            DType::U8,
13669            Some(TensorMemory::Mem),
13670            edgefirst_tensor::CpuAccess::ReadWrite,
13671        )
13672        .unwrap();
13673        {
13674            let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
13675            map.as_mut_slice().fill(128);
13676        }
13677
13678        let mut dst = TensorDyn::image(
13679            W,
13680            H,
13681            PixelFormat::PlanarRgb,
13682            DType::F16,
13683            Some(TensorMemory::Mem),
13684            edgefirst_tensor::CpuAccess::ReadWrite,
13685        )
13686        .unwrap();
13687
13688        let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
13689            backend: ComputeBackend::Cpu,
13690            ..Default::default()
13691        })
13692        .unwrap();
13693
13694        let result = proc.convert(&src, &mut dst, None);
13695        assert!(
13696            result.is_ok(),
13697            "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
13698            result.err()
13699        );
13700
13701        let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
13702        let halfs = map.as_slice();
13703        assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
13704        for (i, h) in halfs.iter().enumerate() {
13705            let v = h.to_f32();
13706            assert!(
13707                v.is_finite() && (0.0..=1.0).contains(&v),
13708                "output[{i}]={v} is not finite or not in [0,1]"
13709            );
13710        }
13711        let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
13712        assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
13713    }
13714
13715    // =========================================================================
13716    // GAP-5: create_image F32 + DMA must return NotSupported
13717    // =========================================================================
13718
13719    fn access_test_src(w: usize, h: usize) -> TensorDyn {
13720        use edgefirst_tensor::TensorMapTrait;
13721        let t = TensorDyn::image(
13722            w,
13723            h,
13724            PixelFormat::Rgba,
13725            DType::U8,
13726            Some(TensorMemory::Mem),
13727            edgefirst_tensor::CpuAccess::ReadWrite,
13728        )
13729        .unwrap();
13730        for (i, b) in t
13731            .as_u8()
13732            .unwrap()
13733            .map_mut()
13734            .unwrap()
13735            .as_mut_slice()
13736            .iter_mut()
13737            .enumerate()
13738        {
13739            *b = (i % 251) as u8;
13740        }
13741        t
13742    }
13743
13744    /// With only the CPU backend, `create_image` declares `ReadWrite` for the
13745    /// destinations whose CPU convert reads back what it wrote (the `I8`
13746    /// bias, the BGRA swizzle), and the convert into them succeeds.
13747    #[test]
13748    fn create_image_adds_readwrite_for_read_modify_write_passes() {
13749        use edgefirst_tensor::CpuAccess;
13750        let _lock = acquire_env_lock();
13751        let _guard = EnvGuard::snapshot(&[
13752            "EDGEFIRST_FORCE_BACKEND",
13753            "EDGEFIRST_DISABLE_GL",
13754            "EDGEFIRST_DISABLE_G2D",
13755            "EDGEFIRST_DISABLE_CPU",
13756        ]);
13757        unsafe {
13758            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
13759            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
13760            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
13761            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
13762        }
13763        let mut proc = ImageProcessor::new().unwrap();
13764        let src = access_test_src(128, 96);
13765        for (format, dtype) in [
13766            (PixelFormat::Rgb, DType::I8),
13767            (PixelFormat::Rgba, DType::I8),
13768            (PixelFormat::Bgra, DType::U8),
13769        ] {
13770            for requested in [CpuAccess::None, CpuAccess::Write] {
13771                let mut dst = proc
13772                    .create_image(64, 48, format, dtype, None, requested)
13773                    .unwrap();
13774                assert_eq!(
13775                    dst.cpu_access(),
13776                    CpuAccess::ReadWrite,
13777                    "{format:?}/{dtype:?} requested {requested:?}"
13778                );
13779                proc.convert(&src, &mut dst, None).unwrap_or_else(|e| {
13780                    panic!("CPU convert into {format:?}/{dtype:?} {requested:?}: {e}")
13781                });
13782            }
13783        }
13784    }
13785
13786    /// The CPU convert refuses up front, asking for `ReadWrite`, a
13787    /// `Write`-only destination it would read back (the `I8` bias, the BGRA
13788    /// swizzle); a BGRA-to-BGRA copy has no swizzle and needs only `Write`.
13789    #[test]
13790    fn cpu_convert_refuses_a_write_only_destination_it_reads_back() {
13791        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13792        let mut cpu = CPUProcessor::new();
13793        let src = access_test_src(32, 24);
13794        for (format, dtype) in [
13795            (PixelFormat::Rgb, DType::I8),
13796            (PixelFormat::Bgra, DType::U8),
13797        ] {
13798            let mut dst = TensorDyn::image(
13799                32,
13800                24,
13801                format,
13802                dtype,
13803                Some(TensorMemory::Mem),
13804                CpuAccess::Write,
13805            )
13806            .unwrap();
13807            match cpu.convert(&src, &mut dst, None) {
13808                Err(Error::Tensor(TensorError::CpuAccessDenied {
13809                    declared: CpuAccess::Write,
13810                    requested: CpuAccess::ReadWrite,
13811                })) => {}
13812                other => panic!("{format:?}/{dtype:?}: {other:?}"),
13813            }
13814        }
13815        let bgra_src = TensorDyn::image(
13816            32,
13817            24,
13818            PixelFormat::Bgra,
13819            DType::U8,
13820            Some(TensorMemory::Mem),
13821            CpuAccess::ReadWrite,
13822        )
13823        .unwrap();
13824        let mut dst = TensorDyn::image(
13825            32,
13826            24,
13827            PixelFormat::Bgra,
13828            DType::U8,
13829            Some(TensorMemory::Mem),
13830            CpuAccess::Write,
13831        )
13832        .unwrap();
13833        cpu.convert(&bgra_src, &mut dst, None).unwrap();
13834    }
13835
13836    /// The prediction `create_image` widens with, per backend mix: the CPU
13837    /// and G2D post-passes when either may run, nothing when the GPU renders
13838    /// the destination, `Write` for a host-memory readback.
13839    #[test]
13840    fn predicted_cpu_access_unions_the_backends_that_may_run() {
13841        use edgefirst_tensor::CpuAccess::{self, None as No, Read, ReadWrite, Write};
13842        let p = predicted_cpu_access;
13843        let takes = |a: CpuAccess| Some(Some(a));
13844        let declines = Some(None);
13845        // A forced backend is the only one that runs.
13846        assert_eq!(
13847            p(
13848                Some(ForcedBackend::G2d),
13849                None,
13850                takes(ReadWrite),
13851                takes(Write)
13852            ),
13853            ReadWrite
13854        );
13855        assert_eq!(
13856            p(Some(ForcedBackend::G2d), None, declines, takes(Write)),
13857            No
13858        );
13859        assert_eq!(
13860            p(
13861                Some(ForcedBackend::OpenGl),
13862                takes(Write),
13863                None,
13864                takes(ReadWrite)
13865            ),
13866            Write
13867        );
13868        assert_eq!(
13869            p(Some(ForcedBackend::Cpu), takes(No), None, takes(Write)),
13870            Write
13871        );
13872        // A GL render with no CPU access ends the chain.
13873        assert_eq!(p(None, takes(No), takes(ReadWrite), takes(ReadWrite)), No);
13874        // A host-memory readback leaves the fallbacks predicted.
13875        assert_eq!(p(None, takes(Write), None, takes(ReadWrite)), ReadWrite);
13876        // G2D may decline at run time, so the CPU stays predicted after it.
13877        assert_eq!(p(None, declines, takes(No), takes(Write)), Write);
13878        assert_eq!(p(None, declines, takes(ReadWrite), None), ReadWrite);
13879        assert_eq!(p(None, None, None, takes(Read)), Read);
13880        assert_eq!(p(None, None, None, None), No);
13881    }
13882
13883    /// The GL prediction follows the float classifier: a float destination
13884    /// GL cannot render (host memory, an unsupported format) is predicted
13885    /// for the CPU fallback.
13886    #[cfg(all(target_os = "linux", feature = "opengl"))]
13887    #[test]
13888    fn float_destinations_gl_declines_are_predicted_for_the_cpu() {
13889        use edgefirst_tensor::CpuAccess;
13890        let Ok(processor) = ImageProcessor::new() else {
13891            eprintln!("WARNING: SKIPPED: float prediction - no image processor");
13892            return;
13893        };
13894        let Some(gl) = processor.opengl.as_ref() else {
13895            eprintln!("WARNING: SKIPPED: float prediction - no OpenGL backend");
13896            return;
13897        };
13898        assert_eq!(
13899            gl.dst_cpu_access(TensorMemory::Mem, PixelFormat::PlanarRgb, DType::F16),
13900            None
13901        );
13902        assert_eq!(
13903            gl.dst_cpu_access(TensorMemory::DmaBuf, PixelFormat::Grey, DType::F32),
13904            None
13905        );
13906        let image = processor
13907            .create_image(
13908                64,
13909                64,
13910                PixelFormat::Rgb,
13911                DType::F32,
13912                Some(TensorMemory::Mem),
13913                CpuAccess::None,
13914            )
13915            .unwrap();
13916        assert!(image.cpu_access().writes());
13917    }
13918
13919    /// When a GPU backend declined and the CPU fallback is refused for
13920    /// access, the error carries the decline; otherwise it is the CPU's.
13921    #[test]
13922    fn a_refused_cpu_fallback_reports_the_gpu_decline() {
13923        use edgefirst_tensor::{CpuAccess, Error as TensorError};
13924        let denied = || {
13925            Error::Tensor(TensorError::CpuAccessDenied {
13926                declared: CpuAccess::None,
13927                requested: CpuAccess::Write,
13928            })
13929        };
13930        let declined = || {
13931            vec![(
13932                "opengl",
13933                Error::NotSupported("unsupported source format".into()),
13934            )]
13935        };
13936        let e = with_gpu_declines(denied(), declined());
13937        assert!(
13938            matches!(
13939                &e,
13940                Error::CpuFallbackDenied {
13941                    denied: TensorError::CpuAccessDenied { .. },
13942                    ..
13943                }
13944            ),
13945            "{e:?}"
13946        );
13947        let msg = e.to_string();
13948        assert!(msg.contains("unsupported source format"), "{msg}");
13949        assert!(msg.contains("CPU access denied"), "{msg}");
13950        assert!(e.cpu_access_denied().is_some());
13951        assert!(matches!(
13952            with_gpu_declines(denied(), Vec::new()),
13953            Error::Tensor(TensorError::CpuAccessDenied { .. })
13954        ));
13955        assert!(matches!(
13956            with_gpu_declines(Error::NoConverter, declined()),
13957            Error::NoConverter
13958        ));
13959    }
13960
13961    /// With only the CPU backend, `create_image` widens a `Read` request to
13962    /// `ReadWrite` and a `None` request to `Write`, so the processor's own
13963    /// convert can fill the image.
13964    #[test]
13965    fn create_image_adds_write_when_the_cpu_backend_fills_it() {
13966        use edgefirst_tensor::CpuAccess;
13967        let _lock = acquire_env_lock();
13968        let _guard = EnvGuard::snapshot(&[
13969            "EDGEFIRST_FORCE_BACKEND",
13970            "EDGEFIRST_DISABLE_GL",
13971            "EDGEFIRST_DISABLE_G2D",
13972            "EDGEFIRST_DISABLE_CPU",
13973        ]);
13974        unsafe {
13975            std::env::remove_var("EDGEFIRST_FORCE_BACKEND");
13976            std::env::remove_var("EDGEFIRST_DISABLE_CPU");
13977            std::env::set_var("EDGEFIRST_DISABLE_GL", "1");
13978            std::env::set_var("EDGEFIRST_DISABLE_G2D", "1");
13979        }
13980        let mut proc = ImageProcessor::new().unwrap();
13981        let src = access_test_src(128, 96);
13982        for (requested, declared) in [
13983            (CpuAccess::Read, CpuAccess::ReadWrite),
13984            (CpuAccess::None, CpuAccess::Write),
13985            (CpuAccess::Write, CpuAccess::Write),
13986            (CpuAccess::ReadWrite, CpuAccess::ReadWrite),
13987        ] {
13988            let mut dst = proc
13989                .create_image(64, 48, PixelFormat::Rgb, DType::U8, None, requested)
13990                .unwrap();
13991            assert_eq!(dst.cpu_access(), declared, "requested {requested:?}");
13992            proc.convert(&src, &mut dst, None)
13993                .unwrap_or_else(|e| panic!("CPU convert into a {requested:?} request: {e}"));
13994        }
13995    }
13996
13997    /// A CPU convert refuses up front a caller destination declared without
13998    /// writes, and a source declared without reads, naming both accesses.
13999    #[test]
14000    fn cpu_convert_refuses_tensors_outside_their_declaration() {
14001        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14002        let mut cpu = CPUProcessor::new();
14003        let src = access_test_src(32, 24);
14004        for declared in [CpuAccess::Read, CpuAccess::None] {
14005            let mut dst = TensorDyn::image(
14006                32,
14007                24,
14008                PixelFormat::Rgb,
14009                DType::U8,
14010                Some(TensorMemory::Mem),
14011                declared,
14012            )
14013            .unwrap();
14014            match cpu.convert(&src, &mut dst, None) {
14015                Err(Error::Tensor(TensorError::CpuAccessDenied {
14016                    declared: d,
14017                    requested: CpuAccess::Write,
14018                })) => assert_eq!(d, declared),
14019                other => panic!("convert into a {declared:?} dst: {other:?}"),
14020            }
14021        }
14022        let write_only = TensorDyn::image(
14023            32,
14024            24,
14025            PixelFormat::Rgba,
14026            DType::U8,
14027            Some(TensorMemory::Mem),
14028            CpuAccess::Write,
14029        )
14030        .unwrap();
14031        let mut dst = TensorDyn::image(
14032            32,
14033            24,
14034            PixelFormat::Rgb,
14035            DType::U8,
14036            Some(TensorMemory::Mem),
14037            CpuAccess::ReadWrite,
14038        )
14039        .unwrap();
14040        let err = cpu.convert(&write_only, &mut dst, None).unwrap_err();
14041        assert!(
14042            matches!(
14043                err,
14044                Error::Tensor(TensorError::CpuAccessDenied {
14045                    declared: CpuAccess::Write,
14046                    requested: CpuAccess::Read,
14047                })
14048            ),
14049            "{err:?}"
14050        );
14051        assert!(err
14052            .to_string()
14053            .contains("declare CpuAccess::ReadWrite (the default)"));
14054    }
14055
14056    /// The CPU mask draw blends into its destination, so a `Write`-only
14057    /// destination is refused before anything is drawn.
14058    #[test]
14059    fn cpu_draw_requires_readwrite_on_the_destination() {
14060        use edgefirst_tensor::{CpuAccess, Error as TensorError};
14061        let mut cpu = CPUProcessor::new();
14062        let mut dst = TensorDyn::image(
14063            32,
14064            24,
14065            PixelFormat::Rgba,
14066            DType::U8,
14067            Some(TensorMemory::Mem),
14068            CpuAccess::Write,
14069        )
14070        .unwrap();
14071        let err = cpu
14072            .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
14073            .unwrap_err();
14074        assert!(
14075            matches!(
14076                err,
14077                Error::Tensor(TensorError::CpuAccessDenied {
14078                    declared: CpuAccess::Write,
14079                    requested: CpuAccess::ReadWrite,
14080                })
14081            ),
14082            "{err:?}"
14083        );
14084    }
14085
14086    /// `create_image_desc` without a compression request is exactly
14087    /// `create_image` (the processor's memory negotiation applies); with
14088    /// `Compression::Any` off-Android it resolves linear and counts the
14089    /// fallback in the processor-visible mirror.
14090    #[test]
14091    fn create_image_desc_negotiates_and_counts_fallbacks() {
14092        use edgefirst_tensor::{CpuAccess, ImageDesc};
14093        let proc = ImageProcessor::new().unwrap();
14094
14095        let desc =
14096            ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
14097        let plain = proc.create_image_desc(&desc).unwrap();
14098        let classic = proc
14099            .create_image(
14100                64,
14101                64,
14102                PixelFormat::Rgba,
14103                DType::U8,
14104                None,
14105                CpuAccess::ReadWrite,
14106            )
14107            .unwrap();
14108        assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
14109        assert_eq!(plain.compression(), None);
14110
14111        #[cfg(not(target_os = "android"))]
14112        {
14113            use edgefirst_tensor::Compression;
14114            let before = proc.compression_fallback_count();
14115            let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
14116                .with_access(CpuAccess::None)
14117                .with_compression(Compression::Any);
14118            let t = proc.create_image_desc(&desc).unwrap();
14119            assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
14120            assert!(
14121                proc.compression_fallback_count() > before,
14122                "Any resolving linear must count"
14123            );
14124        }
14125    }
14126
14127    /// A contiguous request through `create_image_desc` is a CMA DMA-BUF,
14128    /// or an error where there is no CMA heap; never a PBO or host memory.
14129    #[test]
14130    #[cfg(target_os = "linux")]
14131    fn create_image_desc_contiguous_is_cma_dma_or_an_error() {
14132        use edgefirst_tensor::{Contiguity, ImageDesc, TensorMemory};
14133        let proc = ImageProcessor::new().unwrap();
14134        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_contiguous(true);
14135        match proc.create_image_desc(&desc) {
14136            Ok(t) => {
14137                assert_eq!(t.memory(), TensorMemory::DmaBuf);
14138                assert_eq!(t.contiguity(), Contiguity::Contiguous);
14139            }
14140            Err(e) => {
14141                assert!(e.to_string().contains("linux,cma"), "{e}");
14142                crate::test_support::report_skip(&format!(
14143                    "{} - no CMA heap, the contiguous DMA-BUF arm did not run: {e}",
14144                    function!()
14145                ));
14146            }
14147        }
14148    }
14149
14150    /// There is no DRM FourCC for F32 images, so `create_image` with
14151    /// `TensorMemory::DmaBuf` and `DType::F32` must return `Err(NotSupported)`.
14152    #[test]
14153    #[cfg(target_os = "linux")]
14154    fn create_image_f32_dma_rejected() {
14155        let proc = ImageProcessor::new().unwrap();
14156        let result = proc.create_image(
14157            64,
14158            64,
14159            PixelFormat::Rgb,
14160            DType::F32,
14161            Some(TensorMemory::DmaBuf),
14162            edgefirst_tensor::CpuAccess::ReadWrite,
14163        );
14164        assert!(
14165            result.is_err(),
14166            "create_image(F32, Dma) must fail — no DRM fourcc for f32"
14167        );
14168    }
14169
14170    /// Verify that `import_image` stores the supplied `Option<Colorimetry>` on
14171    /// the returned `TensorDyn`.
14172    ///
14173    /// `import_image` requires a DMA-backed fd on Linux.  When DMA is
14174    /// unavailable we skip the DMA call but still verify the storage contract
14175    /// by inspecting `set_colorimetry` / `colorimetry` on a plain `TensorDyn`
14176    /// constructed the same way the function body does it — and we confirm via
14177    /// code-read that the new parameter is unconditionally stored.
14178    #[test]
14179    #[cfg(target_os = "linux")]
14180    fn import_image_carries_colorimetry() {
14181        use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
14182
14183        let expected = Colorimetry::default()
14184            .with_encoding(ColorEncoding::Bt709)
14185            .with_range(ColorRange::Limited);
14186
14187        if !is_dma_available() {
14188            // DMA unavailable on this host: exercise the storage path via
14189            // TensorDyn directly (mirrors what import_image does internally).
14190            let mut t = TensorDyn::image(
14191                8,
14192                8,
14193                PixelFormat::Rgba,
14194                DType::U8,
14195                Some(TensorMemory::Mem),
14196                edgefirst_tensor::CpuAccess::ReadWrite,
14197            )
14198            .expect("alloc");
14199            assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
14200            t.set_colorimetry(Some(expected));
14201            assert_eq!(
14202                t.colorimetry(),
14203                Some(expected),
14204                "set_colorimetry must round-trip"
14205            );
14206            crate::test_support::report_skip("import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
14207            return;
14208        }
14209
14210        // DMA is available: allocate a real DMA tensor, extract its fd, and
14211        // call import_image with an explicit Colorimetry.
14212        use edgefirst_tensor::{PlaneDescriptor, Tensor};
14213
14214        let rgba_bytes = 64 * 64 * 4; // 64×64 RGBA8
14215        let dma_tensor = Tensor::<u8>::new(
14216            &[rgba_bytes],
14217            Some(TensorMemory::DmaBuf),
14218            Some("import_test"),
14219        )
14220        .expect("dma alloc");
14221        let pd =
14222            PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
14223
14224        let proc = ImageProcessor::new().expect("ImageProcessor");
14225        let result = proc.import_image(
14226            pd,
14227            None,
14228            64,
14229            64,
14230            PixelFormat::Rgba,
14231            DType::U8,
14232            Some(expected),
14233        );
14234        let tensor = result.expect("import_image must succeed on DMA fd");
14235        assert_eq!(
14236            tensor.colorimetry(),
14237            Some(expected),
14238            "import_image must store the supplied colorimetry on the returned TensorDyn"
14239        );
14240    }
14241}