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