Skip to main content

edgefirst_image/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5
6## EdgeFirst HAL - Image Converter
7
8The `edgefirst_image` crate is part of the EdgeFirst Hardware Abstraction
9Layer (HAL) and provides functionality for converting images between
10different formats and sizes.  The crate is designed to work with hardware
11acceleration when available, but also provides a CPU-based fallback for
12environments where hardware acceleration is not present or not suitable.
13
14The main features of the `edgefirst_image` crate include:
15- Support for various image formats, including YUYV, RGB, RGBA, and GREY.
16- Support for source crop, destination crop, rotation, and flipping.
17- Image conversion using hardware acceleration (G2D, OpenGL) when available.
18- CPU-based image conversion as a fallback option.
19
20The crate uses [`TensorDyn`] from `edgefirst_tensor` to represent images,
21with [`PixelFormat`] metadata describing the pixel layout. The
22[`ImageProcessor`] struct manages the conversion process, selecting
23the appropriate conversion method based on the available hardware.
24
25## Examples
26
27```rust
28# use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait, load_image};
29# use edgefirst_tensor::{PixelFormat, DType, TensorDyn};
30# fn main() -> Result<(), edgefirst_image::Error> {
31let image = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../testdata/zidane.jpg"));
32let src = load_image(image, Some(PixelFormat::Rgba), None)?;
33let mut converter = ImageProcessor::new()?;
34let mut dst = converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None)?;
35converter.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())?;
36# Ok(())
37# }
38```
39
40## Environment Variables
41The behavior of the `edgefirst_image::ImageProcessor` struct can be influenced by the
42following environment variables:
43- `EDGEFIRST_FORCE_BACKEND`: When set to `cpu`, `g2d`, or `opengl` (case-insensitive),
44  only that single backend is initialized and no fallback chain is used. If the
45  forced backend fails to initialize, an error is returned immediately. This is
46  useful for benchmarking individual backends in isolation. When this variable is
47  set, the `EDGEFIRST_DISABLE_*` variables are ignored.
48- `EDGEFIRST_DISABLE_GL`: If set to `1`, disables the use of OpenGL for image
49  conversion, forcing the use of CPU or other available hardware methods.
50- `EDGEFIRST_DISABLE_G2D`: If set to `1`, disables the use of G2D for image
51  conversion, forcing the use of CPU or other available hardware methods.
52- `EDGEFIRST_DISABLE_CPU`: If set to `1`, disables the use of CPU for image
53  conversion, forcing the use of hardware acceleration methods. If no hardware
54  acceleration methods are available, an error will be returned when attempting
55  to create an `ImageProcessor`.
56
57Additionally the TensorMemory used by default allocations can be controlled using the
58`EDGEFIRST_TENSOR_FORCE_MEM` environment variable. If set to `1`, default tensor memory
59uses system memory. This will disable the use of specialized memory regions for tensors
60and hardware acceleration. However, this will increase the performance of the CPU converter.
61*/
62#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
63
64/// Pitch alignment requirement for DMA-BUF tensors that may be imported as
65/// EGLImages by the GL backend. Mali Valhall (i.MX 95 / G310) rejects
66/// `eglCreateImageKHR` with `EGL_BAD_ALLOC` for any DMA-BUF whose row pitch
67/// is not a multiple of 64 bytes; Vivante GC7000UL (i.MX 8MP) accepts any
68/// pitch so the constant is harmless on that path. 64 is the smallest
69/// alignment that satisfies every embedded ARM GPU we ship to.
70///
71/// Applied automatically inside [`ImageProcessor::create_image`] when the
72/// allocation lands on `TensorMemory::Dma`. External callers that allocate
73/// their own DMA-BUF tensors (e.g. GStreamer plugins, video pipelines) can
74/// use [`align_width_for_gpu_pitch`] to compute a width whose resulting row
75/// stride satisfies this requirement.
76pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
77
78/// Round `width` (in pixels) up so the resulting row stride
79/// `width * bpp` is a multiple of [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]
80/// AND a multiple of `bpp` (so the rounded width is an integer pixel count).
81///
82/// `bpp` must be the per-pixel byte count for the image's primary plane
83/// (e.g. 4 for RGBA8/BGRA8, 3 for RGB888, 1 for Grey/NV12-luma).
84///
85/// External callers — GStreamer plugins, video pipelines, anyone wrapping a
86/// foreign DMA-BUF — should call this when sizing the destination so that
87/// `eglCreateImageKHR` doesn't reject the import on Mali. Pre-aligned widths
88/// (640, 1280, 1920, 3008, 3840 …) round-trip unchanged; misaligned widths
89/// are bumped up to the next valid value.
90///
91/// # Overflow behaviour
92///
93/// All arithmetic is checked. If the alignment computation or the rounded
94/// width would overflow `usize`, the function logs a warning and returns the
95/// original `width` unchanged rather than wrapping or producing a smaller
96/// value. Callers can rely on the returned width being **at least** the
97/// requested width.
98///
99/// `bpp == 0` and `width == 0` short-circuit to return the input unchanged.
100///
101/// # Examples
102///
103/// ```
104/// use edgefirst_image::align_width_for_gpu_pitch;
105///
106/// // RGBA8 (bpp=4): width must round to a multiple of 16 pixels (64-byte stride).
107/// assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // already aligned
108/// assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // crowd.png case: +4 px
109/// assert_eq!(align_width_for_gpu_pitch(1281, 4), 1296); // +15 px
110///
111/// // RGB888 (bpp=3): width must round to a multiple of 64 pixels (192-byte stride).
112/// assert_eq!(align_width_for_gpu_pitch(640, 3), 640);
113/// assert_eq!(align_width_for_gpu_pitch(641, 3), 704);
114/// ```
115pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
116    if bpp == 0 || width == 0 {
117        return width;
118    }
119
120    // The minimum aligned stride must be a common multiple of both the
121    // GPU's pitch alignment and the per-pixel byte count. Using the LCM
122    // guarantees the rounded stride is an integer multiple of `bpp`, so
123    // converting back to a pixel count is exact.
124    //
125    // Compute the alignment in pixels (`width_alignment`) so we never need
126    // to multiply `width * bpp`, which is the only operation that could
127    // realistically overflow for large caller-supplied widths.
128    let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
129    else {
130        log::warn!(
131            "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
132             overflows usize, returning unaligned width {width}"
133        );
134        return width;
135    };
136    if lcm_alignment == 0 {
137        return width;
138    }
139
140    debug_assert_eq!(lcm_alignment % bpp, 0);
141    let width_alignment = lcm_alignment / bpp;
142    if width_alignment == 0 {
143        return width;
144    }
145
146    let remainder = width % width_alignment;
147    if remainder == 0 {
148        return width;
149    }
150
151    let pad = width_alignment - remainder;
152    match width.checked_add(pad) {
153        Some(aligned) => aligned,
154        None => {
155            log::warn!(
156                "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
157                 returning unaligned (caller should use a smaller width or pre-aligned size)"
158            );
159            width
160        }
161    }
162}
163
164/// Round `min_pitch_bytes` up to the next multiple of
165/// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`]. Returns `None` if the rounded
166/// value would overflow `usize`. Returns `Some(0)` for input 0.
167///
168/// Used internally by [`ImageProcessor::create_image`] to compute the
169/// padded row stride for DMA-backed image allocations. External callers
170/// that need pixel-counted alignment (instead of raw byte pitch) should
171/// use [`align_width_for_gpu_pitch`] instead.
172#[cfg(target_os = "linux")]
173pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
174    let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
175    if min_pitch_bytes == 0 {
176        return Some(0);
177    }
178    let remainder = min_pitch_bytes % alignment;
179    if remainder == 0 {
180        return Some(min_pitch_bytes);
181    }
182    min_pitch_bytes.checked_add(alignment - remainder)
183}
184
185/// Overflow-safe least common multiple. Returns `None` when `(a / gcd) * b`
186/// would wrap.
187fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
188    if a == 0 || b == 0 {
189        return Some(0);
190    }
191    let g = num_integer_gcd(a, b);
192    // a / g is exact (g divides a by definition) and at most a, so this
193    // division never panics. Only the subsequent multiply can overflow.
194    (a / g).checked_mul(b)
195}
196
197fn num_integer_gcd(a: usize, b: usize) -> usize {
198    if b == 0 {
199        a
200    } else {
201        num_integer_gcd(b, a % b)
202    }
203}
204
205/// Bytes-per-pixel for the primary plane of `format` at element size `elem`.
206/// Returns `None` for formats that don't have a single packed BPP (semi-planar
207/// chroma is handled separately, returning the luma-plane bpp).
208///
209/// External callers can use this together with [`align_width_for_gpu_pitch`]
210/// to size their own DMA-BUFs without having to remember per-format BPPs:
211///
212/// ```
213/// use edgefirst_image::{align_width_for_gpu_pitch, primary_plane_bpp};
214/// use edgefirst_tensor::PixelFormat;
215///
216/// let bpp = primary_plane_bpp(PixelFormat::Rgba, 1).unwrap();
217/// let aligned = align_width_for_gpu_pitch(3004, bpp);
218/// assert_eq!(aligned, 3008);
219/// ```
220pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
221    use edgefirst_tensor::PixelLayout;
222    match format.layout() {
223        PixelLayout::Packed => Some(format.channels() * elem),
224        PixelLayout::Planar => Some(elem),
225        // For NV12/NV16 the luma plane is single-channel so the pitch
226        // matches `elem`; the chroma plane uses the same pitch in bytes
227        // (UV is half-width but two interleaved channels = same pitch).
228        PixelLayout::SemiPlanar => Some(elem),
229        // `PixelLayout` is non-exhaustive — fall through unaligned for
230        // any future variant we don't yet recognise.
231        _ => None,
232    }
233}
234
235use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
236use edgefirst_tensor::{
237    DType, PixelFormat, PixelLayout, Tensor, TensorDyn, TensorMemory, TensorTrait as _,
238};
239use enum_dispatch::enum_dispatch;
240use std::{fmt::Display, time::Instant};
241use zune_jpeg::{
242    zune_core::{colorspace::ColorSpace, options::DecoderOptions},
243    JpegDecoder,
244};
245use zune_png::PngDecoder;
246
247pub use cpu::CPUProcessor;
248pub use error::{Error, Result};
249#[cfg(target_os = "linux")]
250pub use g2d::G2DProcessor;
251#[cfg(target_os = "linux")]
252#[cfg(feature = "opengl")]
253pub use opengl_headless::GLProcessorThreaded;
254#[cfg(target_os = "linux")]
255#[cfg(feature = "opengl")]
256pub use opengl_headless::Int8InterpolationMode;
257#[cfg(target_os = "linux")]
258#[cfg(feature = "opengl")]
259pub use opengl_headless::{probe_egl_displays, EglDisplayInfo, EglDisplayKind};
260
261mod cpu;
262mod error;
263mod g2d;
264#[path = "gl/mod.rs"]
265mod opengl_headless;
266
267// Use `edgefirst_tensor::PixelFormat` variants (Rgb, Rgba, Grey, etc.) and
268// `TensorDyn` / `Tensor<u8>` with `.format()` metadata instead.
269
270/// Flips the image data, then rotates it. Returns a new `TensorDyn`.
271fn rotate_flip_to_dyn(
272    src: &Tensor<u8>,
273    src_fmt: PixelFormat,
274    rotation: Rotation,
275    flip: Flip,
276    memory: Option<TensorMemory>,
277) -> Result<TensorDyn, Error> {
278    let src_w = src.width().unwrap();
279    let src_h = src.height().unwrap();
280    let channels = src_fmt.channels();
281
282    let (dst_w, dst_h) = match rotation {
283        Rotation::None | Rotation::Rotate180 => (src_w, src_h),
284        Rotation::Clockwise90 | Rotation::CounterClockwise90 => (src_h, src_w),
285    };
286
287    let dst = Tensor::<u8>::image(dst_w, dst_h, src_fmt, memory)?;
288    let src_map = src.map()?;
289    let mut dst_map = dst.map()?;
290
291    CPUProcessor::flip_rotate_ndarray_pf(
292        &src_map,
293        &mut dst_map,
294        dst_w,
295        dst_h,
296        channels,
297        rotation,
298        flip,
299    )?;
300    drop(dst_map);
301    drop(src_map);
302
303    Ok(TensorDyn::from(dst))
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum Rotation {
308    None = 0,
309    Clockwise90 = 1,
310    Rotate180 = 2,
311    CounterClockwise90 = 3,
312}
313impl Rotation {
314    /// Creates a Rotation enum from an angle in degrees. The angle must be a
315    /// multiple of 90.
316    ///
317    /// # Panics
318    /// Panics if the angle is not a multiple of 90.
319    ///
320    /// # Examples
321    /// ```rust
322    /// # use edgefirst_image::Rotation;
323    /// let rotation = Rotation::from_degrees_clockwise(270);
324    /// assert_eq!(rotation, Rotation::CounterClockwise90);
325    /// ```
326    pub fn from_degrees_clockwise(angle: usize) -> Rotation {
327        match angle.rem_euclid(360) {
328            0 => Rotation::None,
329            90 => Rotation::Clockwise90,
330            180 => Rotation::Rotate180,
331            270 => Rotation::CounterClockwise90,
332            _ => panic!("rotation angle is not a multiple of 90"),
333        }
334    }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum Flip {
339    None = 0,
340    Vertical = 1,
341    Horizontal = 2,
342}
343
344/// Controls how the color palette index is chosen for each detected object.
345#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
346pub enum ColorMode {
347    /// Color is chosen by object class label (`det.label`). Default.
348    ///
349    /// Preserves backward compatibility and is correct for semantic
350    /// segmentation where colors carry class meaning.
351    #[default]
352    Class,
353    /// Color is chosen by instance order (loop index, zero-based).
354    ///
355    /// Each detected object gets a unique color regardless of class,
356    /// useful for instance segmentation.
357    Instance,
358    /// Color is chosen by track ID (future use; currently behaves like
359    /// [`Instance`](Self::Instance)).
360    Track,
361}
362
363impl ColorMode {
364    /// Return the palette index for a detection given its loop index and label.
365    #[inline]
366    pub fn index(self, idx: usize, label: usize) -> usize {
367        match self {
368            ColorMode::Class => label,
369            ColorMode::Instance | ColorMode::Track => idx,
370        }
371    }
372}
373
374/// Options for mask overlay rendering.
375///
376/// Controls how segmentation masks are composited onto the destination image:
377/// - `background`: when set, the background image is drawn first and masks
378///   are composited over it (result written to `dst`). When `None`, masks
379///   are composited directly over `dst`'s existing content.
380/// - `opacity`: scales the alpha of rendered mask colors. `1.0` (default)
381///   preserves the class color's alpha unchanged; `0.5` makes masks
382///   semi-transparent.
383/// - `color_mode`: controls whether colors are assigned by class label,
384///   instance index, or track ID. Defaults to [`ColorMode::Class`].
385#[derive(Debug, Clone, Copy)]
386pub struct MaskOverlay<'a> {
387    pub background: Option<&'a TensorDyn>,
388    pub opacity: f32,
389    /// Normalized letterbox region `[xmin, ymin, xmax, ymax]` in model-input
390    /// space that contains actual image content (the rest is padding).
391    ///
392    /// When set, bounding boxes and mask coordinates from the decoder (which
393    /// are in model-input normalized space) are mapped back to the original
394    /// image coordinate space before rendering.
395    ///
396    /// Use [`with_letterbox_crop`](Self::with_letterbox_crop) to compute this
397    /// from the [`Crop`] that was used in the model input [`convert`](crate::ImageProcessorTrait::convert) call.
398    pub letterbox: Option<[f32; 4]>,
399    pub color_mode: ColorMode,
400}
401
402impl Default for MaskOverlay<'_> {
403    fn default() -> Self {
404        Self {
405            background: None,
406            opacity: 1.0,
407            letterbox: None,
408            color_mode: ColorMode::Class,
409        }
410    }
411}
412
413impl<'a> MaskOverlay<'a> {
414    pub fn new() -> Self {
415        Self::default()
416    }
417
418    pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
419        self.background = Some(bg);
420        self
421    }
422
423    pub fn with_opacity(mut self, opacity: f32) -> Self {
424        self.opacity = opacity.clamp(0.0, 1.0);
425        self
426    }
427
428    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
429        self.color_mode = mode;
430        self
431    }
432
433    /// Set the letterbox transform from the [`Crop`] used when preparing the
434    /// model input, so that bounding boxes and masks are correctly mapped back
435    /// to the original image coordinate space during rendering.
436    ///
437    /// Pass the same `crop` that was given to
438    /// [`convert`](crate::ImageProcessorTrait::convert) along with the model
439    /// input dimensions (`model_w` × `model_h`).
440    ///
441    /// Has no effect when `crop.dst_rect` is `None` (no letterbox applied).
442    pub fn with_letterbox_crop(mut self, crop: &Crop, model_w: usize, model_h: usize) -> Self {
443        if let Some(r) = crop.dst_rect {
444            self.letterbox = Some([
445                r.left as f32 / model_w as f32,
446                r.top as f32 / model_h as f32,
447                (r.left + r.width) as f32 / model_w as f32,
448                (r.top + r.height) as f32 / model_h as f32,
449            ]);
450        }
451        self
452    }
453
454    /// Blit background into dst (if set) and return an overlay with
455    /// background cleared so backends don't need to handle it.
456    fn apply_background(&self, dst: &mut TensorDyn) -> Result<MaskOverlay<'static>> {
457        use edgefirst_tensor::TensorMapTrait;
458        if let Some(bg) = self.background {
459            if bg.shape() != dst.shape() {
460                return Err(Error::InvalidShape(
461                    "background shape does not match dst".into(),
462                ));
463            }
464            if bg.format() != dst.format() {
465                return Err(Error::InvalidShape(
466                    "background pixel format does not match dst".into(),
467                ));
468            }
469            let bg_u8 = bg.as_u8().ok_or(Error::NotAnImage)?;
470            let dst_u8 = dst.as_u8_mut().ok_or(Error::NotAnImage)?;
471            let bg_map = bg_u8.map()?;
472            let mut dst_map = dst_u8.map()?;
473            let bg_slice = bg_map.as_slice();
474            let dst_slice = dst_map.as_mut_slice();
475            if bg_slice.len() != dst_slice.len() {
476                return Err(Error::InvalidShape(
477                    "background buffer size does not match dst".into(),
478                ));
479            }
480            dst_slice.copy_from_slice(bg_slice);
481        }
482        Ok(MaskOverlay {
483            background: None,
484            opacity: self.opacity.clamp(0.0, 1.0),
485            letterbox: self.letterbox,
486            color_mode: self.color_mode,
487        })
488    }
489}
490
491/// Apply the inverse letterbox transform to a bounding box.
492///
493/// `letterbox` is `[lx0, ly0, lx1, ly1]` — the normalized region of the model
494/// input that contains actual image content (output of
495/// [`MaskOverlay::with_letterbox_crop`]).
496///
497/// Converts model-input-normalized coords to output-image-normalized coords,
498/// clamped to `[0.0, 1.0]`. Also canonicalises the bbox (ensures xmin ≤ xmax).
499#[inline]
500fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
501    let b = bbox.bbox.to_canonical();
502    let [lx0, ly0, lx1, ly1] = lb;
503    let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
504    let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
505    DetectBox {
506        bbox: edgefirst_decoder::BoundingBox {
507            xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
508            ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
509            xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
510            ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
511        },
512        ..bbox
513    }
514}
515
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub struct Crop {
518    pub src_rect: Option<Rect>,
519    pub dst_rect: Option<Rect>,
520    pub dst_color: Option<[u8; 4]>,
521}
522
523impl Default for Crop {
524    fn default() -> Self {
525        Crop::new()
526    }
527}
528impl Crop {
529    // Creates a new Crop with default values (no cropping).
530    pub fn new() -> Self {
531        Crop {
532            src_rect: None,
533            dst_rect: None,
534            dst_color: None,
535        }
536    }
537
538    // Sets the source rectangle for cropping.
539    pub fn with_src_rect(mut self, src_rect: Option<Rect>) -> Self {
540        self.src_rect = src_rect;
541        self
542    }
543
544    // Sets the destination rectangle for cropping.
545    pub fn with_dst_rect(mut self, dst_rect: Option<Rect>) -> Self {
546        self.dst_rect = dst_rect;
547        self
548    }
549
550    // Sets the destination color for areas outside the cropped region.
551    pub fn with_dst_color(mut self, dst_color: Option<[u8; 4]>) -> Self {
552        self.dst_color = dst_color;
553        self
554    }
555
556    // Creates a new Crop with no cropping.
557    pub fn no_crop() -> Self {
558        Crop::new()
559    }
560
561    /// Validate crop rectangles against explicit dimensions.
562    pub(crate) fn check_crop_dims(
563        &self,
564        src_w: usize,
565        src_h: usize,
566        dst_w: usize,
567        dst_h: usize,
568    ) -> Result<(), Error> {
569        let src_ok = self
570            .src_rect
571            .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
572        let dst_ok = self
573            .dst_rect
574            .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
575        match (src_ok, dst_ok) {
576            (true, true) => Ok(()),
577            (true, false) => Err(Error::CropInvalid(format!(
578                "Dest crop invalid: {:?}",
579                self.dst_rect
580            ))),
581            (false, true) => Err(Error::CropInvalid(format!(
582                "Src crop invalid: {:?}",
583                self.src_rect
584            ))),
585            (false, false) => Err(Error::CropInvalid(format!(
586                "Dest and Src crop invalid: {:?} {:?}",
587                self.dst_rect, self.src_rect
588            ))),
589        }
590    }
591
592    /// Validate crop rectangles against TensorDyn source and destination.
593    pub fn check_crop_dyn(
594        &self,
595        src: &edgefirst_tensor::TensorDyn,
596        dst: &edgefirst_tensor::TensorDyn,
597    ) -> Result<(), Error> {
598        self.check_crop_dims(
599            src.width().unwrap_or(0),
600            src.height().unwrap_or(0),
601            dst.width().unwrap_or(0),
602            dst.height().unwrap_or(0),
603        )
604    }
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub struct Rect {
609    pub left: usize,
610    pub top: usize,
611    pub width: usize,
612    pub height: usize,
613}
614
615impl Rect {
616    // Creates a new Rect with the specified left, top, width, and height.
617    pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
618        Self {
619            left,
620            top,
621            width,
622            height,
623        }
624    }
625
626    // Checks if the rectangle is valid for the given TensorDyn image.
627    pub fn check_rect_dyn(&self, image: &TensorDyn) -> bool {
628        let w = image.width().unwrap_or(0);
629        let h = image.height().unwrap_or(0);
630        self.left + self.width <= w && self.top + self.height <= h
631    }
632}
633
634#[enum_dispatch(ImageProcessor)]
635pub trait ImageProcessorTrait {
636    /// Converts the source image to the destination image format and size. The
637    /// image is cropped first, then flipped, then rotated
638    ///
639    /// # Arguments
640    ///
641    /// * `dst` - The destination image to be converted to.
642    /// * `src` - The source image to convert from.
643    /// * `rotation` - The rotation to apply to the destination image.
644    /// * `flip` - Flips the image
645    /// * `crop` - An optional rectangle specifying the area to crop from the
646    ///   source image
647    ///
648    /// # Returns
649    ///
650    /// A `Result` indicating success or failure of the conversion.
651    fn convert(
652        &mut self,
653        src: &TensorDyn,
654        dst: &mut TensorDyn,
655        rotation: Rotation,
656        flip: Flip,
657        crop: Crop,
658    ) -> Result<()>;
659
660    /// Draw pre-decoded detection boxes and segmentation masks onto `dst`.
661    ///
662    /// Supports two segmentation modes based on the mask channel count:
663    /// - **Instance segmentation** (`C=1`): one `Segmentation` per detection,
664    ///   `segmentation` and `detect` are zipped.
665    /// - **Semantic segmentation** (`C>1`): a single `Segmentation` covering
666    ///   all classes; only the first element is used.
667    ///
668    /// # Format requirements
669    ///
670    /// - CPU backend: `dst` must be `RGBA` or `RGB`.
671    /// - OpenGL backend: `dst` must be `RGBA`, `BGRA`, or `RGB`.
672    /// - G2D backend: not implemented (returns `NotImplemented`).
673    ///
674    /// An empty `segmentation` slice is valid — only bounding boxes are drawn.
675    ///
676    /// `overlay` controls compositing: `background` replaces dst's base
677    /// content; `opacity` scales mask alpha. Use `MaskOverlay::default()`
678    /// for backward-compatible behaviour.
679    fn draw_decoded_masks(
680        &mut self,
681        dst: &mut TensorDyn,
682        detect: &[DetectBox],
683        segmentation: &[Segmentation],
684        overlay: MaskOverlay<'_>,
685    ) -> Result<()>;
686
687    /// Draw masks from proto data onto image (fused decode+draw).
688    ///
689    /// For YOLO segmentation models, this avoids materializing intermediate
690    /// `Array3<u8>` masks. The `ProtoData` contains mask coefficients and the
691    /// prototype tensor; the renderer computes `mask_coeff @ protos` directly
692    /// at the output resolution using bilinear sampling.
693    ///
694    /// `detect` and `proto_data.mask_coefficients` must have the same length
695    /// (enforced by zip — excess entries are silently ignored). An empty
696    /// `detect` slice is valid and returns immediately after drawing nothing.
697    ///
698    /// # Format requirements
699    ///
700    /// Same as [`draw_decoded_masks`](Self::draw_decoded_masks). G2D returns `NotImplemented`.
701    ///
702    /// `overlay` controls compositing — see [`draw_decoded_masks`](Self::draw_decoded_masks).
703    fn draw_proto_masks(
704        &mut self,
705        dst: &mut TensorDyn,
706        detect: &[DetectBox],
707        proto_data: &ProtoData,
708        overlay: MaskOverlay<'_>,
709    ) -> Result<()>;
710
711    /// Sets the colors used for rendering segmentation masks. Up to 20 colors
712    /// can be set.
713    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
714}
715
716/// Configuration for [`ImageProcessor`] construction.
717///
718/// Use with [`ImageProcessor::with_config`] to override the default EGL
719/// display auto-detection and backend selection. The default configuration
720/// preserves the existing auto-detection behaviour.
721#[derive(Debug, Clone, Default)]
722pub struct ImageProcessorConfig {
723    /// Force OpenGL to use this EGL display type instead of auto-detecting.
724    ///
725    /// When `None`, the processor probes displays in priority order: GBM,
726    /// PlatformDevice, Default. Use [`probe_egl_displays`] to discover
727    /// which displays are available on the current system.
728    ///
729    /// Ignored when `EDGEFIRST_DISABLE_GL=1` is set.
730    #[cfg(target_os = "linux")]
731    #[cfg(feature = "opengl")]
732    pub egl_display: Option<EglDisplayKind>,
733
734    /// Preferred compute backend.
735    ///
736    /// When set to a specific backend (not [`ComputeBackend::Auto`]), the
737    /// processor initializes that backend with no fallback — returns an error if the conversion is not supported.
738    /// This takes precedence over `EDGEFIRST_FORCE_BACKEND` and the
739    /// `EDGEFIRST_DISABLE_*` environment variables.
740    ///
741    /// - [`ComputeBackend::OpenGl`]: init OpenGL + CPU, skip G2D
742    /// - [`ComputeBackend::G2d`]: init G2D + CPU, skip OpenGL
743    /// - [`ComputeBackend::Cpu`]: init CPU only
744    /// - [`ComputeBackend::Auto`]: existing env-var-driven selection
745    pub backend: ComputeBackend,
746}
747
748/// Compute backend selection for [`ImageProcessor`].
749///
750/// Use with [`ImageProcessorConfig::backend`] to select which backend the
751/// processor should prefer. When a specific backend is selected, the
752/// processor initializes that backend plus CPU as a fallback. When `Auto`
753/// is used, the existing environment-variable-driven selection applies.
754#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
755pub enum ComputeBackend {
756    /// Auto-detect based on available hardware and environment variables.
757    #[default]
758    Auto,
759    /// CPU-only processing (no hardware acceleration).
760    Cpu,
761    /// Prefer G2D hardware blitter (+ CPU fallback).
762    G2d,
763    /// Prefer OpenGL ES (+ CPU fallback).
764    OpenGl,
765}
766
767/// Backend forced via the `EDGEFIRST_FORCE_BACKEND` environment variable
768/// or [`ImageProcessorConfig::backend`].
769///
770/// When set, the [`ImageProcessor`] only initializes and dispatches to the
771/// selected backend — no fallback chain is used.
772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
773pub(crate) enum ForcedBackend {
774    Cpu,
775    G2d,
776    OpenGl,
777}
778
779/// Image converter that uses available hardware acceleration or CPU as a
780/// fallback.
781#[derive(Debug)]
782pub struct ImageProcessor {
783    /// CPU-based image converter as a fallback. This is only None if the
784    /// EDGEFIRST_DISABLE_CPU environment variable is set.
785    pub cpu: Option<CPUProcessor>,
786
787    #[cfg(target_os = "linux")]
788    /// G2D-based image converter for Linux systems. This is only available if
789    /// the EDGEFIRST_DISABLE_G2D environment variable is not set and libg2d.so
790    /// is available.
791    pub g2d: Option<G2DProcessor>,
792    #[cfg(target_os = "linux")]
793    #[cfg(feature = "opengl")]
794    /// OpenGL-based image converter for Linux systems. This is only available
795    /// if the EDGEFIRST_DISABLE_GL environment variable is not set and OpenGL
796    /// ES is available.
797    pub opengl: Option<GLProcessorThreaded>,
798
799    /// When set, only the specified backend is used — no fallback chain.
800    pub(crate) forced_backend: Option<ForcedBackend>,
801}
802
803unsafe impl Send for ImageProcessor {}
804unsafe impl Sync for ImageProcessor {}
805
806impl ImageProcessor {
807    /// Creates a new `ImageProcessor` instance, initializing available
808    /// hardware converters based on the system capabilities and environment
809    /// variables.
810    ///
811    /// # Examples
812    /// ```rust
813    /// # use edgefirst_image::{ImageProcessor, Rotation, Flip, Crop, ImageProcessorTrait, load_image};
814    /// # use edgefirst_tensor::{PixelFormat, DType, TensorDyn};
815    /// # fn main() -> Result<(), edgefirst_image::Error> {
816    /// let image = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../testdata/zidane.jpg"));
817    /// let src = load_image(image, Some(PixelFormat::Rgba), None)?;
818    /// let mut converter = ImageProcessor::new()?;
819    /// let mut dst = converter.create_image(640, 480, PixelFormat::Rgb, DType::U8, None)?;
820    /// converter.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())?;
821    /// # Ok(())
822    /// # }
823    /// ```
824    pub fn new() -> Result<Self> {
825        Self::with_config(ImageProcessorConfig::default())
826    }
827
828    /// Creates a new `ImageProcessor` with the given configuration.
829    ///
830    /// When [`ImageProcessorConfig::backend`] is set to a specific backend,
831    /// environment variables are ignored and the processor initializes the
832    /// requested backend plus CPU as a fallback.
833    ///
834    /// When `Auto`, the existing `EDGEFIRST_FORCE_BACKEND` and
835    /// `EDGEFIRST_DISABLE_*` environment variables apply.
836    #[allow(unused_variables)]
837    pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
838        // ── Config-driven backend selection ──────────────────────────
839        // When the caller explicitly requests a backend via the config,
840        // skip all environment variable logic.
841        match config.backend {
842            ComputeBackend::Cpu => {
843                log::info!("ComputeBackend::Cpu — CPU only");
844                return Ok(Self {
845                    cpu: Some(CPUProcessor::new()),
846                    #[cfg(target_os = "linux")]
847                    g2d: None,
848                    #[cfg(target_os = "linux")]
849                    #[cfg(feature = "opengl")]
850                    opengl: None,
851                    forced_backend: None,
852                });
853            }
854            ComputeBackend::G2d => {
855                log::info!("ComputeBackend::G2d — G2D + CPU fallback");
856                #[cfg(target_os = "linux")]
857                {
858                    let g2d = match G2DProcessor::new() {
859                        Ok(g) => Some(g),
860                        Err(e) => {
861                            log::warn!("G2D requested but failed to initialize: {e:?}");
862                            None
863                        }
864                    };
865                    return Ok(Self {
866                        cpu: Some(CPUProcessor::new()),
867                        g2d,
868                        #[cfg(feature = "opengl")]
869                        opengl: None,
870                        forced_backend: None,
871                    });
872                }
873                #[cfg(not(target_os = "linux"))]
874                {
875                    log::warn!("G2D requested but not available on this platform, using CPU");
876                    return Ok(Self {
877                        cpu: Some(CPUProcessor::new()),
878                        forced_backend: None,
879                    });
880                }
881            }
882            ComputeBackend::OpenGl => {
883                log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
884                #[cfg(target_os = "linux")]
885                {
886                    #[cfg(feature = "opengl")]
887                    let opengl = match GLProcessorThreaded::new(config.egl_display) {
888                        Ok(gl) => Some(gl),
889                        Err(e) => {
890                            log::warn!("OpenGL requested but failed to initialize: {e:?}");
891                            None
892                        }
893                    };
894                    return Ok(Self {
895                        cpu: Some(CPUProcessor::new()),
896                        g2d: None,
897                        #[cfg(feature = "opengl")]
898                        opengl,
899                        forced_backend: None,
900                    });
901                }
902                #[cfg(not(target_os = "linux"))]
903                {
904                    log::warn!("OpenGL requested but not available on this platform, using CPU");
905                    return Ok(Self {
906                        cpu: Some(CPUProcessor::new()),
907                        forced_backend: None,
908                    });
909                }
910            }
911            ComputeBackend::Auto => { /* fall through to env-var logic below */ }
912        }
913
914        // ── EDGEFIRST_FORCE_BACKEND ──────────────────────────────────
915        // When set, only the requested backend is initialised and no
916        // fallback chain is used. Accepted values (case-insensitive):
917        //   "cpu", "g2d", "opengl"
918        if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
919            let val_lower = val.to_lowercase();
920            let forced = match val_lower.as_str() {
921                "cpu" => ForcedBackend::Cpu,
922                "g2d" => ForcedBackend::G2d,
923                "opengl" => ForcedBackend::OpenGl,
924                other => {
925                    return Err(Error::ForcedBackendUnavailable(format!(
926                        "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
927                    )));
928                }
929            };
930
931            log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
932
933            return match forced {
934                ForcedBackend::Cpu => Ok(Self {
935                    cpu: Some(CPUProcessor::new()),
936                    #[cfg(target_os = "linux")]
937                    g2d: None,
938                    #[cfg(target_os = "linux")]
939                    #[cfg(feature = "opengl")]
940                    opengl: None,
941                    forced_backend: Some(ForcedBackend::Cpu),
942                }),
943                ForcedBackend::G2d => {
944                    #[cfg(target_os = "linux")]
945                    {
946                        let g2d = G2DProcessor::new().map_err(|e| {
947                            Error::ForcedBackendUnavailable(format!(
948                                "g2d forced but failed to initialize: {e:?}"
949                            ))
950                        })?;
951                        Ok(Self {
952                            cpu: None,
953                            g2d: Some(g2d),
954                            #[cfg(feature = "opengl")]
955                            opengl: None,
956                            forced_backend: Some(ForcedBackend::G2d),
957                        })
958                    }
959                    #[cfg(not(target_os = "linux"))]
960                    {
961                        Err(Error::ForcedBackendUnavailable(
962                            "g2d backend is only available on Linux".into(),
963                        ))
964                    }
965                }
966                ForcedBackend::OpenGl => {
967                    #[cfg(target_os = "linux")]
968                    #[cfg(feature = "opengl")]
969                    {
970                        let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
971                            Error::ForcedBackendUnavailable(format!(
972                                "opengl forced but failed to initialize: {e:?}"
973                            ))
974                        })?;
975                        Ok(Self {
976                            cpu: None,
977                            g2d: None,
978                            opengl: Some(opengl),
979                            forced_backend: Some(ForcedBackend::OpenGl),
980                        })
981                    }
982                    #[cfg(not(all(target_os = "linux", feature = "opengl")))]
983                    {
984                        Err(Error::ForcedBackendUnavailable(
985                            "opengl backend requires Linux with the 'opengl' feature enabled"
986                                .into(),
987                        ))
988                    }
989                }
990            };
991        }
992
993        // ── Existing DISABLE logic (unchanged) ──────────────────────
994        #[cfg(target_os = "linux")]
995        let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
996            .map(|x| x != "0" && x.to_lowercase() != "false")
997            .unwrap_or(false)
998        {
999            log::debug!("EDGEFIRST_DISABLE_G2D is set");
1000            None
1001        } else {
1002            match G2DProcessor::new() {
1003                Ok(g2d_converter) => Some(g2d_converter),
1004                Err(err) => {
1005                    log::warn!("Failed to initialize G2D converter: {err:?}");
1006                    None
1007                }
1008            }
1009        };
1010
1011        #[cfg(target_os = "linux")]
1012        #[cfg(feature = "opengl")]
1013        let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1014            .map(|x| x != "0" && x.to_lowercase() != "false")
1015            .unwrap_or(false)
1016        {
1017            log::debug!("EDGEFIRST_DISABLE_GL is set");
1018            None
1019        } else {
1020            match GLProcessorThreaded::new(config.egl_display) {
1021                Ok(gl_converter) => Some(gl_converter),
1022                Err(err) => {
1023                    log::warn!("Failed to initialize GL converter: {err:?}");
1024                    None
1025                }
1026            }
1027        };
1028
1029        let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1030            .map(|x| x != "0" && x.to_lowercase() != "false")
1031            .unwrap_or(false)
1032        {
1033            log::debug!("EDGEFIRST_DISABLE_CPU is set");
1034            None
1035        } else {
1036            Some(CPUProcessor::new())
1037        };
1038        Ok(Self {
1039            cpu,
1040            #[cfg(target_os = "linux")]
1041            g2d,
1042            #[cfg(target_os = "linux")]
1043            #[cfg(feature = "opengl")]
1044            opengl,
1045            forced_backend: None,
1046        })
1047    }
1048
1049    /// Sets the interpolation mode for int8 proto textures on the OpenGL
1050    /// backend. No-op if OpenGL is not available.
1051    #[cfg(target_os = "linux")]
1052    #[cfg(feature = "opengl")]
1053    pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
1054        if let Some(ref mut gl) = self.opengl {
1055            gl.set_int8_interpolation_mode(mode)?;
1056        }
1057        Ok(())
1058    }
1059
1060    /// Create a [`TensorDyn`] image with the best available memory backend.
1061    ///
1062    /// Priority: DMA-buf → PBO (byte-sized types: u8, i8) → system memory.
1063    ///
1064    /// Use this method instead of [`TensorDyn::image()`] when the tensor will
1065    /// be used with [`ImageProcessor::convert()`]. It selects the optimal
1066    /// memory backing (including PBO for GPU zero-copy) which direct
1067    /// allocation cannot achieve.
1068    ///
1069    /// This method is on [`ImageProcessor`] rather than [`ImageProcessorTrait`]
1070    /// because optimal allocation requires knowledge of the active compute
1071    /// backends (e.g. the GL context handle for PBO allocation). Individual
1072    /// backend implementations ([`CPUProcessor`], etc.) do not have this
1073    /// cross-backend visibility.
1074    ///
1075    /// # Arguments
1076    ///
1077    /// * `width` - Image width in pixels
1078    /// * `height` - Image height in pixels
1079    /// * `format` - Pixel format
1080    /// * `dtype` - Element data type (e.g. `DType::U8`, `DType::I8`)
1081    /// * `memory` - Optional memory type override; when `None`, the best
1082    ///   available backend is selected automatically.
1083    ///
1084    /// # Returns
1085    ///
1086    /// A [`TensorDyn`] backed by the highest-performance memory type
1087    /// available on this system.
1088    ///
1089    /// # Pitch alignment for DMA-backed allocations
1090    ///
1091    /// DMA-BUF imports into the GL backend (Mali Valhall on i.MX 95
1092    /// specifically) require every row pitch to be a multiple of
1093    /// [`GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES`] (currently 64). When this
1094    /// method lands on `TensorMemory::Dma`, the underlying allocation is
1095    /// silently padded so the row stride satisfies that requirement.
1096    ///
1097    /// **The user-requested `width` is preserved** — `tensor.width()`
1098    /// returns the same value you passed in. The padding is carried by
1099    /// [`TensorDyn::row_stride`] / `effective_row_stride()`, which the
1100    /// GL backend reads when importing the buffer as an EGLImage.
1101    /// Callers that compute byte offsets from the tensor must use the
1102    /// stride, not `width × bytes_per_pixel`; the CPU mapping spans the
1103    /// full `stride × height` bytes.
1104    ///
1105    /// Pre-aligned widths (640, 1280, 1920, 3008, 3840 …) allocate
1106    /// exactly `width × bpp × height` bytes with no padding. PBO and
1107    /// Mem fallbacks never pad — they don't go through EGLImage import.
1108    ///
1109    /// See also [`align_width_for_gpu_pitch`] for an advisory helper
1110    /// that external callers (GStreamer plugins, video pipelines) can
1111    /// use to size their own DMA-BUFs for GL compatibility.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns an error if all allocation strategies fail.
1116    pub fn create_image(
1117        &self,
1118        width: usize,
1119        height: usize,
1120        format: PixelFormat,
1121        dtype: DType,
1122        memory: Option<TensorMemory>,
1123    ) -> Result<TensorDyn> {
1124        // Compute the GPU-aligned row stride in bytes for this image.
1125        // `None` means either the format has no defined primary-plane bpp
1126        // (unknown future layout) or the stride calculation would overflow
1127        // — in both cases we fall back to the natural layout via the plain
1128        // `TensorDyn::image` constructor, and the slow-path warning inside
1129        // `draw_*_masks` will fire if the subsequent GL import fails.
1130        //
1131        // DMA allocation is Linux-only (see `TensorMemory::Dma` cfg gate),
1132        // so both the stride computation and the helper closure are gated
1133        // accordingly — the callers below are already Linux-only.
1134        #[cfg(target_os = "linux")]
1135        let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
1136            .and_then(|bpp| width.checked_mul(bpp))
1137            .and_then(align_pitch_bytes_to_gpu_alignment);
1138
1139        // Helper: allocate a DMA image, using the padded-stride constructor
1140        // when the computed stride exceeds the natural pitch, otherwise the
1141        // plain constructor (byte-identical result in the common case).
1142        #[cfg(target_os = "linux")]
1143        let try_dma = || -> Result<TensorDyn> {
1144            // Stride padding is only meaningful for packed pixel layouts
1145            // (RGBA8, BGRA8, RGB888, Grey) — the formats the GL backend
1146            // renders into. Semi-planar (NV12, NV16) and planar (PlanarRgb,
1147            // PlanarRgba) tensors go through `TensorDyn::image(...)` with
1148            // their natural layout; they're imported from camera capture
1149            // via `from_fd` far more often than allocated here, and
1150            // `Tensor::image_with_stride` explicitly rejects them.
1151            let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
1152            match dma_stride_bytes {
1153                Some(stride)
1154                    if packed
1155                        && primary_plane_bpp(format, dtype.size())
1156                            .and_then(|bpp| width.checked_mul(bpp))
1157                            .is_some_and(|natural| stride > natural) =>
1158                {
1159                    log::debug!(
1160                        "create_image: padding row stride for {format:?} {width}x{height} \
1161                         from natural pitch to {stride} bytes for GPU alignment"
1162                    );
1163                    Ok(TensorDyn::image_with_stride(
1164                        width,
1165                        height,
1166                        format,
1167                        dtype,
1168                        stride,
1169                        Some(edgefirst_tensor::TensorMemory::Dma),
1170                    )?)
1171                }
1172                _ => Ok(TensorDyn::image(
1173                    width,
1174                    height,
1175                    format,
1176                    dtype,
1177                    Some(edgefirst_tensor::TensorMemory::Dma),
1178                )?),
1179            }
1180        };
1181
1182        // If an explicit memory type is requested, honour it directly.
1183        // On Linux, `TensorMemory::Dma` gets the padded-stride treatment;
1184        // other memory types take the user-requested width verbatim.
1185        match memory {
1186            #[cfg(target_os = "linux")]
1187            Some(TensorMemory::Dma) => {
1188                return try_dma();
1189            }
1190            Some(mem) => {
1191                return Ok(TensorDyn::image(width, height, format, dtype, Some(mem))?);
1192            }
1193            None => {}
1194        }
1195
1196        // Try DMA first on Linux — skip only when GL has explicitly selected PBO
1197        // as the preferred transfer path (PBO is better than DMA in that case).
1198        #[cfg(target_os = "linux")]
1199        {
1200            #[cfg(feature = "opengl")]
1201            let gl_uses_pbo = self
1202                .opengl
1203                .as_ref()
1204                .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
1205            #[cfg(not(feature = "opengl"))]
1206            let gl_uses_pbo = false;
1207
1208            if !gl_uses_pbo {
1209                if let Ok(img) = try_dma() {
1210                    return Ok(img);
1211                }
1212            }
1213        }
1214
1215        // Try PBO (if GL available).
1216        // PBO buffers are u8-sized; the int8 shader emulates i8 output via
1217        // XOR 0x80 on the same underlying buffer, so both U8 and I8 work.
1218        #[cfg(target_os = "linux")]
1219        #[cfg(feature = "opengl")]
1220        if dtype.size() == 1 {
1221            if let Some(gl) = &self.opengl {
1222                match gl.create_pbo_image(width, height, format) {
1223                    Ok(t) => {
1224                        if dtype == DType::I8 {
1225                            // SAFETY: Tensor<u8> and Tensor<i8> are layout-
1226                            // identical (same element size, no T-dependent
1227                            // drop glue). The int8 shader applies XOR 0x80
1228                            // on the same PBO buffer. Same rationale as
1229                            // gl::processor::tensor_i8_as_u8_mut.
1230                            // Invariant: PBO tensors never have chroma
1231                            // (create_pbo_image → Tensor::wrap sets it None).
1232                            debug_assert!(
1233                                t.chroma().is_none(),
1234                                "PBO i8 transmute requires chroma == None"
1235                            );
1236                            let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
1237                            return Ok(TensorDyn::from(t_i8));
1238                        }
1239                        return Ok(TensorDyn::from(t));
1240                    }
1241                    Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
1242                }
1243            }
1244        }
1245
1246        // Fallback to Mem
1247        Ok(TensorDyn::image(
1248            width,
1249            height,
1250            format,
1251            dtype,
1252            Some(edgefirst_tensor::TensorMemory::Mem),
1253        )?)
1254    }
1255
1256    /// Import an external DMA-BUF image.
1257    ///
1258    /// Each [`PlaneDescriptor`] owns an already-duped fd; this method
1259    /// consumes the descriptors and takes ownership of those fds (whether
1260    /// the call succeeds or fails).
1261    ///
1262    /// The caller must ensure the DMA-BUF allocation is large enough for the
1263    /// specified width, height, format, and any stride/offset on the plane
1264    /// descriptors. No buffer-size validation is performed; an undersized
1265    /// buffer may cause GPU faults or EGL import failure.
1266    ///
1267    /// # Arguments
1268    ///
1269    /// * `image` - Plane descriptor for the primary (or only) plane
1270    /// * `chroma` - Optional plane descriptor for the UV chroma plane
1271    ///   (required for multiplane NV12)
1272    /// * `width` - Image width in pixels
1273    /// * `height` - Image height in pixels
1274    /// * `format` - Pixel format of the buffer
1275    /// * `dtype` - Element data type (e.g. `DType::U8`)
1276    ///
1277    /// # Returns
1278    ///
1279    /// A `TensorDyn` configured as an image.
1280    ///
1281    /// # Errors
1282    ///
1283    /// * [`Error::NotSupported`] if `chroma` is `Some` for a non-semi-planar
1284    ///   format, or multiplane NV16 (not yet supported), or the fd is not
1285    ///   DMA-backed
1286    /// * [`Error::InvalidShape`] if NV12 height is odd
1287    ///
1288    /// # Platform
1289    ///
1290    /// Linux only.
1291    ///
1292    /// # Examples
1293    ///
1294    /// ```rust,ignore
1295    /// use edgefirst_tensor::PlaneDescriptor;
1296    ///
1297    /// // Single-plane RGBA
1298    /// let pd = PlaneDescriptor::new(fd.as_fd())?;
1299    /// let src = proc.import_image(pd, None, 1920, 1080, PixelFormat::Rgba, DType::U8)?;
1300    ///
1301    /// // Multi-plane NV12 with stride
1302    /// let y_pd = PlaneDescriptor::new(y_fd.as_fd())?.with_stride(2048);
1303    /// let uv_pd = PlaneDescriptor::new(uv_fd.as_fd())?.with_stride(2048);
1304    /// let src = proc.import_image(y_pd, Some(uv_pd), 1920, 1080,
1305    ///                             PixelFormat::Nv12, DType::U8)?;
1306    /// ```
1307    #[cfg(target_os = "linux")]
1308    pub fn import_image(
1309        &self,
1310        image: edgefirst_tensor::PlaneDescriptor,
1311        chroma: Option<edgefirst_tensor::PlaneDescriptor>,
1312        width: usize,
1313        height: usize,
1314        format: PixelFormat,
1315        dtype: DType,
1316    ) -> Result<TensorDyn> {
1317        use edgefirst_tensor::{Tensor, TensorMemory};
1318
1319        // Capture stride/offset from descriptors before consuming them
1320        let image_stride = image.stride();
1321        let image_offset = image.offset();
1322        let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
1323        let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
1324
1325        if let Some(chroma_pd) = chroma {
1326            // ── Multiplane path ──────────────────────────────────────
1327            // Multiplane tensors are backed by Tensor<u8> (or transmuted to
1328            // Tensor<i8>). Reject other dtypes to avoid silently returning a
1329            // tensor with the wrong element type.
1330            if dtype != DType::U8 && dtype != DType::I8 {
1331                return Err(Error::NotSupported(format!(
1332                    "multiplane import only supports U8/I8, got {dtype:?}"
1333                )));
1334            }
1335            if format.layout() != PixelLayout::SemiPlanar {
1336                return Err(Error::NotSupported(format!(
1337                    "import_image with chroma requires a semi-planar format, got {format:?}"
1338                )));
1339            }
1340
1341            let chroma_h = match format {
1342                PixelFormat::Nv12 => {
1343                    if !height.is_multiple_of(2) {
1344                        return Err(Error::InvalidShape(format!(
1345                            "NV12 requires even height, got {height}"
1346                        )));
1347                    }
1348                    height / 2
1349                }
1350                // NV16 multiplane will be supported in a future release;
1351                // the GL backend currently only handles NV12 plane1 attributes.
1352                PixelFormat::Nv16 => {
1353                    return Err(Error::NotSupported(
1354                        "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
1355                    ))
1356                }
1357                _ => {
1358                    return Err(Error::NotSupported(format!(
1359                        "unsupported semi-planar format: {format:?}"
1360                    )))
1361                }
1362            };
1363
1364            let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
1365            if luma.memory() != TensorMemory::Dma {
1366                return Err(Error::NotSupported(format!(
1367                    "luma fd must be DMA-backed, got {:?}",
1368                    luma.memory()
1369                )));
1370            }
1371
1372            let chroma_tensor =
1373                Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
1374            if chroma_tensor.memory() != TensorMemory::Dma {
1375                return Err(Error::NotSupported(format!(
1376                    "chroma fd must be DMA-backed, got {:?}",
1377                    chroma_tensor.memory()
1378                )));
1379            }
1380
1381            // from_planes creates the combined tensor with format set,
1382            // preserving luma's row_stride (currently None since luma was raw).
1383            let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
1384
1385            // Apply stride/offset to the combined tensor (luma plane)
1386            if let Some(s) = image_stride {
1387                tensor.set_row_stride(s)?;
1388            }
1389            if let Some(o) = image_offset {
1390                tensor.set_plane_offset(o);
1391            }
1392
1393            // Apply stride/offset to the chroma sub-tensor.
1394            // The chroma tensor is a raw 2D [chroma_h, width] tensor without
1395            // format metadata, so we validate stride manually rather than
1396            // using set_row_stride (which requires format).
1397            if let Some(chroma_ref) = tensor.chroma_mut() {
1398                if let Some(s) = chroma_stride {
1399                    if s < width {
1400                        return Err(Error::InvalidShape(format!(
1401                            "chroma stride {s} < minimum {width} for {format:?}"
1402                        )));
1403                    }
1404                    chroma_ref.set_row_stride_unchecked(s);
1405                }
1406                if let Some(o) = chroma_offset {
1407                    chroma_ref.set_plane_offset(o);
1408                }
1409            }
1410
1411            if dtype == DType::I8 {
1412                // SAFETY: Tensor<u8> and Tensor<i8> have identical layout because
1413                // the struct contains only type-erased storage (OwnedFd, shape, name),
1414                // no inline T values. This assertion catches layout drift at compile time.
1415                const {
1416                    assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
1417                    assert!(
1418                        std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
1419                    );
1420                }
1421                let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
1422                return Ok(TensorDyn::from(tensor_i8));
1423            }
1424            Ok(TensorDyn::from(tensor))
1425        } else {
1426            // ── Single-plane path ────────────────────────────────────
1427            let shape = match format.layout() {
1428                PixelLayout::Packed => vec![height, width, format.channels()],
1429                PixelLayout::Planar => vec![format.channels(), height, width],
1430                PixelLayout::SemiPlanar => {
1431                    let total_h = match format {
1432                        PixelFormat::Nv12 => {
1433                            if !height.is_multiple_of(2) {
1434                                return Err(Error::InvalidShape(format!(
1435                                    "NV12 requires even height, got {height}"
1436                                )));
1437                            }
1438                            height * 3 / 2
1439                        }
1440                        PixelFormat::Nv16 => height * 2,
1441                        _ => {
1442                            return Err(Error::InvalidShape(format!(
1443                                "unknown semi-planar height multiplier for {format:?}"
1444                            )))
1445                        }
1446                    };
1447                    vec![total_h, width]
1448                }
1449                _ => {
1450                    return Err(Error::NotSupported(format!(
1451                        "unsupported pixel layout for import_image: {:?}",
1452                        format.layout()
1453                    )));
1454                }
1455            };
1456            let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
1457            if tensor.memory() != TensorMemory::Dma {
1458                return Err(Error::NotSupported(format!(
1459                    "import_image requires DMA-backed fd, got {:?}",
1460                    tensor.memory()
1461                )));
1462            }
1463            let mut tensor = tensor.with_format(format)?;
1464            if let Some(s) = image_stride {
1465                tensor.set_row_stride(s)?;
1466            }
1467            if let Some(o) = image_offset {
1468                tensor.set_plane_offset(o);
1469            }
1470            Ok(tensor)
1471        }
1472    }
1473
1474    /// Decode model outputs and draw segmentation masks onto `dst`.
1475    ///
1476    /// This is the primary mask rendering API. The processor decodes via the
1477    /// provided [`Decoder`], selects the optimal rendering path (hybrid
1478    /// CPU+GL or fused GPU), and composites masks onto `dst`.
1479    ///
1480    /// Returns the detected bounding boxes.
1481    pub fn draw_masks(
1482        &mut self,
1483        decoder: &edgefirst_decoder::Decoder,
1484        outputs: &[&TensorDyn],
1485        dst: &mut TensorDyn,
1486        overlay: MaskOverlay<'_>,
1487    ) -> Result<Vec<DetectBox>> {
1488        let mut output_boxes = Vec::with_capacity(100);
1489
1490        // Try proto path first (fused rendering without materializing masks)
1491        let proto_result = decoder
1492            .decode_proto(outputs, &mut output_boxes)
1493            .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
1494
1495        if let Some(proto_data) = proto_result {
1496            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
1497        } else {
1498            // Detection-only or unsupported model: full decode + render
1499            let mut output_masks = Vec::with_capacity(100);
1500            decoder
1501                .decode(outputs, &mut output_boxes, &mut output_masks)
1502                .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
1503            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
1504        }
1505        Ok(output_boxes)
1506    }
1507
1508    /// Decode tracked model outputs and draw segmentation masks onto `dst`.
1509    ///
1510    /// Like [`draw_masks`](Self::draw_masks) but integrates a tracker for
1511    /// maintaining object identities across frames. The tracker runs after
1512    /// NMS but before mask extraction.
1513    ///
1514    /// Returns detected boxes and track info.
1515    #[cfg(feature = "tracker")]
1516    pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
1517        &mut self,
1518        decoder: &edgefirst_decoder::Decoder,
1519        tracker: &mut TR,
1520        timestamp: u64,
1521        outputs: &[&TensorDyn],
1522        dst: &mut TensorDyn,
1523        overlay: MaskOverlay<'_>,
1524    ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
1525        let mut output_boxes = Vec::with_capacity(100);
1526        let mut output_tracks = Vec::new();
1527
1528        let proto_result = decoder
1529            .decode_proto_tracked(
1530                tracker,
1531                timestamp,
1532                outputs,
1533                &mut output_boxes,
1534                &mut output_tracks,
1535            )
1536            .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
1537
1538        if let Some(proto_data) = proto_result {
1539            self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
1540        } else {
1541            // Note: decode_proto_tracked returns None for detection-only/ModelPack
1542            // models WITHOUT calling the tracker. The else branch below is the
1543            // first (and only) tracker call for those model types.
1544            let mut output_masks = Vec::with_capacity(100);
1545            decoder
1546                .decode_tracked(
1547                    tracker,
1548                    timestamp,
1549                    outputs,
1550                    &mut output_boxes,
1551                    &mut output_masks,
1552                    &mut output_tracks,
1553                )
1554                .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
1555            self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
1556        }
1557        Ok((output_boxes, output_tracks))
1558    }
1559
1560    /// Materialize per-instance segmentation masks from raw prototype data.
1561    ///
1562    /// Computes `mask_coeff @ protos` with sigmoid activation for each detection,
1563    /// producing compact masks at prototype resolution (e.g., 160×160 crops).
1564    /// Mask values are continuous sigmoid confidence outputs quantized to u8
1565    /// (0 = background, 255 = full confidence), NOT binary thresholded.
1566    ///
1567    /// The returned [`Vec<Segmentation>`] can be:
1568    /// - Inspected or exported for analytics, IoU computation, etc.
1569    /// - Passed directly to [`ImageProcessorTrait::draw_decoded_masks`] for
1570    ///   GPU-interpolated rendering.
1571    ///
1572    /// # Performance Note
1573    ///
1574    /// Calling `materialize_masks` + `draw_decoded_masks` separately prevents
1575    /// the HAL from using its internal fused optimization path. For render-only
1576    /// use cases, prefer [`ImageProcessorTrait::draw_proto_masks`] which selects
1577    /// the fastest path automatically (currently 1.6×–27× faster on tested
1578    /// platforms). Use this method when you need access to the intermediate masks.
1579    ///
1580    /// # Errors
1581    ///
1582    /// Returns [`Error::NoConverter`] if the CPU backend is not available.
1583    pub fn materialize_masks(
1584        &self,
1585        detect: &[DetectBox],
1586        proto_data: &ProtoData,
1587        letterbox: Option<[f32; 4]>,
1588    ) -> Result<Vec<Segmentation>> {
1589        let cpu = self.cpu.as_ref().ok_or(Error::NoConverter)?;
1590        cpu.materialize_segmentations(detect, proto_data, letterbox)
1591    }
1592}
1593
1594impl ImageProcessorTrait for ImageProcessor {
1595    /// Converts the source image to the destination image format and size. The
1596    /// image is cropped first, then flipped, then rotated
1597    ///
1598    /// Prefer hardware accelerators when available, falling back to CPU if
1599    /// necessary.
1600    fn convert(
1601        &mut self,
1602        src: &TensorDyn,
1603        dst: &mut TensorDyn,
1604        rotation: Rotation,
1605        flip: Flip,
1606        crop: Crop,
1607    ) -> Result<()> {
1608        let start = Instant::now();
1609        let src_fmt = src.format();
1610        let dst_fmt = dst.format();
1611        log::trace!(
1612            "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
1613             rotation={rotation:?}, flip={flip:?}, backend={:?}",
1614            src.dtype(),
1615            src.memory(),
1616            dst.dtype(),
1617            dst.memory(),
1618            self.forced_backend,
1619        );
1620
1621        // ── Forced backend: no fallback chain ────────────────────────
1622        if let Some(forced) = self.forced_backend {
1623            return match forced {
1624                ForcedBackend::Cpu => {
1625                    if let Some(cpu) = self.cpu.as_mut() {
1626                        let r = cpu.convert(src, dst, rotation, flip, crop);
1627                        log::trace!(
1628                            "convert: forced=cpu result={} ({:?})",
1629                            if r.is_ok() { "ok" } else { "err" },
1630                            start.elapsed()
1631                        );
1632                        return r;
1633                    }
1634                    Err(Error::ForcedBackendUnavailable("cpu".into()))
1635                }
1636                ForcedBackend::G2d => {
1637                    #[cfg(target_os = "linux")]
1638                    if let Some(g2d) = self.g2d.as_mut() {
1639                        let r = g2d.convert(src, dst, rotation, flip, crop);
1640                        log::trace!(
1641                            "convert: forced=g2d result={} ({:?})",
1642                            if r.is_ok() { "ok" } else { "err" },
1643                            start.elapsed()
1644                        );
1645                        return r;
1646                    }
1647                    Err(Error::ForcedBackendUnavailable("g2d".into()))
1648                }
1649                ForcedBackend::OpenGl => {
1650                    #[cfg(target_os = "linux")]
1651                    #[cfg(feature = "opengl")]
1652                    if let Some(opengl) = self.opengl.as_mut() {
1653                        let r = opengl.convert(src, dst, rotation, flip, crop);
1654                        log::trace!(
1655                            "convert: forced=opengl result={} ({:?})",
1656                            if r.is_ok() { "ok" } else { "err" },
1657                            start.elapsed()
1658                        );
1659                        return r;
1660                    }
1661                    Err(Error::ForcedBackendUnavailable("opengl".into()))
1662                }
1663            };
1664        }
1665
1666        // ── Auto fallback chain: OpenGL → G2D → CPU ──────────────────
1667        #[cfg(target_os = "linux")]
1668        #[cfg(feature = "opengl")]
1669        if let Some(opengl) = self.opengl.as_mut() {
1670            match opengl.convert(src, dst, rotation, flip, crop) {
1671                Ok(_) => {
1672                    log::trace!(
1673                        "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
1674                        start.elapsed()
1675                    );
1676                    return Ok(());
1677                }
1678                Err(e) => {
1679                    log::trace!("convert: auto opengl declined {src_fmt:?}→{dst_fmt:?}: {e}");
1680                }
1681            }
1682        }
1683
1684        #[cfg(target_os = "linux")]
1685        if let Some(g2d) = self.g2d.as_mut() {
1686            match g2d.convert(src, dst, rotation, flip, crop) {
1687                Ok(_) => {
1688                    log::trace!(
1689                        "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
1690                        start.elapsed()
1691                    );
1692                    return Ok(());
1693                }
1694                Err(e) => {
1695                    log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
1696                }
1697            }
1698        }
1699
1700        if let Some(cpu) = self.cpu.as_mut() {
1701            match cpu.convert(src, dst, rotation, flip, crop) {
1702                Ok(_) => {
1703                    log::trace!(
1704                        "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
1705                        start.elapsed()
1706                    );
1707                    return Ok(());
1708                }
1709                Err(e) => {
1710                    log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
1711                    return Err(e);
1712                }
1713            }
1714        }
1715        Err(Error::NoConverter)
1716    }
1717
1718    fn draw_decoded_masks(
1719        &mut self,
1720        dst: &mut TensorDyn,
1721        detect: &[DetectBox],
1722        segmentation: &[Segmentation],
1723        overlay: MaskOverlay<'_>,
1724    ) -> Result<()> {
1725        let start = Instant::now();
1726
1727        if detect.is_empty() && segmentation.is_empty() {
1728            return Ok(());
1729        }
1730
1731        // Un-letterbox detect boxes and segmentation bboxes for rendering when
1732        // a letterbox was applied to prepare the model input.
1733        let lb_boxes: Vec<DetectBox>;
1734        let lb_segs: Vec<Segmentation>;
1735        let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
1736            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
1737            // Keep segmentation bboxes in sync with the transformed detect boxes
1738            // when we have a 1:1 correspondence (instance segmentation).
1739            lb_segs = if segmentation.len() == lb_boxes.len() {
1740                segmentation
1741                    .iter()
1742                    .zip(lb_boxes.iter())
1743                    .map(|(s, d)| Segmentation {
1744                        xmin: d.bbox.xmin,
1745                        ymin: d.bbox.ymin,
1746                        xmax: d.bbox.xmax,
1747                        ymax: d.bbox.ymax,
1748                        segmentation: s.segmentation.clone(),
1749                    })
1750                    .collect()
1751            } else {
1752                segmentation.to_vec()
1753            };
1754            (lb_boxes.as_slice(), lb_segs.as_slice())
1755        } else {
1756            (detect, segmentation)
1757        };
1758
1759        // ── Forced backend: no fallback chain ────────────────────────
1760        if let Some(forced) = self.forced_backend {
1761            return match forced {
1762                ForcedBackend::Cpu => {
1763                    // CPU needs background pre-blitted
1764                    let overlay = overlay.apply_background(dst)?;
1765                    if let Some(cpu) = self.cpu.as_mut() {
1766                        return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
1767                    }
1768                    Err(Error::ForcedBackendUnavailable("cpu".into()))
1769                }
1770                ForcedBackend::G2d => Err(Error::NotSupported(
1771                    "g2d does not support draw_decoded_masks".into(),
1772                )),
1773                ForcedBackend::OpenGl => {
1774                    // GL handles background natively via GPU blit
1775                    #[cfg(target_os = "linux")]
1776                    #[cfg(feature = "opengl")]
1777                    if let Some(opengl) = self.opengl.as_mut() {
1778                        return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
1779                    }
1780                    Err(Error::ForcedBackendUnavailable("opengl".into()))
1781                }
1782            };
1783        }
1784
1785        // skip G2D as it doesn't support rendering to image
1786
1787        // GL path: pass overlay with background — GL will GPU-blit if DMA-BUF
1788        #[cfg(target_os = "linux")]
1789        #[cfg(feature = "opengl")]
1790        if let Some(opengl) = self.opengl.as_mut() {
1791            log::trace!(
1792                "draw_decoded_masks started with opengl in {:?}",
1793                start.elapsed()
1794            );
1795            match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
1796                Ok(_) => {
1797                    log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
1798                    return Ok(());
1799                }
1800                Err(e) => {
1801                    log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
1802                }
1803            }
1804        }
1805
1806        // CPU fallback: blit background via memcpy before rendering
1807        let overlay = overlay.apply_background(dst)?;
1808        log::trace!(
1809            "draw_decoded_masks started with cpu in {:?}",
1810            start.elapsed()
1811        );
1812        if let Some(cpu) = self.cpu.as_mut() {
1813            match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
1814                Ok(_) => {
1815                    log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
1816                    return Ok(());
1817                }
1818                Err(e) => {
1819                    log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
1820                    return Err(e);
1821                }
1822            }
1823        }
1824        Err(Error::NoConverter)
1825    }
1826
1827    fn draw_proto_masks(
1828        &mut self,
1829        dst: &mut TensorDyn,
1830        detect: &[DetectBox],
1831        proto_data: &ProtoData,
1832        overlay: MaskOverlay<'_>,
1833    ) -> Result<()> {
1834        let start = Instant::now();
1835
1836        if detect.is_empty() {
1837            return Ok(());
1838        }
1839
1840        // Un-letterbox detect boxes for rendering when a letterbox was applied
1841        // to prepare the model input.  The original `detect` coords are still
1842        // passed to `materialize_segmentations` (which needs model-space coords
1843        // to correctly crop the proto tensor) alongside `overlay.letterbox` so
1844        // it can emit `Segmentation` structs in output-image space.
1845        let lb_boxes: Vec<DetectBox>;
1846        let render_detect = if let Some(lb) = overlay.letterbox {
1847            lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
1848            lb_boxes.as_slice()
1849        } else {
1850            detect
1851        };
1852
1853        // ── Forced backend: no fallback chain ────────────────────────
1854        if let Some(forced) = self.forced_backend {
1855            return match forced {
1856                ForcedBackend::Cpu => {
1857                    let overlay = overlay.apply_background(dst)?;
1858                    if let Some(cpu) = self.cpu.as_mut() {
1859                        return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
1860                    }
1861                    Err(Error::ForcedBackendUnavailable("cpu".into()))
1862                }
1863                ForcedBackend::G2d => Err(Error::NotSupported(
1864                    "g2d does not support draw_proto_masks".into(),
1865                )),
1866                ForcedBackend::OpenGl => {
1867                    #[cfg(target_os = "linux")]
1868                    #[cfg(feature = "opengl")]
1869                    if let Some(opengl) = self.opengl.as_mut() {
1870                        return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
1871                    }
1872                    Err(Error::ForcedBackendUnavailable("opengl".into()))
1873                }
1874            };
1875        }
1876
1877        // skip G2D as it doesn't support rendering to image
1878
1879        // Hybrid path: CPU materialize + GL overlay (benchmarked faster than
1880        // full-GPU draw_proto_masks on all tested platforms: 27× on imx8mp,
1881        // 4× on imx95, 2.5× on rpi5, 1.6× on x86).
1882        // GL handles background natively via GPU blit.
1883        #[cfg(target_os = "linux")]
1884        #[cfg(feature = "opengl")]
1885        if let Some(opengl) = self.opengl.as_mut() {
1886            let Some(cpu) = self.cpu.as_ref() else {
1887                return Err(Error::Internal(
1888                    "draw_proto_masks requires CPU backend for hybrid path".into(),
1889                ));
1890            };
1891            log::trace!(
1892                "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
1893                start.elapsed()
1894            );
1895            let segmentation =
1896                cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?;
1897            match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
1898                Ok(_) => {
1899                    log::trace!(
1900                        "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
1901                        start.elapsed()
1902                    );
1903                    return Ok(());
1904                }
1905                Err(e) => {
1906                    log::trace!("draw_proto_masks hybrid path failed, falling back to cpu: {e:?}");
1907                }
1908            }
1909        }
1910
1911        // CPU-only fallback: blit background via memcpy
1912        let overlay = overlay.apply_background(dst)?;
1913        let Some(cpu) = self.cpu.as_mut() else {
1914            return Err(Error::Internal(
1915                "draw_proto_masks requires CPU backend for fallback path".into(),
1916            ));
1917        };
1918        log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
1919        cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
1920    }
1921
1922    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
1923        let start = Instant::now();
1924
1925        // ── Forced backend: no fallback chain ────────────────────────
1926        if let Some(forced) = self.forced_backend {
1927            return match forced {
1928                ForcedBackend::Cpu => {
1929                    if let Some(cpu) = self.cpu.as_mut() {
1930                        return cpu.set_class_colors(colors);
1931                    }
1932                    Err(Error::ForcedBackendUnavailable("cpu".into()))
1933                }
1934                ForcedBackend::G2d => Err(Error::NotSupported(
1935                    "g2d does not support set_class_colors".into(),
1936                )),
1937                ForcedBackend::OpenGl => {
1938                    #[cfg(target_os = "linux")]
1939                    #[cfg(feature = "opengl")]
1940                    if let Some(opengl) = self.opengl.as_mut() {
1941                        return opengl.set_class_colors(colors);
1942                    }
1943                    Err(Error::ForcedBackendUnavailable("opengl".into()))
1944                }
1945            };
1946        }
1947
1948        // skip G2D as it doesn't support rendering to image
1949
1950        #[cfg(target_os = "linux")]
1951        #[cfg(feature = "opengl")]
1952        if let Some(opengl) = self.opengl.as_mut() {
1953            log::trace!("image started with opengl in {:?}", start.elapsed());
1954            match opengl.set_class_colors(colors) {
1955                Ok(_) => {
1956                    log::trace!("colors set with opengl in {:?}", start.elapsed());
1957                    return Ok(());
1958                }
1959                Err(e) => {
1960                    log::trace!("colors didn't set with opengl: {e:?}")
1961                }
1962            }
1963        }
1964        log::trace!("image started with cpu in {:?}", start.elapsed());
1965        if let Some(cpu) = self.cpu.as_mut() {
1966            match cpu.set_class_colors(colors) {
1967                Ok(_) => {
1968                    log::trace!("colors set with cpu in {:?}", start.elapsed());
1969                    return Ok(());
1970                }
1971                Err(e) => {
1972                    log::trace!("colors didn't set with cpu: {e:?}");
1973                    return Err(e);
1974                }
1975            }
1976        }
1977        Err(Error::NoConverter)
1978    }
1979}
1980
1981// ---------------------------------------------------------------------------
1982// Image loading / saving helpers
1983// ---------------------------------------------------------------------------
1984
1985/// Read EXIF orientation from raw EXIF bytes and return (Rotation, Flip).
1986fn read_exif_orientation(exif_bytes: &[u8]) -> (Rotation, Flip) {
1987    let exifreader = exif::Reader::new();
1988    let Ok(exif_) = exifreader.read_raw(exif_bytes.to_vec()) else {
1989        return (Rotation::None, Flip::None);
1990    };
1991    let Some(orientation) = exif_.get_field(exif::Tag::Orientation, exif::In::PRIMARY) else {
1992        return (Rotation::None, Flip::None);
1993    };
1994    match orientation.value.get_uint(0) {
1995        Some(1) => (Rotation::None, Flip::None),
1996        Some(2) => (Rotation::None, Flip::Horizontal),
1997        Some(3) => (Rotation::Rotate180, Flip::None),
1998        Some(4) => (Rotation::Rotate180, Flip::Horizontal),
1999        Some(5) => (Rotation::Clockwise90, Flip::Horizontal),
2000        Some(6) => (Rotation::Clockwise90, Flip::None),
2001        Some(7) => (Rotation::CounterClockwise90, Flip::Horizontal),
2002        Some(8) => (Rotation::CounterClockwise90, Flip::None),
2003        Some(v) => {
2004            log::warn!("broken orientation EXIF value: {v}");
2005            (Rotation::None, Flip::None)
2006        }
2007        None => (Rotation::None, Flip::None),
2008    }
2009}
2010
2011/// Map a [`PixelFormat`] to the zune-jpeg `ColorSpace` for decoding.
2012/// Returns `None` for formats that the JPEG decoder cannot output directly.
2013fn pixelfmt_to_colorspace(fmt: PixelFormat) -> Option<ColorSpace> {
2014    match fmt {
2015        PixelFormat::Rgb => Some(ColorSpace::RGB),
2016        PixelFormat::Rgba => Some(ColorSpace::RGBA),
2017        PixelFormat::Grey => Some(ColorSpace::Luma),
2018        _ => None,
2019    }
2020}
2021
2022/// Map a zune-jpeg `ColorSpace` to a [`PixelFormat`].
2023fn colorspace_to_pixelfmt(cs: ColorSpace) -> Option<PixelFormat> {
2024    match cs {
2025        ColorSpace::RGB => Some(PixelFormat::Rgb),
2026        ColorSpace::RGBA => Some(PixelFormat::Rgba),
2027        ColorSpace::Luma => Some(PixelFormat::Grey),
2028        _ => None,
2029    }
2030}
2031
2032/// Load a JPEG image from raw bytes and return a [`TensorDyn`].
2033fn load_jpeg(
2034    image: &[u8],
2035    format: Option<PixelFormat>,
2036    memory: Option<TensorMemory>,
2037) -> Result<TensorDyn> {
2038    let colour = match format {
2039        Some(f) => pixelfmt_to_colorspace(f)
2040            .ok_or_else(|| Error::NotSupported(format!("Unsupported image format {f:?}")))?,
2041        None => ColorSpace::RGB,
2042    };
2043    let options = DecoderOptions::default().jpeg_set_out_colorspace(colour);
2044    let mut decoder = JpegDecoder::new_with_options(image, options);
2045    decoder.decode_headers()?;
2046
2047    let image_info = decoder.info().ok_or(Error::Internal(
2048        "JPEG did not return decoded image info".to_string(),
2049    ))?;
2050
2051    let converted_cs = decoder
2052        .get_output_colorspace()
2053        .ok_or(Error::Internal("No output colorspace".to_string()))?;
2054
2055    let converted_fmt = colorspace_to_pixelfmt(converted_cs).ok_or(Error::NotSupported(
2056        "Unsupported JPEG decoder output".to_string(),
2057    ))?;
2058
2059    let dest_fmt = format.unwrap_or(converted_fmt);
2060
2061    let (rotation, flip) = decoder
2062        .exif()
2063        .map(|x| read_exif_orientation(x))
2064        .unwrap_or((Rotation::None, Flip::None));
2065
2066    let w = image_info.width as usize;
2067    let h = image_info.height as usize;
2068
2069    if (rotation, flip) == (Rotation::None, Flip::None) {
2070        let mut img = Tensor::<u8>::image(w, h, dest_fmt, memory)?;
2071
2072        if converted_fmt != dest_fmt {
2073            let tmp = Tensor::<u8>::image(w, h, converted_fmt, Some(TensorMemory::Mem))?;
2074            decoder.decode_into(&mut tmp.map()?)?;
2075            CPUProcessor::convert_format_pf(&tmp, &mut img, converted_fmt, dest_fmt)?;
2076            return Ok(TensorDyn::from(img));
2077        }
2078        decoder.decode_into(&mut img.map()?)?;
2079        return Ok(TensorDyn::from(img));
2080    }
2081
2082    let mut tmp = Tensor::<u8>::image(w, h, dest_fmt, Some(TensorMemory::Mem))?;
2083
2084    if converted_fmt != dest_fmt {
2085        let tmp2 = Tensor::<u8>::image(w, h, converted_fmt, Some(TensorMemory::Mem))?;
2086        decoder.decode_into(&mut tmp2.map()?)?;
2087        CPUProcessor::convert_format_pf(&tmp2, &mut tmp, converted_fmt, dest_fmt)?;
2088    } else {
2089        decoder.decode_into(&mut tmp.map()?)?;
2090    }
2091
2092    rotate_flip_to_dyn(&tmp, dest_fmt, rotation, flip, memory)
2093}
2094
2095/// Load a PNG image from raw bytes and return a [`TensorDyn`].
2096fn load_png(
2097    image: &[u8],
2098    format: Option<PixelFormat>,
2099    memory: Option<TensorMemory>,
2100) -> Result<TensorDyn> {
2101    let fmt = format.unwrap_or(PixelFormat::Rgb);
2102    let alpha = match fmt {
2103        PixelFormat::Rgb => false,
2104        PixelFormat::Rgba => true,
2105        _ => {
2106            return Err(Error::NotImplemented(
2107                "Unsupported image format".to_string(),
2108            ));
2109        }
2110    };
2111
2112    let options = DecoderOptions::default()
2113        .png_set_add_alpha_channel(alpha)
2114        .png_set_decode_animated(false);
2115    let mut decoder = PngDecoder::new_with_options(image, options);
2116    decoder.decode_headers()?;
2117    let image_info = decoder.get_info().ok_or(Error::Internal(
2118        "PNG did not return decoded image info".to_string(),
2119    ))?;
2120
2121    let (rotation, flip) = image_info
2122        .exif
2123        .as_ref()
2124        .map(|x| read_exif_orientation(x))
2125        .unwrap_or((Rotation::None, Flip::None));
2126
2127    if (rotation, flip) == (Rotation::None, Flip::None) {
2128        let img = Tensor::<u8>::image(image_info.width, image_info.height, fmt, memory)?;
2129        decoder.decode_into(&mut img.map()?)?;
2130        return Ok(TensorDyn::from(img));
2131    }
2132
2133    let tmp = Tensor::<u8>::image(
2134        image_info.width,
2135        image_info.height,
2136        fmt,
2137        Some(TensorMemory::Mem),
2138    )?;
2139    decoder.decode_into(&mut tmp.map()?)?;
2140
2141    rotate_flip_to_dyn(&tmp, fmt, rotation, flip, memory)
2142}
2143
2144/// Load an image from raw bytes (JPEG or PNG) and return a [`TensorDyn`].
2145///
2146/// The optional `format` specifies the desired output pixel format (e.g.,
2147/// [`PixelFormat::Rgb`], [`PixelFormat::Rgba`]); if `None`, the native
2148/// format of the file is used (typically RGB for JPEG).
2149///
2150/// # Examples
2151/// ```rust
2152/// use edgefirst_image::load_image;
2153/// use edgefirst_tensor::PixelFormat;
2154/// # fn main() -> Result<(), edgefirst_image::Error> {
2155/// let jpeg = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../testdata/zidane.jpg"));
2156/// let img = load_image(jpeg, Some(PixelFormat::Rgb), None)?;
2157/// assert_eq!(img.width(), Some(1280));
2158/// assert_eq!(img.height(), Some(720));
2159/// # Ok(())
2160/// # }
2161/// ```
2162pub fn load_image(
2163    image: &[u8],
2164    format: Option<PixelFormat>,
2165    memory: Option<TensorMemory>,
2166) -> Result<TensorDyn> {
2167    if let Ok(i) = load_jpeg(image, format, memory) {
2168        return Ok(i);
2169    }
2170    if let Ok(i) = load_png(image, format, memory) {
2171        return Ok(i);
2172    }
2173    Err(Error::NotSupported(
2174        "Could not decode as jpeg or png".to_string(),
2175    ))
2176}
2177
2178/// Save a [`TensorDyn`] image as a JPEG file.
2179///
2180/// Only packed RGB and RGBA formats are supported.
2181pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
2182    let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
2183        "save_jpeg requires u8 tensor".to_string(),
2184    ))?;
2185    let fmt = t.format().ok_or(Error::NotAnImage)?;
2186    if fmt.layout() != PixelLayout::Packed {
2187        return Err(Error::NotImplemented(
2188            "Saving planar images is not supported".to_string(),
2189        ));
2190    }
2191
2192    let colour = match fmt {
2193        PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
2194        PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
2195        _ => {
2196            return Err(Error::NotImplemented(
2197                "Unsupported image format for saving".to_string(),
2198            ));
2199        }
2200    };
2201
2202    let w = t.width().ok_or(Error::NotAnImage)?;
2203    let h = t.height().ok_or(Error::NotAnImage)?;
2204    let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
2205    let tensor_map = t.map()?;
2206
2207    encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
2208
2209    Ok(())
2210}
2211
2212pub(crate) struct FunctionTimer<T: Display> {
2213    name: T,
2214    start: std::time::Instant,
2215}
2216
2217impl<T: Display> FunctionTimer<T> {
2218    pub fn new(name: T) -> Self {
2219        Self {
2220            name,
2221            start: std::time::Instant::now(),
2222        }
2223    }
2224}
2225
2226impl<T: Display> Drop for FunctionTimer<T> {
2227    fn drop(&mut self) {
2228        log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
2229    }
2230}
2231
2232const DEFAULT_COLORS: [[f32; 4]; 20] = [
2233    [0., 1., 0., 0.7],
2234    [1., 0.5568628, 0., 0.7],
2235    [0.25882353, 0.15294118, 0.13333333, 0.7],
2236    [0.8, 0.7647059, 0.78039216, 0.7],
2237    [0.3137255, 0.3137255, 0.3137255, 0.7],
2238    [0.1411765, 0.3098039, 0.1215686, 0.7],
2239    [1., 0.95686275, 0.5137255, 0.7],
2240    [0.3529412, 0.32156863, 0., 0.7],
2241    [0.4235294, 0.6235294, 0.6509804, 0.7],
2242    [0.5098039, 0.5098039, 0.7294118, 0.7],
2243    [0.00784314, 0.18823529, 0.29411765, 0.7],
2244    [0.0, 0.2706, 1.0, 0.7],
2245    [0.0, 0.0, 0.0, 0.7],
2246    [0.0, 0.5, 0.0, 0.7],
2247    [1.0, 0.0, 0.0, 0.7],
2248    [0.0, 0.0, 1.0, 0.7],
2249    [1.0, 0.5, 0.5, 0.7],
2250    [0.1333, 0.5451, 0.1333, 0.7],
2251    [0.1176, 0.4118, 0.8235, 0.7],
2252    [1., 1., 1., 0.7],
2253];
2254
2255const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
2256    let mut result = [[0; M]; N];
2257    let mut i = 0;
2258    while i < N {
2259        let mut j = 0;
2260        while j < M {
2261            result[i][j] = (a[i][j] * 255.0).round() as u8;
2262            j += 1;
2263        }
2264        i += 1;
2265    }
2266    result
2267}
2268
2269const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
2270
2271#[cfg(test)]
2272#[cfg_attr(coverage_nightly, coverage(off))]
2273mod alignment_tests {
2274    use super::*;
2275
2276    #[test]
2277    fn align_width_rgba8_common_widths() {
2278        // RGBA8 (bpp=4, lcm(64,4)=64, so width must round to multiple of 16 px).
2279        assert_eq!(align_width_for_gpu_pitch(640, 4), 640); // 2560 byte pitch — already aligned
2280        assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); // 5120
2281        assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); // 7680
2282        assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); // 15360
2283                                                              // crowd.png case from the imx95 investigation:
2284        assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); // 12016 → 12032
2285        assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); // 12000 → 12032
2286        assert_eq!(align_width_for_gpu_pitch(17, 4), 32); // 68 → 128
2287        assert_eq!(align_width_for_gpu_pitch(1, 4), 16); // 4 → 64
2288    }
2289
2290    #[test]
2291    fn align_width_rgb888_packed() {
2292        // RGB888 (bpp=3, lcm(64,3)=192, so width must round to multiple of 64 px).
2293        assert_eq!(align_width_for_gpu_pitch(64, 3), 64); // 192 byte pitch
2294        assert_eq!(align_width_for_gpu_pitch(640, 3), 640); // 1920
2295        assert_eq!(align_width_for_gpu_pitch(1, 3), 64); // 3 → 192
2296        assert_eq!(align_width_for_gpu_pitch(65, 3), 128); // 195 → 384
2297                                                           // Verify the rounded width × bpp is a clean multiple of the LCM.
2298        for w in [3004usize, 1281, 100, 17] {
2299            let padded = align_width_for_gpu_pitch(w, 3);
2300            assert!(padded >= w);
2301            assert_eq!((padded * 3) % 64, 0);
2302            assert_eq!((padded * 3) % 3, 0);
2303        }
2304    }
2305
2306    #[test]
2307    fn align_width_grey_u8() {
2308        // Grey (bpp=1, lcm(64,1)=64, so width must round to multiple of 64 px).
2309        assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
2310        assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
2311        assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
2312        assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
2313    }
2314
2315    #[test]
2316    fn align_width_zero_inputs() {
2317        assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
2318        assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
2319    }
2320
2321    #[test]
2322    fn align_width_never_returns_smaller_than_input() {
2323        // Spot-check the "returned width >= input width" contract across a
2324        // range of values that would previously have hit `width * bpp`
2325        // overflow paths.
2326        for &bpp in &[1usize, 2, 3, 4, 8] {
2327            for &w in &[
2328                1usize,
2329                17,
2330                64,
2331                65,
2332                100,
2333                1280,
2334                1281,
2335                1920,
2336                3004,
2337                3072,
2338                3840,
2339                usize::MAX / 8,
2340                usize::MAX / 4,
2341                usize::MAX / 2,
2342                usize::MAX - 1,
2343                usize::MAX,
2344            ] {
2345                let aligned = align_width_for_gpu_pitch(w, bpp);
2346                assert!(
2347                    aligned >= w,
2348                    "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
2349                );
2350            }
2351        }
2352    }
2353
2354    #[test]
2355    fn align_width_overflow_returns_unaligned_not_smaller() {
2356        // For width values close to usize::MAX, padding up would wrap. The
2357        // function must return the original width rather than wrapping or
2358        // panicking. A pre-aligned width round-trips unchanged even at the
2359        // extreme.
2360        let aligned_extreme = usize::MAX - 15; // 16-pixel boundary for RGBA8
2361        assert_eq!(
2362            align_width_for_gpu_pitch(aligned_extreme, 4),
2363            aligned_extreme
2364        );
2365        // A misaligned extreme value cannot be rounded up — the function
2366        // returns the original.
2367        let misaligned_extreme = usize::MAX - 1;
2368        let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
2369        assert!(
2370            result == misaligned_extreme || result >= misaligned_extreme,
2371            "extreme misaligned width must not be rounded down to {result}"
2372        );
2373    }
2374
2375    #[test]
2376    fn checked_lcm_basic_and_overflow() {
2377        assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
2378        assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
2379        assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
2380        assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
2381        assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
2382        // Coprime values whose product exceeds usize::MAX must return None.
2383        assert_eq!(
2384            checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
2385            None,
2386            "coprime extreme values must overflow detect, not panic"
2387        );
2388    }
2389
2390    #[test]
2391    fn primary_plane_bpp_known_formats() {
2392        // Packed formats use channels × elem_size.
2393        assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
2394        assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
2395        assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
2396        assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
2397        // Semi-planar (NV12) reports the luma plane's bpp.
2398        assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
2399    }
2400}
2401
2402#[cfg(test)]
2403#[cfg_attr(coverage_nightly, coverage(off))]
2404mod image_tests {
2405    use super::*;
2406    use crate::{CPUProcessor, Rotation};
2407    #[cfg(target_os = "linux")]
2408    use edgefirst_tensor::is_dma_available;
2409    use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
2410    use image::buffer::ConvertBuffer;
2411
2412    /// Test helper: call `ImageProcessorTrait::convert()` on two `TensorDyn`s
2413    /// by going through the `TensorDyn` API.
2414    ///
2415    /// Returns the `(src_image, dst_image)` reconstructed from the TensorDyn
2416    /// round-trip so the caller can feed them to `compare_images` etc.
2417    fn convert_img(
2418        proc: &mut dyn ImageProcessorTrait,
2419        src: TensorDyn,
2420        dst: TensorDyn,
2421        rotation: Rotation,
2422        flip: Flip,
2423        crop: Crop,
2424    ) -> (Result<()>, TensorDyn, TensorDyn) {
2425        let src_fourcc = src.format().unwrap();
2426        let dst_fourcc = dst.format().unwrap();
2427        let src_dyn = src;
2428        let mut dst_dyn = dst;
2429        let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
2430        let src_back = {
2431            let mut __t = src_dyn.into_u8().unwrap();
2432            __t.set_format(src_fourcc).unwrap();
2433            TensorDyn::from(__t)
2434        };
2435        let dst_back = {
2436            let mut __t = dst_dyn.into_u8().unwrap();
2437            __t.set_format(dst_fourcc).unwrap();
2438            TensorDyn::from(__t)
2439        };
2440        (result, src_back, dst_back)
2441    }
2442
2443    #[ctor::ctor]
2444    fn init() {
2445        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
2446    }
2447
2448    macro_rules! function {
2449        () => {{
2450            fn f() {}
2451            fn type_name_of<T>(_: T) -> &'static str {
2452                std::any::type_name::<T>()
2453            }
2454            let name = type_name_of(f);
2455
2456            // Find and cut the rest of the path
2457            match &name[..name.len() - 3].rfind(':') {
2458                Some(pos) => &name[pos + 1..name.len() - 3],
2459                None => &name[..name.len() - 3],
2460            }
2461        }};
2462    }
2463
2464    #[test]
2465    fn test_invalid_crop() {
2466        let src = TensorDyn::image(100, 100, PixelFormat::Rgb, DType::U8, None).unwrap();
2467        let dst = TensorDyn::image(100, 100, PixelFormat::Rgb, DType::U8, None).unwrap();
2468
2469        let crop = Crop::new()
2470            .with_src_rect(Some(Rect::new(50, 50, 60, 60)))
2471            .with_dst_rect(Some(Rect::new(0, 0, 150, 150)));
2472
2473        let result = crop.check_crop_dyn(&src, &dst);
2474        assert!(matches!(
2475            result,
2476            Err(Error::CropInvalid(e)) if e.starts_with("Dest and Src crop invalid")
2477        ));
2478
2479        let crop = crop.with_src_rect(Some(Rect::new(0, 0, 10, 10)));
2480        let result = crop.check_crop_dyn(&src, &dst);
2481        assert!(matches!(
2482            result,
2483            Err(Error::CropInvalid(e)) if e.starts_with("Dest crop invalid")
2484        ));
2485
2486        let crop = crop
2487            .with_src_rect(Some(Rect::new(50, 50, 60, 60)))
2488            .with_dst_rect(Some(Rect::new(0, 0, 50, 50)));
2489        let result = crop.check_crop_dyn(&src, &dst);
2490        assert!(matches!(
2491            result,
2492            Err(Error::CropInvalid(e)) if e.starts_with("Src crop invalid")
2493        ));
2494
2495        let crop = crop.with_src_rect(Some(Rect::new(50, 50, 50, 50)));
2496
2497        let result = crop.check_crop_dyn(&src, &dst);
2498        assert!(result.is_ok());
2499    }
2500
2501    #[test]
2502    fn test_invalid_tensor_format() -> Result<(), Error> {
2503        // 4D tensor cannot be set to a 3-channel pixel format
2504        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
2505        let result = tensor.set_format(PixelFormat::Rgb);
2506        assert!(result.is_err(), "4D tensor should reject set_format");
2507
2508        // Tensor with wrong channel count for the format
2509        let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
2510        let result = tensor.set_format(PixelFormat::Rgb);
2511        assert!(result.is_err(), "4-channel tensor should reject RGB format");
2512
2513        Ok(())
2514    }
2515
2516    #[test]
2517    fn test_invalid_image_file() -> Result<(), Error> {
2518        let result = crate::load_image(&[123; 5000], None, None);
2519        assert!(matches!(
2520            result,
2521            Err(Error::NotSupported(e)) if e == "Could not decode as jpeg or png"));
2522
2523        Ok(())
2524    }
2525
2526    #[test]
2527    fn test_invalid_jpeg_format() -> Result<(), Error> {
2528        let result = crate::load_image(&[123; 5000], Some(PixelFormat::Yuyv), None);
2529        assert!(matches!(
2530            result,
2531            Err(Error::NotSupported(e)) if e == "Could not decode as jpeg or png"));
2532
2533        Ok(())
2534    }
2535
2536    #[test]
2537    fn test_load_resize_save() {
2538        let file = include_bytes!(concat!(
2539            env!("CARGO_MANIFEST_DIR"),
2540            "/../../testdata/zidane.jpg"
2541        ));
2542        let img = crate::load_image(file, Some(PixelFormat::Rgba), None).unwrap();
2543        assert_eq!(img.width(), Some(1280));
2544        assert_eq!(img.height(), Some(720));
2545
2546        let dst = TensorDyn::image(640, 360, PixelFormat::Rgba, DType::U8, None).unwrap();
2547        let mut converter = CPUProcessor::new();
2548        let (result, _img, dst) = convert_img(
2549            &mut converter,
2550            img,
2551            dst,
2552            Rotation::None,
2553            Flip::None,
2554            Crop::no_crop(),
2555        );
2556        result.unwrap();
2557        assert_eq!(dst.width(), Some(640));
2558        assert_eq!(dst.height(), Some(360));
2559
2560        crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
2561
2562        let file = std::fs::read("zidane_resized.jpg").unwrap();
2563        let img = crate::load_image(&file, None, None).unwrap();
2564        assert_eq!(img.width(), Some(640));
2565        assert_eq!(img.height(), Some(360));
2566        assert_eq!(img.format().unwrap(), PixelFormat::Rgb);
2567    }
2568
2569    #[test]
2570    fn test_from_tensor_planar() -> Result<(), Error> {
2571        let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
2572        tensor.map()?.copy_from_slice(include_bytes!(concat!(
2573            env!("CARGO_MANIFEST_DIR"),
2574            "/../../testdata/camera720p.8bps"
2575        )));
2576        let planar = {
2577            tensor
2578                .set_format(PixelFormat::PlanarRgb)
2579                .map_err(|e| crate::Error::Internal(e.to_string()))?;
2580            TensorDyn::from(tensor)
2581        };
2582
2583        let rbga = load_bytes_to_tensor(
2584            1280,
2585            720,
2586            PixelFormat::Rgba,
2587            None,
2588            include_bytes!(concat!(
2589                env!("CARGO_MANIFEST_DIR"),
2590                "/../../testdata/camera720p.rgba"
2591            )),
2592        )?;
2593        compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
2594
2595        Ok(())
2596    }
2597
2598    #[test]
2599    fn test_from_tensor_invalid_format() {
2600        // PixelFormat::from_fourcc_str returns None for unknown FourCC codes.
2601        // Since there's no "TEST" pixel format, this validates graceful handling.
2602        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
2603    }
2604
2605    #[test]
2606    #[should_panic(expected = "Failed to save planar RGB image")]
2607    fn test_save_planar() {
2608        let planar_img = load_bytes_to_tensor(
2609            1280,
2610            720,
2611            PixelFormat::PlanarRgb,
2612            None,
2613            include_bytes!(concat!(
2614                env!("CARGO_MANIFEST_DIR"),
2615                "/../../testdata/camera720p.8bps"
2616            )),
2617        )
2618        .unwrap();
2619
2620        let save_path = "/tmp/planar_rgb.jpg";
2621        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
2622    }
2623
2624    #[test]
2625    #[should_panic(expected = "Failed to save YUYV image")]
2626    fn test_save_yuyv() {
2627        let planar_img = load_bytes_to_tensor(
2628            1280,
2629            720,
2630            PixelFormat::Yuyv,
2631            None,
2632            include_bytes!(concat!(
2633                env!("CARGO_MANIFEST_DIR"),
2634                "/../../testdata/camera720p.yuyv"
2635            )),
2636        )
2637        .unwrap();
2638
2639        let save_path = "/tmp/yuyv.jpg";
2640        crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
2641    }
2642
2643    #[test]
2644    fn test_rotation_angle() {
2645        assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
2646        assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
2647        assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
2648        assert_eq!(
2649            Rotation::from_degrees_clockwise(270),
2650            Rotation::CounterClockwise90
2651        );
2652        assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
2653        assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
2654        assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
2655        assert_eq!(
2656            Rotation::from_degrees_clockwise(630),
2657            Rotation::CounterClockwise90
2658        );
2659    }
2660
2661    #[test]
2662    #[should_panic(expected = "rotation angle is not a multiple of 90")]
2663    fn test_rotation_angle_panic() {
2664        Rotation::from_degrees_clockwise(361);
2665    }
2666
2667    #[test]
2668    fn test_disable_env_var() -> Result<(), Error> {
2669        // EDGEFIRST_FORCE_BACKEND takes precedence over EDGEFIRST_DISABLE_*,
2670        // so clear it for the duration of this test to avoid races with
2671        // test_force_backend_cpu running in parallel.
2672        let saved_force = std::env::var("EDGEFIRST_FORCE_BACKEND").ok();
2673        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
2674
2675        #[cfg(target_os = "linux")]
2676        {
2677            let original = std::env::var("EDGEFIRST_DISABLE_G2D").ok();
2678            unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
2679            let converter = ImageProcessor::new()?;
2680            match original {
2681                Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", s) },
2682                None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") },
2683            }
2684            assert!(converter.g2d.is_none());
2685        }
2686
2687        #[cfg(target_os = "linux")]
2688        #[cfg(feature = "opengl")]
2689        {
2690            let original = std::env::var("EDGEFIRST_DISABLE_GL").ok();
2691            unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
2692            let converter = ImageProcessor::new()?;
2693            match original {
2694                Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", s) },
2695                None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") },
2696            }
2697            assert!(converter.opengl.is_none());
2698        }
2699
2700        let original = std::env::var("EDGEFIRST_DISABLE_CPU").ok();
2701        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
2702        let converter = ImageProcessor::new()?;
2703        match original {
2704            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", s) },
2705            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") },
2706        }
2707        assert!(converter.cpu.is_none());
2708
2709        let original_cpu = std::env::var("EDGEFIRST_DISABLE_CPU").ok();
2710        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
2711        let original_gl = std::env::var("EDGEFIRST_DISABLE_GL").ok();
2712        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
2713        let original_g2d = std::env::var("EDGEFIRST_DISABLE_G2D").ok();
2714        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
2715        let mut converter = ImageProcessor::new()?;
2716
2717        let src = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
2718        let dst = TensorDyn::image(640, 360, PixelFormat::Rgba, DType::U8, None)?;
2719        let (result, _src, _dst) = convert_img(
2720            &mut converter,
2721            src,
2722            dst,
2723            Rotation::None,
2724            Flip::None,
2725            Crop::no_crop(),
2726        );
2727        assert!(matches!(result, Err(Error::NoConverter)));
2728
2729        match original_cpu {
2730            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", s) },
2731            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") },
2732        }
2733        match original_gl {
2734            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", s) },
2735            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") },
2736        }
2737        match original_g2d {
2738            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", s) },
2739            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") },
2740        }
2741        match saved_force {
2742            Some(s) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", s) },
2743            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
2744        }
2745
2746        Ok(())
2747    }
2748
2749    #[test]
2750    fn test_unsupported_conversion() {
2751        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
2752        let dst = TensorDyn::image(640, 360, PixelFormat::Nv12, DType::U8, None).unwrap();
2753        let mut converter = ImageProcessor::new().unwrap();
2754        let (result, _src, _dst) = convert_img(
2755            &mut converter,
2756            src,
2757            dst,
2758            Rotation::None,
2759            Flip::None,
2760            Crop::no_crop(),
2761        );
2762        log::debug!("result: {:?}", result);
2763        assert!(matches!(
2764            result,
2765            Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
2766        ));
2767    }
2768
2769    #[test]
2770    fn test_load_grey() {
2771        let grey_img = crate::load_image(
2772            include_bytes!(concat!(
2773                env!("CARGO_MANIFEST_DIR"),
2774                "/../../testdata/grey.jpg"
2775            )),
2776            Some(PixelFormat::Rgba),
2777            None,
2778        )
2779        .unwrap();
2780
2781        let grey_but_rgb_img = crate::load_image(
2782            include_bytes!(concat!(
2783                env!("CARGO_MANIFEST_DIR"),
2784                "/../../testdata/grey-rgb.jpg"
2785            )),
2786            Some(PixelFormat::Rgba),
2787            None,
2788        )
2789        .unwrap();
2790
2791        compare_images(&grey_img, &grey_but_rgb_img, 0.99, function!());
2792    }
2793
2794    #[test]
2795    fn test_new_nv12() {
2796        let nv12 = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
2797        assert_eq!(nv12.height(), Some(720));
2798        assert_eq!(nv12.width(), Some(1280));
2799        assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
2800        // PixelFormat::Nv12.channels() returns 1 (luma plane channel count)
2801        assert_eq!(nv12.format().unwrap().channels(), 1);
2802        assert!(nv12.format().is_some_and(
2803            |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
2804        ))
2805    }
2806
2807    #[test]
2808    #[cfg(target_os = "linux")]
2809    fn test_new_image_converter() {
2810        let dst_width = 640;
2811        let dst_height = 360;
2812        let file = include_bytes!(concat!(
2813            env!("CARGO_MANIFEST_DIR"),
2814            "/../../testdata/zidane.jpg"
2815        ))
2816        .to_vec();
2817        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
2818
2819        let mut converter = ImageProcessor::new().unwrap();
2820        let converter_dst = converter
2821            .create_image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None)
2822            .unwrap();
2823        let (result, src, converter_dst) = convert_img(
2824            &mut converter,
2825            src,
2826            converter_dst,
2827            Rotation::None,
2828            Flip::None,
2829            Crop::no_crop(),
2830        );
2831        result.unwrap();
2832
2833        let cpu_dst =
2834            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
2835        let mut cpu_converter = CPUProcessor::new();
2836        let (result, _src, cpu_dst) = convert_img(
2837            &mut cpu_converter,
2838            src,
2839            cpu_dst,
2840            Rotation::None,
2841            Flip::None,
2842            Crop::no_crop(),
2843        );
2844        result.unwrap();
2845
2846        compare_images(&converter_dst, &cpu_dst, 0.98, function!());
2847    }
2848
2849    #[test]
2850    #[cfg(target_os = "linux")]
2851    fn test_create_image_dtype_i8() {
2852        let mut converter = ImageProcessor::new().unwrap();
2853
2854        // I8 image should allocate successfully via create_image
2855        let dst = converter
2856            .create_image(320, 240, PixelFormat::Rgb, DType::I8, None)
2857            .unwrap();
2858        assert_eq!(dst.dtype(), DType::I8);
2859        assert!(dst.width() == Some(320));
2860        assert!(dst.height() == Some(240));
2861        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
2862
2863        // U8 for comparison
2864        let dst_u8 = converter
2865            .create_image(320, 240, PixelFormat::Rgb, DType::U8, None)
2866            .unwrap();
2867        assert_eq!(dst_u8.dtype(), DType::U8);
2868
2869        // Convert into I8 dst should succeed
2870        let file = include_bytes!(concat!(
2871            env!("CARGO_MANIFEST_DIR"),
2872            "/../../testdata/zidane.jpg"
2873        ))
2874        .to_vec();
2875        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
2876        let mut dst_i8 = converter
2877            .create_image(320, 240, PixelFormat::Rgb, DType::I8, None)
2878            .unwrap();
2879        converter
2880            .convert(
2881                &src,
2882                &mut dst_i8,
2883                Rotation::None,
2884                Flip::None,
2885                Crop::no_crop(),
2886            )
2887            .unwrap();
2888    }
2889
2890    #[test]
2891    #[cfg(target_os = "linux")]
2892    fn test_create_image_nv12_dma_non_aligned_width() {
2893        // Regression for C2: create_image must not apply stride padding to
2894        // non-packed formats. NV12 is semi-planar (PixelLayout::SemiPlanar),
2895        // so the try_dma path should fall through to the plain
2896        // TensorDyn::image allocation for any width, regardless of the
2897        // 64-byte GPU pitch alignment.
2898        let converter = ImageProcessor::new().unwrap();
2899
2900        // 100 is intentionally not a multiple of 64 (the Mali pitch
2901        // alignment) to prove that non-packed layouts do not take the
2902        // stride-padded branch.
2903        let result = converter.create_image(
2904            100,
2905            64,
2906            PixelFormat::Nv12,
2907            DType::U8,
2908            Some(TensorMemory::Dma),
2909        );
2910
2911        match result {
2912            Ok(img) => {
2913                assert_eq!(img.width(), Some(100));
2914                assert_eq!(img.height(), Some(64));
2915                assert_eq!(img.format(), Some(PixelFormat::Nv12));
2916                // Non-packed formats must never carry a row_stride override.
2917                assert!(
2918                    img.row_stride().is_none(),
2919                    "NV12 must not be stride-padded by create_image",
2920                );
2921            }
2922            Err(e) => {
2923                // Accept skip on hosts without a dma-heap, but never the
2924                // "NotImplemented" we used to return for non-packed layouts.
2925                let msg = format!("{e}");
2926                assert!(
2927                    !msg.contains("image_with_stride"),
2928                    "NV12 should not hit the stride-padded path: {msg}",
2929                );
2930            }
2931        }
2932    }
2933
2934    #[test]
2935    #[ignore] // Hangs on desktop platforms where DMA-buf is unavailable and PBO
2936              // fallback triggers a GPU driver hang during SHM→texture upload (e.g.,
2937              // NVIDIA without /dev/dma_heap permissions). Works on embedded targets.
2938    fn test_crop_skip() {
2939        let file = include_bytes!(concat!(
2940            env!("CARGO_MANIFEST_DIR"),
2941            "/../../testdata/zidane.jpg"
2942        ))
2943        .to_vec();
2944        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
2945
2946        let mut converter = ImageProcessor::new().unwrap();
2947        let converter_dst = converter
2948            .create_image(1280, 720, PixelFormat::Rgba, DType::U8, None)
2949            .unwrap();
2950        let crop = Crop::new()
2951            .with_src_rect(Some(Rect::new(0, 0, 640, 640)))
2952            .with_dst_rect(Some(Rect::new(0, 0, 640, 640)));
2953        let (result, src, converter_dst) = convert_img(
2954            &mut converter,
2955            src,
2956            converter_dst,
2957            Rotation::None,
2958            Flip::None,
2959            crop,
2960        );
2961        result.unwrap();
2962
2963        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
2964        let mut cpu_converter = CPUProcessor::new();
2965        let (result, _src, cpu_dst) = convert_img(
2966            &mut cpu_converter,
2967            src,
2968            cpu_dst,
2969            Rotation::None,
2970            Flip::None,
2971            crop,
2972        );
2973        result.unwrap();
2974
2975        compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
2976    }
2977
2978    #[test]
2979    fn test_invalid_pixel_format() {
2980        // PixelFormat::from_fourcc returns None for unknown formats,
2981        // so TensorDyn::image cannot be called with an invalid format.
2982        assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
2983    }
2984
2985    // Helper function to check if G2D library is available (Linux/i.MX8 only)
2986    #[cfg(target_os = "linux")]
2987    static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2988
2989    #[cfg(target_os = "linux")]
2990    fn is_g2d_available() -> bool {
2991        *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
2992    }
2993
2994    #[cfg(target_os = "linux")]
2995    #[cfg(feature = "opengl")]
2996    static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2997
2998    #[cfg(target_os = "linux")]
2999    #[cfg(feature = "opengl")]
3000    // Helper function to check if OpenGL is available
3001    fn is_opengl_available() -> bool {
3002        #[cfg(all(target_os = "linux", feature = "opengl"))]
3003        {
3004            *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
3005        }
3006
3007        #[cfg(not(all(target_os = "linux", feature = "opengl")))]
3008        {
3009            false
3010        }
3011    }
3012
3013    #[test]
3014    fn test_load_jpeg_with_exif() {
3015        let file = include_bytes!(concat!(
3016            env!("CARGO_MANIFEST_DIR"),
3017            "/../../testdata/zidane_rotated_exif.jpg"
3018        ))
3019        .to_vec();
3020        let loaded = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3021
3022        assert_eq!(loaded.height(), Some(1280));
3023        assert_eq!(loaded.width(), Some(720));
3024
3025        let file = include_bytes!(concat!(
3026            env!("CARGO_MANIFEST_DIR"),
3027            "/../../testdata/zidane.jpg"
3028        ))
3029        .to_vec();
3030        let cpu_src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3031
3032        let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
3033
3034        let cpu_dst =
3035            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3036        let mut cpu_converter = CPUProcessor::new();
3037
3038        let (result, _cpu_src, cpu_dst) = convert_img(
3039            &mut cpu_converter,
3040            cpu_src,
3041            cpu_dst,
3042            Rotation::Clockwise90,
3043            Flip::None,
3044            Crop::no_crop(),
3045        );
3046        result.unwrap();
3047
3048        compare_images(&loaded, &cpu_dst, 0.98, function!());
3049    }
3050
3051    #[test]
3052    fn test_load_png_with_exif() {
3053        let file = include_bytes!(concat!(
3054            env!("CARGO_MANIFEST_DIR"),
3055            "/../../testdata/zidane_rotated_exif_180.png"
3056        ))
3057        .to_vec();
3058        let loaded = crate::load_png(&file, Some(PixelFormat::Rgba), None).unwrap();
3059
3060        assert_eq!(loaded.height(), Some(720));
3061        assert_eq!(loaded.width(), Some(1280));
3062
3063        let file = include_bytes!(concat!(
3064            env!("CARGO_MANIFEST_DIR"),
3065            "/../../testdata/zidane.jpg"
3066        ))
3067        .to_vec();
3068        let cpu_src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3069
3070        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3071        let mut cpu_converter = CPUProcessor::new();
3072
3073        let (result, _cpu_src, cpu_dst) = convert_img(
3074            &mut cpu_converter,
3075            cpu_src,
3076            cpu_dst,
3077            Rotation::Rotate180,
3078            Flip::None,
3079            Crop::no_crop(),
3080        );
3081        result.unwrap();
3082
3083        compare_images(&loaded, &cpu_dst, 0.98, function!());
3084    }
3085
3086    #[test]
3087    #[cfg(target_os = "linux")]
3088    fn test_g2d_resize() {
3089        if !is_g2d_available() {
3090            eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
3091            return;
3092        }
3093        if !is_dma_available() {
3094            eprintln!(
3095                "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
3096            );
3097            return;
3098        }
3099
3100        let dst_width = 640;
3101        let dst_height = 360;
3102        let file = include_bytes!(concat!(
3103            env!("CARGO_MANIFEST_DIR"),
3104            "/../../testdata/zidane.jpg"
3105        ))
3106        .to_vec();
3107        let src =
3108            crate::load_image(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma)).unwrap();
3109
3110        let g2d_dst = TensorDyn::image(
3111            dst_width,
3112            dst_height,
3113            PixelFormat::Rgba,
3114            DType::U8,
3115            Some(TensorMemory::Dma),
3116        )
3117        .unwrap();
3118        let mut g2d_converter = G2DProcessor::new().unwrap();
3119        let (result, src, g2d_dst) = convert_img(
3120            &mut g2d_converter,
3121            src,
3122            g2d_dst,
3123            Rotation::None,
3124            Flip::None,
3125            Crop::no_crop(),
3126        );
3127        result.unwrap();
3128
3129        let cpu_dst =
3130            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3131        let mut cpu_converter = CPUProcessor::new();
3132        let (result, _src, cpu_dst) = convert_img(
3133            &mut cpu_converter,
3134            src,
3135            cpu_dst,
3136            Rotation::None,
3137            Flip::None,
3138            Crop::no_crop(),
3139        );
3140        result.unwrap();
3141
3142        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
3143    }
3144
3145    #[test]
3146    #[cfg(target_os = "linux")]
3147    #[cfg(feature = "opengl")]
3148    fn test_opengl_resize() {
3149        if !is_opengl_available() {
3150            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3151            return;
3152        }
3153
3154        let dst_width = 640;
3155        let dst_height = 360;
3156        let file = include_bytes!(concat!(
3157            env!("CARGO_MANIFEST_DIR"),
3158            "/../../testdata/zidane.jpg"
3159        ))
3160        .to_vec();
3161        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3162
3163        let cpu_dst =
3164            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3165        let mut cpu_converter = CPUProcessor::new();
3166        let (result, src, cpu_dst) = convert_img(
3167            &mut cpu_converter,
3168            src,
3169            cpu_dst,
3170            Rotation::None,
3171            Flip::None,
3172            Crop::no_crop(),
3173        );
3174        result.unwrap();
3175
3176        let mut src = src;
3177        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
3178
3179        for _ in 0..5 {
3180            let gl_dst =
3181                TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None)
3182                    .unwrap();
3183            let (result, src_back, gl_dst) = convert_img(
3184                &mut gl_converter,
3185                src,
3186                gl_dst,
3187                Rotation::None,
3188                Flip::None,
3189                Crop::no_crop(),
3190            );
3191            result.unwrap();
3192            src = src_back;
3193
3194            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
3195        }
3196    }
3197
3198    #[test]
3199    #[cfg(target_os = "linux")]
3200    #[cfg(feature = "opengl")]
3201    fn test_opengl_10_threads() {
3202        if !is_opengl_available() {
3203            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3204            return;
3205        }
3206
3207        let handles: Vec<_> = (0..10)
3208            .map(|i| {
3209                std::thread::Builder::new()
3210                    .name(format!("Thread {i}"))
3211                    .spawn(test_opengl_resize)
3212                    .unwrap()
3213            })
3214            .collect();
3215        handles.into_iter().for_each(|h| {
3216            if let Err(e) = h.join() {
3217                std::panic::resume_unwind(e)
3218            }
3219        });
3220    }
3221
3222    #[test]
3223    #[cfg(target_os = "linux")]
3224    #[cfg(feature = "opengl")]
3225    fn test_opengl_grey() {
3226        if !is_opengl_available() {
3227            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3228            return;
3229        }
3230
3231        let img = crate::load_image(
3232            include_bytes!(concat!(
3233                env!("CARGO_MANIFEST_DIR"),
3234                "/../../testdata/grey.jpg"
3235            )),
3236            Some(PixelFormat::Grey),
3237            None,
3238        )
3239        .unwrap();
3240
3241        let gl_dst = TensorDyn::image(640, 640, PixelFormat::Grey, DType::U8, None).unwrap();
3242        let cpu_dst = TensorDyn::image(640, 640, PixelFormat::Grey, DType::U8, None).unwrap();
3243
3244        let mut converter = CPUProcessor::new();
3245
3246        let (result, img, cpu_dst) = convert_img(
3247            &mut converter,
3248            img,
3249            cpu_dst,
3250            Rotation::None,
3251            Flip::None,
3252            Crop::no_crop(),
3253        );
3254        result.unwrap();
3255
3256        let mut gl = GLProcessorThreaded::new(None).unwrap();
3257        let (result, _img, gl_dst) = convert_img(
3258            &mut gl,
3259            img,
3260            gl_dst,
3261            Rotation::None,
3262            Flip::None,
3263            Crop::no_crop(),
3264        );
3265        result.unwrap();
3266
3267        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
3268    }
3269
3270    #[test]
3271    #[cfg(target_os = "linux")]
3272    fn test_g2d_src_crop() {
3273        if !is_g2d_available() {
3274            eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
3275            return;
3276        }
3277        if !is_dma_available() {
3278            eprintln!(
3279                "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
3280            );
3281            return;
3282        }
3283
3284        let dst_width = 640;
3285        let dst_height = 640;
3286        let file = include_bytes!(concat!(
3287            env!("CARGO_MANIFEST_DIR"),
3288            "/../../testdata/zidane.jpg"
3289        ))
3290        .to_vec();
3291        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3292
3293        let cpu_dst =
3294            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3295        let mut cpu_converter = CPUProcessor::new();
3296        let crop = Crop {
3297            src_rect: Some(Rect {
3298                left: 0,
3299                top: 0,
3300                width: 640,
3301                height: 360,
3302            }),
3303            dst_rect: None,
3304            dst_color: None,
3305        };
3306        let (result, src, cpu_dst) = convert_img(
3307            &mut cpu_converter,
3308            src,
3309            cpu_dst,
3310            Rotation::None,
3311            Flip::None,
3312            crop,
3313        );
3314        result.unwrap();
3315
3316        let g2d_dst =
3317            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3318        let mut g2d_converter = G2DProcessor::new().unwrap();
3319        let (result, _src, g2d_dst) = convert_img(
3320            &mut g2d_converter,
3321            src,
3322            g2d_dst,
3323            Rotation::None,
3324            Flip::None,
3325            crop,
3326        );
3327        result.unwrap();
3328
3329        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
3330    }
3331
3332    #[test]
3333    #[cfg(target_os = "linux")]
3334    fn test_g2d_dst_crop() {
3335        if !is_g2d_available() {
3336            eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
3337            return;
3338        }
3339        if !is_dma_available() {
3340            eprintln!(
3341                "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
3342            );
3343            return;
3344        }
3345
3346        let dst_width = 640;
3347        let dst_height = 640;
3348        let file = include_bytes!(concat!(
3349            env!("CARGO_MANIFEST_DIR"),
3350            "/../../testdata/zidane.jpg"
3351        ))
3352        .to_vec();
3353        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3354
3355        let cpu_dst =
3356            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3357        let mut cpu_converter = CPUProcessor::new();
3358        let crop = Crop {
3359            src_rect: None,
3360            dst_rect: Some(Rect::new(100, 100, 512, 288)),
3361            dst_color: None,
3362        };
3363        let (result, src, cpu_dst) = convert_img(
3364            &mut cpu_converter,
3365            src,
3366            cpu_dst,
3367            Rotation::None,
3368            Flip::None,
3369            crop,
3370        );
3371        result.unwrap();
3372
3373        let g2d_dst =
3374            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3375        let mut g2d_converter = G2DProcessor::new().unwrap();
3376        let (result, _src, g2d_dst) = convert_img(
3377            &mut g2d_converter,
3378            src,
3379            g2d_dst,
3380            Rotation::None,
3381            Flip::None,
3382            crop,
3383        );
3384        result.unwrap();
3385
3386        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
3387    }
3388
3389    #[test]
3390    #[cfg(target_os = "linux")]
3391    fn test_g2d_all_rgba() {
3392        if !is_g2d_available() {
3393            eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
3394            return;
3395        }
3396        if !is_dma_available() {
3397            eprintln!(
3398                "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
3399            );
3400            return;
3401        }
3402
3403        let dst_width = 640;
3404        let dst_height = 640;
3405        let file = include_bytes!(concat!(
3406            env!("CARGO_MANIFEST_DIR"),
3407            "/../../testdata/zidane.jpg"
3408        ))
3409        .to_vec();
3410        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3411        let src_dyn = src;
3412
3413        let mut cpu_dst =
3414            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3415        let mut cpu_converter = CPUProcessor::new();
3416        let mut g2d_dst =
3417            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3418        let mut g2d_converter = G2DProcessor::new().unwrap();
3419
3420        let crop = Crop {
3421            src_rect: Some(Rect::new(50, 120, 1024, 576)),
3422            dst_rect: Some(Rect::new(100, 100, 512, 288)),
3423            dst_color: None,
3424        };
3425
3426        for rot in [
3427            Rotation::None,
3428            Rotation::Clockwise90,
3429            Rotation::Rotate180,
3430            Rotation::CounterClockwise90,
3431        ] {
3432            cpu_dst
3433                .as_u8()
3434                .unwrap()
3435                .map()
3436                .unwrap()
3437                .as_mut_slice()
3438                .fill(114);
3439            g2d_dst
3440                .as_u8()
3441                .unwrap()
3442                .map()
3443                .unwrap()
3444                .as_mut_slice()
3445                .fill(114);
3446            for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
3447                let mut cpu_dst_dyn = cpu_dst;
3448                cpu_converter
3449                    .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
3450                    .unwrap();
3451                cpu_dst = {
3452                    let mut __t = cpu_dst_dyn.into_u8().unwrap();
3453                    __t.set_format(PixelFormat::Rgba).unwrap();
3454                    TensorDyn::from(__t)
3455                };
3456
3457                let mut g2d_dst_dyn = g2d_dst;
3458                g2d_converter
3459                    .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
3460                    .unwrap();
3461                g2d_dst = {
3462                    let mut __t = g2d_dst_dyn.into_u8().unwrap();
3463                    __t.set_format(PixelFormat::Rgba).unwrap();
3464                    TensorDyn::from(__t)
3465                };
3466
3467                compare_images(
3468                    &g2d_dst,
3469                    &cpu_dst,
3470                    0.98,
3471                    &format!("{} {:?} {:?}", function!(), rot, flip),
3472                );
3473            }
3474        }
3475    }
3476
3477    #[test]
3478    #[cfg(target_os = "linux")]
3479    #[cfg(feature = "opengl")]
3480    fn test_opengl_src_crop() {
3481        if !is_opengl_available() {
3482            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3483            return;
3484        }
3485
3486        let dst_width = 640;
3487        let dst_height = 360;
3488        let file = include_bytes!(concat!(
3489            env!("CARGO_MANIFEST_DIR"),
3490            "/../../testdata/zidane.jpg"
3491        ))
3492        .to_vec();
3493        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3494        let crop = Crop {
3495            src_rect: Some(Rect {
3496                left: 320,
3497                top: 180,
3498                width: 1280 - 320,
3499                height: 720 - 180,
3500            }),
3501            dst_rect: None,
3502            dst_color: None,
3503        };
3504
3505        let cpu_dst =
3506            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3507        let mut cpu_converter = CPUProcessor::new();
3508        let (result, src, cpu_dst) = convert_img(
3509            &mut cpu_converter,
3510            src,
3511            cpu_dst,
3512            Rotation::None,
3513            Flip::None,
3514            crop,
3515        );
3516        result.unwrap();
3517
3518        let gl_dst =
3519            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3520        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
3521        let (result, _src, gl_dst) = convert_img(
3522            &mut gl_converter,
3523            src,
3524            gl_dst,
3525            Rotation::None,
3526            Flip::None,
3527            crop,
3528        );
3529        result.unwrap();
3530
3531        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
3532    }
3533
3534    #[test]
3535    #[cfg(target_os = "linux")]
3536    #[cfg(feature = "opengl")]
3537    fn test_opengl_dst_crop() {
3538        if !is_opengl_available() {
3539            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3540            return;
3541        }
3542
3543        let dst_width = 640;
3544        let dst_height = 640;
3545        let file = include_bytes!(concat!(
3546            env!("CARGO_MANIFEST_DIR"),
3547            "/../../testdata/zidane.jpg"
3548        ))
3549        .to_vec();
3550        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3551
3552        let cpu_dst =
3553            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3554        let mut cpu_converter = CPUProcessor::new();
3555        let crop = Crop {
3556            src_rect: None,
3557            dst_rect: Some(Rect::new(100, 100, 512, 288)),
3558            dst_color: None,
3559        };
3560        let (result, src, cpu_dst) = convert_img(
3561            &mut cpu_converter,
3562            src,
3563            cpu_dst,
3564            Rotation::None,
3565            Flip::None,
3566            crop,
3567        );
3568        result.unwrap();
3569
3570        let gl_dst =
3571            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3572        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
3573        let (result, _src, gl_dst) = convert_img(
3574            &mut gl_converter,
3575            src,
3576            gl_dst,
3577            Rotation::None,
3578            Flip::None,
3579            crop,
3580        );
3581        result.unwrap();
3582
3583        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
3584    }
3585
3586    #[test]
3587    #[cfg(target_os = "linux")]
3588    #[cfg(feature = "opengl")]
3589    fn test_opengl_all_rgba() {
3590        if !is_opengl_available() {
3591            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3592            return;
3593        }
3594
3595        let dst_width = 640;
3596        let dst_height = 640;
3597        let file = include_bytes!(concat!(
3598            env!("CARGO_MANIFEST_DIR"),
3599            "/../../testdata/zidane.jpg"
3600        ))
3601        .to_vec();
3602
3603        let mut cpu_converter = CPUProcessor::new();
3604
3605        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
3606
3607        let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
3608        if is_dma_available() {
3609            mem.push(Some(TensorMemory::Dma));
3610        }
3611        let crop = Crop {
3612            src_rect: Some(Rect::new(50, 120, 1024, 576)),
3613            dst_rect: Some(Rect::new(100, 100, 512, 288)),
3614            dst_color: None,
3615        };
3616        for m in mem {
3617            let src = crate::load_image(&file, Some(PixelFormat::Rgba), m).unwrap();
3618            let src_dyn = src;
3619
3620            for rot in [
3621                Rotation::None,
3622                Rotation::Clockwise90,
3623                Rotation::Rotate180,
3624                Rotation::CounterClockwise90,
3625            ] {
3626                for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
3627                    let cpu_dst =
3628                        TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, m)
3629                            .unwrap();
3630                    let gl_dst =
3631                        TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, m)
3632                            .unwrap();
3633                    cpu_dst
3634                        .as_u8()
3635                        .unwrap()
3636                        .map()
3637                        .unwrap()
3638                        .as_mut_slice()
3639                        .fill(114);
3640                    gl_dst
3641                        .as_u8()
3642                        .unwrap()
3643                        .map()
3644                        .unwrap()
3645                        .as_mut_slice()
3646                        .fill(114);
3647
3648                    let mut cpu_dst_dyn = cpu_dst;
3649                    cpu_converter
3650                        .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
3651                        .unwrap();
3652                    let cpu_dst = {
3653                        let mut __t = cpu_dst_dyn.into_u8().unwrap();
3654                        __t.set_format(PixelFormat::Rgba).unwrap();
3655                        TensorDyn::from(__t)
3656                    };
3657
3658                    let mut gl_dst_dyn = gl_dst;
3659                    gl_converter
3660                        .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
3661                        .map_err(|e| {
3662                            log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
3663                            e
3664                        })
3665                        .unwrap();
3666                    let gl_dst = {
3667                        let mut __t = gl_dst_dyn.into_u8().unwrap();
3668                        __t.set_format(PixelFormat::Rgba).unwrap();
3669                        TensorDyn::from(__t)
3670                    };
3671
3672                    compare_images(
3673                        &gl_dst,
3674                        &cpu_dst,
3675                        0.98,
3676                        &format!("{} {:?} {:?}", function!(), rot, flip),
3677                    );
3678                }
3679            }
3680        }
3681    }
3682
3683    #[test]
3684    #[cfg(target_os = "linux")]
3685    fn test_cpu_rotate() {
3686        for rot in [
3687            Rotation::Clockwise90,
3688            Rotation::Rotate180,
3689            Rotation::CounterClockwise90,
3690        ] {
3691            test_cpu_rotate_(rot);
3692        }
3693    }
3694
3695    #[cfg(target_os = "linux")]
3696    fn test_cpu_rotate_(rot: Rotation) {
3697        // This test rotates the image 4 times and checks that the image was returned to
3698        // be the same Currently doesn't check if rotations actually rotated in
3699        // right direction
3700        let file = include_bytes!(concat!(
3701            env!("CARGO_MANIFEST_DIR"),
3702            "/../../testdata/zidane.jpg"
3703        ))
3704        .to_vec();
3705
3706        let unchanged_src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3707        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
3708
3709        let (dst_width, dst_height) = match rot {
3710            Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
3711            Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
3712                (src.height().unwrap(), src.width().unwrap())
3713            }
3714        };
3715
3716        let cpu_dst =
3717            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3718        let mut cpu_converter = CPUProcessor::new();
3719
3720        // After rotating 4 times, the image should be the same as the original
3721
3722        let (result, src, cpu_dst) = convert_img(
3723            &mut cpu_converter,
3724            src,
3725            cpu_dst,
3726            rot,
3727            Flip::None,
3728            Crop::no_crop(),
3729        );
3730        result.unwrap();
3731
3732        let (result, cpu_dst, src) = convert_img(
3733            &mut cpu_converter,
3734            cpu_dst,
3735            src,
3736            rot,
3737            Flip::None,
3738            Crop::no_crop(),
3739        );
3740        result.unwrap();
3741
3742        let (result, src, cpu_dst) = convert_img(
3743            &mut cpu_converter,
3744            src,
3745            cpu_dst,
3746            rot,
3747            Flip::None,
3748            Crop::no_crop(),
3749        );
3750        result.unwrap();
3751
3752        let (result, _cpu_dst, src) = convert_img(
3753            &mut cpu_converter,
3754            cpu_dst,
3755            src,
3756            rot,
3757            Flip::None,
3758            Crop::no_crop(),
3759        );
3760        result.unwrap();
3761
3762        compare_images(&src, &unchanged_src, 0.98, function!());
3763    }
3764
3765    #[test]
3766    #[cfg(target_os = "linux")]
3767    #[cfg(feature = "opengl")]
3768    fn test_opengl_rotate() {
3769        if !is_opengl_available() {
3770            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3771            return;
3772        }
3773
3774        let size = (1280, 720);
3775        let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
3776
3777        if is_dma_available() {
3778            mem.push(Some(TensorMemory::Dma));
3779        }
3780        for m in mem {
3781            for rot in [
3782                Rotation::Clockwise90,
3783                Rotation::Rotate180,
3784                Rotation::CounterClockwise90,
3785            ] {
3786                test_opengl_rotate_(size, rot, m);
3787            }
3788        }
3789    }
3790
3791    #[cfg(target_os = "linux")]
3792    #[cfg(feature = "opengl")]
3793    fn test_opengl_rotate_(
3794        size: (usize, usize),
3795        rot: Rotation,
3796        tensor_memory: Option<TensorMemory>,
3797    ) {
3798        let (dst_width, dst_height) = match rot {
3799            Rotation::None | Rotation::Rotate180 => size,
3800            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
3801        };
3802
3803        let file = include_bytes!(concat!(
3804            env!("CARGO_MANIFEST_DIR"),
3805            "/../../testdata/zidane.jpg"
3806        ))
3807        .to_vec();
3808        let src = crate::load_image(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
3809
3810        let cpu_dst =
3811            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3812        let mut cpu_converter = CPUProcessor::new();
3813
3814        let (result, mut src, cpu_dst) = convert_img(
3815            &mut cpu_converter,
3816            src,
3817            cpu_dst,
3818            rot,
3819            Flip::None,
3820            Crop::no_crop(),
3821        );
3822        result.unwrap();
3823
3824        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
3825
3826        for _ in 0..5 {
3827            let gl_dst = TensorDyn::image(
3828                dst_width,
3829                dst_height,
3830                PixelFormat::Rgba,
3831                DType::U8,
3832                tensor_memory,
3833            )
3834            .unwrap();
3835            let (result, src_back, gl_dst) = convert_img(
3836                &mut gl_converter,
3837                src,
3838                gl_dst,
3839                rot,
3840                Flip::None,
3841                Crop::no_crop(),
3842            );
3843            result.unwrap();
3844            src = src_back;
3845            compare_images(&gl_dst, &cpu_dst, 0.98, function!());
3846        }
3847    }
3848
3849    #[test]
3850    #[cfg(target_os = "linux")]
3851    fn test_g2d_rotate() {
3852        if !is_g2d_available() {
3853            eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
3854            return;
3855        }
3856        if !is_dma_available() {
3857            eprintln!(
3858                "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
3859            );
3860            return;
3861        }
3862
3863        let size = (1280, 720);
3864        for rot in [
3865            Rotation::Clockwise90,
3866            Rotation::Rotate180,
3867            Rotation::CounterClockwise90,
3868        ] {
3869            test_g2d_rotate_(size, rot);
3870        }
3871    }
3872
3873    #[cfg(target_os = "linux")]
3874    fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
3875        let (dst_width, dst_height) = match rot {
3876            Rotation::None | Rotation::Rotate180 => size,
3877            Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
3878        };
3879
3880        let file = include_bytes!(concat!(
3881            env!("CARGO_MANIFEST_DIR"),
3882            "/../../testdata/zidane.jpg"
3883        ))
3884        .to_vec();
3885        let src =
3886            crate::load_image(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma)).unwrap();
3887
3888        let cpu_dst =
3889            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3890        let mut cpu_converter = CPUProcessor::new();
3891
3892        let (result, src, cpu_dst) = convert_img(
3893            &mut cpu_converter,
3894            src,
3895            cpu_dst,
3896            rot,
3897            Flip::None,
3898            Crop::no_crop(),
3899        );
3900        result.unwrap();
3901
3902        let g2d_dst = TensorDyn::image(
3903            dst_width,
3904            dst_height,
3905            PixelFormat::Rgba,
3906            DType::U8,
3907            Some(TensorMemory::Dma),
3908        )
3909        .unwrap();
3910        let mut g2d_converter = G2DProcessor::new().unwrap();
3911
3912        let (result, _src, g2d_dst) = convert_img(
3913            &mut g2d_converter,
3914            src,
3915            g2d_dst,
3916            rot,
3917            Flip::None,
3918            Crop::no_crop(),
3919        );
3920        result.unwrap();
3921
3922        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
3923    }
3924
3925    #[test]
3926    fn test_rgba_to_yuyv_resize_cpu() {
3927        let src = load_bytes_to_tensor(
3928            1280,
3929            720,
3930            PixelFormat::Rgba,
3931            None,
3932            include_bytes!(concat!(
3933                env!("CARGO_MANIFEST_DIR"),
3934                "/../../testdata/camera720p.rgba"
3935            )),
3936        )
3937        .unwrap();
3938
3939        let (dst_width, dst_height) = (640, 360);
3940
3941        let dst =
3942            TensorDyn::image(dst_width, dst_height, PixelFormat::Yuyv, DType::U8, None).unwrap();
3943
3944        let dst_through_yuyv =
3945            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3946        let dst_direct =
3947            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3948
3949        let mut cpu_converter = CPUProcessor::new();
3950
3951        let (result, src, dst) = convert_img(
3952            &mut cpu_converter,
3953            src,
3954            dst,
3955            Rotation::None,
3956            Flip::None,
3957            Crop::no_crop(),
3958        );
3959        result.unwrap();
3960
3961        let (result, _dst, dst_through_yuyv) = convert_img(
3962            &mut cpu_converter,
3963            dst,
3964            dst_through_yuyv,
3965            Rotation::None,
3966            Flip::None,
3967            Crop::no_crop(),
3968        );
3969        result.unwrap();
3970
3971        let (result, _src, dst_direct) = convert_img(
3972            &mut cpu_converter,
3973            src,
3974            dst_direct,
3975            Rotation::None,
3976            Flip::None,
3977            Crop::no_crop(),
3978        );
3979        result.unwrap();
3980
3981        compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
3982    }
3983
3984    #[test]
3985    #[cfg(target_os = "linux")]
3986    #[cfg(feature = "opengl")]
3987    #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
3988    fn test_rgba_to_yuyv_resize_opengl() {
3989        if !is_opengl_available() {
3990            eprintln!("SKIPPED: {} - OpenGL not available", function!());
3991            return;
3992        }
3993
3994        if !is_dma_available() {
3995            eprintln!(
3996                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
3997                function!()
3998            );
3999            return;
4000        }
4001
4002        let src = load_bytes_to_tensor(
4003            1280,
4004            720,
4005            PixelFormat::Rgba,
4006            None,
4007            include_bytes!(concat!(
4008                env!("CARGO_MANIFEST_DIR"),
4009                "/../../testdata/camera720p.rgba"
4010            )),
4011        )
4012        .unwrap();
4013
4014        let (dst_width, dst_height) = (640, 360);
4015
4016        let dst = TensorDyn::image(
4017            dst_width,
4018            dst_height,
4019            PixelFormat::Yuyv,
4020            DType::U8,
4021            Some(TensorMemory::Dma),
4022        )
4023        .unwrap();
4024
4025        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4026
4027        let (result, src, dst) = convert_img(
4028            &mut gl_converter,
4029            src,
4030            dst,
4031            Rotation::None,
4032            Flip::None,
4033            Crop::new()
4034                .with_dst_rect(Some(Rect::new(100, 100, 100, 100)))
4035                .with_dst_color(Some([255, 255, 255, 255])),
4036        );
4037        result.unwrap();
4038
4039        std::fs::write(
4040            "rgba_to_yuyv_opengl.yuyv",
4041            dst.as_u8().unwrap().map().unwrap().as_slice(),
4042        )
4043        .unwrap();
4044        let cpu_dst = TensorDyn::image(
4045            dst_width,
4046            dst_height,
4047            PixelFormat::Yuyv,
4048            DType::U8,
4049            Some(TensorMemory::Dma),
4050        )
4051        .unwrap();
4052        let (result, _src, cpu_dst) = convert_img(
4053            &mut CPUProcessor::new(),
4054            src,
4055            cpu_dst,
4056            Rotation::None,
4057            Flip::None,
4058            Crop::no_crop(),
4059        );
4060        result.unwrap();
4061
4062        compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
4063    }
4064
4065    #[test]
4066    #[cfg(target_os = "linux")]
4067    fn test_rgba_to_yuyv_resize_g2d() {
4068        if !is_g2d_available() {
4069            eprintln!(
4070                "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
4071            );
4072            return;
4073        }
4074        if !is_dma_available() {
4075            eprintln!(
4076                "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4077            );
4078            return;
4079        }
4080
4081        let src = load_bytes_to_tensor(
4082            1280,
4083            720,
4084            PixelFormat::Rgba,
4085            Some(TensorMemory::Dma),
4086            include_bytes!(concat!(
4087                env!("CARGO_MANIFEST_DIR"),
4088                "/../../testdata/camera720p.rgba"
4089            )),
4090        )
4091        .unwrap();
4092
4093        let (dst_width, dst_height) = (1280, 720);
4094
4095        let cpu_dst = TensorDyn::image(
4096            dst_width,
4097            dst_height,
4098            PixelFormat::Yuyv,
4099            DType::U8,
4100            Some(TensorMemory::Dma),
4101        )
4102        .unwrap();
4103
4104        let g2d_dst = TensorDyn::image(
4105            dst_width,
4106            dst_height,
4107            PixelFormat::Yuyv,
4108            DType::U8,
4109            Some(TensorMemory::Dma),
4110        )
4111        .unwrap();
4112
4113        let mut g2d_converter = G2DProcessor::new().unwrap();
4114        let crop = Crop {
4115            src_rect: None,
4116            dst_rect: Some(Rect::new(100, 100, 2, 2)),
4117            dst_color: None,
4118        };
4119
4120        g2d_dst
4121            .as_u8()
4122            .unwrap()
4123            .map()
4124            .unwrap()
4125            .as_mut_slice()
4126            .fill(128);
4127        let (result, src, g2d_dst) = convert_img(
4128            &mut g2d_converter,
4129            src,
4130            g2d_dst,
4131            Rotation::None,
4132            Flip::None,
4133            crop,
4134        );
4135        result.unwrap();
4136
4137        let cpu_dst_img = cpu_dst;
4138        cpu_dst_img
4139            .as_u8()
4140            .unwrap()
4141            .map()
4142            .unwrap()
4143            .as_mut_slice()
4144            .fill(128);
4145        let (result, _src, cpu_dst) = convert_img(
4146            &mut CPUProcessor::new(),
4147            src,
4148            cpu_dst_img,
4149            Rotation::None,
4150            Flip::None,
4151            crop,
4152        );
4153        result.unwrap();
4154
4155        compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
4156    }
4157
4158    #[test]
4159    fn test_yuyv_to_rgba_cpu() {
4160        let file = include_bytes!(concat!(
4161            env!("CARGO_MANIFEST_DIR"),
4162            "/../../testdata/camera720p.yuyv"
4163        ))
4164        .to_vec();
4165        let src = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
4166        src.as_u8()
4167            .unwrap()
4168            .map()
4169            .unwrap()
4170            .as_mut_slice()
4171            .copy_from_slice(&file);
4172
4173        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4174        let mut cpu_converter = CPUProcessor::new();
4175
4176        let (result, _src, dst) = convert_img(
4177            &mut cpu_converter,
4178            src,
4179            dst,
4180            Rotation::None,
4181            Flip::None,
4182            Crop::no_crop(),
4183        );
4184        result.unwrap();
4185
4186        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4187        target_image
4188            .as_u8()
4189            .unwrap()
4190            .map()
4191            .unwrap()
4192            .as_mut_slice()
4193            .copy_from_slice(include_bytes!(concat!(
4194                env!("CARGO_MANIFEST_DIR"),
4195                "/../../testdata/camera720p.rgba"
4196            )));
4197
4198        compare_images(&dst, &target_image, 0.98, function!());
4199    }
4200
4201    #[test]
4202    fn test_yuyv_to_rgb_cpu() {
4203        let file = include_bytes!(concat!(
4204            env!("CARGO_MANIFEST_DIR"),
4205            "/../../testdata/camera720p.yuyv"
4206        ))
4207        .to_vec();
4208        let src = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
4209        src.as_u8()
4210            .unwrap()
4211            .map()
4212            .unwrap()
4213            .as_mut_slice()
4214            .copy_from_slice(&file);
4215
4216        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
4217        let mut cpu_converter = CPUProcessor::new();
4218
4219        let (result, _src, dst) = convert_img(
4220            &mut cpu_converter,
4221            src,
4222            dst,
4223            Rotation::None,
4224            Flip::None,
4225            Crop::no_crop(),
4226        );
4227        result.unwrap();
4228
4229        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
4230        target_image
4231            .as_u8()
4232            .unwrap()
4233            .map()
4234            .unwrap()
4235            .as_mut_slice()
4236            .as_chunks_mut::<3>()
4237            .0
4238            .iter_mut()
4239            .zip(
4240                include_bytes!(concat!(
4241                    env!("CARGO_MANIFEST_DIR"),
4242                    "/../../testdata/camera720p.rgba"
4243                ))
4244                .as_chunks::<4>()
4245                .0,
4246            )
4247            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
4248
4249        compare_images(&dst, &target_image, 0.98, function!());
4250    }
4251
4252    #[test]
4253    #[cfg(target_os = "linux")]
4254    fn test_yuyv_to_rgba_g2d() {
4255        if !is_g2d_available() {
4256            eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
4257            return;
4258        }
4259        if !is_dma_available() {
4260            eprintln!(
4261                "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4262            );
4263            return;
4264        }
4265
4266        let src = load_bytes_to_tensor(
4267            1280,
4268            720,
4269            PixelFormat::Yuyv,
4270            None,
4271            include_bytes!(concat!(
4272                env!("CARGO_MANIFEST_DIR"),
4273                "/../../testdata/camera720p.yuyv"
4274            )),
4275        )
4276        .unwrap();
4277
4278        let dst = TensorDyn::image(
4279            1280,
4280            720,
4281            PixelFormat::Rgba,
4282            DType::U8,
4283            Some(TensorMemory::Dma),
4284        )
4285        .unwrap();
4286        let mut g2d_converter = G2DProcessor::new().unwrap();
4287
4288        let (result, _src, dst) = convert_img(
4289            &mut g2d_converter,
4290            src,
4291            dst,
4292            Rotation::None,
4293            Flip::None,
4294            Crop::no_crop(),
4295        );
4296        result.unwrap();
4297
4298        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4299        target_image
4300            .as_u8()
4301            .unwrap()
4302            .map()
4303            .unwrap()
4304            .as_mut_slice()
4305            .copy_from_slice(include_bytes!(concat!(
4306                env!("CARGO_MANIFEST_DIR"),
4307                "/../../testdata/camera720p.rgba"
4308            )));
4309
4310        compare_images(&dst, &target_image, 0.98, function!());
4311    }
4312
4313    #[test]
4314    #[cfg(target_os = "linux")]
4315    #[cfg(feature = "opengl")]
4316    fn test_yuyv_to_rgba_opengl() {
4317        if !is_opengl_available() {
4318            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4319            return;
4320        }
4321        if !is_dma_available() {
4322            eprintln!(
4323                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
4324                function!()
4325            );
4326            return;
4327        }
4328
4329        let src = load_bytes_to_tensor(
4330            1280,
4331            720,
4332            PixelFormat::Yuyv,
4333            Some(TensorMemory::Dma),
4334            include_bytes!(concat!(
4335                env!("CARGO_MANIFEST_DIR"),
4336                "/../../testdata/camera720p.yuyv"
4337            )),
4338        )
4339        .unwrap();
4340
4341        let dst = TensorDyn::image(
4342            1280,
4343            720,
4344            PixelFormat::Rgba,
4345            DType::U8,
4346            Some(TensorMemory::Dma),
4347        )
4348        .unwrap();
4349        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4350
4351        let (result, _src, dst) = convert_img(
4352            &mut gl_converter,
4353            src,
4354            dst,
4355            Rotation::None,
4356            Flip::None,
4357            Crop::no_crop(),
4358        );
4359        result.unwrap();
4360
4361        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4362        target_image
4363            .as_u8()
4364            .unwrap()
4365            .map()
4366            .unwrap()
4367            .as_mut_slice()
4368            .copy_from_slice(include_bytes!(concat!(
4369                env!("CARGO_MANIFEST_DIR"),
4370                "/../../testdata/camera720p.rgba"
4371            )));
4372
4373        compare_images(&dst, &target_image, 0.98, function!());
4374    }
4375
4376    #[test]
4377    #[cfg(target_os = "linux")]
4378    fn test_yuyv_to_rgb_g2d() {
4379        if !is_g2d_available() {
4380            eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
4381            return;
4382        }
4383        if !is_dma_available() {
4384            eprintln!(
4385                "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4386            );
4387            return;
4388        }
4389
4390        let src = load_bytes_to_tensor(
4391            1280,
4392            720,
4393            PixelFormat::Yuyv,
4394            None,
4395            include_bytes!(concat!(
4396                env!("CARGO_MANIFEST_DIR"),
4397                "/../../testdata/camera720p.yuyv"
4398            )),
4399        )
4400        .unwrap();
4401
4402        let g2d_dst = TensorDyn::image(
4403            1280,
4404            720,
4405            PixelFormat::Rgb,
4406            DType::U8,
4407            Some(TensorMemory::Dma),
4408        )
4409        .unwrap();
4410        let mut g2d_converter = G2DProcessor::new().unwrap();
4411
4412        let (result, src, g2d_dst) = convert_img(
4413            &mut g2d_converter,
4414            src,
4415            g2d_dst,
4416            Rotation::None,
4417            Flip::None,
4418            Crop::no_crop(),
4419        );
4420        result.unwrap();
4421
4422        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
4423        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
4424
4425        let (result, _src, cpu_dst) = convert_img(
4426            &mut cpu_converter,
4427            src,
4428            cpu_dst,
4429            Rotation::None,
4430            Flip::None,
4431            Crop::no_crop(),
4432        );
4433        result.unwrap();
4434
4435        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4436    }
4437
4438    #[test]
4439    #[cfg(target_os = "linux")]
4440    fn test_yuyv_to_yuyv_resize_g2d() {
4441        if !is_g2d_available() {
4442            eprintln!(
4443                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
4444            );
4445            return;
4446        }
4447        if !is_dma_available() {
4448            eprintln!(
4449                "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4450            );
4451            return;
4452        }
4453
4454        let src = load_bytes_to_tensor(
4455            1280,
4456            720,
4457            PixelFormat::Yuyv,
4458            None,
4459            include_bytes!(concat!(
4460                env!("CARGO_MANIFEST_DIR"),
4461                "/../../testdata/camera720p.yuyv"
4462            )),
4463        )
4464        .unwrap();
4465
4466        let g2d_dst = TensorDyn::image(
4467            600,
4468            400,
4469            PixelFormat::Yuyv,
4470            DType::U8,
4471            Some(TensorMemory::Dma),
4472        )
4473        .unwrap();
4474        let mut g2d_converter = G2DProcessor::new().unwrap();
4475
4476        let (result, src, g2d_dst) = convert_img(
4477            &mut g2d_converter,
4478            src,
4479            g2d_dst,
4480            Rotation::None,
4481            Flip::None,
4482            Crop::no_crop(),
4483        );
4484        result.unwrap();
4485
4486        let cpu_dst = TensorDyn::image(600, 400, PixelFormat::Yuyv, DType::U8, None).unwrap();
4487        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
4488
4489        let (result, _src, cpu_dst) = convert_img(
4490            &mut cpu_converter,
4491            src,
4492            cpu_dst,
4493            Rotation::None,
4494            Flip::None,
4495            Crop::no_crop(),
4496        );
4497        result.unwrap();
4498
4499        // TODO: compare PixelFormat::Yuyv and PixelFormat::Yuyv images without having to convert them to PixelFormat::Rgb
4500        compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.98, function!());
4501    }
4502
4503    #[test]
4504    fn test_yuyv_to_rgba_resize_cpu() {
4505        let src = load_bytes_to_tensor(
4506            1280,
4507            720,
4508            PixelFormat::Yuyv,
4509            None,
4510            include_bytes!(concat!(
4511                env!("CARGO_MANIFEST_DIR"),
4512                "/../../testdata/camera720p.yuyv"
4513            )),
4514        )
4515        .unwrap();
4516
4517        let (dst_width, dst_height) = (960, 540);
4518
4519        let dst =
4520            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4521        let mut cpu_converter = CPUProcessor::new();
4522
4523        let (result, _src, dst) = convert_img(
4524            &mut cpu_converter,
4525            src,
4526            dst,
4527            Rotation::None,
4528            Flip::None,
4529            Crop::no_crop(),
4530        );
4531        result.unwrap();
4532
4533        let dst_target =
4534            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4535        let src_target = load_bytes_to_tensor(
4536            1280,
4537            720,
4538            PixelFormat::Rgba,
4539            None,
4540            include_bytes!(concat!(
4541                env!("CARGO_MANIFEST_DIR"),
4542                "/../../testdata/camera720p.rgba"
4543            )),
4544        )
4545        .unwrap();
4546        let (result, _src_target, dst_target) = convert_img(
4547            &mut cpu_converter,
4548            src_target,
4549            dst_target,
4550            Rotation::None,
4551            Flip::None,
4552            Crop::no_crop(),
4553        );
4554        result.unwrap();
4555
4556        compare_images(&dst, &dst_target, 0.98, function!());
4557    }
4558
4559    #[test]
4560    #[cfg(target_os = "linux")]
4561    fn test_yuyv_to_rgba_crop_flip_g2d() {
4562        if !is_g2d_available() {
4563            eprintln!(
4564                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
4565            );
4566            return;
4567        }
4568        if !is_dma_available() {
4569            eprintln!(
4570                "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4571            );
4572            return;
4573        }
4574
4575        let src = load_bytes_to_tensor(
4576            1280,
4577            720,
4578            PixelFormat::Yuyv,
4579            Some(TensorMemory::Dma),
4580            include_bytes!(concat!(
4581                env!("CARGO_MANIFEST_DIR"),
4582                "/../../testdata/camera720p.yuyv"
4583            )),
4584        )
4585        .unwrap();
4586
4587        let (dst_width, dst_height) = (640, 640);
4588
4589        let dst_g2d = TensorDyn::image(
4590            dst_width,
4591            dst_height,
4592            PixelFormat::Rgba,
4593            DType::U8,
4594            Some(TensorMemory::Dma),
4595        )
4596        .unwrap();
4597        let mut g2d_converter = G2DProcessor::new().unwrap();
4598        let crop = Crop {
4599            src_rect: Some(Rect {
4600                left: 20,
4601                top: 15,
4602                width: 400,
4603                height: 300,
4604            }),
4605            dst_rect: None,
4606            dst_color: None,
4607        };
4608
4609        let (result, src, dst_g2d) = convert_img(
4610            &mut g2d_converter,
4611            src,
4612            dst_g2d,
4613            Rotation::None,
4614            Flip::Horizontal,
4615            crop,
4616        );
4617        result.unwrap();
4618
4619        let dst_cpu = TensorDyn::image(
4620            dst_width,
4621            dst_height,
4622            PixelFormat::Rgba,
4623            DType::U8,
4624            Some(TensorMemory::Dma),
4625        )
4626        .unwrap();
4627        let mut cpu_converter = CPUProcessor::new();
4628
4629        let (result, _src, dst_cpu) = convert_img(
4630            &mut cpu_converter,
4631            src,
4632            dst_cpu,
4633            Rotation::None,
4634            Flip::Horizontal,
4635            crop,
4636        );
4637        result.unwrap();
4638        compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
4639    }
4640
4641    #[test]
4642    #[cfg(target_os = "linux")]
4643    #[cfg(feature = "opengl")]
4644    fn test_yuyv_to_rgba_crop_flip_opengl() {
4645        if !is_opengl_available() {
4646            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4647            return;
4648        }
4649
4650        if !is_dma_available() {
4651            eprintln!(
4652                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
4653                function!()
4654            );
4655            return;
4656        }
4657
4658        let src = load_bytes_to_tensor(
4659            1280,
4660            720,
4661            PixelFormat::Yuyv,
4662            Some(TensorMemory::Dma),
4663            include_bytes!(concat!(
4664                env!("CARGO_MANIFEST_DIR"),
4665                "/../../testdata/camera720p.yuyv"
4666            )),
4667        )
4668        .unwrap();
4669
4670        let (dst_width, dst_height) = (640, 640);
4671
4672        let dst_gl = TensorDyn::image(
4673            dst_width,
4674            dst_height,
4675            PixelFormat::Rgba,
4676            DType::U8,
4677            Some(TensorMemory::Dma),
4678        )
4679        .unwrap();
4680        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4681        let crop = Crop {
4682            src_rect: Some(Rect {
4683                left: 20,
4684                top: 15,
4685                width: 400,
4686                height: 300,
4687            }),
4688            dst_rect: None,
4689            dst_color: None,
4690        };
4691
4692        let (result, src, dst_gl) = convert_img(
4693            &mut gl_converter,
4694            src,
4695            dst_gl,
4696            Rotation::None,
4697            Flip::Horizontal,
4698            crop,
4699        );
4700        result.unwrap();
4701
4702        let dst_cpu = TensorDyn::image(
4703            dst_width,
4704            dst_height,
4705            PixelFormat::Rgba,
4706            DType::U8,
4707            Some(TensorMemory::Dma),
4708        )
4709        .unwrap();
4710        let mut cpu_converter = CPUProcessor::new();
4711
4712        let (result, _src, dst_cpu) = convert_img(
4713            &mut cpu_converter,
4714            src,
4715            dst_cpu,
4716            Rotation::None,
4717            Flip::Horizontal,
4718            crop,
4719        );
4720        result.unwrap();
4721        compare_images(&dst_gl, &dst_cpu, 0.98, function!());
4722    }
4723
4724    #[test]
4725    fn test_vyuy_to_rgba_cpu() {
4726        let file = include_bytes!(concat!(
4727            env!("CARGO_MANIFEST_DIR"),
4728            "/../../testdata/camera720p.vyuy"
4729        ))
4730        .to_vec();
4731        let src = TensorDyn::image(1280, 720, PixelFormat::Vyuy, DType::U8, None).unwrap();
4732        src.as_u8()
4733            .unwrap()
4734            .map()
4735            .unwrap()
4736            .as_mut_slice()
4737            .copy_from_slice(&file);
4738
4739        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4740        let mut cpu_converter = CPUProcessor::new();
4741
4742        let (result, _src, dst) = convert_img(
4743            &mut cpu_converter,
4744            src,
4745            dst,
4746            Rotation::None,
4747            Flip::None,
4748            Crop::no_crop(),
4749        );
4750        result.unwrap();
4751
4752        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4753        target_image
4754            .as_u8()
4755            .unwrap()
4756            .map()
4757            .unwrap()
4758            .as_mut_slice()
4759            .copy_from_slice(include_bytes!(concat!(
4760                env!("CARGO_MANIFEST_DIR"),
4761                "/../../testdata/camera720p.rgba"
4762            )));
4763
4764        compare_images(&dst, &target_image, 0.98, function!());
4765    }
4766
4767    #[test]
4768    fn test_vyuy_to_rgb_cpu() {
4769        let file = include_bytes!(concat!(
4770            env!("CARGO_MANIFEST_DIR"),
4771            "/../../testdata/camera720p.vyuy"
4772        ))
4773        .to_vec();
4774        let src = TensorDyn::image(1280, 720, PixelFormat::Vyuy, DType::U8, None).unwrap();
4775        src.as_u8()
4776            .unwrap()
4777            .map()
4778            .unwrap()
4779            .as_mut_slice()
4780            .copy_from_slice(&file);
4781
4782        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
4783        let mut cpu_converter = CPUProcessor::new();
4784
4785        let (result, _src, dst) = convert_img(
4786            &mut cpu_converter,
4787            src,
4788            dst,
4789            Rotation::None,
4790            Flip::None,
4791            Crop::no_crop(),
4792        );
4793        result.unwrap();
4794
4795        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
4796        target_image
4797            .as_u8()
4798            .unwrap()
4799            .map()
4800            .unwrap()
4801            .as_mut_slice()
4802            .as_chunks_mut::<3>()
4803            .0
4804            .iter_mut()
4805            .zip(
4806                include_bytes!(concat!(
4807                    env!("CARGO_MANIFEST_DIR"),
4808                    "/../../testdata/camera720p.rgba"
4809                ))
4810                .as_chunks::<4>()
4811                .0,
4812            )
4813            .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
4814
4815        compare_images(&dst, &target_image, 0.98, function!());
4816    }
4817
4818    #[test]
4819    #[cfg(target_os = "linux")]
4820    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
4821    fn test_vyuy_to_rgba_g2d() {
4822        if !is_g2d_available() {
4823            eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
4824            return;
4825        }
4826        if !is_dma_available() {
4827            eprintln!(
4828                "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4829            );
4830            return;
4831        }
4832
4833        let src = load_bytes_to_tensor(
4834            1280,
4835            720,
4836            PixelFormat::Vyuy,
4837            None,
4838            include_bytes!(concat!(
4839                env!("CARGO_MANIFEST_DIR"),
4840                "/../../testdata/camera720p.vyuy"
4841            )),
4842        )
4843        .unwrap();
4844
4845        let dst = TensorDyn::image(
4846            1280,
4847            720,
4848            PixelFormat::Rgba,
4849            DType::U8,
4850            Some(TensorMemory::Dma),
4851        )
4852        .unwrap();
4853        let mut g2d_converter = G2DProcessor::new().unwrap();
4854
4855        let (result, _src, dst) = convert_img(
4856            &mut g2d_converter,
4857            src,
4858            dst,
4859            Rotation::None,
4860            Flip::None,
4861            Crop::no_crop(),
4862        );
4863        match result {
4864            Err(Error::G2D(_)) => {
4865                eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
4866                return;
4867            }
4868            r => r.unwrap(),
4869        }
4870
4871        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
4872        target_image
4873            .as_u8()
4874            .unwrap()
4875            .map()
4876            .unwrap()
4877            .as_mut_slice()
4878            .copy_from_slice(include_bytes!(concat!(
4879                env!("CARGO_MANIFEST_DIR"),
4880                "/../../testdata/camera720p.rgba"
4881            )));
4882
4883        compare_images(&dst, &target_image, 0.98, function!());
4884    }
4885
4886    #[test]
4887    #[cfg(target_os = "linux")]
4888    #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
4889    fn test_vyuy_to_rgb_g2d() {
4890        if !is_g2d_available() {
4891            eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
4892            return;
4893        }
4894        if !is_dma_available() {
4895            eprintln!(
4896                "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4897            );
4898            return;
4899        }
4900
4901        let src = load_bytes_to_tensor(
4902            1280,
4903            720,
4904            PixelFormat::Vyuy,
4905            None,
4906            include_bytes!(concat!(
4907                env!("CARGO_MANIFEST_DIR"),
4908                "/../../testdata/camera720p.vyuy"
4909            )),
4910        )
4911        .unwrap();
4912
4913        let g2d_dst = TensorDyn::image(
4914            1280,
4915            720,
4916            PixelFormat::Rgb,
4917            DType::U8,
4918            Some(TensorMemory::Dma),
4919        )
4920        .unwrap();
4921        let mut g2d_converter = G2DProcessor::new().unwrap();
4922
4923        let (result, src, g2d_dst) = convert_img(
4924            &mut g2d_converter,
4925            src,
4926            g2d_dst,
4927            Rotation::None,
4928            Flip::None,
4929            Crop::no_crop(),
4930        );
4931        match result {
4932            Err(Error::G2D(_)) => {
4933                eprintln!(
4934                    "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
4935                );
4936                return;
4937            }
4938            r => r.unwrap(),
4939        }
4940
4941        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
4942        let mut cpu_converter: CPUProcessor = CPUProcessor::new();
4943
4944        let (result, _src, cpu_dst) = convert_img(
4945            &mut cpu_converter,
4946            src,
4947            cpu_dst,
4948            Rotation::None,
4949            Flip::None,
4950            Crop::no_crop(),
4951        );
4952        result.unwrap();
4953
4954        compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4955    }
4956
4957    #[test]
4958    #[cfg(target_os = "linux")]
4959    #[cfg(feature = "opengl")]
4960    fn test_vyuy_to_rgba_opengl() {
4961        if !is_opengl_available() {
4962            eprintln!("SKIPPED: {} - OpenGL not available", function!());
4963            return;
4964        }
4965        if !is_dma_available() {
4966            eprintln!(
4967                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
4968                function!()
4969            );
4970            return;
4971        }
4972
4973        let src = load_bytes_to_tensor(
4974            1280,
4975            720,
4976            PixelFormat::Vyuy,
4977            Some(TensorMemory::Dma),
4978            include_bytes!(concat!(
4979                env!("CARGO_MANIFEST_DIR"),
4980                "/../../testdata/camera720p.vyuy"
4981            )),
4982        )
4983        .unwrap();
4984
4985        let dst = TensorDyn::image(
4986            1280,
4987            720,
4988            PixelFormat::Rgba,
4989            DType::U8,
4990            Some(TensorMemory::Dma),
4991        )
4992        .unwrap();
4993        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4994
4995        let (result, _src, dst) = convert_img(
4996            &mut gl_converter,
4997            src,
4998            dst,
4999            Rotation::None,
5000            Flip::None,
5001            Crop::no_crop(),
5002        );
5003        match result {
5004            Err(Error::NotSupported(_)) => {
5005                eprintln!(
5006                    "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
5007                    function!()
5008                );
5009                return;
5010            }
5011            r => r.unwrap(),
5012        }
5013
5014        let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5015        target_image
5016            .as_u8()
5017            .unwrap()
5018            .map()
5019            .unwrap()
5020            .as_mut_slice()
5021            .copy_from_slice(include_bytes!(concat!(
5022                env!("CARGO_MANIFEST_DIR"),
5023                "/../../testdata/camera720p.rgba"
5024            )));
5025
5026        compare_images(&dst, &target_image, 0.98, function!());
5027    }
5028
5029    #[test]
5030    fn test_nv12_to_rgba_cpu() {
5031        let file = include_bytes!(concat!(
5032            env!("CARGO_MANIFEST_DIR"),
5033            "/../../testdata/zidane.nv12"
5034        ))
5035        .to_vec();
5036        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
5037        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
5038            .copy_from_slice(&file);
5039
5040        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5041        let mut cpu_converter = CPUProcessor::new();
5042
5043        let (result, _src, dst) = convert_img(
5044            &mut cpu_converter,
5045            src,
5046            dst,
5047            Rotation::None,
5048            Flip::None,
5049            Crop::no_crop(),
5050        );
5051        result.unwrap();
5052
5053        let target_image = crate::load_image(
5054            include_bytes!(concat!(
5055                env!("CARGO_MANIFEST_DIR"),
5056                "/../../testdata/zidane.jpg"
5057            )),
5058            Some(PixelFormat::Rgba),
5059            None,
5060        )
5061        .unwrap();
5062
5063        compare_images(&dst, &target_image, 0.98, function!());
5064    }
5065
5066    #[test]
5067    fn test_nv12_to_rgb_cpu() {
5068        let file = include_bytes!(concat!(
5069            env!("CARGO_MANIFEST_DIR"),
5070            "/../../testdata/zidane.nv12"
5071        ))
5072        .to_vec();
5073        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
5074        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
5075            .copy_from_slice(&file);
5076
5077        let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
5078        let mut cpu_converter = CPUProcessor::new();
5079
5080        let (result, _src, dst) = convert_img(
5081            &mut cpu_converter,
5082            src,
5083            dst,
5084            Rotation::None,
5085            Flip::None,
5086            Crop::no_crop(),
5087        );
5088        result.unwrap();
5089
5090        let target_image = crate::load_image(
5091            include_bytes!(concat!(
5092                env!("CARGO_MANIFEST_DIR"),
5093                "/../../testdata/zidane.jpg"
5094            )),
5095            Some(PixelFormat::Rgb),
5096            None,
5097        )
5098        .unwrap();
5099
5100        compare_images(&dst, &target_image, 0.98, function!());
5101    }
5102
5103    #[test]
5104    fn test_nv12_to_grey_cpu() {
5105        let file = include_bytes!(concat!(
5106            env!("CARGO_MANIFEST_DIR"),
5107            "/../../testdata/zidane.nv12"
5108        ))
5109        .to_vec();
5110        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
5111        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
5112            .copy_from_slice(&file);
5113
5114        let dst = TensorDyn::image(1280, 720, PixelFormat::Grey, DType::U8, None).unwrap();
5115        let mut cpu_converter = CPUProcessor::new();
5116
5117        let (result, _src, dst) = convert_img(
5118            &mut cpu_converter,
5119            src,
5120            dst,
5121            Rotation::None,
5122            Flip::None,
5123            Crop::no_crop(),
5124        );
5125        result.unwrap();
5126
5127        let target_image = crate::load_image(
5128            include_bytes!(concat!(
5129                env!("CARGO_MANIFEST_DIR"),
5130                "/../../testdata/zidane.jpg"
5131            )),
5132            Some(PixelFormat::Grey),
5133            None,
5134        )
5135        .unwrap();
5136
5137        compare_images(&dst, &target_image, 0.98, function!());
5138    }
5139
5140    #[test]
5141    fn test_nv12_to_yuyv_cpu() {
5142        let file = include_bytes!(concat!(
5143            env!("CARGO_MANIFEST_DIR"),
5144            "/../../testdata/zidane.nv12"
5145        ))
5146        .to_vec();
5147        let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
5148        src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
5149            .copy_from_slice(&file);
5150
5151        let dst = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
5152        let mut cpu_converter = CPUProcessor::new();
5153
5154        let (result, _src, dst) = convert_img(
5155            &mut cpu_converter,
5156            src,
5157            dst,
5158            Rotation::None,
5159            Flip::None,
5160            Crop::no_crop(),
5161        );
5162        result.unwrap();
5163
5164        let target_image = crate::load_image(
5165            include_bytes!(concat!(
5166                env!("CARGO_MANIFEST_DIR"),
5167                "/../../testdata/zidane.jpg"
5168            )),
5169            Some(PixelFormat::Rgb),
5170            None,
5171        )
5172        .unwrap();
5173
5174        compare_images_convert_to_rgb(&dst, &target_image, 0.98, function!());
5175    }
5176
5177    #[test]
5178    fn test_cpu_resize_planar_rgb() {
5179        let src = TensorDyn::image(4, 4, PixelFormat::Rgba, DType::U8, None).unwrap();
5180        #[rustfmt::skip]
5181        let src_image = [
5182                    255, 0, 0, 255,     0, 255, 0, 255,     0, 0, 255, 255,     255, 255, 0, 255,
5183                    255, 0, 0, 0,       0, 0, 0, 255,       255,  0, 255, 0,    255, 0, 255, 255,
5184                    0, 0, 255, 0,       0, 255, 255, 255,   255, 255, 0, 0,     0, 0, 0, 255,
5185                    255, 0, 0, 0,       0, 0, 0, 255,       255,  0, 255, 0,    255, 0, 255, 255,
5186        ];
5187        src.as_u8()
5188            .unwrap()
5189            .map()
5190            .unwrap()
5191            .as_mut_slice()
5192            .copy_from_slice(&src_image);
5193
5194        let cpu_dst = TensorDyn::image(5, 5, PixelFormat::PlanarRgb, DType::U8, None).unwrap();
5195        let mut cpu_converter = CPUProcessor::new();
5196
5197        let (result, _src, cpu_dst) = convert_img(
5198            &mut cpu_converter,
5199            src,
5200            cpu_dst,
5201            Rotation::None,
5202            Flip::None,
5203            Crop::new()
5204                .with_dst_rect(Some(Rect {
5205                    left: 1,
5206                    top: 1,
5207                    width: 4,
5208                    height: 4,
5209                }))
5210                .with_dst_color(Some([114, 114, 114, 255])),
5211        );
5212        result.unwrap();
5213
5214        #[rustfmt::skip]
5215        let expected_dst = [
5216            114, 114, 114, 114, 114,    114, 255, 0, 0, 255,    114, 255, 0, 255, 255,      114, 0, 0, 255, 0,        114, 255, 0, 255, 255,
5217            114, 114, 114, 114, 114,    114, 0, 255, 0, 255,    114, 0, 0, 0, 0,            114, 0, 255, 255, 0,      114, 0, 0, 0, 0,
5218            114, 114, 114, 114, 114,    114, 0, 0, 255, 0,      114, 0, 0, 255, 255,        114, 255, 255, 0, 0,      114, 0, 0, 255, 255,
5219        ];
5220
5221        assert_eq!(
5222            cpu_dst.as_u8().unwrap().map().unwrap().as_slice(),
5223            &expected_dst
5224        );
5225    }
5226
5227    #[test]
5228    fn test_cpu_resize_planar_rgba() {
5229        let src = TensorDyn::image(4, 4, PixelFormat::Rgba, DType::U8, None).unwrap();
5230        #[rustfmt::skip]
5231        let src_image = [
5232                    255, 0, 0, 255,     0, 255, 0, 255,     0, 0, 255, 255,     255, 255, 0, 255,
5233                    255, 0, 0, 0,       0, 0, 0, 255,       255,  0, 255, 0,    255, 0, 255, 255,
5234                    0, 0, 255, 0,       0, 255, 255, 255,   255, 255, 0, 0,     0, 0, 0, 255,
5235                    255, 0, 0, 0,       0, 0, 0, 255,       255,  0, 255, 0,    255, 0, 255, 255,
5236        ];
5237        src.as_u8()
5238            .unwrap()
5239            .map()
5240            .unwrap()
5241            .as_mut_slice()
5242            .copy_from_slice(&src_image);
5243
5244        let cpu_dst = TensorDyn::image(5, 5, PixelFormat::PlanarRgba, DType::U8, None).unwrap();
5245        let mut cpu_converter = CPUProcessor::new();
5246
5247        let (result, _src, cpu_dst) = convert_img(
5248            &mut cpu_converter,
5249            src,
5250            cpu_dst,
5251            Rotation::None,
5252            Flip::None,
5253            Crop::new()
5254                .with_dst_rect(Some(Rect {
5255                    left: 1,
5256                    top: 1,
5257                    width: 4,
5258                    height: 4,
5259                }))
5260                .with_dst_color(Some([114, 114, 114, 255])),
5261        );
5262        result.unwrap();
5263
5264        #[rustfmt::skip]
5265        let expected_dst = [
5266            114, 114, 114, 114, 114,    114, 255, 0, 0, 255,        114, 255, 0, 255, 255,      114, 0, 0, 255, 0,        114, 255, 0, 255, 255,
5267            114, 114, 114, 114, 114,    114, 0, 255, 0, 255,        114, 0, 0, 0, 0,            114, 0, 255, 255, 0,      114, 0, 0, 0, 0,
5268            114, 114, 114, 114, 114,    114, 0, 0, 255, 0,          114, 0, 0, 255, 255,        114, 255, 255, 0, 0,      114, 0, 0, 255, 255,
5269            255, 255, 255, 255, 255,    255, 255, 255, 255, 255,    255, 0, 255, 0, 255,        255, 0, 255, 0, 255,      255, 0, 255, 0, 255,
5270        ];
5271
5272        assert_eq!(
5273            cpu_dst.as_u8().unwrap().map().unwrap().as_slice(),
5274            &expected_dst
5275        );
5276    }
5277
5278    #[test]
5279    #[cfg(target_os = "linux")]
5280    #[cfg(feature = "opengl")]
5281    fn test_opengl_resize_planar_rgb() {
5282        if !is_opengl_available() {
5283            eprintln!("SKIPPED: {} - OpenGL not available", function!());
5284            return;
5285        }
5286
5287        if !is_dma_available() {
5288            eprintln!(
5289                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5290                function!()
5291            );
5292            return;
5293        }
5294
5295        let dst_width = 640;
5296        let dst_height = 640;
5297        let file = include_bytes!(concat!(
5298            env!("CARGO_MANIFEST_DIR"),
5299            "/../../testdata/test_image.jpg"
5300        ))
5301        .to_vec();
5302        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
5303
5304        let cpu_dst = TensorDyn::image(
5305            dst_width,
5306            dst_height,
5307            PixelFormat::PlanarRgb,
5308            DType::U8,
5309            None,
5310        )
5311        .unwrap();
5312        let mut cpu_converter = CPUProcessor::new();
5313        let (result, src, cpu_dst) = convert_img(
5314            &mut cpu_converter,
5315            src,
5316            cpu_dst,
5317            Rotation::None,
5318            Flip::None,
5319            Crop::no_crop(),
5320        );
5321        result.unwrap();
5322        let crop_letterbox = Crop::new()
5323            .with_dst_rect(Some(Rect {
5324                left: 102,
5325                top: 102,
5326                width: 440,
5327                height: 440,
5328            }))
5329            .with_dst_color(Some([114, 114, 114, 114]));
5330        let (result, src, cpu_dst) = convert_img(
5331            &mut cpu_converter,
5332            src,
5333            cpu_dst,
5334            Rotation::None,
5335            Flip::None,
5336            crop_letterbox,
5337        );
5338        result.unwrap();
5339
5340        let gl_dst = TensorDyn::image(
5341            dst_width,
5342            dst_height,
5343            PixelFormat::PlanarRgb,
5344            DType::U8,
5345            None,
5346        )
5347        .unwrap();
5348        let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5349
5350        let (result, _src, gl_dst) = convert_img(
5351            &mut gl_converter,
5352            src,
5353            gl_dst,
5354            Rotation::None,
5355            Flip::None,
5356            crop_letterbox,
5357        );
5358        result.unwrap();
5359        compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5360    }
5361
5362    #[test]
5363    fn test_cpu_resize_nv16() {
5364        let file = include_bytes!(concat!(
5365            env!("CARGO_MANIFEST_DIR"),
5366            "/../../testdata/zidane.jpg"
5367        ))
5368        .to_vec();
5369        let src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
5370
5371        let cpu_nv16_dst = TensorDyn::image(640, 640, PixelFormat::Nv16, DType::U8, None).unwrap();
5372        let cpu_rgb_dst = TensorDyn::image(640, 640, PixelFormat::Rgb, DType::U8, None).unwrap();
5373        let mut cpu_converter = CPUProcessor::new();
5374        let crop = Crop::new()
5375            .with_dst_rect(Some(Rect {
5376                left: 20,
5377                top: 140,
5378                width: 600,
5379                height: 360,
5380            }))
5381            .with_dst_color(Some([255, 128, 0, 255]));
5382
5383        let (result, src, cpu_nv16_dst) = convert_img(
5384            &mut cpu_converter,
5385            src,
5386            cpu_nv16_dst,
5387            Rotation::None,
5388            Flip::None,
5389            crop,
5390        );
5391        result.unwrap();
5392
5393        let (result, _src, cpu_rgb_dst) = convert_img(
5394            &mut cpu_converter,
5395            src,
5396            cpu_rgb_dst,
5397            Rotation::None,
5398            Flip::None,
5399            crop,
5400        );
5401        result.unwrap();
5402        compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
5403    }
5404
5405    fn load_bytes_to_tensor(
5406        width: usize,
5407        height: usize,
5408        format: PixelFormat,
5409        memory: Option<TensorMemory>,
5410        bytes: &[u8],
5411    ) -> Result<TensorDyn, Error> {
5412        let src = TensorDyn::image(width, height, format, DType::U8, memory)?;
5413        src.as_u8()
5414            .unwrap()
5415            .map()?
5416            .as_mut_slice()
5417            .copy_from_slice(bytes);
5418        Ok(src)
5419    }
5420
5421    fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
5422        assert_eq!(img1.height(), img2.height(), "Heights differ");
5423        assert_eq!(img1.width(), img2.width(), "Widths differ");
5424        assert_eq!(
5425            img1.format().unwrap(),
5426            img2.format().unwrap(),
5427            "PixelFormat differ"
5428        );
5429        assert!(
5430            matches!(
5431                img1.format().unwrap(),
5432                PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
5433            ),
5434            "format must be Rgb or Rgba for comparison"
5435        );
5436
5437        let image1 = match img1.format().unwrap() {
5438            PixelFormat::Rgb => image::RgbImage::from_vec(
5439                img1.width().unwrap() as u32,
5440                img1.height().unwrap() as u32,
5441                img1.as_u8().unwrap().map().unwrap().to_vec(),
5442            )
5443            .unwrap(),
5444            PixelFormat::Rgba => image::RgbaImage::from_vec(
5445                img1.width().unwrap() as u32,
5446                img1.height().unwrap() as u32,
5447                img1.as_u8().unwrap().map().unwrap().to_vec(),
5448            )
5449            .unwrap()
5450            .convert(),
5451            PixelFormat::Grey => image::GrayImage::from_vec(
5452                img1.width().unwrap() as u32,
5453                img1.height().unwrap() as u32,
5454                img1.as_u8().unwrap().map().unwrap().to_vec(),
5455            )
5456            .unwrap()
5457            .convert(),
5458            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
5459                img1.width().unwrap() as u32,
5460                (img1.height().unwrap() * 3) as u32,
5461                img1.as_u8().unwrap().map().unwrap().to_vec(),
5462            )
5463            .unwrap()
5464            .convert(),
5465            _ => return,
5466        };
5467
5468        let image2 = match img2.format().unwrap() {
5469            PixelFormat::Rgb => image::RgbImage::from_vec(
5470                img2.width().unwrap() as u32,
5471                img2.height().unwrap() as u32,
5472                img2.as_u8().unwrap().map().unwrap().to_vec(),
5473            )
5474            .unwrap(),
5475            PixelFormat::Rgba => image::RgbaImage::from_vec(
5476                img2.width().unwrap() as u32,
5477                img2.height().unwrap() as u32,
5478                img2.as_u8().unwrap().map().unwrap().to_vec(),
5479            )
5480            .unwrap()
5481            .convert(),
5482            PixelFormat::Grey => image::GrayImage::from_vec(
5483                img2.width().unwrap() as u32,
5484                img2.height().unwrap() as u32,
5485                img2.as_u8().unwrap().map().unwrap().to_vec(),
5486            )
5487            .unwrap()
5488            .convert(),
5489            PixelFormat::PlanarRgb => image::GrayImage::from_vec(
5490                img2.width().unwrap() as u32,
5491                (img2.height().unwrap() * 3) as u32,
5492                img2.as_u8().unwrap().map().unwrap().to_vec(),
5493            )
5494            .unwrap()
5495            .convert(),
5496            _ => return,
5497        };
5498
5499        let similarity = image_compare::rgb_similarity_structure(
5500            &image_compare::Algorithm::RootMeanSquared,
5501            &image1,
5502            &image2,
5503        )
5504        .expect("Image Comparison failed");
5505        if similarity.score < threshold {
5506            // image1.save(format!("{name}_1.png"));
5507            // image2.save(format!("{name}_2.png"));
5508            similarity
5509                .image
5510                .to_color_map()
5511                .save(format!("{name}.png"))
5512                .unwrap();
5513            panic!(
5514                "{name}: converted image and target image have similarity score too low: {} < {}",
5515                similarity.score, threshold
5516            )
5517        }
5518    }
5519
5520    fn compare_images_convert_to_rgb(
5521        img1: &TensorDyn,
5522        img2: &TensorDyn,
5523        threshold: f64,
5524        name: &str,
5525    ) {
5526        assert_eq!(img1.height(), img2.height(), "Heights differ");
5527        assert_eq!(img1.width(), img2.width(), "Widths differ");
5528
5529        let mut img_rgb1 = TensorDyn::image(
5530            img1.width().unwrap(),
5531            img1.height().unwrap(),
5532            PixelFormat::Rgb,
5533            DType::U8,
5534            Some(TensorMemory::Mem),
5535        )
5536        .unwrap();
5537        let mut img_rgb2 = TensorDyn::image(
5538            img1.width().unwrap(),
5539            img1.height().unwrap(),
5540            PixelFormat::Rgb,
5541            DType::U8,
5542            Some(TensorMemory::Mem),
5543        )
5544        .unwrap();
5545        let mut __cv = CPUProcessor::default();
5546        let r1 = __cv.convert(
5547            img1,
5548            &mut img_rgb1,
5549            crate::Rotation::None,
5550            crate::Flip::None,
5551            crate::Crop::default(),
5552        );
5553        let r2 = __cv.convert(
5554            img2,
5555            &mut img_rgb2,
5556            crate::Rotation::None,
5557            crate::Flip::None,
5558            crate::Crop::default(),
5559        );
5560        if r1.is_err() || r2.is_err() {
5561            // Fallback: compare raw bytes as greyscale strip
5562            let w = img1.width().unwrap() as u32;
5563            let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
5564            let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
5565            let h1 = (data1.len() as u32) / w;
5566            let h2 = (data2.len() as u32) / w;
5567            let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
5568            let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
5569            let similarity = image_compare::gray_similarity_structure(
5570                &image_compare::Algorithm::RootMeanSquared,
5571                &g1,
5572                &g2,
5573            )
5574            .expect("Image Comparison failed");
5575            if similarity.score < threshold {
5576                panic!(
5577                    "{name}: converted image and target image have similarity score too low: {} < {}",
5578                    similarity.score, threshold
5579                )
5580            }
5581            return;
5582        }
5583
5584        let image1 = image::RgbImage::from_vec(
5585            img_rgb1.width().unwrap() as u32,
5586            img_rgb1.height().unwrap() as u32,
5587            img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
5588        )
5589        .unwrap();
5590
5591        let image2 = image::RgbImage::from_vec(
5592            img_rgb2.width().unwrap() as u32,
5593            img_rgb2.height().unwrap() as u32,
5594            img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
5595        )
5596        .unwrap();
5597
5598        let similarity = image_compare::rgb_similarity_structure(
5599            &image_compare::Algorithm::RootMeanSquared,
5600            &image1,
5601            &image2,
5602        )
5603        .expect("Image Comparison failed");
5604        if similarity.score < threshold {
5605            // image1.save(format!("{name}_1.png"));
5606            // image2.save(format!("{name}_2.png"));
5607            similarity
5608                .image
5609                .to_color_map()
5610                .save(format!("{name}.png"))
5611                .unwrap();
5612            panic!(
5613                "{name}: converted image and target image have similarity score too low: {} < {}",
5614                similarity.score, threshold
5615            )
5616        }
5617    }
5618
5619    // =========================================================================
5620    // PixelFormat::Nv12 Format Tests
5621    // =========================================================================
5622
5623    #[test]
5624    fn test_nv12_image_creation() {
5625        let width = 640;
5626        let height = 480;
5627        let img = TensorDyn::image(width, height, PixelFormat::Nv12, DType::U8, None).unwrap();
5628
5629        assert_eq!(img.width(), Some(width));
5630        assert_eq!(img.height(), Some(height));
5631        assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
5632        // PixelFormat::Nv12 uses shape [H*3/2, W] to store Y plane + UV plane
5633        assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
5634    }
5635
5636    #[test]
5637    fn test_nv12_channels() {
5638        let img = TensorDyn::image(640, 480, PixelFormat::Nv12, DType::U8, None).unwrap();
5639        // PixelFormat::Nv12.channels() returns 1 (luma plane)
5640        assert_eq!(img.format().unwrap().channels(), 1);
5641    }
5642
5643    // =========================================================================
5644    // Tensor Format Metadata Tests
5645    // =========================================================================
5646
5647    #[test]
5648    fn test_tensor_set_format_planar() {
5649        let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
5650        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
5651        assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
5652        assert_eq!(tensor.width(), Some(640));
5653        assert_eq!(tensor.height(), Some(480));
5654    }
5655
5656    #[test]
5657    fn test_tensor_set_format_interleaved() {
5658        let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
5659        tensor.set_format(PixelFormat::Rgba).unwrap();
5660        assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
5661        assert_eq!(tensor.width(), Some(640));
5662        assert_eq!(tensor.height(), Some(480));
5663    }
5664
5665    #[test]
5666    fn test_tensordyn_image_rgb() {
5667        let img = TensorDyn::image(640, 480, PixelFormat::Rgb, DType::U8, None).unwrap();
5668        assert_eq!(img.width(), Some(640));
5669        assert_eq!(img.height(), Some(480));
5670        assert_eq!(img.format(), Some(PixelFormat::Rgb));
5671    }
5672
5673    #[test]
5674    fn test_tensordyn_image_planar_rgb() {
5675        let img = TensorDyn::image(640, 480, PixelFormat::PlanarRgb, DType::U8, None).unwrap();
5676        assert_eq!(img.width(), Some(640));
5677        assert_eq!(img.height(), Some(480));
5678        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
5679    }
5680
5681    #[test]
5682    fn test_rgb_int8_format() {
5683        // Int8 variant: same PixelFormat::Rgb but with DType::I8
5684        let img = TensorDyn::image(
5685            1280,
5686            720,
5687            PixelFormat::Rgb,
5688            DType::I8,
5689            Some(TensorMemory::Mem),
5690        )
5691        .unwrap();
5692        assert_eq!(img.width(), Some(1280));
5693        assert_eq!(img.height(), Some(720));
5694        assert_eq!(img.format(), Some(PixelFormat::Rgb));
5695        assert_eq!(img.dtype(), DType::I8);
5696    }
5697
5698    #[test]
5699    fn test_planar_rgb_int8_format() {
5700        let img = TensorDyn::image(
5701            1280,
5702            720,
5703            PixelFormat::PlanarRgb,
5704            DType::I8,
5705            Some(TensorMemory::Mem),
5706        )
5707        .unwrap();
5708        assert_eq!(img.width(), Some(1280));
5709        assert_eq!(img.height(), Some(720));
5710        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
5711        assert_eq!(img.dtype(), DType::I8);
5712    }
5713
5714    #[test]
5715    fn test_rgb_from_tensor() {
5716        let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
5717        tensor.set_format(PixelFormat::Rgb).unwrap();
5718        let img = TensorDyn::from(tensor);
5719        assert_eq!(img.width(), Some(1280));
5720        assert_eq!(img.height(), Some(720));
5721        assert_eq!(img.format(), Some(PixelFormat::Rgb));
5722    }
5723
5724    #[test]
5725    fn test_planar_rgb_from_tensor() {
5726        let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
5727        tensor.set_format(PixelFormat::PlanarRgb).unwrap();
5728        let img = TensorDyn::from(tensor);
5729        assert_eq!(img.width(), Some(1280));
5730        assert_eq!(img.height(), Some(720));
5731        assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
5732    }
5733
5734    #[test]
5735    fn test_dtype_determines_int8() {
5736        // DType::I8 indicates int8 data
5737        let u8_img = TensorDyn::image(64, 64, PixelFormat::Rgb, DType::U8, None).unwrap();
5738        let i8_img = TensorDyn::image(64, 64, PixelFormat::Rgb, DType::I8, None).unwrap();
5739        assert_eq!(u8_img.dtype(), DType::U8);
5740        assert_eq!(i8_img.dtype(), DType::I8);
5741    }
5742
5743    #[test]
5744    fn test_pixel_layout_packed_vs_planar() {
5745        // Packed vs planar layout classification
5746        assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
5747        assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
5748        assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
5749        assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
5750    }
5751
5752    /// Integration test that exercises the PBO-to-PBO convert path.
5753    /// Uses ImageProcessor::create_image() to allocate PBO-backed tensors,
5754    /// then converts between them. Skipped when GL is unavailable or the
5755    /// backend is not PBO (e.g. DMA-buf systems).
5756    #[cfg(target_os = "linux")]
5757    #[cfg(feature = "opengl")]
5758    #[test]
5759    fn test_convert_pbo_to_pbo() {
5760        let mut converter = ImageProcessor::new().unwrap();
5761
5762        // Skip if GL is not available or backend is not PBO
5763        let is_pbo = converter
5764            .opengl
5765            .as_ref()
5766            .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
5767        if !is_pbo {
5768            eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
5769            return;
5770        }
5771
5772        let src_w = 640;
5773        let src_h = 480;
5774        let dst_w = 320;
5775        let dst_h = 240;
5776
5777        // Create PBO-backed source image
5778        let pbo_src = converter
5779            .create_image(src_w, src_h, PixelFormat::Rgba, DType::U8, None)
5780            .unwrap();
5781        assert_eq!(
5782            pbo_src.as_u8().unwrap().memory(),
5783            TensorMemory::Pbo,
5784            "create_image should produce a PBO tensor"
5785        );
5786
5787        // Fill source PBO with test pattern: load JPEG then convert Mem→PBO
5788        let file = include_bytes!(concat!(
5789            env!("CARGO_MANIFEST_DIR"),
5790            "/../../testdata/zidane.jpg"
5791        ))
5792        .to_vec();
5793        let jpeg_src = crate::load_image(&file, Some(PixelFormat::Rgba), None).unwrap();
5794
5795        // Resize JPEG into a Mem temp of the right size, then copy into PBO
5796        let mem_src = TensorDyn::image(
5797            src_w,
5798            src_h,
5799            PixelFormat::Rgba,
5800            DType::U8,
5801            Some(TensorMemory::Mem),
5802        )
5803        .unwrap();
5804        let (result, _jpeg_src, mem_src) = convert_img(
5805            &mut CPUProcessor::new(),
5806            jpeg_src,
5807            mem_src,
5808            Rotation::None,
5809            Flip::None,
5810            Crop::no_crop(),
5811        );
5812        result.unwrap();
5813
5814        // Copy pixel data into the PBO source by mapping it
5815        {
5816            let src_data = mem_src.as_u8().unwrap().map().unwrap();
5817            let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
5818            pbo_map.copy_from_slice(&src_data);
5819        }
5820
5821        // Create PBO-backed destination image
5822        let pbo_dst = converter
5823            .create_image(dst_w, dst_h, PixelFormat::Rgba, DType::U8, None)
5824            .unwrap();
5825        assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
5826
5827        // Convert PBO→PBO (this exercises convert_pbo_to_pbo)
5828        let mut pbo_dst = pbo_dst;
5829        let result = converter.convert(
5830            &pbo_src,
5831            &mut pbo_dst,
5832            Rotation::None,
5833            Flip::None,
5834            Crop::no_crop(),
5835        );
5836        result.unwrap();
5837
5838        // Verify: compare with CPU-only conversion of the same input
5839        let cpu_dst = TensorDyn::image(
5840            dst_w,
5841            dst_h,
5842            PixelFormat::Rgba,
5843            DType::U8,
5844            Some(TensorMemory::Mem),
5845        )
5846        .unwrap();
5847        let (result, _mem_src, cpu_dst) = convert_img(
5848            &mut CPUProcessor::new(),
5849            mem_src,
5850            cpu_dst,
5851            Rotation::None,
5852            Flip::None,
5853            Crop::no_crop(),
5854        );
5855        result.unwrap();
5856
5857        let pbo_dst_img = {
5858            let mut __t = pbo_dst.into_u8().unwrap();
5859            __t.set_format(PixelFormat::Rgba).unwrap();
5860            TensorDyn::from(__t)
5861        };
5862        compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
5863        log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
5864    }
5865
5866    #[test]
5867    fn test_image_bgra() {
5868        let img = TensorDyn::image(
5869            640,
5870            480,
5871            PixelFormat::Bgra,
5872            DType::U8,
5873            Some(edgefirst_tensor::TensorMemory::Mem),
5874        )
5875        .unwrap();
5876        assert_eq!(img.width(), Some(640));
5877        assert_eq!(img.height(), Some(480));
5878        assert_eq!(img.format().unwrap().channels(), 4);
5879        assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
5880    }
5881
5882    // ========================================================================
5883    // Tests for EDGEFIRST_FORCE_BACKEND env var
5884    // ========================================================================
5885
5886    #[test]
5887    fn test_force_backend_cpu() {
5888        let original = std::env::var("EDGEFIRST_FORCE_BACKEND").ok();
5889        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
5890        let result = ImageProcessor::new();
5891        match original {
5892            Some(s) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", s) },
5893            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
5894        }
5895        let converter = result.unwrap();
5896        assert!(converter.cpu.is_some());
5897        assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
5898    }
5899
5900    #[test]
5901    fn test_force_backend_invalid() {
5902        let original = std::env::var("EDGEFIRST_FORCE_BACKEND").ok();
5903        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
5904        let result = ImageProcessor::new();
5905        match original {
5906            Some(s) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", s) },
5907            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
5908        }
5909        assert!(
5910            matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
5911            "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
5912        );
5913    }
5914
5915    #[test]
5916    fn test_force_backend_unset() {
5917        let original = std::env::var("EDGEFIRST_FORCE_BACKEND").ok();
5918        unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
5919        let result = ImageProcessor::new();
5920        match original {
5921            Some(s) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", s) },
5922            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
5923        }
5924        let converter = result.unwrap();
5925        assert!(converter.forced_backend.is_none());
5926    }
5927
5928    // ========================================================================
5929    // Tests for hybrid mask path error handling
5930    // ========================================================================
5931
5932    #[test]
5933    fn test_draw_proto_masks_no_cpu_returns_error() {
5934        // Disable CPU backend to trigger the error path
5935        let original_cpu = std::env::var("EDGEFIRST_DISABLE_CPU").ok();
5936        unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
5937        let original_gl = std::env::var("EDGEFIRST_DISABLE_GL").ok();
5938        unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
5939        let original_g2d = std::env::var("EDGEFIRST_DISABLE_G2D").ok();
5940        unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
5941
5942        let result = ImageProcessor::new();
5943
5944        match original_cpu {
5945            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", s) },
5946            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") },
5947        }
5948        match original_gl {
5949            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", s) },
5950            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") },
5951        }
5952        match original_g2d {
5953            Some(s) => unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", s) },
5954            None => unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") },
5955        }
5956
5957        let mut converter = result.unwrap();
5958        assert!(converter.cpu.is_none(), "CPU should be disabled");
5959
5960        let dst = TensorDyn::image(
5961            640,
5962            480,
5963            PixelFormat::Rgba,
5964            DType::U8,
5965            Some(TensorMemory::Mem),
5966        )
5967        .unwrap();
5968        let mut dst_dyn = dst;
5969        let det = [DetectBox {
5970            bbox: edgefirst_decoder::BoundingBox {
5971                xmin: 0.1,
5972                ymin: 0.1,
5973                xmax: 0.5,
5974                ymax: 0.5,
5975            },
5976            score: 0.9,
5977            label: 0,
5978        }];
5979        let proto_data = ProtoData {
5980            mask_coefficients: vec![vec![0.5; 4]],
5981            protos: edgefirst_decoder::ProtoTensor::Float(ndarray::Array3::<f32>::zeros((8, 8, 4))),
5982        };
5983        let result =
5984            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
5985        assert!(
5986            matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
5987            "draw_proto_masks without CPU should return Internal error: {result:?}"
5988        );
5989    }
5990
5991    #[test]
5992    fn test_draw_proto_masks_cpu_fallback_works() {
5993        // Force CPU-only backend to ensure the CPU fallback path executes
5994        let original = std::env::var("EDGEFIRST_FORCE_BACKEND").ok();
5995        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
5996        let result = ImageProcessor::new();
5997        match original {
5998            Some(s) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", s) },
5999            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
6000        }
6001
6002        let mut converter = result.unwrap();
6003        assert!(converter.cpu.is_some());
6004
6005        let dst = TensorDyn::image(
6006            64,
6007            64,
6008            PixelFormat::Rgba,
6009            DType::U8,
6010            Some(TensorMemory::Mem),
6011        )
6012        .unwrap();
6013        let mut dst_dyn = dst;
6014        let det = [DetectBox {
6015            bbox: edgefirst_decoder::BoundingBox {
6016                xmin: 0.1,
6017                ymin: 0.1,
6018                xmax: 0.5,
6019                ymax: 0.5,
6020            },
6021            score: 0.9,
6022            label: 0,
6023        }];
6024        let proto_data = ProtoData {
6025            mask_coefficients: vec![vec![0.5; 4]],
6026            protos: edgefirst_decoder::ProtoTensor::Float(ndarray::Array3::<f32>::zeros((8, 8, 4))),
6027        };
6028        let result =
6029            converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
6030        assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
6031    }
6032
6033    #[test]
6034    fn test_set_format_then_cpu_convert() {
6035        // Force CPU backend (save/restore to avoid leaking into other tests)
6036        let original = std::env::var("EDGEFIRST_FORCE_BACKEND").ok();
6037        unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
6038        let mut processor = ImageProcessor::new().unwrap();
6039        match original {
6040            Some(s) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", s) },
6041            None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
6042        }
6043
6044        // Load a source image
6045        let image = include_bytes!(concat!(
6046            env!("CARGO_MANIFEST_DIR"),
6047            "/../../testdata/zidane.jpg"
6048        ));
6049        let src = load_image(image, Some(PixelFormat::Rgba), None).unwrap();
6050
6051        // Create a raw tensor, then attach format — simulating the from_fd workflow
6052        let mut dst =
6053            TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
6054        dst.set_format(PixelFormat::Rgb).unwrap();
6055
6056        // Convert should work with the set_format-annotated tensor
6057        processor
6058            .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6059            .unwrap();
6060
6061        // Verify format survived conversion
6062        assert_eq!(dst.format(), Some(PixelFormat::Rgb));
6063        assert_eq!(dst.width(), Some(640));
6064        assert_eq!(dst.height(), Some(640));
6065    }
6066
6067    /// Verify that creating multiple ImageProcessors on the same thread and
6068    /// performing a resize on each does not deadlock or error.
6069    ///
6070    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
6071    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
6072    #[test]
6073    fn test_multiple_image_processors_same_thread() {
6074        let mut processors: Vec<ImageProcessor> = (0..4)
6075            .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
6076            .collect();
6077
6078        for proc in &mut processors {
6079            let src = proc
6080                .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
6081                .expect("create src failed");
6082            let mut dst = proc
6083                .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
6084                .expect("create dst failed");
6085            proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6086                .expect("convert failed");
6087            assert_eq!(dst.width(), Some(64));
6088            assert_eq!(dst.height(), Some(64));
6089        }
6090    }
6091
6092    /// Verify that creating ImageProcessors on separate threads and performing
6093    /// a resize on each does not deadlock or error.
6094    ///
6095    /// Uses automatic memory allocation (DMA → PBO → Mem fallback) so that
6096    /// hardware backends (OpenGL, G2D) are exercised on capable targets.
6097    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
6098    #[test]
6099    fn test_multiple_image_processors_separate_threads() {
6100        use std::sync::mpsc;
6101        use std::time::Duration;
6102
6103        const TIMEOUT: Duration = Duration::from_secs(60);
6104
6105        let (tx, rx) = mpsc::channel::<()>();
6106
6107        std::thread::spawn(move || {
6108            let handles: Vec<_> = (0..4)
6109                .map(|i| {
6110                    std::thread::spawn(move || {
6111                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
6112                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
6113                        });
6114                        let src = proc
6115                            .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
6116                            .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
6117                        let mut dst = proc
6118                            .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
6119                            .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
6120                        proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
6121                            .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
6122                        assert_eq!(dst.width(), Some(64));
6123                        assert_eq!(dst.height(), Some(64));
6124                    })
6125                })
6126                .collect();
6127
6128            for (i, h) in handles.into_iter().enumerate() {
6129                h.join()
6130                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
6131            }
6132
6133            let _ = tx.send(());
6134        });
6135
6136        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
6137            panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
6138        });
6139    }
6140
6141    /// Verify that 4 fully-initialized ImageProcessors on separate threads can
6142    /// all operate concurrently without deadlocking each other.
6143    ///
6144    /// All processors are created first, then a barrier synchronizes them so
6145    /// they all start converting at the same instant — maximizing contention.
6146    /// A 60-second timeout prevents CI from hanging on deadlock regressions.
6147    #[test]
6148    fn test_image_processors_concurrent_operations() {
6149        use std::sync::{mpsc, Arc, Barrier};
6150        use std::time::Duration;
6151
6152        const N: usize = 4;
6153        const ROUNDS: usize = 10;
6154        const TIMEOUT: Duration = Duration::from_secs(60);
6155
6156        let (tx, rx) = mpsc::channel::<()>();
6157
6158        std::thread::spawn(move || {
6159            let barrier = Arc::new(Barrier::new(N));
6160
6161            let handles: Vec<_> = (0..N)
6162                .map(|i| {
6163                    let barrier = Arc::clone(&barrier);
6164                    std::thread::spawn(move || {
6165                        let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
6166                            panic!("ImageProcessor::new() failed on thread {i}: {e}")
6167                        });
6168
6169                        // All threads wait here until every processor is initialized.
6170                        barrier.wait();
6171
6172                        // Now all 4 hammer the GPU concurrently.
6173                        for round in 0..ROUNDS {
6174                            let src = proc
6175                                .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
6176                                .unwrap_or_else(|e| {
6177                                    panic!("create src failed on thread {i} round {round}: {e}")
6178                                });
6179                            let mut dst = proc
6180                                .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
6181                                .unwrap_or_else(|e| {
6182                                    panic!("create dst failed on thread {i} round {round}: {e}")
6183                                });
6184                            proc.convert(
6185                                &src,
6186                                &mut dst,
6187                                Rotation::None,
6188                                Flip::None,
6189                                Crop::default(),
6190                            )
6191                            .unwrap_or_else(|e| {
6192                                panic!("convert failed on thread {i} round {round}: {e}")
6193                            });
6194                            assert_eq!(dst.width(), Some(64));
6195                            assert_eq!(dst.height(), Some(64));
6196                        }
6197                    })
6198                })
6199                .collect();
6200
6201            for (i, h) in handles.into_iter().enumerate() {
6202                h.join()
6203                    .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
6204            }
6205
6206            let _ = tx.send(());
6207        });
6208
6209        rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
6210            panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
6211        });
6212    }
6213}