#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[cfg(all(coverage, target_os = "linux"))]
#[used]
#[link_section = ".init_array"]
static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
extern "C" fn ctor() {
edgefirst_tensor::covguard::install();
}
ctor
};
pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
if bpp == 0 || width == 0 {
return width;
}
let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
else {
log::warn!(
"align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
overflows usize, returning unaligned width {width}"
);
return width;
};
if lcm_alignment == 0 {
return width;
}
debug_assert_eq!(lcm_alignment % bpp, 0);
let width_alignment = lcm_alignment / bpp;
if width_alignment == 0 {
return width;
}
let remainder = width % width_alignment;
if remainder == 0 {
return width;
}
let pad = width_alignment - remainder;
match width.checked_add(pad) {
Some(aligned) => aligned,
None => {
log::warn!(
"align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
returning unaligned (caller should use a smaller width or pre-aligned size)"
);
width
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
if min_pitch_bytes == 0 {
return Some(0);
}
let remainder = min_pitch_bytes % alignment;
if remainder == 0 {
return Some(min_pitch_bytes);
}
min_pitch_bytes.checked_add(alignment - remainder)
}
fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
if a == 0 || b == 0 {
return Some(0);
}
let g = num_integer_gcd(a, b);
(a / g).checked_mul(b)
}
fn num_integer_gcd(a: usize, b: usize) -> usize {
if b == 0 {
a
} else {
num_integer_gcd(b, a % b)
}
}
pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
use edgefirst_tensor::PixelLayout;
match format.layout() {
PixelLayout::Packed => Some(format.channels() * elem),
PixelLayout::Planar => Some(elem),
PixelLayout::SemiPlanar => Some(elem),
_ => None,
}
}
#[cfg(all(target_os = "linux", test))]
pub(crate) fn padded_dma_pitch_for(
fmt: PixelFormat,
width: usize,
memory: &Option<TensorMemory>,
) -> Option<usize> {
match memory {
Some(TensorMemory::Dma) => {}
None if edgefirst_tensor::is_dma_available() => {}
_ => return None,
}
if fmt.layout() != PixelLayout::Packed {
return None;
}
let bpp = primary_plane_bpp(fmt, 1)?;
let natural = width.checked_mul(bpp)?;
let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
if aligned > natural {
Some(aligned)
} else {
None
}
}
pub use cpu::CPUProcessor;
pub use edgefirst_codec as codec;
#[cfg(test)]
use edgefirst_decoder::ProtoLayout;
use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
#[doc(inline)]
pub use edgefirst_tensor::Region;
#[cfg(any(test, all(target_os = "linux", feature = "opengl")))]
use edgefirst_tensor::Tensor;
use edgefirst_tensor::{
DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
};
use enum_dispatch::enum_dispatch;
pub use error::{Error, Result};
#[cfg(target_os = "linux")]
pub use g2d::G2DProcessor;
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub use opengl_headless::EglDisplayKind;
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub use opengl_headless::GLProcessorThreaded;
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub use opengl_headless::Int8InterpolationMode;
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
use std::{fmt::Display, time::Instant};
mod colorimetry;
mod cpu;
mod error;
mod g2d;
#[path = "gl/mod.rs"]
mod opengl_headless;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rotation {
None = 0,
Clockwise90 = 1,
Rotate180 = 2,
CounterClockwise90 = 3,
}
impl Rotation {
pub fn from_degrees_clockwise(angle: usize) -> Rotation {
match angle.rem_euclid(360) {
0 => Rotation::None,
90 => Rotation::Clockwise90,
180 => Rotation::Rotate180,
270 => Rotation::CounterClockwise90,
_ => panic!("rotation angle is not a multiple of 90"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flip {
None = 0,
Vertical = 1,
Horizontal = 2,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorMode {
#[default]
Class,
Instance,
Track,
}
impl ColorMode {
#[inline]
pub fn index(self, idx: usize, label: usize) -> usize {
match self {
ColorMode::Class => label,
ColorMode::Instance | ColorMode::Track => idx,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum MaskResolution {
#[default]
Proto,
Scaled {
width: u32,
height: u32,
},
}
#[derive(Debug, Clone, Copy)]
pub struct MaskOverlay<'a> {
pub background: Option<&'a TensorDyn>,
pub opacity: f32,
pub letterbox: Option<[f32; 4]>,
pub color_mode: ColorMode,
}
impl Default for MaskOverlay<'_> {
fn default() -> Self {
Self {
background: None,
opacity: 1.0,
letterbox: None,
color_mode: ColorMode::Class,
}
}
}
impl<'a> MaskOverlay<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
self.background = Some(bg);
self
}
pub fn with_opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity.clamp(0.0, 1.0);
self
}
pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
self.color_mode = mode;
self
}
pub fn with_letterbox_crop(
mut self,
crop: &Crop,
src_w: usize,
src_h: usize,
model_w: usize,
model_h: usize,
) -> Self {
if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
if let Some(r) = resolved.dst_rect {
self.letterbox = Some([
r.left as f32 / model_w as f32,
r.top as f32 / model_h as f32,
(r.left + r.width) as f32 / model_w as f32,
(r.top + r.height) as f32 / model_h as f32,
]);
}
}
self
}
}
#[inline]
fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
let b = bbox.bbox.to_canonical();
let [lx0, ly0, lx1, ly1] = lb;
let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
DetectBox {
bbox: edgefirst_decoder::BoundingBox {
xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
},
..bbox
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Fit {
#[default]
Stretch,
Letterbox { pad: [u8; 4] },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Crop {
pub source: Option<Region>,
pub fit: Fit,
}
impl Crop {
pub fn new() -> Self {
Self::default()
}
pub fn no_crop() -> Self {
Self::default()
}
pub fn letterbox(pad: [u8; 4]) -> Self {
Self {
source: None,
fit: Fit::Letterbox { pad },
}
}
pub fn with_source(mut self, source: Option<Region>) -> Self {
self.source = source;
self
}
pub fn with_fit(mut self, fit: Fit) -> Self {
self.fit = fit;
self
}
pub(crate) fn resolve(
&self,
src_w: usize,
src_h: usize,
dst_w: usize,
dst_h: usize,
) -> Result<ResolvedCrop, Error> {
let src_rect = self.source.map(region_to_rect);
let (sw, sh) = match self.source {
Some(r) => (r.width, r.height),
None => (src_w, src_h),
};
let resolved = match self.fit {
Fit::Stretch => ResolvedCrop {
src_rect,
dst_rect: None,
dst_color: None,
},
Fit::Letterbox { pad } => ResolvedCrop {
src_rect,
dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
dst_color: Some(pad),
},
};
resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
Ok(resolved)
}
pub fn check_crop_dyn(
&self,
src: &edgefirst_tensor::TensorDyn,
dst: &edgefirst_tensor::TensorDyn,
) -> Result<(), Error> {
self.resolve(
src.width().unwrap_or(0),
src.height().unwrap_or(0),
dst.width().unwrap_or(0),
dst.height().unwrap_or(0),
)
.map(|_| ())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct ResolvedCrop {
pub(crate) src_rect: Option<Rect>,
pub(crate) dst_rect: Option<Rect>,
pub(crate) dst_color: Option<[u8; 4]>,
}
impl ResolvedCrop {
#[allow(dead_code)] pub(crate) fn no_crop() -> Self {
Self::default()
}
pub(crate) fn check_crop_dims(
&self,
src_w: usize,
src_h: usize,
dst_w: usize,
dst_h: usize,
) -> Result<(), Error> {
let src_ok = self
.src_rect
.is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
let dst_ok = self
.dst_rect
.is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
match (src_ok, dst_ok) {
(true, true) => Ok(()),
(true, false) => Err(Error::CropInvalid(format!(
"Dest crop invalid: {:?}",
self.dst_rect
))),
(false, true) => Err(Error::CropInvalid(format!(
"Src crop invalid: {:?}",
self.src_rect
))),
(false, false) => Err(Error::CropInvalid(format!(
"Dest and Src crop invalid: {:?} {:?}",
self.dst_rect, self.src_rect
))),
}
}
}
fn region_to_rect(r: Region) -> Rect {
Rect {
left: r.x,
top: r.y,
width: r.width,
height: r.height,
}
}
fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
if sw == 0 || sh == 0 {
return Rect::new(0, 0, dw, dh);
}
let src_aspect = sw as f64 / sh as f64;
let dst_aspect = dw as f64 / dh as f64;
let (new_w, new_h) = if src_aspect > dst_aspect {
(dw, ((dw as f64 / src_aspect).round() as usize).max(1))
} else {
(((dh as f64 * src_aspect).round() as usize).max(1), dh)
};
let left = dw.saturating_sub(new_w) / 2;
let top = dh.saturating_sub(new_h) / 2;
Rect::new(left, top, new_w, new_h)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Rect {
pub left: usize,
pub top: usize,
pub width: usize,
pub height: usize,
}
impl Rect {
pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
Self {
left,
top,
width,
height,
}
}
}
#[enum_dispatch(ImageProcessor)]
pub trait ImageProcessorTrait {
fn convert(
&mut self,
src: &TensorDyn,
dst: &mut TensorDyn,
rotation: Rotation,
flip: Flip,
crop: Crop,
) -> Result<()>;
fn draw_decoded_masks(
&mut self,
dst: &mut TensorDyn,
detect: &[DetectBox],
segmentation: &[Segmentation],
overlay: MaskOverlay<'_>,
) -> Result<()>;
fn draw_proto_masks(
&mut self,
dst: &mut TensorDyn,
detect: &[DetectBox],
proto_data: &ProtoData,
overlay: MaskOverlay<'_>,
) -> Result<()>;
fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
fn convert_deferred(
&mut self,
src: &TensorDyn,
dst: &mut TensorDyn,
rotation: Rotation,
flip: Flip,
crop: Crop,
) -> Result<()> {
self.convert(src, dst, rotation, flip, crop)
}
fn flush(&mut self) -> Result<()> {
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct ImageProcessorConfig {
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub egl_display: Option<EglDisplayKind>,
pub backend: ComputeBackend,
pub colorimetry: ColorimetryMode,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorimetryMode {
#[default]
Fast,
Exact,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ComputeBackend {
#[default]
Auto,
Cpu,
G2d,
OpenGl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForcedBackend {
Cpu,
G2d,
OpenGl,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RenderDtypeSupport {
pub f32: bool,
pub f16: bool,
}
#[cfg(all(target_os = "linux", feature = "opengl"))]
pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
match dtype {
DType::F16 => support.f16,
DType::F32 => support.f32,
_ => false,
}
}
#[derive(Debug)]
pub struct ImageProcessor {
pub cpu: Option<CPUProcessor>,
#[cfg(target_os = "linux")]
pub g2d: Option<G2DProcessor>,
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
pub opengl: Option<GLProcessorThreaded>,
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
pub opengl: Option<GLProcessorThreaded>,
pub(crate) forced_backend: Option<ForcedBackend>,
pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
}
unsafe impl Send for ImageProcessor {}
unsafe impl Sync for ImageProcessor {}
impl ImageProcessor {
pub fn new() -> Result<Self> {
Self::with_config(ImageProcessorConfig::default())
}
pub fn convert_fallback_count(&self) -> u64 {
self.convert_fallbacks
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn compression_fallback_count(&self) -> u64 {
edgefirst_tensor::compression_fallback_count()
}
#[cfg(unix)]
pub fn convert_with_fence(
&mut self,
src: &TensorDyn,
dst: &mut TensorDyn,
rotation: Rotation,
flip: Flip,
crop: Crop,
) -> Result<Option<std::os::fd::OwnedFd>> {
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
))]
#[cfg(feature = "opengl")]
{
let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
if self.forced_backend.is_none() || gl_forced {
if let Some(opengl) = self.opengl.as_mut() {
match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
Ok(fd) => return Ok(fd),
Err(e) if gl_forced => return Err(e),
Err(e) => {
log::debug!(
"convert_with_fence: opengl declined, \
falling back to the blocking chain: {e}"
);
}
}
} else if gl_forced {
return Err(Error::ForcedBackendUnavailable("opengl".into()));
}
}
}
self.convert(src, dst, rotation, flip, crop)?;
Ok(None)
}
pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
#[cfg(all(
any(target_os = "macos", target_os = "ios", target_os = "android"),
feature = "opengl"
))]
if let Some(gl) = self.opengl.as_ref() {
return gl.supported_render_dtypes();
}
#[cfg(all(target_os = "linux", feature = "opengl"))]
if let Some(gl) = self.opengl.as_ref() {
return gl.supported_render_dtypes();
}
RenderDtypeSupport {
f32: false,
f16: false,
}
}
#[allow(unused_variables)]
pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
match config.backend {
ComputeBackend::Cpu => {
log::info!("ComputeBackend::Cpu — CPU only");
return Ok(Self {
cpu: Some(CPUProcessor::new()),
#[cfg(target_os = "linux")]
g2d: None,
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
opengl: None,
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
opengl: None,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
});
}
ComputeBackend::G2d => {
log::info!("ComputeBackend::G2d — G2D + CPU fallback");
#[cfg(target_os = "linux")]
{
let g2d = match G2DProcessor::new() {
Ok(g) => Some(g),
Err(e) => {
log::warn!("G2D requested but failed to initialize: {e:?}");
None
}
};
return Ok(Self {
cpu: Some(CPUProcessor::new()),
g2d,
#[cfg(feature = "opengl")]
opengl: None,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
});
}
#[cfg(not(target_os = "linux"))]
{
log::warn!("G2D requested but not available on this platform, using CPU");
return Ok(Self {
cpu: Some(CPUProcessor::new()),
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
opengl: None,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
});
}
}
ComputeBackend::OpenGl => {
log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
#[cfg(target_os = "linux")]
{
#[cfg(feature = "opengl")]
let opengl = match GLProcessorThreaded::new(config.egl_display) {
Ok(gl) => Some(gl),
Err(e) => {
log::warn!("OpenGL requested but failed to initialize: {e:?}");
None
}
};
return Ok(Self {
cpu: Some(CPUProcessor::new()),
g2d: None,
#[cfg(feature = "opengl")]
opengl,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
}
.apply_colorimetry_mode(config.colorimetry));
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
{
#[cfg(feature = "opengl")]
let opengl = match GLProcessorThreaded::new(config.egl_display) {
Ok(gl) => Some(gl),
Err(e) => {
log::warn!(
"OpenGL requested on macOS but ANGLE init failed: {e:?} \
(install ANGLE via `brew install startergo/angle/angle` \
and re-sign the dylibs — see README.md § macOS GPU \
Acceleration). Falling back to CPU."
);
None
}
};
return Ok(Self {
cpu: Some(CPUProcessor::new()),
#[cfg(feature = "opengl")]
opengl,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
}
.apply_colorimetry_mode(config.colorimetry));
}
#[cfg(target_os = "android")]
{
#[cfg(feature = "opengl")]
let opengl = match GLProcessorThreaded::new(config.egl_display) {
Ok(gl) => Some(gl),
Err(e) => {
log::warn!(
"OpenGL requested but native EGL init failed: {e:?}. \
Falling back to CPU."
);
None
}
};
return Ok(Self {
cpu: Some(CPUProcessor::new()),
#[cfg(feature = "opengl")]
opengl,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
}
.apply_colorimetry_mode(config.colorimetry));
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
)))]
{
log::warn!("OpenGL requested but not available on this platform, using CPU");
return Ok(Self {
cpu: Some(CPUProcessor::new()),
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
});
}
}
ComputeBackend::Auto => { }
}
if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
let val_lower = val.to_lowercase();
let forced = match val_lower.as_str() {
"cpu" => ForcedBackend::Cpu,
"g2d" => ForcedBackend::G2d,
"opengl" => ForcedBackend::OpenGl,
other => {
return Err(Error::ForcedBackendUnavailable(format!(
"unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
)));
}
};
log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
return match forced {
ForcedBackend::Cpu => Ok(Self {
cpu: Some(CPUProcessor::new()),
#[cfg(target_os = "linux")]
g2d: None,
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
opengl: None,
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
opengl: None,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: Some(ForcedBackend::Cpu),
}),
ForcedBackend::G2d => {
#[cfg(target_os = "linux")]
{
let g2d = G2DProcessor::new().map_err(|e| {
Error::ForcedBackendUnavailable(format!(
"g2d forced but failed to initialize: {e:?}"
))
})?;
Ok(Self {
cpu: None,
g2d: Some(g2d),
#[cfg(feature = "opengl")]
opengl: None,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: Some(ForcedBackend::G2d),
})
}
#[cfg(not(target_os = "linux"))]
{
Err(Error::ForcedBackendUnavailable(
"g2d backend is only available on Linux".into(),
))
}
}
ForcedBackend::OpenGl => {
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
{
let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
Error::ForcedBackendUnavailable(format!(
"opengl forced but failed to initialize: {e:?}"
))
})?;
Ok(Self {
cpu: None,
g2d: None,
opengl: Some(opengl),
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: Some(ForcedBackend::OpenGl),
}
.apply_colorimetry_mode(config.colorimetry))
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[cfg(feature = "opengl")]
{
let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
Error::ForcedBackendUnavailable(format!(
"opengl forced on macOS but ANGLE init failed: {e:?}"
))
})?;
Ok(Self {
cpu: None,
opengl: Some(opengl),
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: Some(ForcedBackend::OpenGl),
}
.apply_colorimetry_mode(config.colorimetry))
}
#[cfg(target_os = "android")]
#[cfg(feature = "opengl")]
{
let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
Error::ForcedBackendUnavailable(format!(
"opengl forced but native EGL init failed: {e:?}"
))
})?;
Ok(Self {
cpu: None,
opengl: Some(opengl),
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: Some(ForcedBackend::OpenGl),
}
.apply_colorimetry_mode(config.colorimetry))
}
#[cfg(not(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
)))]
{
Err(Error::ForcedBackendUnavailable(
"opengl backend requires Linux or macOS with the 'opengl' feature \
enabled"
.into(),
))
}
}
};
}
#[cfg(target_os = "linux")]
let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
.map(|x| x != "0" && x.to_lowercase() != "false")
.unwrap_or(false)
{
log::debug!("EDGEFIRST_DISABLE_G2D is set");
None
} else {
match G2DProcessor::new() {
Ok(g2d_converter) => Some(g2d_converter),
Err(err) => {
log::warn!("Failed to initialize G2D converter: {err:?}");
None
}
}
};
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
.map(|x| x != "0" && x.to_lowercase() != "false")
.unwrap_or(false)
{
log::debug!("EDGEFIRST_DISABLE_GL is set");
None
} else {
match GLProcessorThreaded::new(config.egl_display) {
Ok(gl_converter) => Some(gl_converter),
Err(err) => {
log::warn!("Failed to initialize GL converter: {err:?}");
None
}
}
};
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
.map(|x| x != "0" && x.to_lowercase() != "false")
.unwrap_or(false)
{
log::debug!("EDGEFIRST_DISABLE_GL is set");
None
} else {
match GLProcessorThreaded::new(config.egl_display) {
Ok(gl_converter) => Some(gl_converter),
Err(err) => {
log::debug!(
"GL backend unavailable: {err:?} \
(CPU fallback will be used)"
);
None
}
}
};
let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
.map(|x| x != "0" && x.to_lowercase() != "false")
.unwrap_or(false)
{
log::debug!("EDGEFIRST_DISABLE_CPU is set");
None
} else {
Some(CPUProcessor::new())
};
Ok(Self {
cpu,
#[cfg(target_os = "linux")]
g2d,
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
opengl,
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
opengl,
convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
forced_backend: None,
}
.apply_colorimetry_mode(config.colorimetry))
}
fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
{
let mut me = self;
if let Err(e) = me.set_colorimetry_mode(_mode) {
log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
}
me
}
#[cfg(not(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
)))]
{
let _ = _mode;
self
}
}
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
if let Some(ref mut gl) = self.opengl {
gl.set_colorimetry_mode(mode)?;
}
Ok(())
}
#[cfg(all(
any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
),
feature = "opengl"
))]
pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
if let Some(ref mut gl) = self.opengl {
gl.set_int8_interpolation_mode(mode)?;
}
Ok(())
}
pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
if desc.compression().is_none() {
return self.create_image(
desc.width(),
desc.height(),
desc.format(),
desc.dtype(),
desc.memory(),
desc.access(),
);
}
Ok(TensorDyn::image_desc(desc)?)
}
pub fn create_image(
&self,
width: usize,
height: usize,
format: PixelFormat,
dtype: DType,
memory: Option<TensorMemory>,
access: edgefirst_tensor::CpuAccess,
) -> Result<TensorDyn> {
#[cfg(target_os = "linux")]
let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
.and_then(|bpp| width.checked_mul(bpp))
.and_then(align_pitch_bytes_to_gpu_alignment);
#[cfg(target_os = "linux")]
let try_dma = || -> Result<TensorDyn> {
let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
match dma_stride_bytes {
Some(stride)
if packed
&& primary_plane_bpp(format, dtype.size())
.and_then(|bpp| width.checked_mul(bpp))
.is_some_and(|natural| stride > natural) =>
{
log::debug!(
"create_image: padding row stride for {format:?} {width}x{height} \
from natural pitch to {stride} bytes for GPU alignment"
);
Ok(TensorDyn::image_with_stride(
width,
height,
format,
dtype,
stride,
Some(edgefirst_tensor::TensorMemory::Dma),
access,
)?)
}
_ => Ok(TensorDyn::image(
width,
height,
format,
dtype,
Some(edgefirst_tensor::TensorMemory::Dma),
access,
)?),
}
};
match memory {
#[cfg(target_os = "linux")]
Some(TensorMemory::Dma) => {
if dtype == DType::F32 {
return Err(Error::NotSupported(
"F32 has no 32-bit-float DRM format for DMA-BUF; \
use TensorMemory::Pbo for F32"
.to_string(),
));
}
return try_dma();
}
Some(mem) => {
return Ok(TensorDyn::image(
width,
height,
format,
dtype,
Some(mem),
access,
)?);
}
None => {}
}
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
#[cfg(feature = "opengl")]
if let Some(gl) = self.opengl.as_ref() {
let _ = gl; match TensorDyn::image(
width,
height,
format,
dtype,
Some(edgefirst_tensor::TensorMemory::Dma),
access,
) {
Ok(img) => return Ok(img),
Err(e) => {
log::debug!(
"create_image: zero-copy Dma allocation declined \
({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
);
}
}
}
#[cfg(target_os = "linux")]
{
#[cfg(feature = "opengl")]
let gl_uses_pbo = self
.opengl
.as_ref()
.is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
#[cfg(not(feature = "opengl"))]
let gl_uses_pbo = false;
if !gl_uses_pbo {
if let Ok(img) = try_dma() {
return Ok(img);
}
}
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if dtype.size() == 1 {
if let Some(gl) = &self.opengl {
match gl.create_pbo_image(width, height, format) {
Ok(t) => {
if dtype == DType::I8 {
debug_assert!(
t.chroma().is_none(),
"PBO i8 transmute requires chroma == None"
);
let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
return Ok(TensorDyn::from(t_i8));
}
return Ok(TensorDyn::from(t));
}
Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
}
}
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
if let Some(gl) = &self.opengl {
match gl.create_pbo_image_dtype(width, height, format, dtype) {
Ok(t) => return Ok(t),
Err(e) => {
log::debug!(
"Float PBO image creation failed for {dtype:?}, \
falling back to Mem: {e:?}"
);
}
}
}
}
Ok(TensorDyn::image(
width,
height,
format,
dtype,
Some(edgefirst_tensor::TensorMemory::Mem),
access,
)?)
}
#[allow(clippy::too_many_arguments)]
#[cfg(target_os = "linux")]
pub fn import_image(
&self,
image: edgefirst_tensor::PlaneDescriptor,
chroma: Option<edgefirst_tensor::PlaneDescriptor>,
width: usize,
height: usize,
format: PixelFormat,
dtype: DType,
colorimetry: Option<edgefirst_tensor::Colorimetry>,
) -> Result<TensorDyn> {
use edgefirst_tensor::{Tensor, TensorMemory};
let image_stride = image.stride();
let image_offset = image.offset();
let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
if let Some(chroma_pd) = chroma {
if dtype != DType::U8 && dtype != DType::I8 {
return Err(Error::NotSupported(format!(
"multiplane import only supports U8/I8, got {dtype:?}"
)));
}
if format.layout() != PixelLayout::SemiPlanar {
return Err(Error::NotSupported(format!(
"import_image with chroma requires a semi-planar format, got {format:?}"
)));
}
let chroma_h = match format {
PixelFormat::Nv12 => {
height.div_ceil(2)
}
PixelFormat::Nv16 => {
return Err(Error::NotSupported(
"multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
))
}
_ => {
return Err(Error::NotSupported(format!(
"unsupported semi-planar format: {format:?}"
)))
}
};
let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
if luma.memory() != TensorMemory::Dma {
return Err(Error::NotSupported(format!(
"luma fd must be DMA-backed, got {:?}",
luma.memory()
)));
}
let chroma_tensor =
Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
if chroma_tensor.memory() != TensorMemory::Dma {
return Err(Error::NotSupported(format!(
"chroma fd must be DMA-backed, got {:?}",
chroma_tensor.memory()
)));
}
let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
if let Some(s) = image_stride {
tensor.set_row_stride(s)?;
}
if let Some(o) = image_offset {
tensor.set_plane_offset(o);
}
if let Some(chroma_ref) = tensor.chroma_mut() {
if let Some(s) = chroma_stride {
if s < width {
return Err(Error::InvalidShape(format!(
"chroma stride {s} < minimum {width} for {format:?}"
)));
}
chroma_ref.set_row_stride_unchecked(s);
}
if let Some(o) = chroma_offset {
chroma_ref.set_plane_offset(o);
}
}
if dtype == DType::I8 {
const {
assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
assert!(
std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
);
}
let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
let mut dyn_tensor = TensorDyn::from(tensor_i8);
dyn_tensor.set_colorimetry(colorimetry);
return Ok(dyn_tensor);
}
let mut dyn_tensor = TensorDyn::from(tensor);
dyn_tensor.set_colorimetry(colorimetry);
Ok(dyn_tensor)
} else {
let shape = format.image_shape(width, height).ok_or_else(|| {
Error::NotSupported(format!(
"unsupported pixel format for import_image: {format:?}"
))
})?;
let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
if tensor.memory() != TensorMemory::Dma {
return Err(Error::NotSupported(format!(
"import_image requires DMA-backed fd, got {:?}",
tensor.memory()
)));
}
let mut tensor = tensor.with_format(format)?;
if let Some(s) = image_stride {
tensor.set_row_stride(s)?;
}
if let Some(o) = image_offset {
tensor.set_plane_offset(o);
}
tensor.set_colorimetry(colorimetry);
Ok(tensor)
}
}
pub fn draw_masks(
&mut self,
decoder: &edgefirst_decoder::Decoder,
outputs: &[&TensorDyn],
dst: &mut TensorDyn,
overlay: MaskOverlay<'_>,
) -> Result<Vec<DetectBox>> {
let mut output_boxes = Vec::with_capacity(100);
let proto_result = decoder
.decode_proto(outputs, &mut output_boxes)
.map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
if let Some(proto_data) = proto_result {
self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
} else {
let mut output_masks = Vec::with_capacity(100);
decoder
.decode(outputs, &mut output_boxes, &mut output_masks)
.map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
}
Ok(output_boxes)
}
#[cfg(feature = "tracker")]
pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
&mut self,
decoder: &edgefirst_decoder::Decoder,
tracker: &mut TR,
timestamp: u64,
outputs: &[&TensorDyn],
dst: &mut TensorDyn,
overlay: MaskOverlay<'_>,
) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
let mut output_boxes = Vec::with_capacity(100);
let mut output_tracks = Vec::new();
let proto_result = decoder
.decode_proto_tracked(
tracker,
timestamp,
outputs,
&mut output_boxes,
&mut output_tracks,
)
.map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
if let Some(proto_data) = proto_result {
self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
} else {
let mut output_masks = Vec::with_capacity(100);
decoder
.decode_tracked(
tracker,
timestamp,
outputs,
&mut output_boxes,
&mut output_masks,
&mut output_tracks,
)
.map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
}
Ok((output_boxes, output_tracks))
}
pub fn materialize_masks(
&mut self,
detect: &[DetectBox],
proto_data: &ProtoData,
letterbox: Option<[f32; 4]>,
resolution: MaskResolution,
) -> Result<Vec<Segmentation>> {
let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
match resolution {
MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
MaskResolution::Scaled { width, height } => {
cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
}
}
}
}
impl ImageProcessorTrait for ImageProcessor {
fn convert(
&mut self,
src: &TensorDyn,
dst: &mut TensorDyn,
rotation: Rotation,
flip: Flip,
crop: Crop,
) -> Result<()> {
let start = Instant::now();
let src_fmt = src.format();
let dst_fmt = dst.format();
let _span = tracing::trace_span!(
"image.convert",
?src_fmt,
?dst_fmt,
src_memory = ?src.memory(),
dst_memory = ?dst.memory(),
?rotation,
?flip,
)
.entered();
log::trace!(
"convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
rotation={rotation:?}, flip={flip:?}, backend={:?}",
src.dtype(),
src.memory(),
dst.dtype(),
dst.memory(),
self.forced_backend,
);
if let Some(forced) = self.forced_backend {
return match forced {
ForcedBackend::Cpu => {
if let Some(cpu) = self.cpu.as_mut() {
let r = cpu.convert(src, dst, rotation, flip, crop);
log::trace!(
"convert: forced=cpu result={} ({:?})",
if r.is_ok() { "ok" } else { "err" },
start.elapsed()
);
return r;
}
Err(Error::ForcedBackendUnavailable("cpu".into()))
}
ForcedBackend::G2d => {
#[cfg(target_os = "linux")]
if let Some(g2d) = self.g2d.as_mut() {
let r = g2d.convert(src, dst, rotation, flip, crop);
log::trace!(
"convert: forced=g2d result={} ({:?})",
if r.is_ok() { "ok" } else { "err" },
start.elapsed()
);
return r;
}
Err(Error::ForcedBackendUnavailable("g2d".into()))
}
ForcedBackend::OpenGl => {
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
))]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
let r = opengl.convert(src, dst, rotation, flip, crop);
log::trace!(
"convert: forced=opengl result={} ({:?})",
if r.is_ok() { "ok" } else { "err" },
start.elapsed()
);
return r;
}
Err(Error::ForcedBackendUnavailable("opengl".into()))
}
};
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
))]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
match opengl.convert(src, dst, rotation, flip, crop) {
Ok(_) => {
log::trace!(
"convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
start.elapsed()
);
return Ok(());
}
Err(e) => {
self.convert_fallbacks
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
log::debug!(
"convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
falling back toward G2D/CPU: {e}",
src.memory(),
dst.memory(),
);
}
}
}
#[cfg(target_os = "linux")]
if let Some(g2d) = self.g2d.as_mut() {
let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
let g2d_eligible = if src_is_yuv || dst_is_yuv {
let cm = if src_is_yuv {
crate::colorimetry::effective_colorimetry(src)
} else {
crate::colorimetry::effective_colorimetry(dst)
};
crate::g2d::g2d_can_handle(&cm, true)
} else {
true
};
if !g2d_eligible {
log::trace!(
"convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
(colorimetry not expressible: full-range/BT.2020)"
);
} else {
match g2d.convert(src, dst, rotation, flip, crop) {
Ok(_) => {
log::trace!(
"convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
start.elapsed()
);
return Ok(());
}
Err(e) => {
log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
}
}
}
}
if let Some(cpu) = self.cpu.as_mut() {
match cpu.convert(src, dst, rotation, flip, crop) {
Ok(_) => {
log::trace!(
"convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
start.elapsed()
);
return Ok(());
}
Err(e) => {
log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
return Err(e);
}
}
}
Err(Error::NoConverter)
}
fn convert_deferred(
&mut self,
src: &TensorDyn,
dst: &mut TensorDyn,
rotation: Rotation,
flip: Flip,
crop: Crop,
) -> Result<()> {
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
))]
#[cfg(feature = "opengl")]
{
let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
if gl_forced || self.forced_backend.is_none() {
if let Some(opengl) = self.opengl.as_mut() {
match opengl.convert_deferred(src, dst, rotation, flip, crop) {
Ok(()) => return Ok(()),
Err(e) => {
log::trace!("convert_deferred: gl declined: {e}; eager fallback");
if gl_forced {
return Err(e);
}
}
}
}
}
}
self.convert(src, dst, rotation, flip, crop)
}
fn flush(&mut self) -> Result<()> {
let _span = tracing::trace_span!("image.flush").entered();
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "android"
))]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
return opengl.flush();
}
Ok(())
}
fn draw_decoded_masks(
&mut self,
dst: &mut TensorDyn,
detect: &[DetectBox],
segmentation: &[Segmentation],
overlay: MaskOverlay<'_>,
) -> Result<()> {
let _span = tracing::trace_span!(
"image.draw_decoded_masks",
n_detections = detect.len(),
n_segmentations = segmentation.len(),
)
.entered();
let start = Instant::now();
if let Some(bg) = overlay.background {
if bg.aliases(dst) {
return Err(Error::AliasedBuffers(
"background must not reference the same buffer as dst".to_string(),
));
}
}
let lb_boxes: Vec<DetectBox>;
let lb_segs: Vec<Segmentation>;
let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
lb_segs = if segmentation.len() == lb_boxes.len() {
segmentation
.iter()
.zip(lb_boxes.iter())
.map(|(s, d)| Segmentation {
xmin: d.bbox.xmin,
ymin: d.bbox.ymin,
xmax: d.bbox.xmax,
ymax: d.bbox.ymax,
segmentation: s.segmentation.clone(),
})
.collect()
} else {
segmentation.to_vec()
};
(lb_boxes.as_slice(), lb_segs.as_slice())
} else {
(detect, segmentation)
};
#[cfg(target_os = "linux")]
let is_empty_frame = detect.is_empty() && segmentation.is_empty();
if let Some(forced) = self.forced_backend {
return match forced {
ForcedBackend::Cpu => {
if let Some(cpu) = self.cpu.as_mut() {
return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
}
Err(Error::ForcedBackendUnavailable("cpu".into()))
}
ForcedBackend::G2d => {
#[cfg(target_os = "linux")]
if let Some(g2d) = self.g2d.as_mut() {
return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
}
Err(Error::ForcedBackendUnavailable("g2d".into()))
}
ForcedBackend::OpenGl => {
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
}
Err(Error::ForcedBackendUnavailable("opengl".into()))
}
};
}
#[cfg(target_os = "linux")]
if is_empty_frame {
if let Some(g2d) = self.g2d.as_mut() {
match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
Ok(_) => {
log::trace!(
"draw_decoded_masks empty frame via g2d in {:?}",
start.elapsed()
);
return Ok(());
}
Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
}
}
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
log::trace!(
"draw_decoded_masks started with opengl in {:?}",
start.elapsed()
);
match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
Ok(_) => {
log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
return Ok(());
}
Err(e) => {
log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
}
}
}
log::trace!(
"draw_decoded_masks started with cpu in {:?}",
start.elapsed()
);
if let Some(cpu) = self.cpu.as_mut() {
match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
Ok(_) => {
log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
return Ok(());
}
Err(e) => {
log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
return Err(e);
}
}
}
Err(Error::NoConverter)
}
fn draw_proto_masks(
&mut self,
dst: &mut TensorDyn,
detect: &[DetectBox],
proto_data: &ProtoData,
overlay: MaskOverlay<'_>,
) -> Result<()> {
let start = Instant::now();
if let Some(bg) = overlay.background {
if bg.aliases(dst) {
return Err(Error::AliasedBuffers(
"background must not reference the same buffer as dst".to_string(),
));
}
}
let lb_boxes: Vec<DetectBox>;
let render_detect = if let Some(lb) = overlay.letterbox {
lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
lb_boxes.as_slice()
} else {
detect
};
#[cfg(target_os = "linux")]
let is_empty_frame = detect.is_empty();
if let Some(forced) = self.forced_backend {
return match forced {
ForcedBackend::Cpu => {
if let Some(cpu) = self.cpu.as_mut() {
return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
}
Err(Error::ForcedBackendUnavailable("cpu".into()))
}
ForcedBackend::G2d => {
#[cfg(target_os = "linux")]
if let Some(g2d) = self.g2d.as_mut() {
return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
}
Err(Error::ForcedBackendUnavailable("g2d".into()))
}
ForcedBackend::OpenGl => {
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
}
Err(Error::ForcedBackendUnavailable("opengl".into()))
}
};
}
#[cfg(target_os = "linux")]
if is_empty_frame {
if let Some(g2d) = self.g2d.as_mut() {
match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
Ok(_) => {
log::trace!(
"draw_proto_masks empty frame via g2d in {:?}",
start.elapsed()
);
return Ok(());
}
Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
}
}
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
let segmentation = match self.cpu.as_mut() {
Some(cpu) => {
log::trace!(
"draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
start.elapsed()
);
cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
}
None => unreachable!("cpu presence checked above"),
};
if let Some(opengl) = self.opengl.as_mut() {
match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
Ok(_) => {
log::trace!(
"draw_proto_masks with hybrid (cpu+opengl) in {:?}",
start.elapsed()
);
return Ok(());
}
Err(e) => {
log::trace!(
"draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
);
}
}
}
}
let Some(cpu) = self.cpu.as_mut() else {
return Err(Error::Internal(
"draw_proto_masks requires CPU backend for fallback path".into(),
));
};
log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
}
fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
let start = Instant::now();
if let Some(forced) = self.forced_backend {
return match forced {
ForcedBackend::Cpu => {
if let Some(cpu) = self.cpu.as_mut() {
return cpu.set_class_colors(colors);
}
Err(Error::ForcedBackendUnavailable("cpu".into()))
}
ForcedBackend::G2d => Err(Error::NotSupported(
"g2d does not support set_class_colors".into(),
)),
ForcedBackend::OpenGl => {
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
return opengl.set_class_colors(colors);
}
Err(Error::ForcedBackendUnavailable("opengl".into()))
}
};
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
if let Some(opengl) = self.opengl.as_mut() {
log::trace!("image started with opengl in {:?}", start.elapsed());
match opengl.set_class_colors(colors) {
Ok(_) => {
log::trace!("colors set with opengl in {:?}", start.elapsed());
return Ok(());
}
Err(e) => {
log::trace!("colors didn't set with opengl: {e:?}")
}
}
}
log::trace!("image started with cpu in {:?}", start.elapsed());
if let Some(cpu) = self.cpu.as_mut() {
match cpu.set_class_colors(colors) {
Ok(_) => {
log::trace!("colors set with cpu in {:?}", start.elapsed());
return Ok(());
}
Err(e) => {
log::trace!("colors didn't set with cpu: {e:?}");
return Err(e);
}
}
}
Err(Error::NoConverter)
}
}
#[cfg(test)]
pub(crate) fn load_image_test_helper(
image: &[u8],
format: Option<PixelFormat>,
memory: Option<TensorMemory>,
) -> Result<TensorDyn> {
use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
let info = peek_info(image)?;
let native_fmt = info.format;
let w = info.width;
let h = info.height;
let mut decoder = ImageDecoder::new();
#[cfg(target_os = "linux")]
let native_src = {
if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
let mut dma = Tensor::<u8>::image_with_stride(
w,
h,
native_fmt,
aligned_pitch,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
dma.load_image(&mut decoder, image)?;
TensorDyn::from(dma)
} else {
let mut img = Tensor::<u8>::image(
w,
h,
native_fmt,
memory,
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
img.load_image(&mut decoder, image)?;
TensorDyn::from(img)
}
};
#[cfg(not(target_os = "linux"))]
let native_src = {
let mut img = Tensor::<u8>::image(
w,
h,
native_fmt,
memory,
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
img.load_image(&mut decoder, image)?;
TensorDyn::from(img)
};
match format {
Some(f) if f != native_fmt => {
let mut dst = TensorDyn::image(
w,
h,
f,
DType::U8,
memory,
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
#[allow(clippy::needless_update)]
let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})?;
proc.convert(
&native_src,
&mut dst,
Rotation::None,
Flip::None,
Crop::default(),
)?;
Ok(dst)
}
_ => Ok(native_src),
}
}
pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
"save_jpeg requires u8 tensor".to_string(),
))?;
let fmt = t.format().ok_or(Error::NotAnImage)?;
if fmt.layout() != PixelLayout::Packed {
return Err(Error::NotImplemented(
"Saving planar images is not supported".to_string(),
));
}
let colour = match fmt {
PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
_ => {
return Err(Error::NotImplemented(
"Unsupported image format for saving".to_string(),
));
}
};
let w = t.width().ok_or(Error::NotAnImage)?;
let h = t.height().ok_or(Error::NotAnImage)?;
let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
let tensor_map = t.map_read()?;
encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
Ok(())
}
pub(crate) struct FunctionTimer<T: Display> {
name: T,
start: std::time::Instant,
}
impl<T: Display> FunctionTimer<T> {
pub fn new(name: T) -> Self {
Self {
name,
start: std::time::Instant::now(),
}
}
}
impl<T: Display> Drop for FunctionTimer<T> {
fn drop(&mut self) {
log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
}
}
const DEFAULT_COLORS: [[f32; 4]; 20] = [
[0., 1., 0., 0.7],
[1., 0.5568628, 0., 0.7],
[0.25882353, 0.15294118, 0.13333333, 0.7],
[0.8, 0.7647059, 0.78039216, 0.7],
[0.3137255, 0.3137255, 0.3137255, 0.7],
[0.1411765, 0.3098039, 0.1215686, 0.7],
[1., 0.95686275, 0.5137255, 0.7],
[0.3529412, 0.32156863, 0., 0.7],
[0.4235294, 0.6235294, 0.6509804, 0.7],
[0.5098039, 0.5098039, 0.7294118, 0.7],
[0.00784314, 0.18823529, 0.29411765, 0.7],
[0.0, 0.2706, 1.0, 0.7],
[0.0, 0.0, 0.0, 0.7],
[0.0, 0.5, 0.0, 0.7],
[1.0, 0.0, 0.0, 0.7],
[0.0, 0.0, 1.0, 0.7],
[1.0, 0.5, 0.5, 0.7],
[0.1333, 0.5451, 0.1333, 0.7],
[0.1176, 0.4118, 0.8235, 0.7],
[1., 1., 1., 0.7],
];
const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
let mut result = [[0; M]; N];
let mut i = 0;
while i < N {
let mut j = 0;
while j < M {
result[i][j] = (a[i][j] * 255.0).round() as u8;
j += 1;
}
i += 1;
}
result
}
const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod alignment_tests {
use super::*;
#[test]
fn align_width_rgba8_common_widths() {
assert_eq!(align_width_for_gpu_pitch(640, 4), 640); assert_eq!(align_width_for_gpu_pitch(1280, 4), 1280); assert_eq!(align_width_for_gpu_pitch(1920, 4), 1920); assert_eq!(align_width_for_gpu_pitch(3840, 4), 3840); assert_eq!(align_width_for_gpu_pitch(3004, 4), 3008); assert_eq!(align_width_for_gpu_pitch(3000, 4), 3008); assert_eq!(align_width_for_gpu_pitch(17, 4), 32); assert_eq!(align_width_for_gpu_pitch(1, 4), 16); }
#[test]
fn align_width_rgb888_packed() {
assert_eq!(align_width_for_gpu_pitch(64, 3), 64); assert_eq!(align_width_for_gpu_pitch(640, 3), 640); assert_eq!(align_width_for_gpu_pitch(1, 3), 64); assert_eq!(align_width_for_gpu_pitch(65, 3), 128); for w in [3004usize, 1281, 100, 17] {
let padded = align_width_for_gpu_pitch(w, 3);
assert!(padded >= w);
assert_eq!((padded * 3) % 64, 0);
assert_eq!((padded * 3) % 3, 0);
}
}
#[test]
fn align_width_grey_u8() {
assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
}
#[test]
fn align_width_zero_inputs() {
assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
}
#[test]
fn align_width_never_returns_smaller_than_input() {
for &bpp in &[1usize, 2, 3, 4, 8] {
for &w in &[
1usize,
17,
64,
65,
100,
1280,
1281,
1920,
3004,
3072,
3840,
usize::MAX / 8,
usize::MAX / 4,
usize::MAX / 2,
usize::MAX - 1,
usize::MAX,
] {
let aligned = align_width_for_gpu_pitch(w, bpp);
assert!(
aligned >= w,
"align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
);
}
}
}
#[test]
fn align_width_overflow_returns_unaligned_not_smaller() {
let aligned_extreme = usize::MAX - 15; assert_eq!(
align_width_for_gpu_pitch(aligned_extreme, 4),
aligned_extreme
);
let misaligned_extreme = usize::MAX - 1;
let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
assert!(
result == misaligned_extreme || result >= misaligned_extreme,
"extreme misaligned width must not be rounded down to {result}"
);
}
#[test]
fn checked_lcm_basic_and_overflow() {
assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
assert_eq!(
checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
None,
"coprime extreme values must overflow detect, not panic"
);
}
#[test]
fn primary_plane_bpp_known_formats() {
assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
#[allow(deprecated)]
mod image_tests {
use super::*;
use crate::{CPUProcessor, Rotation};
#[cfg(target_os = "linux")]
use edgefirst_tensor::is_dma_available;
use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
use image::buffer::ConvertBuffer;
fn convert_img(
proc: &mut dyn ImageProcessorTrait,
src: TensorDyn,
dst: TensorDyn,
rotation: Rotation,
flip: Flip,
crop: Crop,
) -> (Result<()>, TensorDyn, TensorDyn) {
let src_fourcc = src.format().unwrap();
let dst_fourcc = dst.format().unwrap();
let src_dyn = src;
let mut dst_dyn = dst;
let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
let src_back = {
let mut __t = src_dyn.into_u8().unwrap();
__t.set_format(src_fourcc).unwrap();
TensorDyn::from(__t)
};
let dst_back = {
let mut __t = dst_dyn.into_u8().unwrap();
__t.set_format(dst_fourcc).unwrap();
TensorDyn::from(__t)
};
(result, src_back, dst_back)
}
#[ctor::ctor(unsafe)]
fn init() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
}
macro_rules! function {
() => {{
fn f() {}
fn type_name_of<T>(_: T) -> &'static str {
std::any::type_name::<T>()
}
let name = type_name_of(f);
match &name[..name.len() - 3].rfind(':') {
Some(pos) => &name[pos + 1..name.len() - 3],
None => &name[..name.len() - 3],
}
}};
}
#[test]
fn batch_view_dst_tiles_match_standalone() {
let mut proc = match ImageProcessor::new() {
Ok(p) => p,
Err(e) => {
eprintln!(
"SKIPPED: {} — ImageProcessor init failed ({e:?})",
function!()
);
return;
}
};
let n = 3usize;
let (w, h) = (32usize, 24usize);
let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
let make_src = |c: [u8; 4]| -> TensorDyn {
let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
};
let parent = match TensorDyn::image(
w,
n * h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(d) => d,
Err(e) => {
eprintln!(
"SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
function!()
);
return;
}
};
for (i, &c) in colors.iter().enumerate().take(n) {
let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
proc.convert_deferred(
&make_src(c),
&mut tile,
Rotation::None,
Flip::None,
Crop::no_crop(),
)
.unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
}
proc.flush().unwrap();
for (i, &c) in colors.iter().enumerate().take(n) {
let mut solo = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
proc.convert(
&make_src(c),
&mut solo,
Rotation::None,
Flip::None,
Crop::no_crop(),
)
.unwrap();
let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
assert_eq!(
band_bytes, solo_bytes,
"tile {i}: band differs from standalone convert (placement or sibling wipe)"
);
assert!(
band_bytes.chunks_exact(4).all(|p| p == c),
"tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
);
}
}
#[test]
fn test_invalid_crop() {
let src = TensorDyn::image(
100,
100,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let dst = TensorDyn::image(
100,
100,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
assert!(matches!(
crop.check_crop_dyn(&src, &dst),
Err(Error::CropInvalid(_))
));
let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
assert!(crop.check_crop_dyn(&src, &dst).is_ok());
assert!(Crop::letterbox([0, 0, 0, 255])
.check_crop_dyn(&src, &dst)
.is_ok());
}
#[test]
fn test_invalid_tensor_format() -> Result<(), Error> {
let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
let result = tensor.set_format(PixelFormat::Rgb);
assert!(result.is_err(), "4D tensor should reject set_format");
let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
let result = tensor.set_format(PixelFormat::Rgb);
assert!(result.is_err(), "4-channel tensor should reject RGB format");
Ok(())
}
#[test]
fn test_invalid_image_file() -> Result<(), Error> {
let result = crate::load_image_test_helper(&[123; 5000], None, None);
assert!(
matches!(result, Err(Error::Codec(_))),
"unrecognised bytes should surface as Error::Codec, got {result:?}"
);
Ok(())
}
#[test]
fn test_invalid_jpeg_format() -> Result<(), Error> {
let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
assert!(
matches!(result, Err(Error::Codec(_))),
"Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
);
Ok(())
}
#[test]
fn test_load_resize_save() {
let file = edgefirst_bench::testdata::read("zidane.jpg");
let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
assert_eq!(img.width(), Some(1280));
assert_eq!(img.height(), Some(720));
let dst = TensorDyn::image(
640,
360,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut converter = CPUProcessor::new();
let (result, _img, dst) = convert_img(
&mut converter,
img,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
assert_eq!(dst.width(), Some(640));
assert_eq!(dst.height(), Some(360));
crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
let file = std::fs::read("zidane_resized.jpg").unwrap();
let img = crate::load_image_test_helper(&file, None, None).unwrap();
assert_eq!(img.width(), Some(640));
assert_eq!(img.height(), Some(360));
assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
}
#[test]
fn test_from_tensor_planar() -> Result<(), Error> {
let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
tensor
.map()?
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
let planar = {
tensor
.set_format(PixelFormat::PlanarRgb)
.map_err(|e| crate::Error::Internal(e.to_string()))?;
TensorDyn::from(tensor)
};
let rbga = load_bytes_to_tensor(
1280,
720,
PixelFormat::Rgba,
None,
&edgefirst_bench::testdata::read("camera720p.rgba"),
)?;
compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
Ok(())
}
#[test]
fn test_from_tensor_invalid_format() {
assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
}
#[test]
#[should_panic(expected = "Failed to save planar RGB image")]
fn test_save_planar() {
let planar_img = load_bytes_to_tensor(
1280,
720,
PixelFormat::PlanarRgb,
None,
&edgefirst_bench::testdata::read("camera720p.8bps"),
)
.unwrap();
let save_path = "/tmp/planar_rgb.jpg";
crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
}
#[test]
#[should_panic(expected = "Failed to save YUYV image")]
fn test_save_yuyv() {
let planar_img = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
None,
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let save_path = "/tmp/yuyv.jpg";
crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
}
#[test]
fn test_rotation_angle() {
assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
assert_eq!(
Rotation::from_degrees_clockwise(270),
Rotation::CounterClockwise90
);
assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
assert_eq!(
Rotation::from_degrees_clockwise(630),
Rotation::CounterClockwise90
);
}
#[test]
#[should_panic(expected = "rotation angle is not a multiple of 90")]
fn test_rotation_angle_panic() {
Rotation::from_degrees_clockwise(361);
}
#[test]
fn test_disable_env_var() -> Result<(), Error> {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&[
"EDGEFIRST_FORCE_BACKEND",
"EDGEFIRST_DISABLE_GL",
"EDGEFIRST_DISABLE_G2D",
"EDGEFIRST_DISABLE_CPU",
]);
unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
#[cfg(target_os = "linux")]
{
unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
let converter = ImageProcessor::new()?;
assert!(converter.g2d.is_none());
unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
{
unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
let converter = ImageProcessor::new()?;
assert!(converter.opengl.is_none());
unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
}
unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
let converter = ImageProcessor::new()?;
assert!(converter.cpu.is_none());
unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
let mut converter = ImageProcessor::new()?;
let src = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
let dst = TensorDyn::image(
640,
360,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
let (result, _src, _dst) = convert_img(
&mut converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
assert!(matches!(result, Err(Error::NoConverter)));
Ok(())
}
#[test]
fn test_unsupported_conversion() {
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let dst = TensorDyn::image(
640,
360,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut converter = ImageProcessor::new().unwrap();
let (result, _src, _dst) = convert_img(
&mut converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
log::debug!("result: {:?}", result);
assert!(matches!(
result,
Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
));
}
#[test]
fn test_load_grey() {
let grey_img = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("grey.jpg"),
Some(PixelFormat::Rgba),
None,
)
.unwrap();
assert_eq!(grey_img.width(), Some(1024));
assert_eq!(grey_img.height(), Some(681));
let grey_but_rgb = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("grey-rgb.jpg"),
Some(PixelFormat::Rgba),
None,
)
.expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
assert_eq!(grey_but_rgb.width(), Some(1024));
assert_eq!(grey_but_rgb.height(), Some(681));
}
#[test]
fn test_new_nv12() {
let nv12 = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(nv12.height(), Some(720));
assert_eq!(nv12.width(), Some(1280));
assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
assert_eq!(nv12.format().unwrap().channels(), 1);
assert!(nv12.format().is_some_and(
|f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
))
}
#[test]
#[cfg(target_os = "linux")]
fn test_new_image_converter() {
let dst_width = 640;
let dst_height = 360;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let mut converter = ImageProcessor::new().unwrap();
let converter_dst = converter
.create_image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, src, converter_dst) = convert_img(
&mut converter,
src,
converter_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&converter_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_create_image_dtype_i8() {
let mut converter = ImageProcessor::new().unwrap();
let dst = converter
.create_image(
320,
240,
PixelFormat::Rgb,
DType::I8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(dst.dtype(), DType::I8);
assert!(dst.width() == Some(320));
assert!(dst.height() == Some(240));
assert_eq!(dst.format(), Some(PixelFormat::Rgb));
let dst_u8 = converter
.create_image(
320,
240,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(dst_u8.dtype(), DType::U8);
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let mut dst_i8 = converter
.create_image(
320,
240,
PixelFormat::Rgb,
DType::I8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
converter
.convert(
&src,
&mut dst_i8,
Rotation::None,
Flip::None,
Crop::no_crop(),
)
.unwrap();
}
#[test]
#[cfg(target_os = "linux")]
fn test_create_image_nv12_dma_non_aligned_width() {
let converter = ImageProcessor::new().unwrap();
let result = converter.create_image(
100,
64,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
);
match result {
Ok(img) => {
assert_eq!(img.width(), Some(100));
assert_eq!(img.height(), Some(64));
assert_eq!(img.format(), Some(PixelFormat::Nv12));
if let Some(stride) = img.row_stride() {
assert!(
stride >= 100,
"NV12 row_stride {stride} must be >= the logical width (100)",
);
}
}
Err(e) => {
eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
}
}
}
#[test]
#[ignore] fn test_crop_skip() {
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let mut converter = ImageProcessor::new().unwrap();
let converter_dst = converter
.create_image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
let (result, src, converter_dst) = convert_img(
&mut converter,
src,
converter_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let cpu_dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
}
#[test]
fn test_invalid_pixel_format() {
assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
}
#[cfg(target_os = "linux")]
static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
#[cfg(target_os = "linux")]
fn is_g2d_available() -> bool {
*G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn is_opengl_available() -> bool {
#[cfg(all(target_os = "linux", feature = "opengl"))]
{
*GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
}
#[cfg(not(all(target_os = "linux", feature = "opengl")))]
{
false
}
}
#[test]
#[cfg(feature = "opengl")]
fn gl_backend_available_canary() {
let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
if !require_gl {
eprintln!(
"SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
function!()
);
return;
}
#[cfg(target_os = "macos")]
if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
eprintln!(
"SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
function!()
);
return;
}
GLProcessorThreaded::new(None).expect(
"HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
check the ANGLE install/re-sign step and binary entitlements \
(macOS) or the EGL stack (Linux)",
);
}
#[test]
fn test_load_jpeg_with_exif() {
use edgefirst_codec::peek_info;
let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
let info = peek_info(&file).unwrap();
assert_eq!(info.rotation_degrees, 90);
assert!(!info.flip_horizontal);
let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
assert_eq!(loaded.width(), Some(1280));
assert_eq!(loaded.height(), Some(720));
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let loaded_rotated = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r0, _loaded, loaded_rotated) = convert_img(
&mut cpu_converter,
loaded,
loaded_rotated,
rotation,
Flip::None,
Crop::no_crop(),
);
r0.unwrap();
let (result, _cpu_src, cpu_dst) = convert_img(
&mut cpu_converter,
cpu_src,
cpu_dst,
rotation,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
}
#[test]
fn test_load_png_with_exif() {
use edgefirst_codec::peek_info;
let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
let info = peek_info(&file).unwrap();
assert_eq!(info.rotation_degrees, 180);
assert!(!info.flip_horizontal);
let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
assert_eq!(loaded.height(), Some(720));
assert_eq!(loaded.width(), Some(1280));
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
let cpu_dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _cpu_src, cpu_dst) = convert_img(
&mut cpu_converter,
cpu_src,
cpu_dst,
rotation,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let loaded_rotated = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r0, _loaded, loaded_rotated) = convert_img(
&mut cpu_converter,
loaded,
loaded_rotated,
rotation,
Flip::None,
Crop::no_crop(),
);
r0.unwrap();
compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
}
#[cfg(target_os = "linux")]
fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::with_capacity((width * height * 3) as usize);
for y in 0..height {
for x in 0..width {
bytes.push(((x + y) & 0xFF) as u8);
bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
}
}
let mut out = Vec::new();
let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
encoder
.encode(
&bytes,
width as u16,
height as u16,
jpeg_encoder::ColorType::Rgb,
)
.expect("jpeg-encoder must succeed on trivial input");
out
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_convert_rgba_non_4_aligned_width_end_to_end() {
use edgefirst_tensor::is_dma_available;
if !is_dma_available() {
eprintln!(
"SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
);
return;
}
let jpeg = make_rgb_jpeg(375, 333);
let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
assert_eq!(src_gl.width(), Some(375));
let stride = src_gl.row_stride().unwrap();
assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
let mut gl_proc = ImageProcessor::new().unwrap();
let gl_dst = gl_proc
.create_image(
640,
640,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r_gl, _src_gl, gl_dst) = convert_img(
&mut gl_proc,
src_gl,
gl_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
let src_cpu =
crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
.unwrap();
let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
let cpu_dst = TensorDyn::image(
640,
640,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r_cpu, _src_cpu, cpu_dst) = convert_img(
&mut cpu_proc,
src_cpu,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r_cpu.unwrap();
compare_images(&gl_dst, &cpu_dst, 0.95, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
use edgefirst_tensor::is_dma_available;
if !is_dma_available() {
eprintln!(
"SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
);
return;
}
for &w in &[500u32, 612, 428] {
let jpeg = make_rgb_jpeg(w, 333);
let loaded =
crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
let natural = (w as usize) * 4;
let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
assert!(
aligned > natural,
"test sanity: width {w} should be unaligned"
);
let stride = loaded
.row_stride()
.expect("padded DMA path must set an explicit row_stride — regression if None");
assert_eq!(
stride, aligned,
"width {w}: expected padded stride {aligned}, got {stride} \
(regression: pitch-padding branch skipped?)"
);
let eff = loaded.effective_row_stride().unwrap();
assert_eq!(
eff, aligned,
"effective_row_stride must match stored stride"
);
assert_eq!(loaded.width(), Some(w as usize));
assert_eq!(loaded.height(), Some(333));
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_padded_dma_pitch_for_respects_memory_choice() {
use edgefirst_tensor::{is_dma_available, TensorMemory};
let unaligned_w = 500;
assert_eq!(
crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
None,
"Mem must never trigger DMA padding"
);
assert_eq!(
crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
None,
"Shm must never trigger DMA padding"
);
assert_eq!(
crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Dma),),
Some(2048),
"explicit Dma must pad regardless of runtime DMA availability"
);
let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
if is_dma_available() {
assert_eq!(
none_result,
Some(2048),
"memory=None + DMA available → pad (will route through DMA)"
);
} else {
assert_eq!(
none_result, None,
"memory=None + DMA unavailable → must NOT pad (would force \
image_with_stride into a DMA-only allocation that fails). \
Regression: padded_dma_pitch_for ignored is_dma_available()."
);
}
}
fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::with_capacity((width * height) as usize);
for y in 0..height {
for x in 0..width {
bytes.push(((x + y) & 0xFF) as u8);
}
}
let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
#[test]
#[cfg(target_os = "linux")]
fn test_load_png_grey_misaligned_width_dma() {
use edgefirst_tensor::is_dma_available;
if !is_dma_available() {
eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
return;
}
let png = make_grey_png(612, 388);
let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
assert_eq!(loaded.width(), Some(612));
assert_eq!(loaded.height(), Some(388));
assert_eq!(loaded.format(), Some(PixelFormat::Grey));
let map = loaded.as_u8().unwrap().map().unwrap();
let stride = loaded.row_stride().unwrap_or(612);
assert!(stride >= 612);
let bytes: &[u8] = ↦
for y in 0..388usize {
for x in 0..612usize {
let expected = ((x + y) & 0xFF) as u8;
let got = bytes[y * stride + x];
assert_eq!(
got, expected,
"grey png mismatch at ({x},{y}): got {got} expected {expected}"
);
}
}
}
#[test]
fn test_load_png_grey_mem() {
use edgefirst_tensor::TensorMemory;
let png = make_grey_png(612, 100);
let loaded =
crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
.unwrap();
assert_eq!(loaded.width(), Some(612));
assert_eq!(loaded.height(), Some(100));
assert_eq!(loaded.format(), Some(PixelFormat::Grey));
let map = loaded.as_u8().unwrap().map().unwrap();
let bytes: &[u8] = ↦
assert_eq!(bytes.len(), 612 * 100);
for y in 0..100 {
for x in 0..612 {
assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
}
}
}
#[test]
fn test_load_png_grey_to_rgb_mem() {
use edgefirst_tensor::TensorMemory;
let png = make_grey_png(620, 240);
let loaded =
crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
.unwrap();
assert_eq!(loaded.width(), Some(620));
assert_eq!(loaded.height(), Some(240));
assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
let map = loaded.as_u8().unwrap().map().unwrap();
let bytes: &[u8] = ↦
for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
let expected = ((x + y) & 0xFF) as u8;
let off = (y * 620 + x) * 3;
assert_eq!(bytes[off], expected, "R@{x},{y}");
assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_g2d_resize() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let dst_width = 640;
let dst_height = 360;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src =
crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
.unwrap();
let g2d_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_resize() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let dst_width = 640;
let dst_height = 360;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let mut src = src;
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
for _ in 0..5 {
let gl_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, src_back, gl_dst) = convert_img(
&mut gl_converter,
src,
gl_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
src = src_back;
compare_images(&gl_dst, &cpu_dst, 0.98, function!());
}
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_10_threads() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let handles: Vec<_> = (0..10)
.map(|i| {
std::thread::Builder::new()
.name(format!("Thread {i}"))
.spawn(test_opengl_resize)
.unwrap()
})
.collect();
handles.into_iter().for_each(|h| {
if let Err(e) = h.join() {
std::panic::resume_unwind(e)
}
});
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_grey() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let img = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("grey.jpg"),
Some(PixelFormat::Grey),
None,
)
.unwrap();
let gl_dst = TensorDyn::image(
640,
640,
PixelFormat::Grey,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let cpu_dst = TensorDyn::image(
640,
640,
PixelFormat::Grey,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut converter = CPUProcessor::new();
let (result, img, cpu_dst) = convert_img(
&mut converter,
img,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let mut gl = GLProcessorThreaded::new(None).unwrap();
let (result, _img, gl_dst) = convert_img(
&mut gl,
img,
gl_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&gl_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_g2d_src_crop() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let dst_width = 640;
let dst_height = 640;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let g2d_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, _src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_g2d_dst_crop() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let dst_width = 640;
let dst_height = 640;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let crop = Crop::new();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let g2d_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, _src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_g2d_all_rgba() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let dst_width = 640;
let dst_height = 640;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let src_dyn = src;
let mut cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let mut g2d_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
for rot in [
Rotation::None,
Rotation::Clockwise90,
Rotation::Rotate180,
Rotation::CounterClockwise90,
] {
cpu_dst
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.fill(114);
g2d_dst
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.fill(114);
for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
let mut cpu_dst_dyn = cpu_dst;
cpu_converter
.convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
.unwrap();
cpu_dst = {
let mut __t = cpu_dst_dyn.into_u8().unwrap();
__t.set_format(PixelFormat::Rgba).unwrap();
TensorDyn::from(__t)
};
let mut g2d_dst_dyn = g2d_dst;
g2d_converter
.convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
.unwrap();
g2d_dst = {
let mut __t = g2d_dst_dyn.into_u8().unwrap();
__t.set_format(PixelFormat::Rgba).unwrap();
TensorDyn::from(__t)
};
compare_images(
&g2d_dst,
&cpu_dst,
0.98,
&format!("{} {:?} {:?}", function!(), rot, flip),
);
}
}
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_src_crop() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let dst_width = 640;
let dst_height = 360;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let gl_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let (result, _src, gl_dst) = convert_img(
&mut gl_converter,
src,
gl_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images(&gl_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_dst_crop() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let dst_width = 640;
let dst_height = 640;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let crop = Crop::new();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let gl_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let (result, _src, gl_dst) = convert_img(
&mut gl_converter,
src,
gl_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images(&gl_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_all_rgba() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let dst_width = 640;
let dst_height = 640;
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let mut cpu_converter = CPUProcessor::new();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
if is_dma_available() {
mem.push(Some(TensorMemory::Dma));
}
let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
for m in mem {
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
let src_dyn = src;
for rot in [
Rotation::None,
Rotation::Clockwise90,
Rotation::Rotate180,
Rotation::CounterClockwise90,
] {
for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
m,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let gl_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
m,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
cpu_dst
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.fill(114);
gl_dst
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.fill(114);
let mut cpu_dst_dyn = cpu_dst;
cpu_converter
.convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
.unwrap();
let cpu_dst = {
let mut __t = cpu_dst_dyn.into_u8().unwrap();
__t.set_format(PixelFormat::Rgba).unwrap();
TensorDyn::from(__t)
};
let mut gl_dst_dyn = gl_dst;
gl_converter
.convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
.map_err(|e| {
log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
e
})
.unwrap();
let gl_dst = {
let mut __t = gl_dst_dyn.into_u8().unwrap();
__t.set_format(PixelFormat::Rgba).unwrap();
TensorDyn::from(__t)
};
compare_images(
&gl_dst,
&cpu_dst,
0.98,
&format!("{} {:?} {:?}", function!(), rot, flip),
);
}
}
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_cpu_rotate() {
for rot in [
Rotation::Clockwise90,
Rotation::Rotate180,
Rotation::CounterClockwise90,
] {
test_cpu_rotate_(rot);
}
}
#[cfg(target_os = "linux")]
fn test_cpu_rotate_(rot: Rotation) {
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let unchanged_src =
crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let (dst_width, dst_height) = match rot {
Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
(src.height().unwrap(), src.width().unwrap())
}
};
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let (result, cpu_dst, src) = convert_img(
&mut cpu_converter,
cpu_dst,
src,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let (result, _cpu_dst, src) = convert_img(
&mut cpu_converter,
cpu_dst,
src,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&src, &unchanged_src, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_rotate() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
let size = (1280, 720);
let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
if is_dma_available() {
mem.push(Some(TensorMemory::Dma));
}
for m in mem {
for rot in [
Rotation::Clockwise90,
Rotation::Rotate180,
Rotation::CounterClockwise90,
] {
test_opengl_rotate_(size, rot, m);
}
}
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_opengl_rotate_(
size: (usize, usize),
rot: Rotation,
tensor_memory: Option<TensorMemory>,
) {
let (dst_width, dst_height) = match rot {
Rotation::None | Rotation::Rotate180 => size,
Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
};
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src =
crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, mut src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
for _ in 0..5 {
let gl_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
tensor_memory,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, src_back, gl_dst) = convert_img(
&mut gl_converter,
src,
gl_dst,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
src = src_back;
compare_images(&gl_dst, &cpu_dst, 0.98, function!());
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_g2d_rotate() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let size = (1280, 720);
for rot in [
Rotation::Clockwise90,
Rotation::Rotate180,
Rotation::CounterClockwise90,
] {
test_g2d_rotate_(size, rot);
}
}
#[cfg(target_os = "linux")]
fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
let (dst_width, dst_height) = match rot {
Rotation::None | Rotation::Rotate180 => size,
Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
};
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src =
crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
.unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let g2d_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, _src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
rot,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
}
#[test]
fn test_rgba_to_yuyv_resize_cpu() {
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Rgba,
None,
&edgefirst_bench::testdata::read("camera720p.rgba"),
)
.unwrap();
let (dst_width, dst_height) = (640, 360);
let dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Yuyv,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let dst_through_yuyv = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let dst_direct = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let (result, _dst, dst_through_yuyv) = convert_img(
&mut cpu_converter,
dst,
dst_through_yuyv,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let (result, _src, dst_direct) = convert_img(
&mut cpu_converter,
src,
dst_direct,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
#[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
fn test_rgba_to_yuyv_resize_opengl() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
function!()
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Rgba,
None,
&edgefirst_bench::testdata::read("camera720p.rgba"),
)
.unwrap();
let (dst_width, dst_height) = (640, 360);
let dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Yuyv,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let (result, src, dst) = convert_img(
&mut gl_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::letterbox([255, 255, 255, 255]),
);
result.unwrap();
std::fs::write(
"rgba_to_yuyv_opengl.yuyv",
dst.as_u8().unwrap().map().unwrap().as_slice(),
)
.unwrap();
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Yuyv,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, _src, cpu_dst) = convert_img(
&mut CPUProcessor::new(),
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_rgba_to_yuyv_resize_g2d() {
if !is_g2d_available() {
eprintln!(
"SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
);
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Rgba,
Some(TensorMemory::Dma),
&edgefirst_bench::testdata::read("camera720p.rgba"),
)
.unwrap();
let (dst_width, dst_height) = (1280, 720);
let cpu_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Yuyv,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let g2d_dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Yuyv,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let crop = Crop::new();
g2d_dst
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.fill(128);
let (result, src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let cpu_dst_img = cpu_dst;
cpu_dst_img
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.fill(128);
let (result, _src, cpu_dst) = convert_img(
&mut CPUProcessor::new(),
src,
cpu_dst_img,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
}
#[test]
fn test_yuyv_to_rgba_cpu() {
let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Yuyv,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
fn test_yuyv_to_rgb_cpu() {
let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Yuyv,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.as_chunks_mut::<3>()
.0
.iter_mut()
.zip(
edgefirst_bench::testdata::read("camera720p.rgba")
.as_chunks::<4>()
.0,
)
.for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_yuyv_to_rgba_g2d() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
None,
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, _src, dst) = convert_img(
&mut g2d_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_yuyv_to_rgba_opengl() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
function!()
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
Some(TensorMemory::Dma),
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let (result, _src, dst) = convert_img(
&mut gl_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
let mut proc = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
return;
}
};
let (w, h) = (16usize, 16usize);
let src = TensorDyn::image(
w,
h,
PixelFormat::Grey,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
{
let su8 = src.as_u8().unwrap();
let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
let mut m = su8.map().unwrap();
let buf = m.as_mut_slice();
for y in 0..h {
for x in 0..w {
buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
}
}
}
let dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, src_back, dst) = convert_img(
&mut proc,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
let src_stride = src_back.as_u8().unwrap().effective_row_stride().unwrap();
let src_map = src_back.as_u8().unwrap().map().unwrap();
let sbytes = src_map.as_slice();
let dst_stride = dst.as_u8().unwrap().effective_row_stride().unwrap();
let dst_map = dst.as_u8().unwrap().map().unwrap();
let dbytes = dst_map.as_slice();
for y in 0..h {
for x in 0..w {
let yv = sbytes[y * src_stride + x] as i16;
let p = y * dst_stride + x * 4;
for c in 0..3 {
assert!(
(dbytes[p + c] as i16 - yv).abs() <= 2,
"pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
dbytes[p + c]
);
}
}
}
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
let mut gpu = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
return;
}
};
let (w, h) = (64usize, 64usize);
let src = match TensorDyn::image(
w,
h,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
return;
}
};
src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
let dst = match TensorDyn::image(
w,
h,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
return;
}
};
let mut dst = dst;
if let Err(e) = ImageProcessorTrait::convert(
&mut gpu,
&src,
&mut dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
) {
eprintln!(
"SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
function!()
);
return;
}
let dt = dst.as_f16().expect("dst is F16 PlanarRgb");
let map = dt.map().unwrap();
let vals = map.as_slice();
let mut checked = 0usize;
for &v in vals.iter() {
let f = f32::from(v);
assert!(
(0.40..=0.60).contains(&f),
"planar F16 value {f} not ~0.5 for neutral-grey NV12"
);
checked += 1;
}
assert!(
checked >= w * h * 3,
"expected >= 3 planes of samples, got {checked}"
);
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
let mut gpu = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
return;
}
};
let (fw, fh) = (96usize, 64usize);
let (pool_w, pool_h) = (256usize, 768usize);
let (model_w, model_h) = (128usize, 128usize);
let mut src = match TensorDyn::image(
pool_w,
pool_h,
PixelFormat::Grey,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
return;
}
};
src.configure_image(fw, fh, PixelFormat::Nv12)
.unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
let mut dst = match TensorDyn::image(
model_w,
model_h,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
return;
}
};
let _ = model_w;
let crop = Crop::new()
.with_source(Some(Region::new(0, 0, fw, fh)))
.with_fit(Fit::Letterbox {
pad: [0, 0, 0, 255],
});
if let Err(e) =
ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
{
eprintln!(
"SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
function!()
);
return;
}
let _ = stride;
let dt = dst.as_f16().expect("dst F16");
let map = dt.map().unwrap();
let any_half = map.as_slice().iter().any(|&v| {
let f = f32::from(v);
(0.40..=0.60).contains(&f)
});
assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
use std::sync::mpsc;
let mut proc = match ImageProcessor::new() {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
return;
}
};
let (fw, fh) = (96usize, 64usize);
let mut src = match TensorDyn::image(
256,
768,
PixelFormat::Grey,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — pool: {e:?}", function!());
return;
}
};
src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
let mut dst = match TensorDyn::image(
128,
128,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — dst: {e:?}", function!());
return;
}
};
let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
let (tx, rx) = mpsc::channel::<bool>();
let worker = std::thread::spawn(move || {
let _ = ImageProcessorTrait::convert(
&mut proc,
&src,
&mut dst,
Rotation::None,
Flip::None,
crop,
);
let _ = tx.send(true);
});
match rx.recv_timeout(std::time::Duration::from_secs(20)) {
Ok(_) => { let _ = worker.join(); }
Err(_) => panic!(
"cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
),
}
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
let mut gpu = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
return;
}
};
let (max_w, max_h) = (640usize, 640usize);
let depth = 4usize;
let mut srcs = Vec::new();
let mut dsts = Vec::new();
for _ in 0..depth {
srcs.push(
match TensorDyn::image(
max_w,
max_h * 3,
PixelFormat::Grey,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — pool: {e:?}", function!());
return;
}
},
);
dsts.push(
match TensorDyn::image(
640,
640,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — dst: {e:?}", function!());
return;
}
},
);
}
let sizes = [
(640, 480),
(500, 375),
(640, 427),
(333, 500),
(480, 640),
(612, 612),
(428, 640),
(576, 432),
];
let mut first_ms = 0f64;
let mut last_ms = 0f64;
let iters = 40usize;
for i in 0..iters {
let (fw, fh) = sizes[i % sizes.len()];
let src = &mut srcs[i % depth];
let dst = &mut dsts[i % depth];
src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
let t0 = std::time::Instant::now();
ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
.unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
let ms = t0.elapsed().as_secs_f64() * 1e3;
if i == 2 {
first_ms = ms;
}
if i == iters - 1 {
last_ms = ms;
}
}
eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
assert!(
last_ms < first_ms * 5.0 + 5.0,
"convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
);
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
let mut gpu = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
return;
}
};
let mut cpu = CPUProcessor::new();
let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
for y in 0..h {
for x in 0..w {
buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
}
}
let (cw, ch, uv_grid_rows) = match fmt {
PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
PixelFormat::Nv16 => (w / 2, h, 1usize),
_ => (w, h, 2usize), };
let uv_plane = h * stride;
for cy in 0..ch {
for cx in 0..cw {
let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
buf[off] = ((cx * 11 + 30) & 0xff) as u8;
buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
}
}
};
for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
for (w, h) in [
(16usize, 16usize), (15, 16), (16, 15), ] {
let mem = TensorDyn::image(
w,
h,
fmt,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
fill(
mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
mem_stride,
fmt,
w,
h,
);
let cpu_dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r, _s, cpu_dst) = convert_img(
&mut cpu,
mem,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
let ios = TensorDyn::image(
w,
h,
fmt,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
fill(
ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
ios_stride,
fmt,
w,
h,
);
let gpu_dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r, _s, gpu_dst) = convert_img(
&mut gpu,
ios,
gpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
let cb = cmap.as_slice();
let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
let gb = gmap.as_slice();
let mut max_d = 0i16;
for y in 0..h {
for x in 0..w {
for c in 0..3 {
let cv = cb[y * cs + x * 4 + c] as i16;
let gv = gb[y * gs + x * 4 + c] as i16;
max_d = max_d.max((cv - gv).abs());
}
}
}
assert!(
max_d <= 3,
"{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
);
}
}
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
let mut gpu = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
return;
}
};
let mut cpu = CPUProcessor::new();
let (pool_w, pool_h) = (256usize, 256usize);
let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
for y in 0..h {
for x in 0..w {
buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
}
}
let (cw, ch, uv_grid_rows) = match fmt {
PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
PixelFormat::Nv16 => (w / 2, h, 1usize),
_ => (w, h, 2usize), };
let uv_plane = h * stride;
for cy in 0..ch {
for cx in 0..cw {
let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
buf[off] = ((cx * 11 + 30) & 0xff) as u8;
buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
}
}
};
for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
for (w, h) in [
(40usize, 24usize), (15, 16), (16, 15), ] {
let ew = w.next_multiple_of(2);
let mem = TensorDyn::image(
w,
h,
fmt,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
fill(
mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
mem_stride,
fmt,
w,
h,
);
let cpu_dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r, _s, cpu_dst) = convert_img(
&mut cpu,
mem,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
let mut ios = match TensorDyn::image(
pool_w,
pool_h,
PixelFormat::Grey,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
) {
Ok(t) => t,
Err(e) => {
eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
return;
}
};
ios.configure_image(w, h, fmt)
.unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
assert!(
ios_stride > ew,
"{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
(test must exercise padding)"
);
fill(
ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
ios_stride,
fmt,
w,
h,
);
let gpu_dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r, _s, gpu_dst) = convert_img(
&mut gpu,
ios,
gpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r.unwrap_or_else(|e| {
panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
});
let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
let cb = cmap.as_slice();
let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
let gb = gmap.as_slice();
let mut max_d = 0i16;
for y in 0..h {
for x in 0..w {
for c in 0..3 {
let cv = cb[y * cs + x * 4 + c] as i16;
let gv = gb[y * gs + x * 4 + c] as i16;
max_d = max_d.max((cv - gv).abs());
}
}
}
assert!(
max_d <= 3,
"{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
);
}
}
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_yuyv_to_rgba_opengl_macos() {
let mut proc = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!(
"SKIPPED: {} — GL engine init failed ({e:?}). \
Install ANGLE via `brew install startergo/angle/angle` \
and re-sign per README.md § macOS GPU Acceleration to \
run this test.",
function!()
);
return;
}
};
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
Some(TensorMemory::Dma),
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, _src, dst) = convert_img(
&mut proc,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
let mut proc = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
return;
}
};
for (w, h) in [(64usize, 32usize), (3840, 2160)] {
let bytes_per_row = w * 2;
let mut yuyv = vec![0u8; bytes_per_row * h];
for chunk in yuyv.chunks_exact_mut(4) {
chunk[0] = 128; chunk[1] = 128; chunk[2] = 128; chunk[3] = 128; }
let src = load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
.unwrap();
let dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, _src, dst) = convert_img(
&mut proc,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.expect("GL convert should succeed at this resolution");
let dst_u8 = dst.as_u8().unwrap();
let dst_map = dst_u8.map().unwrap();
let dst_bytes = dst_map.as_slice();
assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
for px in dst_bytes.chunks_exact(4) {
for (i, &c) in px[..3].iter().enumerate() {
assert!(
(120..=140).contains(&c),
"{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
function!(),
);
}
assert_eq!(px[3], 255, "alpha must be 1.0");
}
}
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
let mut proc = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
return;
}
};
let mut yuyv = vec![0u8; 64 * 32 * 2];
for chunk in yuyv.chunks_exact_mut(4) {
chunk[0] = 200;
chunk[1] = 100;
chunk[2] = 200;
chunk[3] = 156;
}
let src = load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
.unwrap();
let dst = TensorDyn::image(
64,
32,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (r1, src, dst) = convert_img(
&mut proc,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r1.unwrap();
let first: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
let (r2, _src, dst) = convert_img(
&mut proc,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
r2.unwrap();
let second: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
assert_eq!(first, second, "cache-hit conversion must be deterministic");
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_macos_gl_pbuffer_cache_steady_state() {
let mut proc = match GLProcessorThreaded::new(None) {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
return;
}
};
let (w, h) = (64usize, 32usize);
const POOL: usize = 3;
const FRAMES: usize = 100;
let yuyv = vec![128u8; w * h * 2];
let pool: Vec<TensorDyn> = (0..POOL)
.map(|_| {
load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
.unwrap()
})
.collect();
let mut dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
for src in pool.iter().cycle().take(POOL * 2) {
proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
.unwrap();
}
let warm = proc.egl_cache_stats().unwrap();
for src in pool.iter().cycle().take(FRAMES) {
proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
.unwrap();
}
let steady = proc.egl_cache_stats().unwrap();
assert_eq!(
warm.total_misses(),
steady.total_misses(),
"steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
);
let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
assert!(
hits(&steady) - hits(&warm) >= FRAMES as u64,
"expected at least {FRAMES} import-cache hits over the loop, got {}",
hits(&steady) - hits(&warm)
);
}
#[test]
#[cfg(target_os = "macos")]
#[cfg(feature = "opengl")]
fn test_macos_gl_f16_planar_is_gl_backed() {
let mut proc = ImageProcessor::new().expect("ImageProcessor");
let Some(ref gl) = proc.opengl else {
eprintln!("SKIPPED: {} — GL backend unavailable", function!());
return;
};
if !gl.supported_render_dtypes().f16 {
eprintln!(
"SKIPPED: {} — configuration lacks F16 color-buffer support",
function!()
);
return;
}
let stats_before = gl.egl_cache_stats().expect("cache stats");
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let t = src.as_u8().unwrap();
let mut m = t.map().unwrap();
for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
*b = ((i * 31) % 211) as u8;
}
}
let mut dst = TensorDyn::image(
640,
640,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
proc.convert(
&src,
&mut dst,
Rotation::None,
Flip::None,
Crop::letterbox([114, 114, 114, 255]),
)
.expect("F16 capability reported but the NV12→PlanarF16 convert failed");
let stats_after = proc
.opengl
.as_ref()
.expect("GL backend present")
.egl_cache_stats()
.expect("cache stats");
assert!(
stats_after.total_misses() >= stats_before.total_misses() + 2,
"convert succeeded but the GL engine did not import both the \
source and the F16 destination — the work did not (fully) run \
on the GL backend (silent CPU fallback); misses before={} after={}",
stats_before.total_misses(),
stats_after.total_misses()
);
}
#[test]
#[cfg(feature = "opengl")]
fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::OpenGl,
..Default::default()
}) {
Ok(p) if p.opengl.is_some() => p,
_ => {
eprintln!("SKIPPED: {} — GL backend unavailable", function!());
return;
}
};
if !gl
.opengl
.as_ref()
.map(|g| g.supported_render_dtypes().f16)
.unwrap_or(false)
{
eprintln!("SKIPPED: {} — no F16 render support", function!());
return;
}
let mem = if edgefirst_tensor::is_gpu_buffer_available() {
TensorMemory::Dma
} else {
eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
return;
};
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
Some(mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let t = src.as_u8().unwrap();
let mut m = t.map().unwrap();
let buf = m.as_mut_slice();
let (w, h) = (1280usize, 720usize);
for y in 0..h {
for x in 0..w {
buf[y * w + x] = ((x * 255) / w) as u8; }
}
for y in 0..(h / 2) {
for x in 0..(w / 2) {
let o = h * w + y * w + 2 * x;
buf[o] = ((y * 255) / (h / 2)) as u8; buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; }
}
}
let crop = Crop::letterbox([114, 114, 114, 255]);
let mut gl_dst = TensorDyn::image(
640,
640,
PixelFormat::PlanarRgb,
DType::F16,
Some(mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
gl.opengl
.as_mut()
.expect("GL backend present")
.convert(&src, &mut gl_dst, Rotation::None, Flip::None, crop)
.expect("fused NV12→PlanarF16 GL convert");
let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
let mut cpu_dst = TensorDyn::image(
640,
640,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
.expect("CPU reference convert");
let g = gl_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
let c = cpu_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
assert_eq!(g.len(), c.len());
let mut max_diff = 0.0f32;
let mut max_at = 0usize;
for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
let d = (a.to_f32() - b.to_f32()).abs();
if d > max_diff {
max_diff = d;
max_at = i;
}
}
let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
let (row, col) = (rem / 640, rem % 640);
eprintln!(
"fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
gl={} cpu={}",
g[max_at].to_f32(),
c[max_at].to_f32()
);
assert!(
max_diff <= 4.0 / 255.0 + 1e-3,
"fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
);
}
#[test]
#[cfg(feature = "opengl")]
fn test_zero_copy_src_to_mem_dst_gl_direct() {
let mut proc = match ImageProcessor::new() {
Ok(p) if p.opengl.is_some() => p,
_ => {
eprintln!("SKIPPED: {} — GL backend unavailable", function!());
return;
}
};
if !edgefirst_tensor::is_gpu_buffer_available() {
eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
return;
}
let src = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let t = src.as_u8().unwrap();
let mut m = t.map().unwrap();
for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
*b = ((i * 31) % 211) as u8;
}
}
let mut gl_dst = TensorDyn::image(
1280,
720,
PixelFormat::Bgra,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
proc.opengl
.as_mut()
.expect("GL backend present")
.convert(
&src,
&mut gl_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
)
.expect("zero-copy src → heap dst GL convert");
let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
let mut cpu_dst = TensorDyn::image(
1280,
720,
PixelFormat::Bgra,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
cpu.convert(
&src,
&mut cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
)
.expect("CPU reference convert");
let g = gl_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
let c = cpu_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
assert_eq!(g.len(), c.len());
let max_diff = g
.iter()
.zip(c.iter())
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap();
assert!(
max_diff <= 2,
"zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
);
}
#[test]
#[cfg(target_os = "linux")]
fn test_yuyv_to_rgb_g2d() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
None,
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let g2d_dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let cpu_dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter: CPUProcessor = CPUProcessor::new();
let (result, _src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_yuyv_to_yuyv_resize_g2d() {
if !is_g2d_available() {
eprintln!(
"SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
);
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
None,
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let g2d_dst = TensorDyn::image(
600,
400,
PixelFormat::Yuyv,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let cpu_dst = TensorDyn::image(
600,
400,
PixelFormat::Yuyv,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter: CPUProcessor = CPUProcessor::new();
let (result, _src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
eprintln!(
"WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
);
compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
}
#[test]
fn test_yuyv_to_rgba_resize_cpu() {
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
None,
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let (dst_width, dst_height) = (960, 540);
let dst = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let dst_target = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let src_target = load_bytes_to_tensor(
1280,
720,
PixelFormat::Rgba,
None,
&edgefirst_bench::testdata::read("camera720p.rgba"),
)
.unwrap();
let (result, _src_target, dst_target) = convert_img(
&mut cpu_converter,
src_target,
dst_target,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&dst, &dst_target, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
fn test_yuyv_to_rgba_crop_flip_g2d() {
if !is_g2d_available() {
eprintln!(
"SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
);
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
Some(TensorMemory::Dma),
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let (dst_width, dst_height) = (640, 640);
let dst_g2d = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
let (result, src, dst_g2d) = convert_img(
&mut g2d_converter,
src,
dst_g2d,
Rotation::None,
Flip::Horizontal,
crop,
);
result.unwrap();
let dst_cpu = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst_cpu) = convert_img(
&mut cpu_converter,
src,
dst_cpu,
Rotation::None,
Flip::Horizontal,
crop,
);
result.unwrap();
compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_yuyv_to_rgba_crop_flip_opengl() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
function!()
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Yuyv,
Some(TensorMemory::Dma),
&edgefirst_bench::testdata::read("camera720p.yuyv"),
)
.unwrap();
let (dst_width, dst_height) = (640, 640);
let dst_gl = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
let (result, src, dst_gl) = convert_img(
&mut gl_converter,
src,
dst_gl,
Rotation::None,
Flip::Horizontal,
crop,
);
result.unwrap();
let dst_cpu = TensorDyn::image(
dst_width,
dst_height,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst_cpu) = convert_img(
&mut cpu_converter,
src,
dst_cpu,
Rotation::None,
Flip::Horizontal,
crop,
);
result.unwrap();
compare_images(&dst_gl, &dst_cpu, 0.98, function!());
}
#[test]
fn test_vyuy_to_rgba_cpu() {
let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Vyuy,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
fn test_vyuy_to_rgb_cpu() {
let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Vyuy,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.as_chunks_mut::<3>()
.0
.iter_mut()
.zip(
edgefirst_bench::testdata::read("camera720p.rgba")
.as_chunks::<4>()
.0,
)
.for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
fn test_vyuy_to_rgba_g2d() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Vyuy,
None,
&edgefirst_bench::testdata::read("camera720p.vyuy"),
)
.unwrap();
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, _src, dst) = convert_img(
&mut g2d_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
match result {
Err(Error::G2D(_)) => {
eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
return;
}
r => r.unwrap(),
}
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
fn test_vyuy_to_rgb_g2d() {
if !is_g2d_available() {
eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Vyuy,
None,
&edgefirst_bench::testdata::read("camera720p.vyuy"),
)
.unwrap();
let g2d_dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut g2d_converter = G2DProcessor::new().unwrap();
let (result, src, g2d_dst) = convert_img(
&mut g2d_converter,
src,
g2d_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
match result {
Err(Error::G2D(_)) => {
eprintln!(
"SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
);
return;
}
r => r.unwrap(),
}
let cpu_dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter: CPUProcessor = CPUProcessor::new();
let (result, _src, cpu_dst) = convert_img(
&mut cpu_converter,
src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
}
#[test]
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
fn test_vyuy_to_rgba_opengl() {
if !is_opengl_available() {
eprintln!("SKIPPED: {} - OpenGL not available", function!());
return;
}
if !is_dma_available() {
eprintln!(
"SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
function!()
);
return;
}
let src = load_bytes_to_tensor(
1280,
720,
PixelFormat::Vyuy,
Some(TensorMemory::Dma),
&edgefirst_bench::testdata::read("camera720p.vyuy"),
)
.unwrap();
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
let (result, _src, dst) = convert_img(
&mut gl_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
match result {
Err(Error::NotSupported(_)) => {
eprintln!(
"SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
function!()
);
return;
}
r => r.unwrap(),
}
let target_image = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
target_image
.as_u8()
.unwrap()
.map()
.unwrap()
.as_mut_slice()
.copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
compare_images(&dst, &target_image, 0.98, function!());
}
#[test]
fn test_nv12_to_rgba_cpu() {
let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("zidane.jpg"),
Some(PixelFormat::Rgba),
None,
)
.unwrap();
compare_images(&dst, &target_image, 0.95, function!());
}
#[test]
fn test_nv12_odd_height_to_rgb_cpu() {
let mut src = TensorDyn::image(
8,
5,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(src.shape(), &[8, 8]);
assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
src.set_colorimetry(Some(
edgefirst_tensor::Colorimetry::default()
.with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
.with_range(edgefirst_tensor::ColorRange::Full),
));
let dst = TensorDyn::image(
8,
5,
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
let map = dst.as_u8().unwrap().map().unwrap();
for (i, &b) in map.as_slice().iter().enumerate() {
assert!(
(b as i16 - 128).abs() <= 2,
"pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
);
}
}
#[test]
fn test_nv24_to_rgb_cpu() {
let mut src = TensorDyn::image(
8,
4,
PixelFormat::Nv24,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(src.shape(), &[12, 8]);
assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
src.set_colorimetry(Some(
edgefirst_tensor::Colorimetry::default()
.with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
.with_range(edgefirst_tensor::ColorRange::Full),
));
let dst = TensorDyn::image(
8,
4,
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
let map = dst.as_u8().unwrap().map().unwrap();
for (i, &b) in map.as_slice().iter().enumerate() {
assert!(
(b as i16 - 128).abs() <= 2,
"pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
);
}
}
#[test]
fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
let mut src = TensorDyn::image(
8,
4,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(src.shape(), &[6, 8]);
{
let mut map = src.as_u8().unwrap().map().unwrap();
let buf = map.as_mut_slice();
buf[..32].fill(120); for px in buf[32..].chunks_exact_mut(2) {
px[0] = 180; px[1] = 64; }
}
src.set_colorimetry(Some(
edgefirst_tensor::Colorimetry::default()
.with_encoding(enc)
.with_range(edgefirst_tensor::ColorRange::Limited),
));
let dst = TensorDyn::image(
8,
4,
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let map = dst.as_u8().unwrap().map().unwrap();
let s = map.as_slice();
[s[0], s[1], s[2]]
}
let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
assert_ne!(
bt2020, bt601,
"BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
);
assert_ne!(
bt2020, bt709,
"BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
);
assert_ne!(
bt601, bt709,
"BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
);
}
#[test]
fn test_nv12_to_rgb_cpu() {
let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("zidane.jpg"),
Some(PixelFormat::Rgb),
None,
)
.unwrap();
compare_images(&dst, &target_image, 0.95, function!());
}
#[test]
fn test_nv12_to_grey_cpu() {
let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Grey,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("zidane.jpg"),
Some(PixelFormat::Grey),
None,
)
.unwrap();
compare_images(&dst, &target_image, 0.95, function!());
}
#[test]
fn test_nv12_to_yuyv_cpu() {
let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
let src = TensorDyn::image(
1280,
720,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
.copy_from_slice(&file);
let dst = TensorDyn::image(
1280,
720,
PixelFormat::Yuyv,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let (result, _src, dst) = convert_img(
&mut cpu_converter,
src,
dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let target_image = crate::load_image_test_helper(
&edgefirst_bench::testdata::read("zidane.jpg"),
Some(PixelFormat::Rgb),
None,
)
.unwrap();
compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
}
#[test]
fn test_cpu_resize_nv16() {
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let cpu_nv16_dst = TensorDyn::image(
640,
640,
PixelFormat::Nv16,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let cpu_rgb_dst = TensorDyn::image(
640,
640,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut cpu_converter = CPUProcessor::new();
let crop = Crop::letterbox([255, 128, 0, 255]);
let (result, src, cpu_nv16_dst) = convert_img(
&mut cpu_converter,
src,
cpu_nv16_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
let (result, _src, cpu_rgb_dst) = convert_img(
&mut cpu_converter,
src,
cpu_rgb_dst,
Rotation::None,
Flip::None,
crop,
);
result.unwrap();
compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
}
fn load_bytes_to_tensor(
width: usize,
height: usize,
format: PixelFormat,
memory: Option<TensorMemory>,
bytes: &[u8],
) -> Result<TensorDyn, Error> {
let src = TensorDyn::image(
width,
height,
format,
DType::U8,
memory,
edgefirst_tensor::CpuAccess::ReadWrite,
)?;
src.as_u8()
.unwrap()
.map()?
.as_mut_slice()
.copy_from_slice(bytes);
Ok(src)
}
fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
assert_eq!(img1.height(), img2.height(), "Heights differ");
assert_eq!(img1.width(), img2.width(), "Widths differ");
assert_eq!(
img1.format().unwrap(),
img2.format().unwrap(),
"PixelFormat differ"
);
assert!(
matches!(
img1.format().unwrap(),
PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
),
"format must be Rgb or Rgba for comparison"
);
let image1 = match img1.format().unwrap() {
PixelFormat::Rgb => image::RgbImage::from_vec(
img1.width().unwrap() as u32,
img1.height().unwrap() as u32,
img1.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap(),
PixelFormat::Rgba => image::RgbaImage::from_vec(
img1.width().unwrap() as u32,
img1.height().unwrap() as u32,
img1.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap()
.convert(),
PixelFormat::Grey => image::GrayImage::from_vec(
img1.width().unwrap() as u32,
img1.height().unwrap() as u32,
img1.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap()
.convert(),
PixelFormat::PlanarRgb => image::GrayImage::from_vec(
img1.width().unwrap() as u32,
(img1.height().unwrap() * 3) as u32,
img1.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap()
.convert(),
_ => return,
};
let image2 = match img2.format().unwrap() {
PixelFormat::Rgb => image::RgbImage::from_vec(
img2.width().unwrap() as u32,
img2.height().unwrap() as u32,
img2.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap(),
PixelFormat::Rgba => image::RgbaImage::from_vec(
img2.width().unwrap() as u32,
img2.height().unwrap() as u32,
img2.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap()
.convert(),
PixelFormat::Grey => image::GrayImage::from_vec(
img2.width().unwrap() as u32,
img2.height().unwrap() as u32,
img2.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap()
.convert(),
PixelFormat::PlanarRgb => image::GrayImage::from_vec(
img2.width().unwrap() as u32,
(img2.height().unwrap() * 3) as u32,
img2.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap()
.convert(),
_ => return,
};
let similarity = image_compare::rgb_similarity_structure(
&image_compare::Algorithm::RootMeanSquared,
&image1,
&image2,
)
.expect("Image Comparison failed");
if similarity.score < threshold {
similarity
.image
.to_color_map()
.save(format!("{name}.png"))
.unwrap();
panic!(
"{name}: converted image and target image have similarity score too low: {} < {}",
similarity.score, threshold
)
}
}
fn compare_images_convert_to_rgb(
img1: &TensorDyn,
img2: &TensorDyn,
threshold: f64,
name: &str,
) {
assert_eq!(img1.height(), img2.height(), "Heights differ");
assert_eq!(img1.width(), img2.width(), "Widths differ");
let mut img_rgb1 = TensorDyn::image(
img1.width().unwrap(),
img1.height().unwrap(),
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut img_rgb2 = TensorDyn::image(
img1.width().unwrap(),
img1.height().unwrap(),
PixelFormat::Rgb,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut __cv = CPUProcessor::default();
let r1 = __cv.convert(
img1,
&mut img_rgb1,
crate::Rotation::None,
crate::Flip::None,
crate::Crop::default(),
);
let r2 = __cv.convert(
img2,
&mut img_rgb2,
crate::Rotation::None,
crate::Flip::None,
crate::Crop::default(),
);
if r1.is_err() || r2.is_err() {
let w = img1.width().unwrap() as u32;
let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
let h1 = (data1.len() as u32) / w;
let h2 = (data2.len() as u32) / w;
let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
let similarity = image_compare::gray_similarity_structure(
&image_compare::Algorithm::RootMeanSquared,
&g1,
&g2,
)
.expect("Image Comparison failed");
if similarity.score < threshold {
panic!(
"{name}: converted image and target image have similarity score too low: {} < {}",
similarity.score, threshold
)
}
return;
}
let image1 = image::RgbImage::from_vec(
img_rgb1.width().unwrap() as u32,
img_rgb1.height().unwrap() as u32,
img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap();
let image2 = image::RgbImage::from_vec(
img_rgb2.width().unwrap() as u32,
img_rgb2.height().unwrap() as u32,
img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
)
.unwrap();
let similarity = image_compare::rgb_similarity_structure(
&image_compare::Algorithm::RootMeanSquared,
&image1,
&image2,
)
.expect("Image Comparison failed");
if similarity.score < threshold {
similarity
.image
.to_color_map()
.save(format!("{name}.png"))
.unwrap();
panic!(
"{name}: converted image and target image have similarity score too low: {} < {}",
similarity.score, threshold
)
}
}
#[test]
fn test_nv12_image_creation() {
let width = 640;
let height = 480;
let img = TensorDyn::image(
width,
height,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.width(), Some(width));
assert_eq!(img.height(), Some(height));
assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
}
#[test]
fn test_nv12_channels() {
let img = TensorDyn::image(
640,
480,
PixelFormat::Nv12,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.format().unwrap().channels(), 1);
}
#[test]
fn test_tensor_set_format_planar() {
let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
tensor.set_format(PixelFormat::PlanarRgb).unwrap();
assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
assert_eq!(tensor.width(), Some(640));
assert_eq!(tensor.height(), Some(480));
}
#[test]
fn test_tensor_set_format_interleaved() {
let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
tensor.set_format(PixelFormat::Rgba).unwrap();
assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
assert_eq!(tensor.width(), Some(640));
assert_eq!(tensor.height(), Some(480));
}
#[test]
fn test_tensordyn_image_rgb() {
let img = TensorDyn::image(
640,
480,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.width(), Some(640));
assert_eq!(img.height(), Some(480));
assert_eq!(img.format(), Some(PixelFormat::Rgb));
}
#[test]
fn test_tensordyn_image_planar_rgb() {
let img = TensorDyn::image(
640,
480,
PixelFormat::PlanarRgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.width(), Some(640));
assert_eq!(img.height(), Some(480));
assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
}
#[test]
fn test_rgb_int8_format() {
let img = TensorDyn::image(
1280,
720,
PixelFormat::Rgb,
DType::I8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.width(), Some(1280));
assert_eq!(img.height(), Some(720));
assert_eq!(img.format(), Some(PixelFormat::Rgb));
assert_eq!(img.dtype(), DType::I8);
}
#[test]
fn test_planar_rgb_int8_format() {
let img = TensorDyn::image(
1280,
720,
PixelFormat::PlanarRgb,
DType::I8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.width(), Some(1280));
assert_eq!(img.height(), Some(720));
assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
assert_eq!(img.dtype(), DType::I8);
}
#[test]
fn test_rgb_from_tensor() {
let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
tensor.set_format(PixelFormat::Rgb).unwrap();
let img = TensorDyn::from(tensor);
assert_eq!(img.width(), Some(1280));
assert_eq!(img.height(), Some(720));
assert_eq!(img.format(), Some(PixelFormat::Rgb));
}
#[test]
fn test_planar_rgb_from_tensor() {
let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
tensor.set_format(PixelFormat::PlanarRgb).unwrap();
let img = TensorDyn::from(tensor);
assert_eq!(img.width(), Some(1280));
assert_eq!(img.height(), Some(720));
assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
}
#[test]
fn test_dtype_determines_int8() {
let u8_img = TensorDyn::image(
64,
64,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let i8_img = TensorDyn::image(
64,
64,
PixelFormat::Rgb,
DType::I8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(u8_img.dtype(), DType::U8);
assert_eq!(i8_img.dtype(), DType::I8);
}
#[test]
fn test_pixel_layout_packed_vs_planar() {
assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
#[test]
fn test_convert_pbo_to_pbo() {
let mut converter = ImageProcessor::new().unwrap();
let is_pbo = converter
.opengl
.as_ref()
.is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
if !is_pbo {
eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
return;
}
let src_w = 640;
let src_h = 480;
let dst_w = 320;
let dst_h = 240;
let pbo_src = converter
.create_image(
src_w,
src_h,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(
pbo_src.as_u8().unwrap().memory(),
TensorMemory::Pbo,
"create_image should produce a PBO tensor"
);
let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
let mem_src = TensorDyn::image(
src_w,
src_h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, _jpeg_src, mem_src) = convert_img(
&mut CPUProcessor::new(),
jpeg_src,
mem_src,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
{
let src_data = mem_src.as_u8().unwrap().map().unwrap();
let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
pbo_map.copy_from_slice(&src_data);
}
let pbo_dst = converter
.create_image(
dst_w,
dst_h,
PixelFormat::Rgba,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
let mut pbo_dst = pbo_dst;
let result = converter.convert(
&pbo_src,
&mut pbo_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let cpu_dst = TensorDyn::image(
dst_w,
dst_h,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let (result, _mem_src, cpu_dst) = convert_img(
&mut CPUProcessor::new(),
mem_src,
cpu_dst,
Rotation::None,
Flip::None,
Crop::no_crop(),
);
result.unwrap();
let pbo_dst_img = {
let mut __t = pbo_dst.into_u8().unwrap();
__t.set_format(PixelFormat::Rgba).unwrap();
TensorDyn::from(__t)
};
compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
}
#[test]
fn test_image_bgra() {
let img = TensorDyn::image(
640,
480,
PixelFormat::Bgra,
DType::U8,
Some(edgefirst_tensor::TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(img.width(), Some(640));
assert_eq!(img.height(), Some(480));
assert_eq!(img.format().unwrap().channels(), 4);
assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
}
#[test]
fn test_force_backend_cpu() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
let converter = ImageProcessor::new().unwrap();
assert!(converter.cpu.is_some());
assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
}
#[test]
fn test_force_backend_invalid() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
let result = ImageProcessor::new();
assert!(
matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
"invalid backend value should return ForcedBackendUnavailable error: {result:?}"
);
}
#[test]
fn test_force_backend_unset() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
let converter = ImageProcessor::new().unwrap();
assert!(converter.forced_backend.is_none());
}
#[test]
fn test_draw_proto_masks_no_cpu_returns_error() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&[
"EDGEFIRST_FORCE_BACKEND",
"EDGEFIRST_DISABLE_GL",
"EDGEFIRST_DISABLE_G2D",
"EDGEFIRST_DISABLE_CPU",
]);
unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
let mut converter = ImageProcessor::new().unwrap();
assert!(converter.cpu.is_none(), "CPU should be disabled");
let dst = TensorDyn::image(
640,
480,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut dst_dyn = dst;
let det = [DetectBox {
bbox: edgefirst_decoder::BoundingBox {
xmin: 0.1,
ymin: 0.1,
xmax: 0.5,
ymax: 0.5,
},
score: 0.9,
label: 0,
}];
let proto_data = {
use edgefirst_tensor::{Tensor, TensorDyn};
let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
let protos_t =
Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
ProtoData {
mask_coefficients: TensorDyn::F32(coeff_t),
protos: TensorDyn::F32(protos_t),
layout: ProtoLayout::Nhwc,
}
};
let result =
converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
assert!(
matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
"draw_proto_masks without CPU should return Internal error: {result:?}"
);
}
#[test]
fn test_draw_proto_masks_cpu_fallback_works() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
let mut converter = ImageProcessor::new().unwrap();
assert!(converter.cpu.is_some());
let dst = TensorDyn::image(
64,
64,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut dst_dyn = dst;
let det = [DetectBox {
bbox: edgefirst_decoder::BoundingBox {
xmin: 0.1,
ymin: 0.1,
xmax: 0.5,
ymax: 0.5,
},
score: 0.9,
label: 0,
}];
let proto_data = {
use edgefirst_tensor::{Tensor, TensorDyn};
let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
let protos_t =
Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
ProtoData {
mask_coefficients: TensorDyn::F32(coeff_t),
protos: TensorDyn::F32(protos_t),
layout: ProtoLayout::Nhwc,
}
};
let result =
converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
}
fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
ENV_MUTEX
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
struct EnvGuard {
vars: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn snapshot(names: &[&'static str]) -> Self {
Self {
vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.vars {
match v {
Some(s) => unsafe { std::env::set_var(k, s) },
None => unsafe { std::env::remove_var(k) },
}
}
}
}
fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
match value {
Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
}
body()
}
fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
let dst = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
mem,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
use edgefirst_tensor::TensorMapTrait;
let u8t = dst.as_u8().unwrap();
let mut map = u8t.map().unwrap();
for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
*b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
}
}
dst
}
fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
let bg = TensorDyn::image(
w,
h,
PixelFormat::Rgba,
DType::U8,
mem,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
use edgefirst_tensor::TensorMapTrait;
let u8t = bg.as_u8().unwrap();
let mut map = u8t.map().unwrap();
for chunk in map.as_mut_slice().chunks_exact_mut(4) {
chunk.copy_from_slice(&rgba);
}
}
bg
}
fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
use edgefirst_tensor::TensorMapTrait;
let w = dst.width().unwrap();
let off = (y * w + x) * 4;
let u8t = dst.as_u8().unwrap();
let map = u8t.map().unwrap();
let s = map.as_slice();
[s[off], s[off + 1], s[off + 2], s[off + 3]]
}
fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
use edgefirst_tensor::TensorMapTrait;
let u8t = dst.as_u8().unwrap();
let map = u8t.map().unwrap();
for (i, chunk) in map.as_slice().chunks_exact(4).enumerate() {
assert_eq!(
chunk, &expected,
"{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
);
}
}
fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
let mut dst = make_dirty_dst(64, 64, None);
processor
.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
.unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
let mut dst = make_dirty_dst(64, 64, None);
let proto = {
use edgefirst_tensor::{Tensor, TensorDyn};
let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
let protos_t =
Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
ProtoData {
mask_coefficients: TensorDyn::F32(coeff_t),
protos: TensorDyn::F32(protos_t),
layout: ProtoLayout::Nhwc,
}
};
processor
.draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
.unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
}
fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
let bg_color = [42, 99, 200, 255];
let bg = make_bg(64, 64, None, bg_color);
let overlay = MaskOverlay::new().with_background(&bg);
let mut dst = make_dirty_dst(64, 64, None);
processor
.draw_decoded_masks(&mut dst, &[], &[], overlay)
.unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
let mut dst = make_dirty_dst(64, 64, None);
let proto = {
use edgefirst_tensor::{Tensor, TensorDyn};
let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
let protos_t =
Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
ProtoData {
mask_coefficients: TensorDyn::F32(coeff_t),
protos: TensorDyn::F32(protos_t),
layout: ProtoLayout::Nhwc,
}
};
processor
.draw_proto_masks(&mut dst, &[], &proto, overlay)
.unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
}
fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
use edgefirst_decoder::Segmentation;
use ndarray::Array3;
processor
.set_class_colors(&[[200, 80, 40, 255]])
.expect("set_class_colors");
let detect = DetectBox {
bbox: [0.25, 0.25, 0.75, 0.75].into(),
score: 0.99,
label: 0,
};
let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
let seg = Segmentation {
segmentation: seg_arr,
xmin: 0.25,
ymin: 0.25,
xmax: 0.75,
ymax: 0.75,
};
let mut dst = make_dirty_dst(64, 64, None);
processor
.draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
.unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
let corner = pixel_at(&dst, 2, 2);
assert_eq!(
corner,
[0, 0, 0, 0],
"{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
);
let center = pixel_at(&dst, 32, 32);
assert!(
center != [0, 0, 0, 0],
"{case}/decoded: center (32,32) was not coloured: {center:?}"
);
}
fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
use edgefirst_decoder::Segmentation;
use ndarray::Array3;
processor
.set_class_colors(&[[200, 80, 40, 255]])
.expect("set_class_colors");
let bg_color = [10, 20, 30, 255];
let bg = make_bg(64, 64, None, bg_color);
let detect = DetectBox {
bbox: [0.25, 0.25, 0.75, 0.75].into(),
score: 0.99,
label: 0,
};
let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
let seg = Segmentation {
segmentation: seg_arr,
xmin: 0.25,
ymin: 0.25,
xmax: 0.75,
ymax: 0.75,
};
let overlay = MaskOverlay::new().with_background(&bg);
let mut dst = make_dirty_dst(64, 64, None);
processor
.draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
.unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
let corner = pixel_at(&dst, 2, 2);
assert_eq!(
corner, bg_color,
"{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
);
let center = pixel_at(&dst, 32, 32);
assert!(
center != bg_color,
"{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
);
}
fn run_all_scenarios(
force_backend: Option<&'static str>,
case: &'static str,
require_dma_for_bg: bool,
) {
if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
eprintln!("SKIPPED: {case} — DMA not available on this host");
return;
}
let processor_result = with_force_backend(force_backend, ImageProcessor::new);
let mut processor = match processor_result {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
return;
}
};
scenario_empty_no_bg(&mut processor, case);
scenario_empty_with_bg(&mut processor, case);
scenario_detect_no_bg(&mut processor, case);
scenario_detect_with_bg(&mut processor, case);
}
#[test]
fn test_draw_masks_4_scenarios_cpu() {
run_all_scenarios(Some("cpu"), "cpu", false);
}
#[test]
fn test_draw_masks_4_scenarios_auto() {
run_all_scenarios(None, "auto", false);
}
#[cfg(target_os = "linux")]
#[cfg(feature = "opengl")]
#[test]
fn test_draw_masks_4_scenarios_opengl() {
run_all_scenarios(Some("opengl"), "opengl", false);
}
#[cfg(target_os = "linux")]
#[test]
fn test_draw_masks_zero_detection_g2d_forced() {
if !edgefirst_tensor::is_dma_available() {
eprintln!("SKIPPED: g2d forced — DMA not available on this host");
return;
}
let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
let mut processor = match processor_result {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
return;
}
};
let mut dst = TensorDyn::image(
64,
64,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
use edgefirst_tensor::TensorMapTrait;
let u8t = dst.as_u8_mut().unwrap();
let mut map = u8t.map().unwrap();
map.as_mut_slice().fill(0xBB);
}
processor
.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
.expect("g2d empty+no-bg");
assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
let bg_color = [7, 11, 13, 255];
let bg = {
let t = TensorDyn::image(
64,
64,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
use edgefirst_tensor::TensorMapTrait;
let u8t = t.as_u8().unwrap();
let mut map = u8t.map().unwrap();
for chunk in map.as_mut_slice().chunks_exact_mut(4) {
chunk.copy_from_slice(&bg_color);
}
}
t
};
let mut dst = TensorDyn::image(
64,
64,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
use edgefirst_tensor::TensorMapTrait;
let u8t = dst.as_u8_mut().unwrap();
let mut map = u8t.map().unwrap();
map.as_mut_slice().fill(0x55);
}
processor
.draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
.expect("g2d empty+bg");
assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
let detect = DetectBox {
bbox: [0.25, 0.25, 0.75, 0.75].into(),
score: 0.9,
label: 0,
};
let mut dst = TensorDyn::image(
64,
64,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let err = processor
.draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
.expect_err("g2d must reject detect-present draw_decoded_masks");
assert!(
matches!(err, Error::NotImplemented(_)),
"g2d case3 wrong error: {err:?}"
);
}
#[test]
fn test_set_format_then_cpu_convert() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
let mut processor = ImageProcessor::new().unwrap();
let image = edgefirst_bench::testdata::read("zidane.jpg");
let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
let mut dst =
TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
dst.set_format(PixelFormat::Rgb).unwrap();
processor
.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
.unwrap();
assert_eq!(dst.format(), Some(PixelFormat::Rgb));
assert_eq!(dst.width(), Some(640));
assert_eq!(dst.height(), Some(640));
}
#[test]
fn test_multiple_image_processors_same_thread() {
let _lock = acquire_env_lock();
let mut processors: Vec<ImageProcessor> = (0..4)
.map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
.collect();
for proc in &mut processors {
let src = proc
.create_image(
128,
128,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.expect("create src failed");
let mut dst = proc
.create_image(
64,
64,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.expect("create dst failed");
proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
.expect("convert failed");
assert_eq!(dst.width(), Some(64));
assert_eq!(dst.height(), Some(64));
}
}
#[test]
fn test_multiple_image_processors_separate_threads() {
use std::sync::mpsc;
use std::time::Duration;
if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
eprintln!(
"SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
GC7000UL concurrent-EGL-teardown double-free \
(EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
);
return;
}
const TIMEOUT: Duration = Duration::from_secs(60);
let _lock = acquire_env_lock();
let (tx, rx) = mpsc::channel::<()>();
std::thread::spawn(move || {
let handles: Vec<_> = (0..4)
.map(|i| {
std::thread::spawn(move || {
let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
panic!("ImageProcessor::new() failed on thread {i}: {e}")
});
let src = proc
.create_image(
128,
128,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
let mut dst = proc
.create_image(
64,
64,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
.unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
assert_eq!(dst.width(), Some(64));
assert_eq!(dst.height(), Some(64));
})
})
.collect();
for (i, h) in handles.into_iter().enumerate() {
h.join()
.unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
}
let _ = tx.send(());
});
rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
});
}
#[test]
fn test_image_processors_concurrent_operations() {
use std::sync::{mpsc, Arc, Barrier};
use std::time::Duration;
const N: usize = 4;
const ROUNDS: usize = 10;
const TIMEOUT: Duration = Duration::from_secs(60);
let _lock = acquire_env_lock();
let (tx, rx) = mpsc::channel::<()>();
std::thread::spawn(move || {
let barrier = Arc::new(Barrier::new(N));
let handles: Vec<_> = (0..N)
.map(|i| {
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
panic!("ImageProcessor::new() failed on thread {i}: {e}")
});
barrier.wait();
for round in 0..ROUNDS {
let src = proc
.create_image(
128,
128,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap_or_else(|e| {
panic!("create src failed on thread {i} round {round}: {e}")
});
let mut dst = proc
.create_image(
64,
64,
PixelFormat::Rgb,
DType::U8,
None,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap_or_else(|e| {
panic!("create dst failed on thread {i} round {round}: {e}")
});
proc.convert(
&src,
&mut dst,
Rotation::None,
Flip::None,
Crop::default(),
)
.unwrap_or_else(|e| {
panic!("convert failed on thread {i} round {round}: {e}")
});
assert_eq!(dst.width(), Some(64));
assert_eq!(dst.height(), Some(64));
}
})
})
.collect();
for (i, h) in handles.into_iter().enumerate() {
h.join()
.unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
}
let _ = tx.send(());
});
rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
});
}
#[test]
fn test_parallel_processors_unique_outputs() {
use std::sync::{mpsc, Arc, Barrier};
use std::time::Duration;
const N: usize = 4;
const ROUNDS: usize = 25;
const TIMEOUT: Duration = Duration::from_secs(60);
if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
eprintln!(
"SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
GC7000UL concurrent-multi-processor driver abort \
(EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
);
return;
}
let _lock = acquire_env_lock();
let (tx, rx) = mpsc::channel::<()>();
std::thread::spawn(move || {
let barrier = Arc::new(Barrier::new(N));
let handles: Vec<_> = (0..N)
.map(|i| {
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
panic!("ImageProcessor::new() failed on thread {i}: {e}")
});
let (w, h) = (640usize, 480usize);
let mem = if edgefirst_tensor::is_dma_available() {
Some(TensorMemory::Dma)
} else {
Some(TensorMemory::Mem)
};
let src = proc
.create_image(
w,
h,
PixelFormat::Nv12,
DType::U8,
mem,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let t = src.as_u8().unwrap();
let mut m = t.map().unwrap();
let s = m.as_mut_slice();
for (j, b) in s[..w * h].iter_mut().enumerate() {
*b = ((i * 53 + j) % 200 + 16) as u8;
}
for b in &mut s[w * h..] {
*b = (80 + i * 24) as u8;
}
}
let lb = Crop::letterbox([114, 114, 114, 255]);
let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
let mut dst = proc
.create_image(
320,
320,
PixelFormat::Rgba,
DType::U8,
mem,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
.unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
let t = dst.as_u8().unwrap();
let m = t.map().unwrap();
m.as_slice().to_vec()
};
let oracle = convert_once(&mut proc);
barrier.wait();
for round in 0..ROUNDS {
let out = convert_once(&mut proc);
let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
assert!(
diffs == 0,
"thread {i} round {round}: {diffs}/{} bytes diverged \
from this processor's own oracle — cross-processor \
GL state leakage under parallel execution",
oracle.len()
);
}
})
})
.collect();
for (i, h) in handles.into_iter().enumerate() {
h.join()
.unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
}
let _ = tx.send(());
});
rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
});
}
#[test]
#[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
fn stress_parallel_processors_oracle() {
use std::sync::{mpsc, Arc, Barrier};
use std::time::Duration;
const N: usize = 4;
const ROUNDS: usize = 200;
const TIMEOUT: Duration = Duration::from_secs(600);
let _lock = acquire_env_lock();
let (tx, rx) = mpsc::channel::<()>();
std::thread::spawn(move || {
let barrier = Arc::new(Barrier::new(N));
let handles: Vec<_> = (0..N)
.map(|i| {
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
panic!("ImageProcessor::new() failed on thread {i}: {e}")
});
let (w, h) = (1280usize, 720usize);
let mem = if edgefirst_tensor::is_dma_available() {
Some(TensorMemory::Dma)
} else {
Some(TensorMemory::Mem)
};
let src = proc
.create_image(
w,
h,
PixelFormat::Nv12,
DType::U8,
mem,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let t = src.as_u8().unwrap();
let mut m = t.map().unwrap();
let s = m.as_mut_slice();
for (j, b) in s[..w * h].iter_mut().enumerate() {
*b = ((i * 37 + j) % 200 + 16) as u8;
}
for b in &mut s[w * h..] {
*b = (96 + i * 16) as u8;
}
}
let lb = Crop::letterbox([114, 114, 114, 255]);
let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
let mut dst = proc
.create_image(
640,
640,
PixelFormat::Rgb,
DType::U8,
mem,
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
.unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
let t = dst.as_u8().unwrap();
let m = t.map().unwrap();
m.as_slice().to_vec()
};
let oracle = convert_once(&mut proc);
barrier.wait();
for round in 0..ROUNDS {
let out = convert_once(&mut proc);
let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
assert!(
diffs == 0,
"thread {i} round {round}: {diffs}/{} bytes diverged \
from the pre-barrier oracle",
oracle.len()
);
}
})
})
.collect();
for (i, h) in handles.into_iter().enumerate() {
h.join()
.unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
}
let _ = tx.send(());
});
rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
});
}
#[test]
fn convert_f32_auto_never_errors_non_gl_combo() {
const W: usize = 64;
const H: usize = 64;
let src = TensorDyn::image(
W,
H,
PixelFormat::Yuyv,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
let data = map.as_mut_slice();
for chunk in data.chunks_exact_mut(4) {
chunk[0] = 128; chunk[1] = 128; chunk[2] = 160; chunk[3] = 128; }
}
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::Rgb,
DType::F32,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut proc = ImageProcessor::new().unwrap();
let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
assert!(
result.is_ok(),
"auto-chain Yuyv→Rgb F32 must not error: {:?}",
result.err()
);
let map = dst.as_f32().unwrap().map().unwrap();
let floats = map.as_slice();
assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
for (i, &v) in floats.iter().enumerate() {
assert!(
v.is_finite() && (0.0..=1.0).contains(&v),
"output[{i}]={v} is not finite or not in [0,1]"
);
}
let first_non_zero = floats.iter().find(|&&v| v > 0.01);
assert!(
first_non_zero.is_some(),
"all-zero output detected — CPU path likely did not write to the destination buffer"
);
let r0 = floats[0];
assert!(
(r0 - 0.502_f32).abs() < 0.05,
"first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
);
}
#[test]
#[allow(clippy::needless_update)]
fn convert_f16_forced_cpu_correct() {
const W: usize = 16;
const H: usize = 16;
const TOL: f32 = 1.0 / 512.0;
let src = TensorDyn::image(
W,
H,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
let data = map.as_mut_slice();
for y in 0..H {
for x in 0..W {
let i = y * W + x;
data[i * 4] = (50 + x) as u8; data[i * 4 + 1] = (100 + y * 8) as u8; data[i * 4 + 2] = 200; data[i * 4 + 3] = 255;
}
}
}
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
.expect("forced-CPU Rgba→PlanarRgb F16 must not error");
let src_map = src.as_u8().unwrap().map().unwrap();
let src_bytes = src_map.as_slice();
let dst_map = dst.as_f16().unwrap().map().unwrap();
let dst_halfs = dst_map.as_slice();
let plane = W * H;
assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
for y in 0..H {
for x in 0..W {
let i = y * W + x;
let r_exp = src_bytes[i * 4] as f32 / 255.0;
let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
let r_got = dst_halfs[i].to_f32();
let g_got = dst_halfs[plane + i].to_f32();
let b_got = dst_halfs[2 * plane + i].to_f32();
assert!(
(r_got - r_exp).abs() <= TOL,
"R plane ({x},{y}): got {r_got}, expected {r_exp}"
);
assert!(
(g_got - g_exp).abs() <= TOL,
"G plane ({x},{y}): got {g_got}, expected {g_exp}"
);
assert!(
(b_got - b_exp).abs() <= TOL,
"B plane ({x},{y}): got {b_got}, expected {b_exp}"
);
if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
}
}
}
}
#[test]
fn convert_f32_with_rotation_falls_back() {
const W: usize = 16;
const H: usize = 16;
let src = TensorDyn::image(
W,
H,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
let data = map.as_mut_slice();
for y in 0..H {
for x in 0..W {
let i = y * W + x;
data[i * 4] = (x * 16) as u8; data[i * 4 + 1] = (y * 16) as u8; data[i * 4 + 2] = 128; data[i * 4 + 3] = 255;
}
}
}
let mut dst = TensorDyn::image(
H, W, PixelFormat::Rgb,
DType::F32,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut proc = ImageProcessor::new().unwrap();
let result = proc.convert(
&src,
&mut dst,
Rotation::Clockwise90,
Flip::None,
Crop::default(),
);
assert!(
result.is_ok(),
"auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
result.err()
);
let map = dst.as_f32().unwrap().map().unwrap();
let floats = map.as_slice();
assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
for (i, &v) in floats.iter().enumerate() {
assert!(
v.is_finite() && (0.0..=1.0).contains(&v),
"output[{i}]={v} is not finite or not in [0,1]"
);
}
}
#[test]
#[cfg(all(target_os = "linux", feature = "opengl"))]
fn convert_f16_gl_cpu_parity_identity() {
if !is_opengl_available() {
eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
return;
}
const W: usize = 16;
const H: usize = 16;
const TOL: f32 = 1.0 / 256.0;
let src = TensorDyn::image(
W,
H,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
let data = map.as_mut_slice();
for y in 0..H {
for x in 0..W {
let i = y * W + x;
data[i * 4] = (40 + x) as u8; data[i * 4 + 1] = (80 + y * 10) as u8; data[i * 4 + 2] = 180; data[i * 4 + 3] = 255;
}
}
}
let gl_result = {
let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::OpenGl,
..Default::default()
}) {
Ok(p) => p,
Err(e) => {
eprintln!(
"SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
);
return;
}
};
if !gl_proc.supported_render_dtypes().f16 {
eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
return;
}
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
Ok(()) => dst,
Err(e) => {
eprintln!(
"SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
);
return;
}
}
};
let cpu_result = {
let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
cpu_proc
.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
.expect("forced-CPU Rgba→PlanarRgb F16 must not error");
dst
};
let gl_map = gl_result.as_f16().unwrap().map().unwrap();
let cpu_map = cpu_result.as_f16().unwrap().map().unwrap();
let gl_halfs = gl_map.as_slice();
let cpu_halfs = cpu_map.as_slice();
assert_eq!(
gl_halfs.len(),
cpu_halfs.len(),
"GL and CPU output sizes differ"
);
let plane = W * H;
let channel_names = ["R", "G", "B"];
for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
let gl_v = gl_h.to_f32();
let cpu_v = cpu_h.to_f32();
let err = (gl_v - cpu_v).abs();
let ch = channel_names[idx / plane];
let pixel = idx % plane;
assert!(
err <= TOL,
"GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
);
}
}
#[test]
#[cfg(all(target_os = "linux", feature = "opengl"))]
fn supported_render_dtypes_linux_smoke() {
let proc = match ImageProcessor::new() {
Ok(p) => p,
Err(e) => {
eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
return;
}
};
if proc.opengl.is_none() {
eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
return;
}
let support = proc.supported_render_dtypes();
eprintln!(
"supported_render_dtypes_linux_smoke: f16={} f32={}",
support.f16, support.f32
);
}
#[test]
fn convert_f16_pbo_non_4_aligned_width_falls_back() {
const W: usize = 18; const H: usize = 16;
let src = TensorDyn::image(
W,
H,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
let data = map.as_mut_slice();
for chunk in data.chunks_exact_mut(4) {
chunk[0] = 128;
chunk[1] = 64;
chunk[2] = 200;
chunk[3] = 255;
}
}
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut proc = ImageProcessor::new().unwrap();
let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
assert!(
result.is_ok(),
"auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
result.err()
);
let map = dst.as_f16().unwrap().map().unwrap();
let halfs = map.as_slice();
assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
for (i, h) in halfs.iter().enumerate() {
let v = h.to_f32();
assert!(
v.is_finite() && (0.0..=1.0).contains(&v),
"output[{i}]={v} is not finite or not in [0,1]"
);
}
}
#[test]
#[allow(clippy::needless_update)]
fn convert_nv12_to_rgb_f32_cpu() {
const W: usize = 16;
const H: usize = 16;
let src = TensorDyn::image(
W,
H,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
map.as_mut_slice().fill(128); }
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::Rgb,
DType::F32,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
assert!(
result.is_ok(),
"forced-CPU NV12→Rgb F32 must not error: {:?}",
result.err()
);
let map = dst.as_f32().unwrap().map().unwrap();
let floats = map.as_slice();
assert_eq!(floats.len(), W * H * 3, "unexpected element count");
for (i, &v) in floats.iter().enumerate() {
assert!(
v.is_finite() && (0.0..=1.0).contains(&v),
"output[{i}]={v} is not finite or not in [0,1]"
);
}
let non_zero = floats.iter().any(|&v| v > 0.01);
assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
}
#[test]
#[allow(clippy::needless_update)]
fn convert_nv12_to_planar_rgb_f16_cpu() {
const W: usize = 16;
const H: usize = 16;
let src = TensorDyn::image(
W,
H,
PixelFormat::Nv12,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
{
let mut map = src.as_u8().unwrap().map().unwrap();
map.as_mut_slice().fill(128);
}
let mut dst = TensorDyn::image(
W,
H,
PixelFormat::PlanarRgb,
DType::F16,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
backend: ComputeBackend::Cpu,
..Default::default()
})
.unwrap();
let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
assert!(
result.is_ok(),
"forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
result.err()
);
let map = dst.as_f16().unwrap().map().unwrap();
let halfs = map.as_slice();
assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
for (i, h) in halfs.iter().enumerate() {
let v = h.to_f32();
assert!(
v.is_finite() && (0.0..=1.0).contains(&v),
"output[{i}]={v} is not finite or not in [0,1]"
);
}
let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
}
#[test]
fn create_image_desc_negotiates_and_counts_fallbacks() {
use edgefirst_tensor::{Compression, CpuAccess, ImageDesc};
let proc = ImageProcessor::new().unwrap();
let desc =
ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
let plain = proc.create_image_desc(&desc).unwrap();
let classic = proc
.create_image(
64,
64,
PixelFormat::Rgba,
DType::U8,
None,
CpuAccess::ReadWrite,
)
.unwrap();
assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
assert_eq!(plain.compression(), None);
#[cfg(not(target_os = "android"))]
{
let before = proc.compression_fallback_count();
let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
.with_compression(Compression::Any);
let t = proc.create_image_desc(&desc).unwrap();
assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
assert!(
proc.compression_fallback_count() > before,
"Any resolving linear must count"
);
}
}
#[test]
#[cfg(target_os = "linux")]
fn create_image_f32_dma_rejected() {
let proc = ImageProcessor::new().unwrap();
let result = proc.create_image(
64,
64,
PixelFormat::Rgb,
DType::F32,
Some(TensorMemory::Dma),
edgefirst_tensor::CpuAccess::ReadWrite,
);
assert!(
result.is_err(),
"create_image(F32, Dma) must fail — no DRM fourcc for f32"
);
}
#[test]
#[cfg(target_os = "linux")]
fn import_image_carries_colorimetry() {
use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
let expected = Colorimetry::default()
.with_encoding(ColorEncoding::Bt709)
.with_range(ColorRange::Limited);
if !is_dma_available() {
let mut t = TensorDyn::image(
8,
8,
PixelFormat::Rgba,
DType::U8,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.expect("alloc");
assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
t.set_colorimetry(Some(expected));
assert_eq!(
t.colorimetry(),
Some(expected),
"set_colorimetry must round-trip"
);
eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
return;
}
use edgefirst_tensor::{PlaneDescriptor, Tensor};
let rgba_bytes = 64 * 64 * 4; let dma_tensor =
Tensor::<u8>::new(&[rgba_bytes], Some(TensorMemory::Dma), Some("import_test"))
.expect("dma alloc");
let pd =
PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
let proc = ImageProcessor::new().expect("ImageProcessor");
let result = proc.import_image(
pd,
None,
64,
64,
PixelFormat::Rgba,
DType::U8,
Some(expected),
);
let tensor = result.expect("import_image must succeed on DMA fd");
assert_eq!(
tensor.colorimetry(),
Some(expected),
"import_image must store the supplied colorimetry on the returned TensorDyn"
);
}
}