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