1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
71
72#[cfg(all(coverage, target_os = "linux"))]
76#[used]
77#[link_section = ".init_array"]
78static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
79 extern "C" fn ctor() {
80 edgefirst_tensor::covguard::install();
81 }
82 ctor
83};
84
85pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
98
99pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
137 if bpp == 0 || width == 0 {
138 return width;
139 }
140
141 let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
150 else {
151 log::warn!(
152 "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
153 overflows usize, returning unaligned width {width}"
154 );
155 return width;
156 };
157 if lcm_alignment == 0 {
158 return width;
159 }
160
161 debug_assert_eq!(lcm_alignment % bpp, 0);
162 let width_alignment = lcm_alignment / bpp;
163 if width_alignment == 0 {
164 return width;
165 }
166
167 let remainder = width % width_alignment;
168 if remainder == 0 {
169 return width;
170 }
171
172 let pad = width_alignment - remainder;
173 match width.checked_add(pad) {
174 Some(aligned) => aligned,
175 None => {
176 log::warn!(
177 "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
178 returning unaligned (caller should use a smaller width or pre-aligned size)"
179 );
180 width
181 }
182 }
183}
184
185#[cfg(target_os = "linux")]
194pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
195 let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
196 if min_pitch_bytes == 0 {
197 return Some(0);
198 }
199 let remainder = min_pitch_bytes % alignment;
200 if remainder == 0 {
201 return Some(min_pitch_bytes);
202 }
203 min_pitch_bytes.checked_add(alignment - remainder)
204}
205
206fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
209 if a == 0 || b == 0 {
210 return Some(0);
211 }
212 let g = num_integer_gcd(a, b);
213 (a / g).checked_mul(b)
216}
217
218fn num_integer_gcd(a: usize, b: usize) -> usize {
219 if b == 0 {
220 a
221 } else {
222 num_integer_gcd(b, a % b)
223 }
224}
225
226pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
242 use edgefirst_tensor::PixelLayout;
243 match format.layout() {
244 PixelLayout::Packed => Some(format.channels() * elem),
245 PixelLayout::Planar => Some(elem),
246 PixelLayout::SemiPlanar => Some(elem),
250 _ => None,
253 }
254}
255
256#[cfg(all(target_os = "linux", test))]
269pub(crate) fn padded_dma_pitch_for(
270 fmt: PixelFormat,
271 width: usize,
272 memory: &Option<TensorMemory>,
273) -> Option<usize> {
274 match memory {
284 Some(TensorMemory::Dma) => {}
285 None if edgefirst_tensor::is_dma_available() => {}
286 _ => return None,
287 }
288 if fmt.layout() != PixelLayout::Packed {
292 return None;
293 }
294 let bpp = primary_plane_bpp(fmt, 1)?;
295 let natural = width.checked_mul(bpp)?;
296 let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
297 if aligned > natural {
298 Some(aligned)
299 } else {
300 None
301 }
302}
303
304pub use cpu::CPUProcessor;
305pub use edgefirst_codec as codec;
306
307#[cfg(test)]
308use edgefirst_decoder::ProtoLayout;
309use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
310#[doc(inline)]
311pub use edgefirst_tensor::Region;
312#[cfg(any(test, all(target_os = "linux", feature = "opengl")))]
313use edgefirst_tensor::Tensor;
314use edgefirst_tensor::{
315 DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
316};
317use enum_dispatch::enum_dispatch;
318pub use error::{Error, Result};
319#[cfg(target_os = "linux")]
320pub use g2d::G2DProcessor;
321#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
322pub use opengl_headless::EglDisplayKind;
323#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
324pub use opengl_headless::GLProcessorThreaded;
325#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
326pub use opengl_headless::Int8InterpolationMode;
327#[cfg(target_os = "linux")]
328#[cfg(feature = "opengl")]
329pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
330#[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
334pub use opengl_headless::{CacheStats, GlCacheStats};
335use std::{fmt::Display, time::Instant};
336
337mod colorimetry;
338mod cpu;
339mod error;
340mod g2d;
341#[path = "gl/mod.rs"]
342mod opengl_headless;
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum Rotation {
349 None = 0,
350 Clockwise90 = 1,
351 Rotate180 = 2,
352 CounterClockwise90 = 3,
353}
354impl Rotation {
355 pub fn from_degrees_clockwise(angle: usize) -> Rotation {
368 match angle.rem_euclid(360) {
369 0 => Rotation::None,
370 90 => Rotation::Clockwise90,
371 180 => Rotation::Rotate180,
372 270 => Rotation::CounterClockwise90,
373 _ => panic!("rotation angle is not a multiple of 90"),
374 }
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum Flip {
380 None = 0,
381 Vertical = 1,
382 Horizontal = 2,
383}
384
385#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
387pub enum ColorMode {
388 #[default]
393 Class,
394 Instance,
399 Track,
402}
403
404impl ColorMode {
405 #[inline]
407 pub fn index(self, idx: usize, label: usize) -> usize {
408 match self {
409 ColorMode::Class => label,
410 ColorMode::Instance | ColorMode::Track => idx,
411 }
412 }
413}
414
415#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
436pub enum MaskResolution {
437 #[default]
439 Proto,
440 Scaled {
444 width: u32,
446 height: u32,
448 },
449}
450
451#[derive(Debug, Clone, Copy)]
467pub struct MaskOverlay<'a> {
468 pub background: Option<&'a TensorDyn>,
472 pub opacity: f32,
473 pub letterbox: Option<[f32; 4]>,
483 pub color_mode: ColorMode,
484}
485
486impl Default for MaskOverlay<'_> {
487 fn default() -> Self {
488 Self {
489 background: None,
490 opacity: 1.0,
491 letterbox: None,
492 color_mode: ColorMode::Class,
493 }
494 }
495}
496
497impl<'a> MaskOverlay<'a> {
498 pub fn new() -> Self {
499 Self::default()
500 }
501
502 pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
510 self.background = Some(bg);
511 self
512 }
513
514 pub fn with_opacity(mut self, opacity: f32) -> Self {
515 self.opacity = opacity.clamp(0.0, 1.0);
516 self
517 }
518
519 pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
520 self.color_mode = mode;
521 self
522 }
523
524 pub fn with_letterbox_crop(
534 mut self,
535 crop: &Crop,
536 src_w: usize,
537 src_h: usize,
538 model_w: usize,
539 model_h: usize,
540 ) -> Self {
541 if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
544 if let Some(r) = resolved.dst_rect {
545 self.letterbox = Some([
546 r.left as f32 / model_w as f32,
547 r.top as f32 / model_h as f32,
548 (r.left + r.width) as f32 / model_w as f32,
549 (r.top + r.height) as f32 / model_h as f32,
550 ]);
551 }
552 }
553 self
554 }
555}
556
557#[inline]
566fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
567 let b = bbox.bbox.to_canonical();
568 let [lx0, ly0, lx1, ly1] = lb;
569 let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
570 let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
571 DetectBox {
572 bbox: edgefirst_decoder::BoundingBox {
573 xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
574 ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
575 xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
576 ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
577 },
578 ..bbox
579 }
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
584pub enum Fit {
585 #[default]
587 Stretch,
588 Letterbox { pad: [u8; 4] },
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
599pub struct Crop {
600 pub source: Option<Region>,
602 pub fit: Fit,
604}
605
606impl Crop {
607 pub fn new() -> Self {
609 Self::default()
610 }
611
612 pub fn no_crop() -> Self {
614 Self::default()
615 }
616
617 pub fn letterbox(pad: [u8; 4]) -> Self {
620 Self {
621 source: None,
622 fit: Fit::Letterbox { pad },
623 }
624 }
625
626 pub fn with_source(mut self, source: Option<Region>) -> Self {
628 self.source = source;
629 self
630 }
631
632 pub fn with_fit(mut self, fit: Fit) -> Self {
634 self.fit = fit;
635 self
636 }
637
638 pub(crate) fn resolve(
644 &self,
645 src_w: usize,
646 src_h: usize,
647 dst_w: usize,
648 dst_h: usize,
649 ) -> Result<ResolvedCrop, Error> {
650 let src_rect = self.source.map(region_to_rect);
651 let (sw, sh) = match self.source {
654 Some(r) => (r.width, r.height),
655 None => (src_w, src_h),
656 };
657 let resolved = match self.fit {
658 Fit::Stretch => ResolvedCrop {
659 src_rect,
660 dst_rect: None,
661 dst_color: None,
662 },
663 Fit::Letterbox { pad } => ResolvedCrop {
664 src_rect,
665 dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
666 dst_color: Some(pad),
667 },
668 };
669 resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
670 Ok(resolved)
671 }
672
673 pub fn check_crop_dyn(
675 &self,
676 src: &edgefirst_tensor::TensorDyn,
677 dst: &edgefirst_tensor::TensorDyn,
678 ) -> Result<(), Error> {
679 self.resolve(
680 src.width().unwrap_or(0),
681 src.height().unwrap_or(0),
682 dst.width().unwrap_or(0),
683 dst.height().unwrap_or(0),
684 )
685 .map(|_| ())
686 }
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
693pub(crate) struct ResolvedCrop {
694 pub(crate) src_rect: Option<Rect>,
695 pub(crate) dst_rect: Option<Rect>,
696 pub(crate) dst_color: Option<[u8; 4]>,
697}
698
699impl ResolvedCrop {
700 #[allow(dead_code)] pub(crate) fn no_crop() -> Self {
703 Self::default()
704 }
705
706 pub(crate) fn check_crop_dims(
708 &self,
709 src_w: usize,
710 src_h: usize,
711 dst_w: usize,
712 dst_h: usize,
713 ) -> Result<(), Error> {
714 let src_ok = self
715 .src_rect
716 .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
717 let dst_ok = self
718 .dst_rect
719 .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
720 match (src_ok, dst_ok) {
721 (true, true) => Ok(()),
722 (true, false) => Err(Error::CropInvalid(format!(
723 "Dest crop invalid: {:?}",
724 self.dst_rect
725 ))),
726 (false, true) => Err(Error::CropInvalid(format!(
727 "Src crop invalid: {:?}",
728 self.src_rect
729 ))),
730 (false, false) => Err(Error::CropInvalid(format!(
731 "Dest and Src crop invalid: {:?} {:?}",
732 self.dst_rect, self.src_rect
733 ))),
734 }
735 }
736}
737
738fn region_to_rect(r: Region) -> Rect {
740 Rect {
741 left: r.x,
742 top: r.y,
743 width: r.width,
744 height: r.height,
745 }
746}
747
748fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
752 if sw == 0 || sh == 0 {
753 return Rect::new(0, 0, dw, dh);
754 }
755 let src_aspect = sw as f64 / sh as f64;
756 let dst_aspect = dw as f64 / dh as f64;
757 let (new_w, new_h) = if src_aspect > dst_aspect {
758 (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
759 } else {
760 (((dh as f64 * src_aspect).round() as usize).max(1), dh)
761 };
762 let left = dw.saturating_sub(new_w) / 2;
763 let top = dh.saturating_sub(new_h) / 2;
764 Rect::new(left, top, new_w, new_h)
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub(crate) struct Rect {
773 pub left: usize,
774 pub top: usize,
775 pub width: usize,
776 pub height: usize,
777}
778
779impl Rect {
780 pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
782 Self {
783 left,
784 top,
785 width,
786 height,
787 }
788 }
789}
790
791#[enum_dispatch(ImageProcessor)]
792pub trait ImageProcessorTrait {
793 fn convert(
809 &mut self,
810 src: &TensorDyn,
811 dst: &mut TensorDyn,
812 rotation: Rotation,
813 flip: Flip,
814 crop: Crop,
815 ) -> Result<()>;
816
817 fn draw_decoded_masks(
874 &mut self,
875 dst: &mut TensorDyn,
876 detect: &[DetectBox],
877 segmentation: &[Segmentation],
878 overlay: MaskOverlay<'_>,
879 ) -> Result<()>;
880
881 fn draw_proto_masks(
901 &mut self,
902 dst: &mut TensorDyn,
903 detect: &[DetectBox],
904 proto_data: &ProtoData,
905 overlay: MaskOverlay<'_>,
906 ) -> Result<()>;
907
908 fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
911
912 fn convert_deferred(
929 &mut self,
930 src: &TensorDyn,
931 dst: &mut TensorDyn,
932 rotation: Rotation,
933 flip: Flip,
934 crop: Crop,
935 ) -> Result<()> {
936 self.convert(src, dst, rotation, flip, crop)
937 }
938
939 fn flush(&mut self) -> Result<()> {
946 Ok(())
947 }
948}
949
950#[derive(Debug, Clone, Default)]
956pub struct ImageProcessorConfig {
957 #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
967 pub egl_display: Option<EglDisplayKind>,
968
969 pub backend: ComputeBackend,
981
982 pub colorimetry: ColorimetryMode,
987}
988
989#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1004pub enum ColorimetryMode {
1005 #[default]
1009 Fast,
1010 Exact,
1013}
1014
1015#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1022pub enum ComputeBackend {
1023 #[default]
1025 Auto,
1026 Cpu,
1028 G2d,
1030 OpenGl,
1032}
1033
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1040pub(crate) enum ForcedBackend {
1041 Cpu,
1042 G2d,
1043 OpenGl,
1044}
1045
1046#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1067pub struct RenderDtypeSupport {
1068 pub f32: bool,
1073 pub f16: bool,
1079}
1080
1081#[cfg(all(target_os = "linux", feature = "opengl"))]
1095pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1096 match dtype {
1097 DType::F16 => support.f16,
1098 DType::F32 => support.f32,
1099 _ => false,
1100 }
1101}
1102
1103#[derive(Debug)]
1106pub struct ImageProcessor {
1107 pub cpu: Option<CPUProcessor>,
1110
1111 #[cfg(target_os = "linux")]
1112 pub g2d: Option<G2DProcessor>,
1116 #[cfg(target_os = "linux")]
1117 #[cfg(feature = "opengl")]
1118 pub opengl: Option<GLProcessorThreaded>,
1122 #[cfg(target_os = "macos")]
1123 #[cfg(feature = "opengl")]
1124 pub opengl: Option<GLProcessorThreaded>,
1130
1131 pub(crate) forced_backend: Option<ForcedBackend>,
1133}
1134
1135unsafe impl Send for ImageProcessor {}
1136unsafe impl Sync for ImageProcessor {}
1137
1138impl ImageProcessor {
1139 pub fn new() -> Result<Self> {
1164 Self::with_config(ImageProcessorConfig::default())
1165 }
1166
1167 pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1180 #[cfg(all(target_os = "macos", feature = "opengl"))]
1181 if let Some(gl) = self.opengl.as_ref() {
1182 return gl.supported_render_dtypes();
1183 }
1184 #[cfg(all(target_os = "linux", feature = "opengl"))]
1185 if let Some(gl) = self.opengl.as_ref() {
1186 return gl.supported_render_dtypes();
1187 }
1188 RenderDtypeSupport {
1189 f32: false,
1190 f16: false,
1191 }
1192 }
1193
1194 #[allow(unused_variables)]
1203 pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1204 match config.backend {
1208 ComputeBackend::Cpu => {
1209 log::info!("ComputeBackend::Cpu — CPU only");
1210 return Ok(Self {
1211 cpu: Some(CPUProcessor::new()),
1212 #[cfg(target_os = "linux")]
1213 g2d: None,
1214 #[cfg(target_os = "linux")]
1215 #[cfg(feature = "opengl")]
1216 opengl: None,
1217 #[cfg(target_os = "macos")]
1218 #[cfg(feature = "opengl")]
1219 opengl: None,
1220 forced_backend: None,
1221 });
1222 }
1223 ComputeBackend::G2d => {
1224 log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1225 #[cfg(target_os = "linux")]
1226 {
1227 let g2d = match G2DProcessor::new() {
1228 Ok(g) => Some(g),
1229 Err(e) => {
1230 log::warn!("G2D requested but failed to initialize: {e:?}");
1231 None
1232 }
1233 };
1234 return Ok(Self {
1235 cpu: Some(CPUProcessor::new()),
1236 g2d,
1237 #[cfg(feature = "opengl")]
1238 opengl: None,
1239 forced_backend: None,
1240 });
1241 }
1242 #[cfg(not(target_os = "linux"))]
1243 {
1244 log::warn!("G2D requested but not available on this platform, using CPU");
1245 return Ok(Self {
1246 cpu: Some(CPUProcessor::new()),
1247 #[cfg(target_os = "macos")]
1248 #[cfg(feature = "opengl")]
1249 opengl: None,
1250 forced_backend: None,
1251 });
1252 }
1253 }
1254 ComputeBackend::OpenGl => {
1255 log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1256 #[cfg(target_os = "linux")]
1257 {
1258 #[cfg(feature = "opengl")]
1259 let opengl = match GLProcessorThreaded::new(config.egl_display) {
1260 Ok(gl) => Some(gl),
1261 Err(e) => {
1262 log::warn!("OpenGL requested but failed to initialize: {e:?}");
1263 None
1264 }
1265 };
1266 return Ok(Self {
1267 cpu: Some(CPUProcessor::new()),
1268 g2d: None,
1269 #[cfg(feature = "opengl")]
1270 opengl,
1271 forced_backend: None,
1272 }
1273 .apply_colorimetry_mode(config.colorimetry));
1274 }
1275 #[cfg(target_os = "macos")]
1276 {
1277 #[cfg(feature = "opengl")]
1278 let opengl = match GLProcessorThreaded::new(config.egl_display) {
1279 Ok(gl) => Some(gl),
1280 Err(e) => {
1281 log::warn!(
1282 "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1283 (install ANGLE via `brew install startergo/angle/angle` \
1284 and re-sign the dylibs — see README.md § macOS GPU \
1285 Acceleration). Falling back to CPU."
1286 );
1287 None
1288 }
1289 };
1290 return Ok(Self {
1291 cpu: Some(CPUProcessor::new()),
1292 #[cfg(feature = "opengl")]
1293 opengl,
1294 forced_backend: None,
1295 }
1296 .apply_colorimetry_mode(config.colorimetry));
1297 }
1298 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1299 {
1300 log::warn!("OpenGL requested but not available on this platform, using CPU");
1301 return Ok(Self {
1302 cpu: Some(CPUProcessor::new()),
1303 forced_backend: None,
1304 });
1305 }
1306 }
1307 ComputeBackend::Auto => { }
1308 }
1309
1310 if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1315 let val_lower = val.to_lowercase();
1316 let forced = match val_lower.as_str() {
1317 "cpu" => ForcedBackend::Cpu,
1318 "g2d" => ForcedBackend::G2d,
1319 "opengl" => ForcedBackend::OpenGl,
1320 other => {
1321 return Err(Error::ForcedBackendUnavailable(format!(
1322 "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1323 )));
1324 }
1325 };
1326
1327 log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1328
1329 return match forced {
1330 ForcedBackend::Cpu => Ok(Self {
1331 cpu: Some(CPUProcessor::new()),
1332 #[cfg(target_os = "linux")]
1333 g2d: None,
1334 #[cfg(target_os = "linux")]
1335 #[cfg(feature = "opengl")]
1336 opengl: None,
1337 #[cfg(target_os = "macos")]
1338 #[cfg(feature = "opengl")]
1339 opengl: None,
1340 forced_backend: Some(ForcedBackend::Cpu),
1341 }),
1342 ForcedBackend::G2d => {
1343 #[cfg(target_os = "linux")]
1344 {
1345 let g2d = G2DProcessor::new().map_err(|e| {
1346 Error::ForcedBackendUnavailable(format!(
1347 "g2d forced but failed to initialize: {e:?}"
1348 ))
1349 })?;
1350 Ok(Self {
1351 cpu: None,
1352 g2d: Some(g2d),
1353 #[cfg(feature = "opengl")]
1354 opengl: None,
1355 forced_backend: Some(ForcedBackend::G2d),
1356 })
1357 }
1358 #[cfg(not(target_os = "linux"))]
1359 {
1360 Err(Error::ForcedBackendUnavailable(
1361 "g2d backend is only available on Linux".into(),
1362 ))
1363 }
1364 }
1365 ForcedBackend::OpenGl => {
1366 #[cfg(target_os = "linux")]
1367 #[cfg(feature = "opengl")]
1368 {
1369 let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1370 Error::ForcedBackendUnavailable(format!(
1371 "opengl forced but failed to initialize: {e:?}"
1372 ))
1373 })?;
1374 Ok(Self {
1375 cpu: None,
1376 g2d: None,
1377 opengl: Some(opengl),
1378 forced_backend: Some(ForcedBackend::OpenGl),
1379 }
1380 .apply_colorimetry_mode(config.colorimetry))
1381 }
1382 #[cfg(target_os = "macos")]
1383 #[cfg(feature = "opengl")]
1384 {
1385 let opengl = GLProcessorThreaded::new(config.egl_display).map_err(|e| {
1386 Error::ForcedBackendUnavailable(format!(
1387 "opengl forced on macOS but ANGLE init failed: {e:?}"
1388 ))
1389 })?;
1390 Ok(Self {
1391 cpu: None,
1392 opengl: Some(opengl),
1393 forced_backend: Some(ForcedBackend::OpenGl),
1394 }
1395 .apply_colorimetry_mode(config.colorimetry))
1396 }
1397 #[cfg(not(all(
1398 any(target_os = "linux", target_os = "macos"),
1399 feature = "opengl"
1400 )))]
1401 {
1402 Err(Error::ForcedBackendUnavailable(
1403 "opengl backend requires Linux or macOS with the 'opengl' feature \
1404 enabled"
1405 .into(),
1406 ))
1407 }
1408 }
1409 };
1410 }
1411
1412 #[cfg(target_os = "linux")]
1414 let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1415 .map(|x| x != "0" && x.to_lowercase() != "false")
1416 .unwrap_or(false)
1417 {
1418 log::debug!("EDGEFIRST_DISABLE_G2D is set");
1419 None
1420 } else {
1421 match G2DProcessor::new() {
1422 Ok(g2d_converter) => Some(g2d_converter),
1423 Err(err) => {
1424 log::warn!("Failed to initialize G2D converter: {err:?}");
1425 None
1426 }
1427 }
1428 };
1429
1430 #[cfg(target_os = "linux")]
1431 #[cfg(feature = "opengl")]
1432 let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1433 .map(|x| x != "0" && x.to_lowercase() != "false")
1434 .unwrap_or(false)
1435 {
1436 log::debug!("EDGEFIRST_DISABLE_GL is set");
1437 None
1438 } else {
1439 match GLProcessorThreaded::new(config.egl_display) {
1440 Ok(gl_converter) => Some(gl_converter),
1441 Err(err) => {
1442 log::warn!("Failed to initialize GL converter: {err:?}");
1443 None
1444 }
1445 }
1446 };
1447
1448 #[cfg(target_os = "macos")]
1449 #[cfg(feature = "opengl")]
1450 let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1451 .map(|x| x != "0" && x.to_lowercase() != "false")
1452 .unwrap_or(false)
1453 {
1454 log::debug!("EDGEFIRST_DISABLE_GL is set");
1455 None
1456 } else {
1457 match GLProcessorThreaded::new(config.egl_display) {
1458 Ok(gl_converter) => Some(gl_converter),
1459 Err(err) => {
1460 log::debug!(
1461 "macOS GL backend unavailable: {err:?} \
1462 (CPU fallback will be used)"
1463 );
1464 None
1465 }
1466 }
1467 };
1468
1469 let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1470 .map(|x| x != "0" && x.to_lowercase() != "false")
1471 .unwrap_or(false)
1472 {
1473 log::debug!("EDGEFIRST_DISABLE_CPU is set");
1474 None
1475 } else {
1476 Some(CPUProcessor::new())
1477 };
1478 Ok(Self {
1479 cpu,
1480 #[cfg(target_os = "linux")]
1481 g2d,
1482 #[cfg(target_os = "linux")]
1483 #[cfg(feature = "opengl")]
1484 opengl,
1485 #[cfg(target_os = "macos")]
1486 #[cfg(feature = "opengl")]
1487 opengl,
1488 forced_backend: None,
1489 }
1490 .apply_colorimetry_mode(config.colorimetry))
1491 }
1492
1493 fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1497 #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
1498 {
1499 let mut me = self;
1500 if let Err(e) = me.set_colorimetry_mode(_mode) {
1501 log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1502 }
1503 me
1504 }
1505 #[cfg(not(all(any(target_os = "linux", target_os = "macos"), feature = "opengl")))]
1506 {
1507 let _ = _mode;
1508 self
1509 }
1510 }
1511
1512 #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
1517 pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1518 if let Some(ref mut gl) = self.opengl {
1519 gl.set_colorimetry_mode(mode)?;
1520 }
1521 Ok(())
1522 }
1523
1524 #[cfg(all(any(target_os = "linux", target_os = "macos"), feature = "opengl"))]
1527 pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
1528 if let Some(ref mut gl) = self.opengl {
1529 gl.set_int8_interpolation_mode(mode)?;
1530 }
1531 Ok(())
1532 }
1533
1534 pub fn create_image(
1602 &self,
1603 width: usize,
1604 height: usize,
1605 format: PixelFormat,
1606 dtype: DType,
1607 memory: Option<TensorMemory>,
1608 ) -> Result<TensorDyn> {
1609 #[cfg(target_os = "linux")]
1620 let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
1621 .and_then(|bpp| width.checked_mul(bpp))
1622 .and_then(align_pitch_bytes_to_gpu_alignment);
1623
1624 #[cfg(target_os = "linux")]
1628 let try_dma = || -> Result<TensorDyn> {
1629 let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
1637 match dma_stride_bytes {
1638 Some(stride)
1639 if packed
1640 && primary_plane_bpp(format, dtype.size())
1641 .and_then(|bpp| width.checked_mul(bpp))
1642 .is_some_and(|natural| stride > natural) =>
1643 {
1644 log::debug!(
1645 "create_image: padding row stride for {format:?} {width}x{height} \
1646 from natural pitch to {stride} bytes for GPU alignment"
1647 );
1648 Ok(TensorDyn::image_with_stride(
1649 width,
1650 height,
1651 format,
1652 dtype,
1653 stride,
1654 Some(edgefirst_tensor::TensorMemory::Dma),
1655 )?)
1656 }
1657 _ => Ok(TensorDyn::image(
1658 width,
1659 height,
1660 format,
1661 dtype,
1662 Some(edgefirst_tensor::TensorMemory::Dma),
1663 )?),
1664 }
1665 };
1666
1667 match memory {
1674 #[cfg(target_os = "linux")]
1675 Some(TensorMemory::Dma) => {
1676 if dtype == DType::F32 {
1678 return Err(Error::NotSupported(
1679 "F32 has no 32-bit-float DRM format for DMA-BUF; \
1680 use TensorMemory::Pbo for F32"
1681 .to_string(),
1682 ));
1683 }
1684 return try_dma();
1685 }
1686 Some(mem) => {
1687 return Ok(TensorDyn::image(width, height, format, dtype, Some(mem))?);
1688 }
1689 None => {}
1690 }
1691
1692 #[cfg(target_os = "macos")]
1697 #[cfg(feature = "opengl")]
1698 if let Some(gl) = self.opengl.as_ref() {
1699 let _ = gl; if let Ok(img) = TensorDyn::image(
1701 width,
1702 height,
1703 format,
1704 dtype,
1705 Some(edgefirst_tensor::TensorMemory::Dma),
1706 ) {
1707 return Ok(img);
1708 }
1709 }
1710
1711 #[cfg(target_os = "linux")]
1714 {
1715 #[cfg(feature = "opengl")]
1716 let gl_uses_pbo = self
1717 .opengl
1718 .as_ref()
1719 .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
1720 #[cfg(not(feature = "opengl"))]
1721 let gl_uses_pbo = false;
1722
1723 if !gl_uses_pbo {
1724 if let Ok(img) = try_dma() {
1725 return Ok(img);
1726 }
1727 }
1728 }
1729
1730 #[cfg(target_os = "linux")]
1734 #[cfg(feature = "opengl")]
1735 if dtype.size() == 1 {
1736 if let Some(gl) = &self.opengl {
1737 match gl.create_pbo_image(width, height, format) {
1738 Ok(t) => {
1739 if dtype == DType::I8 {
1740 debug_assert!(
1748 t.chroma().is_none(),
1749 "PBO i8 transmute requires chroma == None"
1750 );
1751 let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
1752 return Ok(TensorDyn::from(t_i8));
1753 }
1754 return Ok(TensorDyn::from(t));
1755 }
1756 Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
1757 }
1758 }
1759 }
1760
1761 #[cfg(target_os = "linux")]
1764 #[cfg(feature = "opengl")]
1765 if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
1766 if let Some(gl) = &self.opengl {
1767 match gl.create_pbo_image_dtype(width, height, format, dtype) {
1768 Ok(t) => return Ok(t),
1769 Err(e) => {
1770 log::debug!(
1771 "Float PBO image creation failed for {dtype:?}, \
1772 falling back to Mem: {e:?}"
1773 );
1774 }
1775 }
1776 }
1777 }
1778
1779 Ok(TensorDyn::image(
1781 width,
1782 height,
1783 format,
1784 dtype,
1785 Some(edgefirst_tensor::TensorMemory::Mem),
1786 )?)
1787 }
1788
1789 #[allow(clippy::too_many_arguments)]
1843 #[cfg(target_os = "linux")]
1844 pub fn import_image(
1845 &self,
1846 image: edgefirst_tensor::PlaneDescriptor,
1847 chroma: Option<edgefirst_tensor::PlaneDescriptor>,
1848 width: usize,
1849 height: usize,
1850 format: PixelFormat,
1851 dtype: DType,
1852 colorimetry: Option<edgefirst_tensor::Colorimetry>,
1853 ) -> Result<TensorDyn> {
1854 use edgefirst_tensor::{Tensor, TensorMemory};
1855
1856 let image_stride = image.stride();
1858 let image_offset = image.offset();
1859 let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
1860 let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
1861
1862 if let Some(chroma_pd) = chroma {
1863 if dtype != DType::U8 && dtype != DType::I8 {
1868 return Err(Error::NotSupported(format!(
1869 "multiplane import only supports U8/I8, got {dtype:?}"
1870 )));
1871 }
1872 if format.layout() != PixelLayout::SemiPlanar {
1873 return Err(Error::NotSupported(format!(
1874 "import_image with chroma requires a semi-planar format, got {format:?}"
1875 )));
1876 }
1877
1878 let chroma_h = match format {
1879 PixelFormat::Nv12 => {
1880 height.div_ceil(2)
1882 }
1883 PixelFormat::Nv16 => {
1886 return Err(Error::NotSupported(
1887 "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
1888 ))
1889 }
1890 _ => {
1891 return Err(Error::NotSupported(format!(
1892 "unsupported semi-planar format: {format:?}"
1893 )))
1894 }
1895 };
1896
1897 let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
1898 if luma.memory() != TensorMemory::Dma {
1899 return Err(Error::NotSupported(format!(
1900 "luma fd must be DMA-backed, got {:?}",
1901 luma.memory()
1902 )));
1903 }
1904
1905 let chroma_tensor =
1906 Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
1907 if chroma_tensor.memory() != TensorMemory::Dma {
1908 return Err(Error::NotSupported(format!(
1909 "chroma fd must be DMA-backed, got {:?}",
1910 chroma_tensor.memory()
1911 )));
1912 }
1913
1914 let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
1917
1918 if let Some(s) = image_stride {
1920 tensor.set_row_stride(s)?;
1921 }
1922 if let Some(o) = image_offset {
1923 tensor.set_plane_offset(o);
1924 }
1925
1926 if let Some(chroma_ref) = tensor.chroma_mut() {
1931 if let Some(s) = chroma_stride {
1932 if s < width {
1933 return Err(Error::InvalidShape(format!(
1934 "chroma stride {s} < minimum {width} for {format:?}"
1935 )));
1936 }
1937 chroma_ref.set_row_stride_unchecked(s);
1938 }
1939 if let Some(o) = chroma_offset {
1940 chroma_ref.set_plane_offset(o);
1941 }
1942 }
1943
1944 if dtype == DType::I8 {
1945 const {
1949 assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
1950 assert!(
1951 std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
1952 );
1953 }
1954 let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
1955 let mut dyn_tensor = TensorDyn::from(tensor_i8);
1956 dyn_tensor.set_colorimetry(colorimetry);
1957 return Ok(dyn_tensor);
1958 }
1959 let mut dyn_tensor = TensorDyn::from(tensor);
1960 dyn_tensor.set_colorimetry(colorimetry);
1961 Ok(dyn_tensor)
1962 } else {
1963 let shape = format.image_shape(width, height).ok_or_else(|| {
1968 Error::NotSupported(format!(
1969 "unsupported pixel format for import_image: {format:?}"
1970 ))
1971 })?;
1972 let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
1973 if tensor.memory() != TensorMemory::Dma {
1974 return Err(Error::NotSupported(format!(
1975 "import_image requires DMA-backed fd, got {:?}",
1976 tensor.memory()
1977 )));
1978 }
1979 let mut tensor = tensor.with_format(format)?;
1980 if let Some(s) = image_stride {
1981 tensor.set_row_stride(s)?;
1982 }
1983 if let Some(o) = image_offset {
1984 tensor.set_plane_offset(o);
1985 }
1986 tensor.set_colorimetry(colorimetry);
1987 Ok(tensor)
1988 }
1989 }
1990
1991 pub fn draw_masks(
1999 &mut self,
2000 decoder: &edgefirst_decoder::Decoder,
2001 outputs: &[&TensorDyn],
2002 dst: &mut TensorDyn,
2003 overlay: MaskOverlay<'_>,
2004 ) -> Result<Vec<DetectBox>> {
2005 let mut output_boxes = Vec::with_capacity(100);
2006
2007 let proto_result = decoder
2009 .decode_proto(outputs, &mut output_boxes)
2010 .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2011
2012 if let Some(proto_data) = proto_result {
2013 self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2014 } else {
2015 let mut output_masks = Vec::with_capacity(100);
2017 decoder
2018 .decode(outputs, &mut output_boxes, &mut output_masks)
2019 .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2020 self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2021 }
2022 Ok(output_boxes)
2023 }
2024
2025 #[cfg(feature = "tracker")]
2033 pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2034 &mut self,
2035 decoder: &edgefirst_decoder::Decoder,
2036 tracker: &mut TR,
2037 timestamp: u64,
2038 outputs: &[&TensorDyn],
2039 dst: &mut TensorDyn,
2040 overlay: MaskOverlay<'_>,
2041 ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2042 let mut output_boxes = Vec::with_capacity(100);
2043 let mut output_tracks = Vec::new();
2044
2045 let proto_result = decoder
2046 .decode_proto_tracked(
2047 tracker,
2048 timestamp,
2049 outputs,
2050 &mut output_boxes,
2051 &mut output_tracks,
2052 )
2053 .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2054
2055 if let Some(proto_data) = proto_result {
2056 self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2057 } else {
2058 let mut output_masks = Vec::with_capacity(100);
2062 decoder
2063 .decode_tracked(
2064 tracker,
2065 timestamp,
2066 outputs,
2067 &mut output_boxes,
2068 &mut output_masks,
2069 &mut output_tracks,
2070 )
2071 .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2072 self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2073 }
2074 Ok((output_boxes, output_tracks))
2075 }
2076
2077 pub fn materialize_masks(
2101 &mut self,
2102 detect: &[DetectBox],
2103 proto_data: &ProtoData,
2104 letterbox: Option<[f32; 4]>,
2105 resolution: MaskResolution,
2106 ) -> Result<Vec<Segmentation>> {
2107 let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2108 match resolution {
2109 MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2110 MaskResolution::Scaled { width, height } => {
2111 cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2112 }
2113 }
2114 }
2115}
2116
2117impl ImageProcessorTrait for ImageProcessor {
2118 fn convert(
2124 &mut self,
2125 src: &TensorDyn,
2126 dst: &mut TensorDyn,
2127 rotation: Rotation,
2128 flip: Flip,
2129 crop: Crop,
2130 ) -> Result<()> {
2131 let start = Instant::now();
2132 let src_fmt = src.format();
2133 let dst_fmt = dst.format();
2134 let _span = tracing::trace_span!(
2135 "image.convert",
2136 ?src_fmt,
2137 ?dst_fmt,
2138 src_memory = ?src.memory(),
2139 dst_memory = ?dst.memory(),
2140 ?rotation,
2141 ?flip,
2142 )
2143 .entered();
2144 log::trace!(
2145 "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2146 rotation={rotation:?}, flip={flip:?}, backend={:?}",
2147 src.dtype(),
2148 src.memory(),
2149 dst.dtype(),
2150 dst.memory(),
2151 self.forced_backend,
2152 );
2153
2154 if let Some(forced) = self.forced_backend {
2156 return match forced {
2157 ForcedBackend::Cpu => {
2158 if let Some(cpu) = self.cpu.as_mut() {
2159 let r = cpu.convert(src, dst, rotation, flip, crop);
2160 log::trace!(
2161 "convert: forced=cpu result={} ({:?})",
2162 if r.is_ok() { "ok" } else { "err" },
2163 start.elapsed()
2164 );
2165 return r;
2166 }
2167 Err(Error::ForcedBackendUnavailable("cpu".into()))
2168 }
2169 ForcedBackend::G2d => {
2170 #[cfg(target_os = "linux")]
2171 if let Some(g2d) = self.g2d.as_mut() {
2172 let r = g2d.convert(src, dst, rotation, flip, crop);
2173 log::trace!(
2174 "convert: forced=g2d result={} ({:?})",
2175 if r.is_ok() { "ok" } else { "err" },
2176 start.elapsed()
2177 );
2178 return r;
2179 }
2180 Err(Error::ForcedBackendUnavailable("g2d".into()))
2181 }
2182 ForcedBackend::OpenGl => {
2183 #[cfg(any(target_os = "linux", target_os = "macos"))]
2184 #[cfg(feature = "opengl")]
2185 if let Some(opengl) = self.opengl.as_mut() {
2186 let r = opengl.convert(src, dst, rotation, flip, crop);
2187 log::trace!(
2188 "convert: forced=opengl result={} ({:?})",
2189 if r.is_ok() { "ok" } else { "err" },
2190 start.elapsed()
2191 );
2192 return r;
2193 }
2194 Err(Error::ForcedBackendUnavailable("opengl".into()))
2195 }
2196 };
2197 }
2198
2199 #[cfg(any(target_os = "linux", target_os = "macos"))]
2201 #[cfg(feature = "opengl")]
2202 if let Some(opengl) = self.opengl.as_mut() {
2203 match opengl.convert(src, dst, rotation, flip, crop) {
2204 Ok(_) => {
2205 log::trace!(
2206 "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2207 start.elapsed()
2208 );
2209 return Ok(());
2210 }
2211 Err(e) => {
2212 log::trace!("convert: auto opengl declined {src_fmt:?}→{dst_fmt:?}: {e}");
2213 }
2214 }
2215 }
2216
2217 #[cfg(target_os = "linux")]
2218 if let Some(g2d) = self.g2d.as_mut() {
2219 let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2226 let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2227 let g2d_eligible = if src_is_yuv || dst_is_yuv {
2228 let cm = if src_is_yuv {
2229 crate::colorimetry::effective_colorimetry(src)
2230 } else {
2231 crate::colorimetry::effective_colorimetry(dst)
2232 };
2233 crate::g2d::g2d_can_handle(&cm, true)
2234 } else {
2235 true
2236 };
2237 if !g2d_eligible {
2238 log::trace!(
2239 "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2240 (colorimetry not expressible: full-range/BT.2020)"
2241 );
2242 } else {
2243 match g2d.convert(src, dst, rotation, flip, crop) {
2244 Ok(_) => {
2245 log::trace!(
2246 "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2247 start.elapsed()
2248 );
2249 return Ok(());
2250 }
2251 Err(e) => {
2252 log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2253 }
2254 }
2255 }
2256 }
2257
2258 if let Some(cpu) = self.cpu.as_mut() {
2259 match cpu.convert(src, dst, rotation, flip, crop) {
2260 Ok(_) => {
2261 log::trace!(
2262 "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2263 start.elapsed()
2264 );
2265 return Ok(());
2266 }
2267 Err(e) => {
2268 log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2269 return Err(e);
2270 }
2271 }
2272 }
2273 Err(Error::NoConverter)
2274 }
2275
2276 fn convert_deferred(
2277 &mut self,
2278 src: &TensorDyn,
2279 dst: &mut TensorDyn,
2280 rotation: Rotation,
2281 flip: Flip,
2282 crop: Crop,
2283 ) -> Result<()> {
2284 #[cfg(any(target_os = "linux", target_os = "macos"))]
2290 #[cfg(feature = "opengl")]
2291 {
2292 let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2293 if gl_forced || self.forced_backend.is_none() {
2294 if let Some(opengl) = self.opengl.as_mut() {
2295 match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2296 Ok(()) => return Ok(()),
2297 Err(e) => {
2298 log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2299 if gl_forced {
2302 return Err(e);
2303 }
2304 }
2305 }
2306 }
2307 }
2308 }
2309 self.convert(src, dst, rotation, flip, crop)
2310 }
2311
2312 fn flush(&mut self) -> Result<()> {
2313 let _span = tracing::trace_span!("image.flush").entered();
2314 #[cfg(any(target_os = "linux", target_os = "macos"))]
2317 #[cfg(feature = "opengl")]
2318 if let Some(opengl) = self.opengl.as_mut() {
2319 return opengl.flush();
2320 }
2321 Ok(())
2322 }
2323
2324 fn draw_decoded_masks(
2325 &mut self,
2326 dst: &mut TensorDyn,
2327 detect: &[DetectBox],
2328 segmentation: &[Segmentation],
2329 overlay: MaskOverlay<'_>,
2330 ) -> Result<()> {
2331 let _span = tracing::trace_span!(
2332 "image.draw_decoded_masks",
2333 n_detections = detect.len(),
2334 n_segmentations = segmentation.len(),
2335 )
2336 .entered();
2337 let start = Instant::now();
2338
2339 if let Some(bg) = overlay.background {
2340 if bg.aliases(dst) {
2341 return Err(Error::AliasedBuffers(
2342 "background must not reference the same buffer as dst".to_string(),
2343 ));
2344 }
2345 }
2346
2347 let lb_boxes: Vec<DetectBox>;
2350 let lb_segs: Vec<Segmentation>;
2351 let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2352 lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2353 lb_segs = if segmentation.len() == lb_boxes.len() {
2356 segmentation
2357 .iter()
2358 .zip(lb_boxes.iter())
2359 .map(|(s, d)| Segmentation {
2360 xmin: d.bbox.xmin,
2361 ymin: d.bbox.ymin,
2362 xmax: d.bbox.xmax,
2363 ymax: d.bbox.ymax,
2364 segmentation: s.segmentation.clone(),
2365 })
2366 .collect()
2367 } else {
2368 segmentation.to_vec()
2369 };
2370 (lb_boxes.as_slice(), lb_segs.as_slice())
2371 } else {
2372 (detect, segmentation)
2373 };
2374 #[cfg(target_os = "linux")]
2375 let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2376
2377 if let Some(forced) = self.forced_backend {
2379 return match forced {
2380 ForcedBackend::Cpu => {
2381 if let Some(cpu) = self.cpu.as_mut() {
2382 return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2383 }
2384 Err(Error::ForcedBackendUnavailable("cpu".into()))
2385 }
2386 ForcedBackend::G2d => {
2387 #[cfg(target_os = "linux")]
2390 if let Some(g2d) = self.g2d.as_mut() {
2391 return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2392 }
2393 Err(Error::ForcedBackendUnavailable("g2d".into()))
2394 }
2395 ForcedBackend::OpenGl => {
2396 #[cfg(target_os = "linux")]
2399 #[cfg(feature = "opengl")]
2400 if let Some(opengl) = self.opengl.as_mut() {
2401 return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
2402 }
2403 Err(Error::ForcedBackendUnavailable("opengl".into()))
2404 }
2405 };
2406 }
2407
2408 #[cfg(target_os = "linux")]
2414 if is_empty_frame {
2415 if let Some(g2d) = self.g2d.as_mut() {
2416 match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
2417 Ok(_) => {
2418 log::trace!(
2419 "draw_decoded_masks empty frame via g2d in {:?}",
2420 start.elapsed()
2421 );
2422 return Ok(());
2423 }
2424 Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2425 }
2426 }
2427 }
2428
2429 #[cfg(target_os = "linux")]
2433 #[cfg(feature = "opengl")]
2434 if let Some(opengl) = self.opengl.as_mut() {
2435 log::trace!(
2436 "draw_decoded_masks started with opengl in {:?}",
2437 start.elapsed()
2438 );
2439 match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
2440 Ok(_) => {
2441 log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
2442 return Ok(());
2443 }
2444 Err(e) => {
2445 log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
2446 }
2447 }
2448 }
2449
2450 log::trace!(
2451 "draw_decoded_masks started with cpu in {:?}",
2452 start.elapsed()
2453 );
2454 if let Some(cpu) = self.cpu.as_mut() {
2455 match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
2456 Ok(_) => {
2457 log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
2458 return Ok(());
2459 }
2460 Err(e) => {
2461 log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
2462 return Err(e);
2463 }
2464 }
2465 }
2466 Err(Error::NoConverter)
2467 }
2468
2469 fn draw_proto_masks(
2470 &mut self,
2471 dst: &mut TensorDyn,
2472 detect: &[DetectBox],
2473 proto_data: &ProtoData,
2474 overlay: MaskOverlay<'_>,
2475 ) -> Result<()> {
2476 let start = Instant::now();
2477
2478 if let Some(bg) = overlay.background {
2479 if bg.aliases(dst) {
2480 return Err(Error::AliasedBuffers(
2481 "background must not reference the same buffer as dst".to_string(),
2482 ));
2483 }
2484 }
2485
2486 let lb_boxes: Vec<DetectBox>;
2492 let render_detect = if let Some(lb) = overlay.letterbox {
2493 lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2494 lb_boxes.as_slice()
2495 } else {
2496 detect
2497 };
2498 #[cfg(target_os = "linux")]
2499 let is_empty_frame = detect.is_empty();
2500
2501 if let Some(forced) = self.forced_backend {
2503 return match forced {
2504 ForcedBackend::Cpu => {
2505 if let Some(cpu) = self.cpu.as_mut() {
2506 return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
2507 }
2508 Err(Error::ForcedBackendUnavailable("cpu".into()))
2509 }
2510 ForcedBackend::G2d => {
2511 #[cfg(target_os = "linux")]
2512 if let Some(g2d) = self.g2d.as_mut() {
2513 return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
2514 }
2515 Err(Error::ForcedBackendUnavailable("g2d".into()))
2516 }
2517 ForcedBackend::OpenGl => {
2518 #[cfg(target_os = "linux")]
2519 #[cfg(feature = "opengl")]
2520 if let Some(opengl) = self.opengl.as_mut() {
2521 return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
2522 }
2523 Err(Error::ForcedBackendUnavailable("opengl".into()))
2524 }
2525 };
2526 }
2527
2528 #[cfg(target_os = "linux")]
2531 if is_empty_frame {
2532 if let Some(g2d) = self.g2d.as_mut() {
2533 match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
2534 Ok(_) => {
2535 log::trace!(
2536 "draw_proto_masks empty frame via g2d in {:?}",
2537 start.elapsed()
2538 );
2539 return Ok(());
2540 }
2541 Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
2542 }
2543 }
2544 }
2545
2546 #[cfg(target_os = "linux")]
2555 #[cfg(feature = "opengl")]
2556 if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
2557 let segmentation = match self.cpu.as_mut() {
2558 Some(cpu) => {
2559 log::trace!(
2560 "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
2561 start.elapsed()
2562 );
2563 cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
2564 }
2565 None => unreachable!("cpu presence checked above"),
2566 };
2567 if let Some(opengl) = self.opengl.as_mut() {
2568 match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
2569 Ok(_) => {
2570 log::trace!(
2571 "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
2572 start.elapsed()
2573 );
2574 return Ok(());
2575 }
2576 Err(e) => {
2577 log::trace!(
2578 "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
2579 );
2580 }
2581 }
2582 }
2583 }
2584
2585 let Some(cpu) = self.cpu.as_mut() else {
2586 return Err(Error::Internal(
2587 "draw_proto_masks requires CPU backend for fallback path".into(),
2588 ));
2589 };
2590 log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
2591 cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
2592 }
2593
2594 fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
2595 let start = Instant::now();
2596
2597 if let Some(forced) = self.forced_backend {
2599 return match forced {
2600 ForcedBackend::Cpu => {
2601 if let Some(cpu) = self.cpu.as_mut() {
2602 return cpu.set_class_colors(colors);
2603 }
2604 Err(Error::ForcedBackendUnavailable("cpu".into()))
2605 }
2606 ForcedBackend::G2d => Err(Error::NotSupported(
2607 "g2d does not support set_class_colors".into(),
2608 )),
2609 ForcedBackend::OpenGl => {
2610 #[cfg(target_os = "linux")]
2611 #[cfg(feature = "opengl")]
2612 if let Some(opengl) = self.opengl.as_mut() {
2613 return opengl.set_class_colors(colors);
2614 }
2615 Err(Error::ForcedBackendUnavailable("opengl".into()))
2616 }
2617 };
2618 }
2619
2620 #[cfg(target_os = "linux")]
2623 #[cfg(feature = "opengl")]
2624 if let Some(opengl) = self.opengl.as_mut() {
2625 log::trace!("image started with opengl in {:?}", start.elapsed());
2626 match opengl.set_class_colors(colors) {
2627 Ok(_) => {
2628 log::trace!("colors set with opengl in {:?}", start.elapsed());
2629 return Ok(());
2630 }
2631 Err(e) => {
2632 log::trace!("colors didn't set with opengl: {e:?}")
2633 }
2634 }
2635 }
2636 log::trace!("image started with cpu in {:?}", start.elapsed());
2637 if let Some(cpu) = self.cpu.as_mut() {
2638 match cpu.set_class_colors(colors) {
2639 Ok(_) => {
2640 log::trace!("colors set with cpu in {:?}", start.elapsed());
2641 return Ok(());
2642 }
2643 Err(e) => {
2644 log::trace!("colors didn't set with cpu: {e:?}");
2645 return Err(e);
2646 }
2647 }
2648 }
2649 Err(Error::NoConverter)
2650 }
2651}
2652
2653#[cfg(test)]
2663pub(crate) fn load_image_test_helper(
2664 image: &[u8],
2665 format: Option<PixelFormat>,
2666 memory: Option<TensorMemory>,
2667) -> Result<TensorDyn> {
2668 use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
2669
2670 let info = peek_info(image)?;
2674 let native_fmt = info.format;
2675 let w = info.width;
2676 let h = info.height;
2677
2678 let mut decoder = ImageDecoder::new();
2679
2680 #[cfg(target_os = "linux")]
2683 let native_src = {
2684 if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
2685 let mut dma = Tensor::<u8>::image_with_stride(
2686 w,
2687 h,
2688 native_fmt,
2689 aligned_pitch,
2690 Some(TensorMemory::Dma),
2691 )?;
2692 dma.load_image(&mut decoder, image)?;
2693 TensorDyn::from(dma)
2694 } else {
2695 let mut img = Tensor::<u8>::image(w, h, native_fmt, memory)?;
2696 img.load_image(&mut decoder, image)?;
2697 TensorDyn::from(img)
2698 }
2699 };
2700 #[cfg(not(target_os = "linux"))]
2701 let native_src = {
2702 let mut img = Tensor::<u8>::image(w, h, native_fmt, memory)?;
2703 img.load_image(&mut decoder, image)?;
2704 TensorDyn::from(img)
2705 };
2706
2707 match format {
2711 Some(f) if f != native_fmt => {
2712 let mut dst = TensorDyn::image(w, h, f, DType::U8, memory)?;
2713 #[allow(clippy::needless_update)]
2720 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
2721 backend: ComputeBackend::Cpu,
2722 ..Default::default()
2723 })?;
2724 proc.convert(
2725 &native_src,
2726 &mut dst,
2727 Rotation::None,
2728 Flip::None,
2729 Crop::default(),
2730 )?;
2731 Ok(dst)
2732 }
2733 _ => Ok(native_src),
2734 }
2735}
2736
2737pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
2741 let t = tensor.as_u8().ok_or(Error::UnsupportedFormat(
2742 "save_jpeg requires u8 tensor".to_string(),
2743 ))?;
2744 let fmt = t.format().ok_or(Error::NotAnImage)?;
2745 if fmt.layout() != PixelLayout::Packed {
2746 return Err(Error::NotImplemented(
2747 "Saving planar images is not supported".to_string(),
2748 ));
2749 }
2750
2751 let colour = match fmt {
2752 PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
2753 PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
2754 _ => {
2755 return Err(Error::NotImplemented(
2756 "Unsupported image format for saving".to_string(),
2757 ));
2758 }
2759 };
2760
2761 let w = t.width().ok_or(Error::NotAnImage)?;
2762 let h = t.height().ok_or(Error::NotAnImage)?;
2763 let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
2764 let tensor_map = t.map()?;
2765
2766 encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
2767
2768 Ok(())
2769}
2770
2771pub(crate) struct FunctionTimer<T: Display> {
2772 name: T,
2773 start: std::time::Instant,
2774}
2775
2776impl<T: Display> FunctionTimer<T> {
2777 pub fn new(name: T) -> Self {
2778 Self {
2779 name,
2780 start: std::time::Instant::now(),
2781 }
2782 }
2783}
2784
2785impl<T: Display> Drop for FunctionTimer<T> {
2786 fn drop(&mut self) {
2787 log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
2788 }
2789}
2790
2791const DEFAULT_COLORS: [[f32; 4]; 20] = [
2792 [0., 1., 0., 0.7],
2793 [1., 0.5568628, 0., 0.7],
2794 [0.25882353, 0.15294118, 0.13333333, 0.7],
2795 [0.8, 0.7647059, 0.78039216, 0.7],
2796 [0.3137255, 0.3137255, 0.3137255, 0.7],
2797 [0.1411765, 0.3098039, 0.1215686, 0.7],
2798 [1., 0.95686275, 0.5137255, 0.7],
2799 [0.3529412, 0.32156863, 0., 0.7],
2800 [0.4235294, 0.6235294, 0.6509804, 0.7],
2801 [0.5098039, 0.5098039, 0.7294118, 0.7],
2802 [0.00784314, 0.18823529, 0.29411765, 0.7],
2803 [0.0, 0.2706, 1.0, 0.7],
2804 [0.0, 0.0, 0.0, 0.7],
2805 [0.0, 0.5, 0.0, 0.7],
2806 [1.0, 0.0, 0.0, 0.7],
2807 [0.0, 0.0, 1.0, 0.7],
2808 [1.0, 0.5, 0.5, 0.7],
2809 [0.1333, 0.5451, 0.1333, 0.7],
2810 [0.1176, 0.4118, 0.8235, 0.7],
2811 [1., 1., 1., 0.7],
2812];
2813
2814const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
2815 let mut result = [[0; M]; N];
2816 let mut i = 0;
2817 while i < N {
2818 let mut j = 0;
2819 while j < M {
2820 result[i][j] = (a[i][j] * 255.0).round() as u8;
2821 j += 1;
2822 }
2823 i += 1;
2824 }
2825 result
2826}
2827
2828const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
2829
2830#[cfg(test)]
2831#[cfg_attr(coverage_nightly, coverage(off))]
2832mod alignment_tests {
2833 use super::*;
2834
2835 #[test]
2836 fn align_width_rgba8_common_widths() {
2837 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); }
2848
2849 #[test]
2850 fn align_width_rgb888_packed() {
2851 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] {
2858 let padded = align_width_for_gpu_pitch(w, 3);
2859 assert!(padded >= w);
2860 assert_eq!((padded * 3) % 64, 0);
2861 assert_eq!((padded * 3) % 3, 0);
2862 }
2863 }
2864
2865 #[test]
2866 fn align_width_grey_u8() {
2867 assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
2869 assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
2870 assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
2871 assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
2872 }
2873
2874 #[test]
2875 fn align_width_zero_inputs() {
2876 assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
2877 assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
2878 }
2879
2880 #[test]
2881 fn align_width_never_returns_smaller_than_input() {
2882 for &bpp in &[1usize, 2, 3, 4, 8] {
2886 for &w in &[
2887 1usize,
2888 17,
2889 64,
2890 65,
2891 100,
2892 1280,
2893 1281,
2894 1920,
2895 3004,
2896 3072,
2897 3840,
2898 usize::MAX / 8,
2899 usize::MAX / 4,
2900 usize::MAX / 2,
2901 usize::MAX - 1,
2902 usize::MAX,
2903 ] {
2904 let aligned = align_width_for_gpu_pitch(w, bpp);
2905 assert!(
2906 aligned >= w,
2907 "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
2908 );
2909 }
2910 }
2911 }
2912
2913 #[test]
2914 fn align_width_overflow_returns_unaligned_not_smaller() {
2915 let aligned_extreme = usize::MAX - 15; assert_eq!(
2921 align_width_for_gpu_pitch(aligned_extreme, 4),
2922 aligned_extreme
2923 );
2924 let misaligned_extreme = usize::MAX - 1;
2927 let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
2928 assert!(
2929 result == misaligned_extreme || result >= misaligned_extreme,
2930 "extreme misaligned width must not be rounded down to {result}"
2931 );
2932 }
2933
2934 #[test]
2935 fn checked_lcm_basic_and_overflow() {
2936 assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
2937 assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
2938 assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
2939 assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
2940 assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
2941 assert_eq!(
2943 checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
2944 None,
2945 "coprime extreme values must overflow detect, not panic"
2946 );
2947 }
2948
2949 #[test]
2950 fn primary_plane_bpp_known_formats() {
2951 assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
2953 assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
2954 assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
2955 assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
2956 assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
2958 }
2959}
2960
2961#[cfg(test)]
2962#[cfg_attr(coverage_nightly, coverage(off))]
2963#[allow(deprecated)]
2964mod image_tests {
2965 use super::*;
2966 use crate::{CPUProcessor, Rotation};
2967 #[cfg(target_os = "linux")]
2968 use edgefirst_tensor::is_dma_available;
2969 use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
2970 use image::buffer::ConvertBuffer;
2971
2972 fn convert_img(
2978 proc: &mut dyn ImageProcessorTrait,
2979 src: TensorDyn,
2980 dst: TensorDyn,
2981 rotation: Rotation,
2982 flip: Flip,
2983 crop: Crop,
2984 ) -> (Result<()>, TensorDyn, TensorDyn) {
2985 let src_fourcc = src.format().unwrap();
2986 let dst_fourcc = dst.format().unwrap();
2987 let src_dyn = src;
2988 let mut dst_dyn = dst;
2989 let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
2990 let src_back = {
2991 let mut __t = src_dyn.into_u8().unwrap();
2992 __t.set_format(src_fourcc).unwrap();
2993 TensorDyn::from(__t)
2994 };
2995 let dst_back = {
2996 let mut __t = dst_dyn.into_u8().unwrap();
2997 __t.set_format(dst_fourcc).unwrap();
2998 TensorDyn::from(__t)
2999 };
3000 (result, src_back, dst_back)
3001 }
3002
3003 #[ctor::ctor(unsafe)]
3004 fn init() {
3005 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3006 }
3007
3008 macro_rules! function {
3009 () => {{
3010 fn f() {}
3011 fn type_name_of<T>(_: T) -> &'static str {
3012 std::any::type_name::<T>()
3013 }
3014 let name = type_name_of(f);
3015
3016 match &name[..name.len() - 3].rfind(':') {
3018 Some(pos) => &name[pos + 1..name.len() - 3],
3019 None => &name[..name.len() - 3],
3020 }
3021 }};
3022 }
3023
3024 #[test]
3037 fn batch_view_dst_tiles_match_standalone() {
3038 let mut proc = match ImageProcessor::new() {
3039 Ok(p) => p,
3040 Err(e) => {
3041 eprintln!(
3042 "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3043 function!()
3044 );
3045 return;
3046 }
3047 };
3048 let n = 3usize;
3049 let (w, h) = (32usize, 24usize);
3050 let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3051 let make_src = |c: [u8; 4]| -> TensorDyn {
3052 let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3053 load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3054 };
3055 let parent = match TensorDyn::image(
3058 w,
3059 n * h,
3060 PixelFormat::Rgba,
3061 DType::U8,
3062 Some(TensorMemory::Dma),
3063 ) {
3064 Ok(d) => d,
3065 Err(e) => {
3066 eprintln!(
3067 "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3068 function!()
3069 );
3070 return;
3071 }
3072 };
3073
3074 for (i, &c) in colors.iter().enumerate().take(n) {
3076 let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3077 proc.convert_deferred(
3078 &make_src(c),
3079 &mut tile,
3080 Rotation::None,
3081 Flip::None,
3082 Crop::no_crop(),
3083 )
3084 .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3085 }
3086 proc.flush().unwrap();
3087
3088 for (i, &c) in colors.iter().enumerate().take(n) {
3089 let mut solo =
3091 TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
3092 .unwrap();
3093 proc.convert(
3094 &make_src(c),
3095 &mut solo,
3096 Rotation::None,
3097 Flip::None,
3098 Crop::no_crop(),
3099 )
3100 .unwrap();
3101
3102 let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3103 let band_bytes = band.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3104 let solo_bytes = solo.as_u8().unwrap().map().unwrap().as_slice().to_vec();
3105 assert_eq!(
3106 band_bytes, solo_bytes,
3107 "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3108 );
3109 assert!(
3110 band_bytes.chunks_exact(4).all(|p| p == c),
3111 "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3112 );
3113 }
3114 }
3115
3116 #[test]
3117 fn test_invalid_crop() {
3118 let src = TensorDyn::image(100, 100, PixelFormat::Rgb, DType::U8, None).unwrap();
3119 let dst = TensorDyn::image(100, 100, PixelFormat::Rgb, DType::U8, None).unwrap();
3120
3121 let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
3123 assert!(matches!(
3124 crop.check_crop_dyn(&src, &dst),
3125 Err(Error::CropInvalid(_))
3126 ));
3127
3128 let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
3130 assert!(crop.check_crop_dyn(&src, &dst).is_ok());
3131
3132 assert!(Crop::letterbox([0, 0, 0, 255])
3134 .check_crop_dyn(&src, &dst)
3135 .is_ok());
3136 }
3137
3138 #[test]
3139 fn test_invalid_tensor_format() -> Result<(), Error> {
3140 let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
3142 let result = tensor.set_format(PixelFormat::Rgb);
3143 assert!(result.is_err(), "4D tensor should reject set_format");
3144
3145 let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
3147 let result = tensor.set_format(PixelFormat::Rgb);
3148 assert!(result.is_err(), "4-channel tensor should reject RGB format");
3149
3150 Ok(())
3151 }
3152
3153 #[test]
3154 fn test_invalid_image_file() -> Result<(), Error> {
3155 let result = crate::load_image_test_helper(&[123; 5000], None, None);
3156 assert!(
3157 matches!(result, Err(Error::Codec(_))),
3158 "unrecognised bytes should surface as Error::Codec, got {result:?}"
3159 );
3160 Ok(())
3161 }
3162
3163 #[test]
3164 fn test_invalid_jpeg_format() -> Result<(), Error> {
3165 let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
3166 assert!(
3169 matches!(result, Err(Error::Codec(_))),
3170 "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
3171 );
3172 Ok(())
3173 }
3174
3175 #[test]
3176 fn test_load_resize_save() {
3177 let file = edgefirst_bench::testdata::read("zidane.jpg");
3178 let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3179 assert_eq!(img.width(), Some(1280));
3180 assert_eq!(img.height(), Some(720));
3181
3182 let dst = TensorDyn::image(640, 360, PixelFormat::Rgba, DType::U8, None).unwrap();
3183 let mut converter = CPUProcessor::new();
3184 let (result, _img, dst) = convert_img(
3185 &mut converter,
3186 img,
3187 dst,
3188 Rotation::None,
3189 Flip::None,
3190 Crop::no_crop(),
3191 );
3192 result.unwrap();
3193 assert_eq!(dst.width(), Some(640));
3194 assert_eq!(dst.height(), Some(360));
3195
3196 crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
3197
3198 let file = std::fs::read("zidane_resized.jpg").unwrap();
3199 let img = crate::load_image_test_helper(&file, None, None).unwrap();
3202 assert_eq!(img.width(), Some(640));
3203 assert_eq!(img.height(), Some(360));
3204 assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
3205 }
3206
3207 #[test]
3208 fn test_from_tensor_planar() -> Result<(), Error> {
3209 let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
3210 tensor
3211 .map()?
3212 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
3213 let planar = {
3214 tensor
3215 .set_format(PixelFormat::PlanarRgb)
3216 .map_err(|e| crate::Error::Internal(e.to_string()))?;
3217 TensorDyn::from(tensor)
3218 };
3219
3220 let rbga = load_bytes_to_tensor(
3221 1280,
3222 720,
3223 PixelFormat::Rgba,
3224 None,
3225 &edgefirst_bench::testdata::read("camera720p.rgba"),
3226 )?;
3227 compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
3228
3229 Ok(())
3230 }
3231
3232 #[test]
3233 fn test_from_tensor_invalid_format() {
3234 assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3237 }
3238
3239 #[test]
3240 #[should_panic(expected = "Failed to save planar RGB image")]
3241 fn test_save_planar() {
3242 let planar_img = load_bytes_to_tensor(
3243 1280,
3244 720,
3245 PixelFormat::PlanarRgb,
3246 None,
3247 &edgefirst_bench::testdata::read("camera720p.8bps"),
3248 )
3249 .unwrap();
3250
3251 let save_path = "/tmp/planar_rgb.jpg";
3252 crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
3253 }
3254
3255 #[test]
3256 #[should_panic(expected = "Failed to save YUYV image")]
3257 fn test_save_yuyv() {
3258 let planar_img = load_bytes_to_tensor(
3259 1280,
3260 720,
3261 PixelFormat::Yuyv,
3262 None,
3263 &edgefirst_bench::testdata::read("camera720p.yuyv"),
3264 )
3265 .unwrap();
3266
3267 let save_path = "/tmp/yuyv.jpg";
3268 crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
3269 }
3270
3271 #[test]
3272 fn test_rotation_angle() {
3273 assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
3274 assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
3275 assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
3276 assert_eq!(
3277 Rotation::from_degrees_clockwise(270),
3278 Rotation::CounterClockwise90
3279 );
3280 assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
3281 assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
3282 assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
3283 assert_eq!(
3284 Rotation::from_degrees_clockwise(630),
3285 Rotation::CounterClockwise90
3286 );
3287 }
3288
3289 #[test]
3290 #[should_panic(expected = "rotation angle is not a multiple of 90")]
3291 fn test_rotation_angle_panic() {
3292 Rotation::from_degrees_clockwise(361);
3293 }
3294
3295 #[test]
3296 fn test_disable_env_var() -> Result<(), Error> {
3297 let _lock = acquire_env_lock();
3300
3301 let _guard = EnvGuard::snapshot(&[
3304 "EDGEFIRST_FORCE_BACKEND",
3305 "EDGEFIRST_DISABLE_GL",
3306 "EDGEFIRST_DISABLE_G2D",
3307 "EDGEFIRST_DISABLE_CPU",
3308 ]);
3309
3310 unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
3313
3314 #[cfg(target_os = "linux")]
3315 {
3316 unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3317 let converter = ImageProcessor::new()?;
3318 assert!(converter.g2d.is_none());
3319 unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
3320 }
3321
3322 #[cfg(target_os = "linux")]
3323 #[cfg(feature = "opengl")]
3324 {
3325 unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3326 let converter = ImageProcessor::new()?;
3327 assert!(converter.opengl.is_none());
3328 unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
3329 }
3330
3331 unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3332 let converter = ImageProcessor::new()?;
3333 assert!(converter.cpu.is_none());
3334 unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
3335
3336 unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
3338 unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
3339 unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
3340 let mut converter = ImageProcessor::new()?;
3341
3342 let src = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
3343 let dst = TensorDyn::image(640, 360, PixelFormat::Rgba, DType::U8, None)?;
3344 let (result, _src, _dst) = convert_img(
3345 &mut converter,
3346 src,
3347 dst,
3348 Rotation::None,
3349 Flip::None,
3350 Crop::no_crop(),
3351 );
3352 assert!(matches!(result, Err(Error::NoConverter)));
3353 Ok(())
3355 }
3356
3357 #[test]
3358 fn test_unsupported_conversion() {
3359 let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
3360 let dst = TensorDyn::image(640, 360, PixelFormat::Nv12, DType::U8, None).unwrap();
3361 let mut converter = ImageProcessor::new().unwrap();
3362 let (result, _src, _dst) = convert_img(
3363 &mut converter,
3364 src,
3365 dst,
3366 Rotation::None,
3367 Flip::None,
3368 Crop::no_crop(),
3369 );
3370 log::debug!("result: {:?}", result);
3371 assert!(matches!(
3372 result,
3373 Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
3374 ));
3375 }
3376
3377 #[test]
3378 fn test_load_grey() {
3379 let grey_img = crate::load_image_test_helper(
3383 &edgefirst_bench::testdata::read("grey.jpg"),
3384 Some(PixelFormat::Rgba),
3385 None,
3386 )
3387 .unwrap();
3388 assert_eq!(grey_img.width(), Some(1024));
3389 assert_eq!(grey_img.height(), Some(681));
3390
3391 let grey_but_rgb = crate::load_image_test_helper(
3397 &edgefirst_bench::testdata::read("grey-rgb.jpg"),
3398 Some(PixelFormat::Rgba),
3399 None,
3400 )
3401 .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
3402 assert_eq!(grey_but_rgb.width(), Some(1024));
3403 assert_eq!(grey_but_rgb.height(), Some(681));
3404 }
3405
3406 #[test]
3407 fn test_new_nv12() {
3408 let nv12 = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
3409 assert_eq!(nv12.height(), Some(720));
3410 assert_eq!(nv12.width(), Some(1280));
3411 assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
3412 assert_eq!(nv12.format().unwrap().channels(), 1);
3414 assert!(nv12.format().is_some_and(
3415 |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
3416 ))
3417 }
3418
3419 #[test]
3420 #[cfg(target_os = "linux")]
3421 fn test_new_image_converter() {
3422 let dst_width = 640;
3423 let dst_height = 360;
3424 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3425 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3426
3427 let mut converter = ImageProcessor::new().unwrap();
3428 let converter_dst = converter
3429 .create_image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None)
3430 .unwrap();
3431 let (result, src, converter_dst) = convert_img(
3432 &mut converter,
3433 src,
3434 converter_dst,
3435 Rotation::None,
3436 Flip::None,
3437 Crop::no_crop(),
3438 );
3439 result.unwrap();
3440
3441 let cpu_dst =
3442 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3443 let mut cpu_converter = CPUProcessor::new();
3444 let (result, _src, cpu_dst) = convert_img(
3445 &mut cpu_converter,
3446 src,
3447 cpu_dst,
3448 Rotation::None,
3449 Flip::None,
3450 Crop::no_crop(),
3451 );
3452 result.unwrap();
3453
3454 compare_images(&converter_dst, &cpu_dst, 0.98, function!());
3455 }
3456
3457 #[test]
3458 #[cfg(target_os = "linux")]
3459 fn test_create_image_dtype_i8() {
3460 let mut converter = ImageProcessor::new().unwrap();
3461
3462 let dst = converter
3464 .create_image(320, 240, PixelFormat::Rgb, DType::I8, None)
3465 .unwrap();
3466 assert_eq!(dst.dtype(), DType::I8);
3467 assert!(dst.width() == Some(320));
3468 assert!(dst.height() == Some(240));
3469 assert_eq!(dst.format(), Some(PixelFormat::Rgb));
3470
3471 let dst_u8 = converter
3473 .create_image(320, 240, PixelFormat::Rgb, DType::U8, None)
3474 .unwrap();
3475 assert_eq!(dst_u8.dtype(), DType::U8);
3476
3477 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3479 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3480 let mut dst_i8 = converter
3481 .create_image(320, 240, PixelFormat::Rgb, DType::I8, None)
3482 .unwrap();
3483 converter
3484 .convert(
3485 &src,
3486 &mut dst_i8,
3487 Rotation::None,
3488 Flip::None,
3489 Crop::no_crop(),
3490 )
3491 .unwrap();
3492 }
3493
3494 #[test]
3495 #[cfg(target_os = "linux")]
3496 fn test_create_image_nv12_dma_non_aligned_width() {
3497 let converter = ImageProcessor::new().unwrap();
3503
3504 let result = converter.create_image(
3506 100,
3507 64,
3508 PixelFormat::Nv12,
3509 DType::U8,
3510 Some(TensorMemory::Dma),
3511 );
3512
3513 match result {
3514 Ok(img) => {
3515 assert_eq!(img.width(), Some(100));
3516 assert_eq!(img.height(), Some(64));
3517 assert_eq!(img.format(), Some(PixelFormat::Nv12));
3518 if let Some(stride) = img.row_stride() {
3519 assert!(
3520 stride >= 100,
3521 "NV12 row_stride {stride} must be >= the logical width (100)",
3522 );
3523 }
3524 }
3525 Err(e) => {
3526 eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
3528 }
3529 }
3530 }
3531
3532 #[test]
3533 #[ignore] fn test_crop_skip() {
3537 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3538 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3539
3540 let mut converter = ImageProcessor::new().unwrap();
3541 let converter_dst = converter
3542 .create_image(1280, 720, PixelFormat::Rgba, DType::U8, None)
3543 .unwrap();
3544 let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
3545 let (result, src, converter_dst) = convert_img(
3546 &mut converter,
3547 src,
3548 converter_dst,
3549 Rotation::None,
3550 Flip::None,
3551 crop,
3552 );
3553 result.unwrap();
3554
3555 let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3556 let mut cpu_converter = CPUProcessor::new();
3557 let (result, _src, cpu_dst) = convert_img(
3558 &mut cpu_converter,
3559 src,
3560 cpu_dst,
3561 Rotation::None,
3562 Flip::None,
3563 crop,
3564 );
3565 result.unwrap();
3566
3567 compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
3568 }
3569
3570 #[test]
3571 fn test_invalid_pixel_format() {
3572 assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
3575 }
3576
3577 #[cfg(target_os = "linux")]
3579 static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3580
3581 #[cfg(target_os = "linux")]
3582 fn is_g2d_available() -> bool {
3583 *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
3584 }
3585
3586 #[cfg(target_os = "linux")]
3587 #[cfg(feature = "opengl")]
3588 static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3589
3590 #[cfg(target_os = "linux")]
3591 #[cfg(feature = "opengl")]
3592 fn is_opengl_available() -> bool {
3594 #[cfg(all(target_os = "linux", feature = "opengl"))]
3595 {
3596 *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
3597 }
3598
3599 #[cfg(not(all(target_os = "linux", feature = "opengl")))]
3600 {
3601 false
3602 }
3603 }
3604
3605 #[test]
3617 #[cfg(feature = "opengl")]
3618 fn gl_backend_available_canary() {
3619 let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
3620 if !require_gl {
3621 eprintln!(
3622 "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
3623 function!()
3624 );
3625 return;
3626 }
3627 #[cfg(target_os = "macos")]
3628 if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
3629 eprintln!(
3630 "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
3631 function!()
3632 );
3633 return;
3634 }
3635 GLProcessorThreaded::new(None).expect(
3636 "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
3637 check the ANGLE install/re-sign step and binary entitlements \
3638 (macOS) or the EGL stack (Linux)",
3639 );
3640 }
3641
3642 #[test]
3643 fn test_load_jpeg_with_exif() {
3644 use edgefirst_codec::peek_info;
3645
3646 let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
3651 let info = peek_info(&file).unwrap();
3652 assert_eq!(info.rotation_degrees, 90);
3653 assert!(!info.flip_horizontal);
3654
3655 let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3656 assert_eq!(loaded.width(), Some(1280));
3658 assert_eq!(loaded.height(), Some(720));
3659
3660 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3663 let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3664
3665 let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
3666 let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
3667
3668 let cpu_dst =
3669 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3670 let mut cpu_converter = CPUProcessor::new();
3671
3672 let loaded_rotated =
3675 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
3676 let (r0, _loaded, loaded_rotated) = convert_img(
3677 &mut cpu_converter,
3678 loaded,
3679 loaded_rotated,
3680 rotation,
3681 Flip::None,
3682 Crop::no_crop(),
3683 );
3684 r0.unwrap();
3685
3686 let (result, _cpu_src, cpu_dst) = convert_img(
3687 &mut cpu_converter,
3688 cpu_src,
3689 cpu_dst,
3690 rotation,
3691 Flip::None,
3692 Crop::no_crop(),
3693 );
3694 result.unwrap();
3695
3696 compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
3697 }
3698
3699 #[test]
3700 fn test_load_png_with_exif() {
3701 use edgefirst_codec::peek_info;
3702
3703 let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
3706 let info = peek_info(&file).unwrap();
3707 assert_eq!(info.rotation_degrees, 180);
3708 assert!(!info.flip_horizontal);
3709
3710 let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3711 assert_eq!(loaded.height(), Some(720));
3713 assert_eq!(loaded.width(), Some(1280));
3714
3715 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
3720 let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
3721
3722 let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
3723 let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3724 let mut cpu_converter = CPUProcessor::new();
3725
3726 let (result, _cpu_src, cpu_dst) = convert_img(
3727 &mut cpu_converter,
3728 cpu_src,
3729 cpu_dst,
3730 rotation,
3731 Flip::None,
3732 Crop::no_crop(),
3733 );
3734 result.unwrap();
3735
3736 let loaded_rotated =
3739 TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
3740 let (r0, _loaded, loaded_rotated) = convert_img(
3741 &mut cpu_converter,
3742 loaded,
3743 loaded_rotated,
3744 rotation,
3745 Flip::None,
3746 Crop::no_crop(),
3747 );
3748 r0.unwrap();
3749
3750 compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
3755 }
3756
3757 #[cfg(target_os = "linux")]
3763 fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
3764 let mut bytes = Vec::with_capacity((width * height * 3) as usize);
3765 for y in 0..height {
3766 for x in 0..width {
3767 bytes.push(((x + y) & 0xFF) as u8);
3768 bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
3769 bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
3770 }
3771 }
3772 let mut out = Vec::new();
3773 let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
3774 encoder
3775 .encode(
3776 &bytes,
3777 width as u16,
3778 height as u16,
3779 jpeg_encoder::ColorType::Rgb,
3780 )
3781 .expect("jpeg-encoder must succeed on trivial input");
3782 out
3783 }
3784
3785 #[test]
3794 #[cfg(target_os = "linux")]
3795 #[cfg(feature = "opengl")]
3796 fn test_convert_rgba_non_4_aligned_width_end_to_end() {
3797 use edgefirst_tensor::is_dma_available;
3798 if !is_dma_available() {
3799 eprintln!(
3800 "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
3801 );
3802 return;
3803 }
3804 let jpeg = make_rgb_jpeg(375, 333);
3808 let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
3809 assert_eq!(src_gl.width(), Some(375));
3810 let stride = src_gl.row_stride().unwrap();
3812 assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
3813
3814 let mut gl_proc = ImageProcessor::new().unwrap();
3816 let gl_dst = gl_proc
3817 .create_image(640, 640, PixelFormat::Rgba, DType::U8, None)
3818 .unwrap();
3819 let (r_gl, _src_gl, gl_dst) = convert_img(
3820 &mut gl_proc,
3821 src_gl,
3822 gl_dst,
3823 Rotation::None,
3824 Flip::None,
3825 Crop::no_crop(),
3826 );
3827 r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
3828
3829 let src_cpu =
3834 crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
3835 .unwrap();
3836 let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
3837 backend: ComputeBackend::Cpu,
3838 ..Default::default()
3839 })
3840 .unwrap();
3841 let cpu_dst = TensorDyn::image(
3842 640,
3843 640,
3844 PixelFormat::Rgba,
3845 DType::U8,
3846 Some(TensorMemory::Mem),
3847 )
3848 .unwrap();
3849 let (r_cpu, _src_cpu, cpu_dst) = convert_img(
3850 &mut cpu_proc,
3851 src_cpu,
3852 cpu_dst,
3853 Rotation::None,
3854 Flip::None,
3855 Crop::no_crop(),
3856 );
3857 r_cpu.unwrap();
3858
3859 compare_images(&gl_dst, &cpu_dst, 0.95, function!());
3863 }
3864
3865 #[test]
3872 #[cfg(target_os = "linux")]
3873 fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
3874 use edgefirst_tensor::is_dma_available;
3875 if !is_dma_available() {
3876 eprintln!(
3877 "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
3878 );
3879 return;
3880 }
3881 for &w in &[500u32, 612, 428] {
3885 let jpeg = make_rgb_jpeg(w, 333);
3886 let loaded =
3887 crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
3888 let natural = (w as usize) * 4;
3889 let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
3890 assert!(
3891 aligned > natural,
3892 "test sanity: width {w} should be unaligned"
3893 );
3894 let stride = loaded
3895 .row_stride()
3896 .expect("padded DMA path must set an explicit row_stride — regression if None");
3897 assert_eq!(
3898 stride, aligned,
3899 "width {w}: expected padded stride {aligned}, got {stride} \
3900 (regression: pitch-padding branch skipped?)"
3901 );
3902 let eff = loaded.effective_row_stride().unwrap();
3903 assert_eq!(
3904 eff, aligned,
3905 "effective_row_stride must match stored stride"
3906 );
3907 assert_eq!(loaded.width(), Some(w as usize));
3908 assert_eq!(loaded.height(), Some(333));
3909 }
3910 }
3911
3912 #[test]
3921 #[cfg(target_os = "linux")]
3922 fn test_padded_dma_pitch_for_respects_memory_choice() {
3923 use edgefirst_tensor::{is_dma_available, TensorMemory};
3924
3925 let unaligned_w = 500;
3928
3929 assert_eq!(
3931 crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
3932 None,
3933 "Mem must never trigger DMA padding"
3934 );
3935 assert_eq!(
3936 crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
3937 None,
3938 "Shm must never trigger DMA padding"
3939 );
3940
3941 assert_eq!(
3946 crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Dma),),
3947 Some(2048),
3948 "explicit Dma must pad regardless of runtime DMA availability"
3949 );
3950
3951 let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
3955 if is_dma_available() {
3956 assert_eq!(
3957 none_result,
3958 Some(2048),
3959 "memory=None + DMA available → pad (will route through DMA)"
3960 );
3961 } else {
3962 assert_eq!(
3963 none_result, None,
3964 "memory=None + DMA unavailable → must NOT pad (would force \
3965 image_with_stride into a DMA-only allocation that fails). \
3966 Regression: padded_dma_pitch_for ignored is_dma_available()."
3967 );
3968 }
3969 }
3970
3971 fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
3975 let mut bytes = Vec::with_capacity((width * height) as usize);
3976 for y in 0..height {
3977 for x in 0..width {
3978 bytes.push(((x + y) & 0xFF) as u8);
3979 }
3980 }
3981 let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
3982 let mut buf = Vec::new();
3983 img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
3984 .unwrap();
3985 buf
3986 }
3987
3988 #[test]
3993 #[cfg(target_os = "linux")]
3994 fn test_load_png_grey_misaligned_width_dma() {
3995 use edgefirst_tensor::is_dma_available;
3996 if !is_dma_available() {
3997 eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
3998 return;
3999 }
4000 let png = make_grey_png(612, 388);
4001 let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
4002 assert_eq!(loaded.width(), Some(612));
4003 assert_eq!(loaded.height(), Some(388));
4004 assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4005
4006 let map = loaded.as_u8().unwrap().map().unwrap();
4009 let stride = loaded.row_stride().unwrap_or(612);
4010 assert!(stride >= 612);
4011 let bytes: &[u8] = ↦
4012 for y in 0..388usize {
4013 for x in 0..612usize {
4014 let expected = ((x + y) & 0xFF) as u8;
4015 let got = bytes[y * stride + x];
4016 assert_eq!(
4017 got, expected,
4018 "grey png mismatch at ({x},{y}): got {got} expected {expected}"
4019 );
4020 }
4021 }
4022 }
4023
4024 #[test]
4028 fn test_load_png_grey_mem() {
4029 use edgefirst_tensor::TensorMemory;
4030 let png = make_grey_png(612, 100);
4031 let loaded =
4032 crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
4033 .unwrap();
4034 assert_eq!(loaded.width(), Some(612));
4035 assert_eq!(loaded.height(), Some(100));
4036 assert_eq!(loaded.format(), Some(PixelFormat::Grey));
4037 let map = loaded.as_u8().unwrap().map().unwrap();
4038 let bytes: &[u8] = ↦
4039 assert_eq!(bytes.len(), 612 * 100);
4041 for y in 0..100 {
4042 for x in 0..612 {
4043 assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
4044 }
4045 }
4046 }
4047
4048 #[test]
4052 fn test_load_png_grey_to_rgb_mem() {
4053 use edgefirst_tensor::TensorMemory;
4054 let png = make_grey_png(620, 240);
4055 let loaded =
4056 crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
4057 .unwrap();
4058 assert_eq!(loaded.width(), Some(620));
4059 assert_eq!(loaded.height(), Some(240));
4060 assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
4061
4062 let map = loaded.as_u8().unwrap().map().unwrap();
4064 let bytes: &[u8] = ↦
4065 for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
4066 let expected = ((x + y) & 0xFF) as u8;
4067 let off = (y * 620 + x) * 3;
4068 assert_eq!(bytes[off], expected, "R@{x},{y}");
4069 assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
4070 assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
4071 }
4072 }
4073
4074 #[test]
4075 #[cfg(target_os = "linux")]
4076 fn test_g2d_resize() {
4077 if !is_g2d_available() {
4078 eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
4079 return;
4080 }
4081 if !is_dma_available() {
4082 eprintln!(
4083 "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4084 );
4085 return;
4086 }
4087
4088 let dst_width = 640;
4089 let dst_height = 360;
4090 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4091 let src =
4092 crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4093 .unwrap();
4094
4095 let g2d_dst = TensorDyn::image(
4096 dst_width,
4097 dst_height,
4098 PixelFormat::Rgba,
4099 DType::U8,
4100 Some(TensorMemory::Dma),
4101 )
4102 .unwrap();
4103 let mut g2d_converter = G2DProcessor::new().unwrap();
4104 let (result, src, g2d_dst) = convert_img(
4105 &mut g2d_converter,
4106 src,
4107 g2d_dst,
4108 Rotation::None,
4109 Flip::None,
4110 Crop::no_crop(),
4111 );
4112 result.unwrap();
4113
4114 let cpu_dst =
4115 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4116 let mut cpu_converter = CPUProcessor::new();
4117 let (result, _src, cpu_dst) = convert_img(
4118 &mut cpu_converter,
4119 src,
4120 cpu_dst,
4121 Rotation::None,
4122 Flip::None,
4123 Crop::no_crop(),
4124 );
4125 result.unwrap();
4126
4127 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4133 }
4134
4135 #[test]
4136 #[cfg(target_os = "linux")]
4137 #[cfg(feature = "opengl")]
4138 fn test_opengl_resize() {
4139 if !is_opengl_available() {
4140 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4141 return;
4142 }
4143
4144 let dst_width = 640;
4145 let dst_height = 360;
4146 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4147 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4148
4149 let cpu_dst =
4150 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4151 let mut cpu_converter = CPUProcessor::new();
4152 let (result, src, cpu_dst) = convert_img(
4153 &mut cpu_converter,
4154 src,
4155 cpu_dst,
4156 Rotation::None,
4157 Flip::None,
4158 Crop::no_crop(),
4159 );
4160 result.unwrap();
4161
4162 let mut src = src;
4163 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4164
4165 for _ in 0..5 {
4166 let gl_dst =
4167 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None)
4168 .unwrap();
4169 let (result, src_back, gl_dst) = convert_img(
4170 &mut gl_converter,
4171 src,
4172 gl_dst,
4173 Rotation::None,
4174 Flip::None,
4175 Crop::no_crop(),
4176 );
4177 result.unwrap();
4178 src = src_back;
4179
4180 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4181 }
4182 }
4183
4184 #[test]
4185 #[cfg(target_os = "linux")]
4186 #[cfg(feature = "opengl")]
4187 fn test_opengl_10_threads() {
4188 if !is_opengl_available() {
4189 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4190 return;
4191 }
4192
4193 let handles: Vec<_> = (0..10)
4194 .map(|i| {
4195 std::thread::Builder::new()
4196 .name(format!("Thread {i}"))
4197 .spawn(test_opengl_resize)
4198 .unwrap()
4199 })
4200 .collect();
4201 handles.into_iter().for_each(|h| {
4202 if let Err(e) = h.join() {
4203 std::panic::resume_unwind(e)
4204 }
4205 });
4206 }
4207
4208 #[test]
4209 #[cfg(target_os = "linux")]
4210 #[cfg(feature = "opengl")]
4211 fn test_opengl_grey() {
4212 if !is_opengl_available() {
4213 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4214 return;
4215 }
4216
4217 let img = crate::load_image_test_helper(
4218 &edgefirst_bench::testdata::read("grey.jpg"),
4219 Some(PixelFormat::Grey),
4220 None,
4221 )
4222 .unwrap();
4223
4224 let gl_dst = TensorDyn::image(640, 640, PixelFormat::Grey, DType::U8, None).unwrap();
4225 let cpu_dst = TensorDyn::image(640, 640, PixelFormat::Grey, DType::U8, None).unwrap();
4226
4227 let mut converter = CPUProcessor::new();
4228
4229 let (result, img, cpu_dst) = convert_img(
4230 &mut converter,
4231 img,
4232 cpu_dst,
4233 Rotation::None,
4234 Flip::None,
4235 Crop::no_crop(),
4236 );
4237 result.unwrap();
4238
4239 let mut gl = GLProcessorThreaded::new(None).unwrap();
4240 let (result, _img, gl_dst) = convert_img(
4241 &mut gl,
4242 img,
4243 gl_dst,
4244 Rotation::None,
4245 Flip::None,
4246 Crop::no_crop(),
4247 );
4248 result.unwrap();
4249
4250 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4251 }
4252
4253 #[test]
4254 #[cfg(target_os = "linux")]
4255 fn test_g2d_src_crop() {
4256 if !is_g2d_available() {
4257 eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
4258 return;
4259 }
4260 if !is_dma_available() {
4261 eprintln!(
4262 "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4263 );
4264 return;
4265 }
4266
4267 let dst_width = 640;
4268 let dst_height = 640;
4269 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4270 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4271
4272 let cpu_dst =
4273 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4274 let mut cpu_converter = CPUProcessor::new();
4275 let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
4276 let (result, src, cpu_dst) = convert_img(
4277 &mut cpu_converter,
4278 src,
4279 cpu_dst,
4280 Rotation::None,
4281 Flip::None,
4282 crop,
4283 );
4284 result.unwrap();
4285
4286 let g2d_dst =
4287 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4288 let mut g2d_converter = G2DProcessor::new().unwrap();
4289 let (result, _src, g2d_dst) = convert_img(
4290 &mut g2d_converter,
4291 src,
4292 g2d_dst,
4293 Rotation::None,
4294 Flip::None,
4295 crop,
4296 );
4297 result.unwrap();
4298
4299 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4305 }
4306
4307 #[test]
4308 #[cfg(target_os = "linux")]
4309 fn test_g2d_dst_crop() {
4310 if !is_g2d_available() {
4311 eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
4312 return;
4313 }
4314 if !is_dma_available() {
4315 eprintln!(
4316 "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4317 );
4318 return;
4319 }
4320
4321 let dst_width = 640;
4322 let dst_height = 640;
4323 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4324 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4325
4326 let cpu_dst =
4327 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4328 let mut cpu_converter = CPUProcessor::new();
4329 let crop = Crop::new();
4330 let (result, src, cpu_dst) = convert_img(
4331 &mut cpu_converter,
4332 src,
4333 cpu_dst,
4334 Rotation::None,
4335 Flip::None,
4336 crop,
4337 );
4338 result.unwrap();
4339
4340 let g2d_dst =
4341 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4342 let mut g2d_converter = G2DProcessor::new().unwrap();
4343 let (result, _src, g2d_dst) = convert_img(
4344 &mut g2d_converter,
4345 src,
4346 g2d_dst,
4347 Rotation::None,
4348 Flip::None,
4349 crop,
4350 );
4351 result.unwrap();
4352
4353 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4359 }
4360
4361 #[test]
4362 #[cfg(target_os = "linux")]
4363 fn test_g2d_all_rgba() {
4364 if !is_g2d_available() {
4365 eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
4366 return;
4367 }
4368 if !is_dma_available() {
4369 eprintln!(
4370 "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4371 );
4372 return;
4373 }
4374
4375 let dst_width = 640;
4376 let dst_height = 640;
4377 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4378 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4379 let src_dyn = src;
4380
4381 let mut cpu_dst =
4382 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4383 let mut cpu_converter = CPUProcessor::new();
4384 let mut g2d_dst =
4385 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4386 let mut g2d_converter = G2DProcessor::new().unwrap();
4387
4388 let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
4389
4390 for rot in [
4391 Rotation::None,
4392 Rotation::Clockwise90,
4393 Rotation::Rotate180,
4394 Rotation::CounterClockwise90,
4395 ] {
4396 cpu_dst
4397 .as_u8()
4398 .unwrap()
4399 .map()
4400 .unwrap()
4401 .as_mut_slice()
4402 .fill(114);
4403 g2d_dst
4404 .as_u8()
4405 .unwrap()
4406 .map()
4407 .unwrap()
4408 .as_mut_slice()
4409 .fill(114);
4410 for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
4411 let mut cpu_dst_dyn = cpu_dst;
4412 cpu_converter
4413 .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
4414 .unwrap();
4415 cpu_dst = {
4416 let mut __t = cpu_dst_dyn.into_u8().unwrap();
4417 __t.set_format(PixelFormat::Rgba).unwrap();
4418 TensorDyn::from(__t)
4419 };
4420
4421 let mut g2d_dst_dyn = g2d_dst;
4422 g2d_converter
4423 .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
4424 .unwrap();
4425 g2d_dst = {
4426 let mut __t = g2d_dst_dyn.into_u8().unwrap();
4427 __t.set_format(PixelFormat::Rgba).unwrap();
4428 TensorDyn::from(__t)
4429 };
4430
4431 compare_images(
4432 &g2d_dst,
4433 &cpu_dst,
4434 0.98,
4435 &format!("{} {:?} {:?}", function!(), rot, flip),
4436 );
4437 }
4438 }
4439 }
4440
4441 #[test]
4442 #[cfg(target_os = "linux")]
4443 #[cfg(feature = "opengl")]
4444 fn test_opengl_src_crop() {
4445 if !is_opengl_available() {
4446 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4447 return;
4448 }
4449
4450 let dst_width = 640;
4451 let dst_height = 360;
4452 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4453 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4454 let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
4455
4456 let cpu_dst =
4457 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4458 let mut cpu_converter = CPUProcessor::new();
4459 let (result, src, cpu_dst) = convert_img(
4460 &mut cpu_converter,
4461 src,
4462 cpu_dst,
4463 Rotation::None,
4464 Flip::None,
4465 crop,
4466 );
4467 result.unwrap();
4468
4469 let gl_dst =
4470 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4471 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4472 let (result, _src, gl_dst) = convert_img(
4473 &mut gl_converter,
4474 src,
4475 gl_dst,
4476 Rotation::None,
4477 Flip::None,
4478 crop,
4479 );
4480 result.unwrap();
4481
4482 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4483 }
4484
4485 #[test]
4486 #[cfg(target_os = "linux")]
4487 #[cfg(feature = "opengl")]
4488 fn test_opengl_dst_crop() {
4489 if !is_opengl_available() {
4490 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4491 return;
4492 }
4493
4494 let dst_width = 640;
4495 let dst_height = 640;
4496 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4497 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4498
4499 let cpu_dst =
4500 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4501 let mut cpu_converter = CPUProcessor::new();
4502 let crop = Crop::new();
4503 let (result, src, cpu_dst) = convert_img(
4504 &mut cpu_converter,
4505 src,
4506 cpu_dst,
4507 Rotation::None,
4508 Flip::None,
4509 crop,
4510 );
4511 result.unwrap();
4512
4513 let gl_dst =
4514 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4515 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4516 let (result, _src, gl_dst) = convert_img(
4517 &mut gl_converter,
4518 src,
4519 gl_dst,
4520 Rotation::None,
4521 Flip::None,
4522 crop,
4523 );
4524 result.unwrap();
4525
4526 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4527 }
4528
4529 #[test]
4530 #[cfg(target_os = "linux")]
4531 #[cfg(feature = "opengl")]
4532 fn test_opengl_all_rgba() {
4533 if !is_opengl_available() {
4534 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4535 return;
4536 }
4537
4538 let dst_width = 640;
4539 let dst_height = 640;
4540 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4541
4542 let mut cpu_converter = CPUProcessor::new();
4543
4544 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4545
4546 let mut mem = vec![None, Some(TensorMemory::Mem), Some(TensorMemory::Shm)];
4547 if is_dma_available() {
4548 mem.push(Some(TensorMemory::Dma));
4549 }
4550 let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
4551 for m in mem {
4552 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
4553 let src_dyn = src;
4554
4555 for rot in [
4556 Rotation::None,
4557 Rotation::Clockwise90,
4558 Rotation::Rotate180,
4559 Rotation::CounterClockwise90,
4560 ] {
4561 for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
4562 let cpu_dst =
4563 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, m)
4564 .unwrap();
4565 let gl_dst =
4566 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, m)
4567 .unwrap();
4568 cpu_dst
4569 .as_u8()
4570 .unwrap()
4571 .map()
4572 .unwrap()
4573 .as_mut_slice()
4574 .fill(114);
4575 gl_dst
4576 .as_u8()
4577 .unwrap()
4578 .map()
4579 .unwrap()
4580 .as_mut_slice()
4581 .fill(114);
4582
4583 let mut cpu_dst_dyn = cpu_dst;
4584 cpu_converter
4585 .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
4586 .unwrap();
4587 let cpu_dst = {
4588 let mut __t = cpu_dst_dyn.into_u8().unwrap();
4589 __t.set_format(PixelFormat::Rgba).unwrap();
4590 TensorDyn::from(__t)
4591 };
4592
4593 let mut gl_dst_dyn = gl_dst;
4594 gl_converter
4595 .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
4596 .map_err(|e| {
4597 log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
4598 e
4599 })
4600 .unwrap();
4601 let gl_dst = {
4602 let mut __t = gl_dst_dyn.into_u8().unwrap();
4603 __t.set_format(PixelFormat::Rgba).unwrap();
4604 TensorDyn::from(__t)
4605 };
4606
4607 compare_images(
4608 &gl_dst,
4609 &cpu_dst,
4610 0.98,
4611 &format!("{} {:?} {:?}", function!(), rot, flip),
4612 );
4613 }
4614 }
4615 }
4616 }
4617
4618 #[test]
4619 #[cfg(target_os = "linux")]
4620 fn test_cpu_rotate() {
4621 for rot in [
4622 Rotation::Clockwise90,
4623 Rotation::Rotate180,
4624 Rotation::CounterClockwise90,
4625 ] {
4626 test_cpu_rotate_(rot);
4627 }
4628 }
4629
4630 #[cfg(target_os = "linux")]
4631 fn test_cpu_rotate_(rot: Rotation) {
4632 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4636
4637 let unchanged_src =
4638 crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4639 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4640
4641 let (dst_width, dst_height) = match rot {
4642 Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
4643 Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
4644 (src.height().unwrap(), src.width().unwrap())
4645 }
4646 };
4647
4648 let cpu_dst =
4649 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4650 let mut cpu_converter = CPUProcessor::new();
4651
4652 let (result, src, cpu_dst) = convert_img(
4655 &mut cpu_converter,
4656 src,
4657 cpu_dst,
4658 rot,
4659 Flip::None,
4660 Crop::no_crop(),
4661 );
4662 result.unwrap();
4663
4664 let (result, cpu_dst, src) = convert_img(
4665 &mut cpu_converter,
4666 cpu_dst,
4667 src,
4668 rot,
4669 Flip::None,
4670 Crop::no_crop(),
4671 );
4672 result.unwrap();
4673
4674 let (result, src, cpu_dst) = convert_img(
4675 &mut cpu_converter,
4676 src,
4677 cpu_dst,
4678 rot,
4679 Flip::None,
4680 Crop::no_crop(),
4681 );
4682 result.unwrap();
4683
4684 let (result, _cpu_dst, src) = convert_img(
4685 &mut cpu_converter,
4686 cpu_dst,
4687 src,
4688 rot,
4689 Flip::None,
4690 Crop::no_crop(),
4691 );
4692 result.unwrap();
4693
4694 compare_images(&src, &unchanged_src, 0.98, function!());
4695 }
4696
4697 #[test]
4698 #[cfg(target_os = "linux")]
4699 #[cfg(feature = "opengl")]
4700 fn test_opengl_rotate() {
4701 if !is_opengl_available() {
4702 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4703 return;
4704 }
4705
4706 let size = (1280, 720);
4707 let mut mem = vec![None, Some(TensorMemory::Shm), Some(TensorMemory::Mem)];
4708
4709 if is_dma_available() {
4710 mem.push(Some(TensorMemory::Dma));
4711 }
4712 for m in mem {
4713 for rot in [
4714 Rotation::Clockwise90,
4715 Rotation::Rotate180,
4716 Rotation::CounterClockwise90,
4717 ] {
4718 test_opengl_rotate_(size, rot, m);
4719 }
4720 }
4721 }
4722
4723 #[cfg(target_os = "linux")]
4724 #[cfg(feature = "opengl")]
4725 fn test_opengl_rotate_(
4726 size: (usize, usize),
4727 rot: Rotation,
4728 tensor_memory: Option<TensorMemory>,
4729 ) {
4730 let (dst_width, dst_height) = match rot {
4731 Rotation::None | Rotation::Rotate180 => size,
4732 Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
4733 };
4734
4735 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4736 let src =
4737 crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
4738
4739 let cpu_dst =
4740 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4741 let mut cpu_converter = CPUProcessor::new();
4742
4743 let (result, mut src, cpu_dst) = convert_img(
4744 &mut cpu_converter,
4745 src,
4746 cpu_dst,
4747 rot,
4748 Flip::None,
4749 Crop::no_crop(),
4750 );
4751 result.unwrap();
4752
4753 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4754
4755 for _ in 0..5 {
4756 let gl_dst = TensorDyn::image(
4757 dst_width,
4758 dst_height,
4759 PixelFormat::Rgba,
4760 DType::U8,
4761 tensor_memory,
4762 )
4763 .unwrap();
4764 let (result, src_back, gl_dst) = convert_img(
4765 &mut gl_converter,
4766 src,
4767 gl_dst,
4768 rot,
4769 Flip::None,
4770 Crop::no_crop(),
4771 );
4772 result.unwrap();
4773 src = src_back;
4774 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
4775 }
4776 }
4777
4778 #[test]
4779 #[cfg(target_os = "linux")]
4780 fn test_g2d_rotate() {
4781 if !is_g2d_available() {
4782 eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
4783 return;
4784 }
4785 if !is_dma_available() {
4786 eprintln!(
4787 "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
4788 );
4789 return;
4790 }
4791
4792 let size = (1280, 720);
4793 for rot in [
4794 Rotation::Clockwise90,
4795 Rotation::Rotate180,
4796 Rotation::CounterClockwise90,
4797 ] {
4798 test_g2d_rotate_(size, rot);
4799 }
4800 }
4801
4802 #[cfg(target_os = "linux")]
4803 fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
4804 let (dst_width, dst_height) = match rot {
4805 Rotation::None | Rotation::Rotate180 => size,
4806 Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
4807 };
4808
4809 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4810 let src =
4811 crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), Some(TensorMemory::Dma))
4812 .unwrap();
4813
4814 let cpu_dst =
4815 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4816 let mut cpu_converter = CPUProcessor::new();
4817
4818 let (result, src, cpu_dst) = convert_img(
4819 &mut cpu_converter,
4820 src,
4821 cpu_dst,
4822 rot,
4823 Flip::None,
4824 Crop::no_crop(),
4825 );
4826 result.unwrap();
4827
4828 let g2d_dst = TensorDyn::image(
4829 dst_width,
4830 dst_height,
4831 PixelFormat::Rgba,
4832 DType::U8,
4833 Some(TensorMemory::Dma),
4834 )
4835 .unwrap();
4836 let mut g2d_converter = G2DProcessor::new().unwrap();
4837
4838 let (result, _src, g2d_dst) = convert_img(
4839 &mut g2d_converter,
4840 src,
4841 g2d_dst,
4842 rot,
4843 Flip::None,
4844 Crop::no_crop(),
4845 );
4846 result.unwrap();
4847
4848 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
4854 }
4855
4856 #[test]
4857 fn test_rgba_to_yuyv_resize_cpu() {
4858 let src = load_bytes_to_tensor(
4859 1280,
4860 720,
4861 PixelFormat::Rgba,
4862 None,
4863 &edgefirst_bench::testdata::read("camera720p.rgba"),
4864 )
4865 .unwrap();
4866
4867 let (dst_width, dst_height) = (640, 360);
4868
4869 let dst =
4870 TensorDyn::image(dst_width, dst_height, PixelFormat::Yuyv, DType::U8, None).unwrap();
4871
4872 let dst_through_yuyv =
4873 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4874 let dst_direct =
4875 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
4876
4877 let mut cpu_converter = CPUProcessor::new();
4878
4879 let (result, src, dst) = convert_img(
4880 &mut cpu_converter,
4881 src,
4882 dst,
4883 Rotation::None,
4884 Flip::None,
4885 Crop::no_crop(),
4886 );
4887 result.unwrap();
4888
4889 let (result, _dst, dst_through_yuyv) = convert_img(
4890 &mut cpu_converter,
4891 dst,
4892 dst_through_yuyv,
4893 Rotation::None,
4894 Flip::None,
4895 Crop::no_crop(),
4896 );
4897 result.unwrap();
4898
4899 let (result, _src, dst_direct) = convert_img(
4900 &mut cpu_converter,
4901 src,
4902 dst_direct,
4903 Rotation::None,
4904 Flip::None,
4905 Crop::no_crop(),
4906 );
4907 result.unwrap();
4908
4909 compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
4910 }
4911
4912 #[test]
4913 #[cfg(target_os = "linux")]
4914 #[cfg(feature = "opengl")]
4915 #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
4916 fn test_rgba_to_yuyv_resize_opengl() {
4917 if !is_opengl_available() {
4918 eprintln!("SKIPPED: {} - OpenGL not available", function!());
4919 return;
4920 }
4921
4922 if !is_dma_available() {
4923 eprintln!(
4924 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
4925 function!()
4926 );
4927 return;
4928 }
4929
4930 let src = load_bytes_to_tensor(
4931 1280,
4932 720,
4933 PixelFormat::Rgba,
4934 None,
4935 &edgefirst_bench::testdata::read("camera720p.rgba"),
4936 )
4937 .unwrap();
4938
4939 let (dst_width, dst_height) = (640, 360);
4940
4941 let dst = TensorDyn::image(
4942 dst_width,
4943 dst_height,
4944 PixelFormat::Yuyv,
4945 DType::U8,
4946 Some(TensorMemory::Dma),
4947 )
4948 .unwrap();
4949
4950 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
4951
4952 let (result, src, dst) = convert_img(
4953 &mut gl_converter,
4954 src,
4955 dst,
4956 Rotation::None,
4957 Flip::None,
4958 Crop::letterbox([255, 255, 255, 255]),
4959 );
4960 result.unwrap();
4961
4962 std::fs::write(
4963 "rgba_to_yuyv_opengl.yuyv",
4964 dst.as_u8().unwrap().map().unwrap().as_slice(),
4965 )
4966 .unwrap();
4967 let cpu_dst = TensorDyn::image(
4968 dst_width,
4969 dst_height,
4970 PixelFormat::Yuyv,
4971 DType::U8,
4972 Some(TensorMemory::Dma),
4973 )
4974 .unwrap();
4975 let (result, _src, cpu_dst) = convert_img(
4976 &mut CPUProcessor::new(),
4977 src,
4978 cpu_dst,
4979 Rotation::None,
4980 Flip::None,
4981 Crop::no_crop(),
4982 );
4983 result.unwrap();
4984
4985 compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
4986 }
4987
4988 #[test]
4989 #[cfg(target_os = "linux")]
4990 fn test_rgba_to_yuyv_resize_g2d() {
4991 if !is_g2d_available() {
4992 eprintln!(
4993 "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
4994 );
4995 return;
4996 }
4997 if !is_dma_available() {
4998 eprintln!(
4999 "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5000 );
5001 return;
5002 }
5003
5004 let src = load_bytes_to_tensor(
5005 1280,
5006 720,
5007 PixelFormat::Rgba,
5008 Some(TensorMemory::Dma),
5009 &edgefirst_bench::testdata::read("camera720p.rgba"),
5010 )
5011 .unwrap();
5012
5013 let (dst_width, dst_height) = (1280, 720);
5014
5015 let cpu_dst = TensorDyn::image(
5016 dst_width,
5017 dst_height,
5018 PixelFormat::Yuyv,
5019 DType::U8,
5020 Some(TensorMemory::Dma),
5021 )
5022 .unwrap();
5023
5024 let g2d_dst = TensorDyn::image(
5025 dst_width,
5026 dst_height,
5027 PixelFormat::Yuyv,
5028 DType::U8,
5029 Some(TensorMemory::Dma),
5030 )
5031 .unwrap();
5032
5033 let mut g2d_converter = G2DProcessor::new().unwrap();
5034 let crop = Crop::new();
5035
5036 g2d_dst
5037 .as_u8()
5038 .unwrap()
5039 .map()
5040 .unwrap()
5041 .as_mut_slice()
5042 .fill(128);
5043 let (result, src, g2d_dst) = convert_img(
5044 &mut g2d_converter,
5045 src,
5046 g2d_dst,
5047 Rotation::None,
5048 Flip::None,
5049 crop,
5050 );
5051 result.unwrap();
5052
5053 let cpu_dst_img = cpu_dst;
5054 cpu_dst_img
5055 .as_u8()
5056 .unwrap()
5057 .map()
5058 .unwrap()
5059 .as_mut_slice()
5060 .fill(128);
5061 let (result, _src, cpu_dst) = convert_img(
5062 &mut CPUProcessor::new(),
5063 src,
5064 cpu_dst_img,
5065 Rotation::None,
5066 Flip::None,
5067 crop,
5068 );
5069 result.unwrap();
5070
5071 compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
5072 }
5073
5074 #[test]
5075 fn test_yuyv_to_rgba_cpu() {
5076 let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
5077 let src = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
5078 src.as_u8()
5079 .unwrap()
5080 .map()
5081 .unwrap()
5082 .as_mut_slice()
5083 .copy_from_slice(&file);
5084
5085 let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5086 let mut cpu_converter = CPUProcessor::new();
5087
5088 let (result, _src, dst) = convert_img(
5089 &mut cpu_converter,
5090 src,
5091 dst,
5092 Rotation::None,
5093 Flip::None,
5094 Crop::no_crop(),
5095 );
5096 result.unwrap();
5097
5098 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5099 target_image
5100 .as_u8()
5101 .unwrap()
5102 .map()
5103 .unwrap()
5104 .as_mut_slice()
5105 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5106
5107 compare_images(&dst, &target_image, 0.98, function!());
5110 }
5111
5112 #[test]
5113 fn test_yuyv_to_rgb_cpu() {
5114 let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
5115 let src = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
5116 src.as_u8()
5117 .unwrap()
5118 .map()
5119 .unwrap()
5120 .as_mut_slice()
5121 .copy_from_slice(&file);
5122
5123 let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
5124 let mut cpu_converter = CPUProcessor::new();
5125
5126 let (result, _src, dst) = convert_img(
5127 &mut cpu_converter,
5128 src,
5129 dst,
5130 Rotation::None,
5131 Flip::None,
5132 Crop::no_crop(),
5133 );
5134 result.unwrap();
5135
5136 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
5137 target_image
5138 .as_u8()
5139 .unwrap()
5140 .map()
5141 .unwrap()
5142 .as_mut_slice()
5143 .as_chunks_mut::<3>()
5144 .0
5145 .iter_mut()
5146 .zip(
5147 edgefirst_bench::testdata::read("camera720p.rgba")
5148 .as_chunks::<4>()
5149 .0,
5150 )
5151 .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
5152
5153 compare_images(&dst, &target_image, 0.98, function!());
5156 }
5157
5158 #[test]
5159 #[cfg(target_os = "linux")]
5160 fn test_yuyv_to_rgba_g2d() {
5161 if !is_g2d_available() {
5162 eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
5163 return;
5164 }
5165 if !is_dma_available() {
5166 eprintln!(
5167 "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5168 );
5169 return;
5170 }
5171
5172 let src = load_bytes_to_tensor(
5173 1280,
5174 720,
5175 PixelFormat::Yuyv,
5176 None,
5177 &edgefirst_bench::testdata::read("camera720p.yuyv"),
5178 )
5179 .unwrap();
5180
5181 let dst = TensorDyn::image(
5182 1280,
5183 720,
5184 PixelFormat::Rgba,
5185 DType::U8,
5186 Some(TensorMemory::Dma),
5187 )
5188 .unwrap();
5189 let mut g2d_converter = G2DProcessor::new().unwrap();
5190
5191 let (result, _src, dst) = convert_img(
5192 &mut g2d_converter,
5193 src,
5194 dst,
5195 Rotation::None,
5196 Flip::None,
5197 Crop::no_crop(),
5198 );
5199 result.unwrap();
5200
5201 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5202 target_image
5203 .as_u8()
5204 .unwrap()
5205 .map()
5206 .unwrap()
5207 .as_mut_slice()
5208 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5209
5210 compare_images(&dst, &target_image, 0.98, function!());
5214 }
5215
5216 #[test]
5217 #[cfg(target_os = "linux")]
5218 #[cfg(feature = "opengl")]
5219 fn test_yuyv_to_rgba_opengl() {
5220 if !is_opengl_available() {
5221 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5222 return;
5223 }
5224 if !is_dma_available() {
5225 eprintln!(
5226 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
5227 function!()
5228 );
5229 return;
5230 }
5231
5232 let src = load_bytes_to_tensor(
5233 1280,
5234 720,
5235 PixelFormat::Yuyv,
5236 Some(TensorMemory::Dma),
5237 &edgefirst_bench::testdata::read("camera720p.yuyv"),
5238 )
5239 .unwrap();
5240
5241 let dst = TensorDyn::image(
5242 1280,
5243 720,
5244 PixelFormat::Rgba,
5245 DType::U8,
5246 Some(TensorMemory::Dma),
5247 )
5248 .unwrap();
5249 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5250
5251 let (result, _src, dst) = convert_img(
5252 &mut gl_converter,
5253 src,
5254 dst,
5255 Rotation::None,
5256 Flip::None,
5257 Crop::no_crop(),
5258 );
5259 result.unwrap();
5260
5261 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5262 target_image
5263 .as_u8()
5264 .unwrap()
5265 .map()
5266 .unwrap()
5267 .as_mut_slice()
5268 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
5269
5270 compare_images(&dst, &target_image, 0.98, function!());
5274 }
5275
5276 #[test]
5286 #[cfg(target_os = "macos")]
5287 #[cfg(feature = "opengl")]
5288 fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
5289 let mut proc = match GLProcessorThreaded::new(None) {
5290 Ok(p) => p,
5291 Err(e) => {
5292 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
5293 return;
5294 }
5295 };
5296
5297 let (w, h) = (16usize, 16usize);
5298 let src = TensorDyn::image(w, h, PixelFormat::Grey, DType::U8, Some(TensorMemory::Dma))
5299 .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
5300 {
5302 let su8 = src.as_u8().unwrap();
5303 let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
5304 let mut m = su8.map().unwrap();
5305 let buf = m.as_mut_slice();
5306 for y in 0..h {
5307 for x in 0..w {
5308 buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
5309 }
5310 }
5311 }
5312
5313 let dst =
5314 TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma)).unwrap();
5315 let (result, src_back, dst) = convert_img(
5316 &mut proc,
5317 src,
5318 dst,
5319 Rotation::None,
5320 Flip::None,
5321 Crop::no_crop(),
5322 );
5323 result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
5324
5325 let src_stride = src_back.as_u8().unwrap().effective_row_stride().unwrap();
5326 let src_map = src_back.as_u8().unwrap().map().unwrap();
5327 let sbytes = src_map.as_slice();
5328 let dst_stride = dst.as_u8().unwrap().effective_row_stride().unwrap();
5329 let dst_map = dst.as_u8().unwrap().map().unwrap();
5330 let dbytes = dst_map.as_slice();
5331 for y in 0..h {
5332 for x in 0..w {
5333 let yv = sbytes[y * src_stride + x] as i16;
5334 let p = y * dst_stride + x * 4;
5335 for c in 0..3 {
5336 assert!(
5337 (dbytes[p + c] as i16 - yv).abs() <= 2,
5338 "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
5339 dbytes[p + c]
5340 );
5341 }
5342 }
5343 }
5344 }
5345
5346 #[test]
5352 #[cfg(target_os = "macos")]
5353 #[cfg(feature = "opengl")]
5354 fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
5355 let mut gpu = match GLProcessorThreaded::new(None) {
5356 Ok(p) => p,
5357 Err(e) => {
5358 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5359 return;
5360 }
5361 };
5362 let (w, h) = (64usize, 64usize);
5363 let src =
5364 match TensorDyn::image(w, h, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Dma)) {
5365 Ok(t) => t,
5366 Err(e) => {
5367 eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
5368 return;
5369 }
5370 };
5371 src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); let dst = match TensorDyn::image(
5374 w,
5375 h,
5376 PixelFormat::PlanarRgb,
5377 DType::F16,
5378 Some(TensorMemory::Dma),
5379 ) {
5380 Ok(t) => t,
5381 Err(e) => {
5382 eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
5383 return;
5384 }
5385 };
5386 let mut dst = dst;
5387 if let Err(e) = ImageProcessorTrait::convert(
5389 &mut gpu,
5390 &src,
5391 &mut dst,
5392 Rotation::None,
5393 Flip::None,
5394 Crop::no_crop(),
5395 ) {
5396 eprintln!(
5400 "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
5401 function!()
5402 );
5403 return;
5404 }
5405 let dt = dst.as_f16().expect("dst is F16 PlanarRgb");
5406 let map = dt.map().unwrap();
5407 let vals = map.as_slice();
5408 let mut checked = 0usize;
5411 for &v in vals.iter() {
5412 let f = f32::from(v);
5413 assert!(
5414 (0.40..=0.60).contains(&f),
5415 "planar F16 value {f} not ~0.5 for neutral-grey NV12"
5416 );
5417 checked += 1;
5418 }
5419 assert!(
5420 checked >= w * h * 3,
5421 "expected >= 3 planes of samples, got {checked}"
5422 );
5423 }
5424
5425 #[test]
5433 #[cfg(target_os = "macos")]
5434 #[cfg(feature = "opengl")]
5435 fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
5436 let mut gpu = match GLProcessorThreaded::new(None) {
5437 Ok(p) => p,
5438 Err(e) => {
5439 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5440 return;
5441 }
5442 };
5443 let (fw, fh) = (96usize, 64usize);
5445 let (pool_w, pool_h) = (256usize, 768usize);
5446 let (model_w, model_h) = (128usize, 128usize);
5447
5448 let mut src = match TensorDyn::image(
5449 pool_w,
5450 pool_h,
5451 PixelFormat::Grey,
5452 DType::U8,
5453 Some(TensorMemory::Dma),
5454 ) {
5455 Ok(t) => t,
5456 Err(e) => {
5457 eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
5458 return;
5459 }
5460 };
5461 src.configure_image(fw, fh, PixelFormat::Nv12)
5462 .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
5463 let stride = src.as_u8().unwrap().effective_row_stride().unwrap();
5464 src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128); let mut dst = match TensorDyn::image(
5467 model_w,
5468 model_h,
5469 PixelFormat::PlanarRgb,
5470 DType::F16,
5471 Some(TensorMemory::Dma),
5472 ) {
5473 Ok(t) => t,
5474 Err(e) => {
5475 eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
5476 return;
5477 }
5478 };
5479
5480 let _ = model_w;
5482 let crop = Crop::new()
5483 .with_source(Some(Region::new(0, 0, fw, fh)))
5484 .with_fit(Fit::Letterbox {
5485 pad: [0, 0, 0, 255],
5486 });
5487 if let Err(e) =
5488 ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
5489 {
5490 eprintln!(
5491 "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
5492 function!()
5493 );
5494 return;
5495 }
5496 let _ = stride;
5497 let dt = dst.as_f16().expect("dst F16");
5500 let map = dt.map().unwrap();
5501 let any_half = map.as_slice().iter().any(|&v| {
5502 let f = f32::from(v);
5503 (0.40..=0.60).contains(&f)
5504 });
5505 assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
5506 }
5507
5508 #[test]
5514 #[cfg(target_os = "macos")]
5515 #[cfg(feature = "opengl")]
5516 fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
5517 use std::sync::mpsc;
5518 let mut proc = match ImageProcessor::new() {
5521 Ok(p) => p,
5522 Err(e) => {
5523 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5524 return;
5525 }
5526 };
5527 let (fw, fh) = (96usize, 64usize);
5528 let mut src = match TensorDyn::image(
5529 256,
5530 768,
5531 PixelFormat::Grey,
5532 DType::U8,
5533 Some(TensorMemory::Dma),
5534 ) {
5535 Ok(t) => t,
5536 Err(e) => {
5537 eprintln!("SKIPPED: {} — pool: {e:?}", function!());
5538 return;
5539 }
5540 };
5541 src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
5542 src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
5543 let mut dst = match TensorDyn::image(
5544 128,
5545 128,
5546 PixelFormat::PlanarRgb,
5547 DType::F16,
5548 Some(TensorMemory::Dma),
5549 ) {
5550 Ok(t) => t,
5551 Err(e) => {
5552 eprintln!("SKIPPED: {} — dst: {e:?}", function!());
5553 return;
5554 }
5555 };
5556 let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
5557
5558 let (tx, rx) = mpsc::channel::<bool>();
5561 let worker = std::thread::spawn(move || {
5562 let _ = ImageProcessorTrait::convert(
5563 &mut proc,
5564 &src,
5565 &mut dst,
5566 Rotation::None,
5567 Flip::None,
5568 crop,
5569 );
5570 let _ = tx.send(true);
5571 });
5572 match rx.recv_timeout(std::time::Duration::from_secs(20)) {
5573 Ok(_) => { let _ = worker.join(); }
5574 Err(_) => panic!(
5575 "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
5576 ),
5577 }
5578 }
5579
5580 #[test]
5587 #[cfg(target_os = "macos")]
5588 #[cfg(feature = "opengl")]
5589 fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
5590 let mut gpu = match GLProcessorThreaded::new(None) {
5591 Ok(p) => p,
5592 Err(e) => {
5593 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
5594 return;
5595 }
5596 };
5597 let (max_w, max_h) = (640usize, 640usize);
5600 let depth = 4usize;
5601 let mut srcs = Vec::new();
5602 let mut dsts = Vec::new();
5603 for _ in 0..depth {
5604 srcs.push(
5605 match TensorDyn::image(
5606 max_w,
5607 max_h * 3,
5608 PixelFormat::Grey,
5609 DType::U8,
5610 Some(TensorMemory::Dma),
5611 ) {
5612 Ok(t) => t,
5613 Err(e) => {
5614 eprintln!("SKIPPED: {} — pool: {e:?}", function!());
5615 return;
5616 }
5617 },
5618 );
5619 dsts.push(
5620 match TensorDyn::image(
5621 640,
5622 640,
5623 PixelFormat::PlanarRgb,
5624 DType::F16,
5625 Some(TensorMemory::Dma),
5626 ) {
5627 Ok(t) => t,
5628 Err(e) => {
5629 eprintln!("SKIPPED: {} — dst: {e:?}", function!());
5630 return;
5631 }
5632 },
5633 );
5634 }
5635 let sizes = [
5637 (640, 480),
5638 (500, 375),
5639 (640, 427),
5640 (333, 500),
5641 (480, 640),
5642 (612, 612),
5643 (428, 640),
5644 (576, 432),
5645 ];
5646 let mut first_ms = 0f64;
5647 let mut last_ms = 0f64;
5648 let iters = 40usize;
5649 for i in 0..iters {
5650 let (fw, fh) = sizes[i % sizes.len()];
5651 let src = &mut srcs[i % depth];
5652 let dst = &mut dsts[i % depth];
5653 src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
5654 src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
5655 let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
5656 let t0 = std::time::Instant::now();
5657 ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
5658 .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
5659 let ms = t0.elapsed().as_secs_f64() * 1e3;
5660 if i == 2 {
5661 first_ms = ms;
5662 }
5663 if i == iters - 1 {
5664 last_ms = ms;
5665 }
5666 }
5667 eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
5668 assert!(
5669 last_ms < first_ms * 5.0 + 5.0,
5670 "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
5671 );
5672 }
5673
5674 #[test]
5681 #[cfg(target_os = "macos")]
5682 #[cfg(feature = "opengl")]
5683 fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
5684 let mut gpu = match GLProcessorThreaded::new(None) {
5685 Ok(p) => p,
5686 Err(e) => {
5687 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
5688 return;
5689 }
5690 };
5691 let mut cpu = CPUProcessor::new();
5692
5693 let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
5705 for y in 0..h {
5706 for x in 0..w {
5707 buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
5708 }
5709 }
5710 let (cw, ch, uv_grid_rows) = match fmt {
5711 PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
5712 PixelFormat::Nv16 => (w / 2, h, 1usize),
5713 _ => (w, h, 2usize), };
5715 let uv_plane = h * stride;
5716 for cy in 0..ch {
5717 for cx in 0..cw {
5718 let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
5719 buf[off] = ((cx * 11 + 30) & 0xff) as u8;
5720 buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
5721 }
5722 }
5723 };
5724
5725 for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
5726 for (w, h) in [
5727 (16usize, 16usize), (15, 16), (16, 15), ] {
5731 let mem = TensorDyn::image(w, h, fmt, DType::U8, None).unwrap();
5732 let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
5733 fill(
5734 mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
5735 mem_stride,
5736 fmt,
5737 w,
5738 h,
5739 );
5740 let cpu_dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, None).unwrap();
5741 let (r, _s, cpu_dst) = convert_img(
5742 &mut cpu,
5743 mem,
5744 cpu_dst,
5745 Rotation::None,
5746 Flip::None,
5747 Crop::no_crop(),
5748 );
5749 r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
5750
5751 let ios = TensorDyn::image(w, h, fmt, DType::U8, Some(TensorMemory::Dma))
5752 .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
5753 let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
5754 fill(
5755 ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
5756 ios_stride,
5757 fmt,
5758 w,
5759 h,
5760 );
5761 let gpu_dst =
5762 TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
5763 .unwrap();
5764 let (r, _s, gpu_dst) = convert_img(
5765 &mut gpu,
5766 ios,
5767 gpu_dst,
5768 Rotation::None,
5769 Flip::None,
5770 Crop::no_crop(),
5771 );
5772 r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
5773
5774 let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5775 let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
5776 let cb = cmap.as_slice();
5777 let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5778 let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
5779 let gb = gmap.as_slice();
5780 let mut max_d = 0i16;
5781 for y in 0..h {
5782 for x in 0..w {
5783 for c in 0..3 {
5784 let cv = cb[y * cs + x * 4 + c] as i16;
5785 let gv = gb[y * gs + x * 4 + c] as i16;
5786 max_d = max_d.max((cv - gv).abs());
5787 }
5788 }
5789 }
5790 assert!(
5791 max_d <= 3,
5792 "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
5793 );
5794 }
5795 }
5796 }
5797
5798 #[test]
5806 #[cfg(target_os = "macos")]
5807 #[cfg(feature = "opengl")]
5808 fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
5809 let mut gpu = match GLProcessorThreaded::new(None) {
5810 Ok(p) => p,
5811 Err(e) => {
5812 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
5813 return;
5814 }
5815 };
5816 let mut cpu = CPUProcessor::new();
5817 let (pool_w, pool_h) = (256usize, 256usize);
5820
5821 let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
5825 for y in 0..h {
5826 for x in 0..w {
5827 buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
5828 }
5829 }
5830 let (cw, ch, uv_grid_rows) = match fmt {
5831 PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
5832 PixelFormat::Nv16 => (w / 2, h, 1usize),
5833 _ => (w, h, 2usize), };
5835 let uv_plane = h * stride;
5836 for cy in 0..ch {
5837 for cx in 0..cw {
5838 let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
5839 buf[off] = ((cx * 11 + 30) & 0xff) as u8;
5840 buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
5841 }
5842 }
5843 };
5844
5845 for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
5846 for (w, h) in [
5847 (40usize, 24usize), (15, 16), (16, 15), ] {
5851 let ew = w.next_multiple_of(2);
5854
5855 let mem = TensorDyn::image(w, h, fmt, DType::U8, None).unwrap();
5857 let mem_stride = mem.as_u8().unwrap().effective_row_stride().unwrap();
5858 fill(
5859 mem.as_u8().unwrap().map().unwrap().as_mut_slice(),
5860 mem_stride,
5861 fmt,
5862 w,
5863 h,
5864 );
5865 let cpu_dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, None).unwrap();
5866 let (r, _s, cpu_dst) = convert_img(
5867 &mut cpu,
5868 mem,
5869 cpu_dst,
5870 Rotation::None,
5871 Flip::None,
5872 Crop::no_crop(),
5873 );
5874 r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
5875
5876 let mut ios = match TensorDyn::image(
5880 pool_w,
5881 pool_h,
5882 PixelFormat::Grey,
5883 DType::U8,
5884 Some(TensorMemory::Dma),
5885 ) {
5886 Ok(t) => t,
5887 Err(e) => {
5888 eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
5889 return;
5890 }
5891 };
5892 ios.configure_image(w, h, fmt)
5893 .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
5894 let ios_stride = ios.as_u8().unwrap().effective_row_stride().unwrap();
5895 assert!(
5896 ios_stride > ew,
5897 "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
5898 (test must exercise padding)"
5899 );
5900 fill(
5901 ios.as_u8().unwrap().map().unwrap().as_mut_slice(),
5902 ios_stride,
5903 fmt,
5904 w,
5905 h,
5906 );
5907
5908 let gpu_dst =
5909 TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
5910 .unwrap();
5911 let (r, _s, gpu_dst) = convert_img(
5912 &mut gpu,
5913 ios,
5914 gpu_dst,
5915 Rotation::None,
5916 Flip::None,
5917 Crop::no_crop(),
5918 );
5919 r.unwrap_or_else(|e| {
5920 panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
5921 });
5922
5923 let cs = cpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5924 let cmap = cpu_dst.as_u8().unwrap().map().unwrap();
5925 let cb = cmap.as_slice();
5926 let gs = gpu_dst.as_u8().unwrap().effective_row_stride().unwrap();
5927 let gmap = gpu_dst.as_u8().unwrap().map().unwrap();
5928 let gb = gmap.as_slice();
5929 let mut max_d = 0i16;
5930 for y in 0..h {
5931 for x in 0..w {
5932 for c in 0..3 {
5933 let cv = cb[y * cs + x * 4 + c] as i16;
5934 let gv = gb[y * gs + x * 4 + c] as i16;
5935 max_d = max_d.max((cv - gv).abs());
5936 }
5937 }
5938 }
5939 assert!(
5940 max_d <= 3,
5941 "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
5942 );
5943 }
5944 }
5945 }
5946
5947 #[test]
5948 #[cfg(target_os = "macos")]
5949 #[cfg(feature = "opengl")]
5950 fn test_yuyv_to_rgba_opengl_macos() {
5951 let mut proc = match GLProcessorThreaded::new(None) {
5952 Ok(p) => p,
5953 Err(e) => {
5954 eprintln!(
5955 "SKIPPED: {} — GL engine init failed ({e:?}). \
5956 Install ANGLE via `brew install startergo/angle/angle` \
5957 and re-sign per README.md § macOS GPU Acceleration to \
5958 run this test.",
5959 function!()
5960 );
5961 return;
5962 }
5963 };
5964
5965 let src = load_bytes_to_tensor(
5966 1280,
5967 720,
5968 PixelFormat::Yuyv,
5969 Some(TensorMemory::Dma),
5970 &edgefirst_bench::testdata::read("camera720p.yuyv"),
5971 )
5972 .unwrap();
5973
5974 let dst = TensorDyn::image(
5975 1280,
5976 720,
5977 PixelFormat::Rgba,
5978 DType::U8,
5979 Some(TensorMemory::Dma),
5980 )
5981 .unwrap();
5982
5983 let (result, _src, dst) = convert_img(
5984 &mut proc,
5985 src,
5986 dst,
5987 Rotation::None,
5988 Flip::None,
5989 Crop::no_crop(),
5990 );
5991 result.unwrap();
5992
5993 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
5994 target_image
5995 .as_u8()
5996 .unwrap()
5997 .map()
5998 .unwrap()
5999 .as_mut_slice()
6000 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6001
6002 compare_images(&dst, &target_image, 0.98, function!());
6007 }
6008
6009 #[test]
6028 #[cfg(target_os = "macos")]
6029 #[cfg(feature = "opengl")]
6030 fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
6031 let mut proc = match GLProcessorThreaded::new(None) {
6032 Ok(p) => p,
6033 Err(e) => {
6034 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6035 return;
6036 }
6037 };
6038
6039 for (w, h) in [(64usize, 32usize), (3840, 2160)] {
6040 let bytes_per_row = w * 2;
6043 let mut yuyv = vec![0u8; bytes_per_row * h];
6044 for chunk in yuyv.chunks_exact_mut(4) {
6045 chunk[0] = 128; chunk[1] = 128; chunk[2] = 128; chunk[3] = 128; }
6050
6051 let src = load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6052 .unwrap();
6053
6054 let dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma))
6055 .unwrap();
6056
6057 let (result, _src, dst) = convert_img(
6058 &mut proc,
6059 src,
6060 dst,
6061 Rotation::None,
6062 Flip::None,
6063 Crop::no_crop(),
6064 );
6065 result.expect("GL convert should succeed at this resolution");
6066
6067 let dst_u8 = dst.as_u8().unwrap();
6072 let dst_map = dst_u8.map().unwrap();
6073 let dst_bytes = dst_map.as_slice();
6074 assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
6075 for px in dst_bytes.chunks_exact(4) {
6076 for (i, &c) in px[..3].iter().enumerate() {
6077 assert!(
6078 (120..=140).contains(&c),
6079 "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
6080 function!(),
6081 );
6082 }
6083 assert_eq!(px[3], 255, "alpha must be 1.0");
6084 }
6085 }
6086 }
6087
6088 #[test]
6098 #[cfg(target_os = "macos")]
6099 #[cfg(feature = "opengl")]
6100 fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
6101 let mut proc = match GLProcessorThreaded::new(None) {
6102 Ok(p) => p,
6103 Err(e) => {
6104 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6105 return;
6106 }
6107 };
6108
6109 let mut yuyv = vec![0u8; 64 * 32 * 2];
6111 for chunk in yuyv.chunks_exact_mut(4) {
6112 chunk[0] = 200;
6113 chunk[1] = 100;
6114 chunk[2] = 200;
6115 chunk[3] = 156;
6116 }
6117 let src = load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6118 .unwrap();
6119 let dst = TensorDyn::image(
6120 64,
6121 32,
6122 PixelFormat::Rgba,
6123 DType::U8,
6124 Some(TensorMemory::Dma),
6125 )
6126 .unwrap();
6127
6128 let (r1, src, dst) = convert_img(
6129 &mut proc,
6130 src,
6131 dst,
6132 Rotation::None,
6133 Flip::None,
6134 Crop::no_crop(),
6135 );
6136 r1.unwrap();
6137 let first: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6138
6139 let (r2, _src, dst) = convert_img(
6140 &mut proc,
6141 src,
6142 dst,
6143 Rotation::None,
6144 Flip::None,
6145 Crop::no_crop(),
6146 );
6147 r2.unwrap();
6148 let second: Vec<u8> = dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6149
6150 assert_eq!(first, second, "cache-hit conversion must be deterministic");
6151 }
6152
6153 #[test]
6161 #[cfg(target_os = "macos")]
6162 #[cfg(feature = "opengl")]
6163 fn test_macos_gl_pbuffer_cache_steady_state() {
6164 let mut proc = match GLProcessorThreaded::new(None) {
6165 Ok(p) => p,
6166 Err(e) => {
6167 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6168 return;
6169 }
6170 };
6171
6172 let (w, h) = (64usize, 32usize);
6173 const POOL: usize = 3;
6174 const FRAMES: usize = 100;
6175
6176 let yuyv = vec![128u8; w * h * 2];
6177 let pool: Vec<TensorDyn> = (0..POOL)
6178 .map(|_| {
6179 load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::Dma), &yuyv)
6180 .unwrap()
6181 })
6182 .collect();
6183 let mut dst =
6184 TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Dma)).unwrap();
6185
6186 for src in pool.iter().cycle().take(POOL * 2) {
6188 proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
6189 .unwrap();
6190 }
6191 let warm = proc.egl_cache_stats().unwrap();
6192
6193 for src in pool.iter().cycle().take(FRAMES) {
6194 proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
6195 .unwrap();
6196 }
6197 let steady = proc.egl_cache_stats().unwrap();
6198
6199 assert_eq!(
6200 warm.total_misses(),
6201 steady.total_misses(),
6202 "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
6203 );
6204 let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
6205 assert!(
6206 hits(&steady) - hits(&warm) >= FRAMES as u64,
6207 "expected at least {FRAMES} import-cache hits over the loop, got {}",
6208 hits(&steady) - hits(&warm)
6209 );
6210 }
6211
6212 #[test]
6224 #[cfg(target_os = "macos")]
6225 #[cfg(feature = "opengl")]
6226 fn test_macos_gl_f16_planar_is_gl_backed() {
6227 let mut proc = ImageProcessor::new().expect("ImageProcessor");
6228 let Some(ref gl) = proc.opengl else {
6229 eprintln!("SKIPPED: {} — GL backend unavailable", function!());
6230 return;
6231 };
6232 if !gl.supported_render_dtypes().f16 {
6233 eprintln!(
6234 "SKIPPED: {} — configuration lacks F16 color-buffer support",
6235 function!()
6236 );
6237 return;
6238 }
6239 let stats_before = gl.egl_cache_stats().expect("cache stats");
6240
6241 let src = TensorDyn::image(
6242 1280,
6243 720,
6244 PixelFormat::Nv12,
6245 DType::U8,
6246 Some(TensorMemory::Dma),
6247 )
6248 .unwrap();
6249 {
6250 let t = src.as_u8().unwrap();
6251 let mut m = t.map().unwrap();
6252 for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
6253 *b = ((i * 31) % 211) as u8;
6254 }
6255 }
6256 let mut dst = TensorDyn::image(
6257 640,
6258 640,
6259 PixelFormat::PlanarRgb,
6260 DType::F16,
6261 Some(TensorMemory::Dma),
6262 )
6263 .unwrap();
6264
6265 proc.convert(
6266 &src,
6267 &mut dst,
6268 Rotation::None,
6269 Flip::None,
6270 Crop::letterbox([114, 114, 114, 255]),
6271 )
6272 .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
6273 let stats_after = proc
6274 .opengl
6275 .as_ref()
6276 .expect("GL backend present")
6277 .egl_cache_stats()
6278 .expect("cache stats");
6279 assert!(
6284 stats_after.total_misses() >= stats_before.total_misses() + 2,
6285 "convert succeeded but the GL engine did not import both the \
6286 source and the F16 destination — the work did not (fully) run \
6287 on the GL backend (silent CPU fallback); misses before={} after={}",
6288 stats_before.total_misses(),
6289 stats_after.total_misses()
6290 );
6291 }
6292
6293 #[test]
6299 #[cfg(feature = "opengl")]
6300 fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
6301 let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
6302 backend: ComputeBackend::OpenGl,
6303 ..Default::default()
6304 }) {
6305 Ok(p) if p.opengl.is_some() => p,
6306 _ => {
6307 eprintln!("SKIPPED: {} — GL backend unavailable", function!());
6308 return;
6309 }
6310 };
6311 if !gl
6312 .opengl
6313 .as_ref()
6314 .map(|g| g.supported_render_dtypes().f16)
6315 .unwrap_or(false)
6316 {
6317 eprintln!("SKIPPED: {} — no F16 render support", function!());
6318 return;
6319 }
6320 let mem = if edgefirst_tensor::is_gpu_buffer_available() {
6321 TensorMemory::Dma
6322 } else {
6323 eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
6324 return;
6325 };
6326
6327 let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, Some(mem)).unwrap();
6328 {
6329 let t = src.as_u8().unwrap();
6335 let mut m = t.map().unwrap();
6336 let buf = m.as_mut_slice();
6337 let (w, h) = (1280usize, 720usize);
6338 for y in 0..h {
6339 for x in 0..w {
6340 buf[y * w + x] = ((x * 255) / w) as u8; }
6342 }
6343 for y in 0..(h / 2) {
6344 for x in 0..(w / 2) {
6345 let o = h * w + y * w + 2 * x;
6346 buf[o] = ((y * 255) / (h / 2)) as u8; buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; }
6349 }
6350 }
6351 let crop = Crop::letterbox([114, 114, 114, 255]);
6352 let mut gl_dst =
6353 TensorDyn::image(640, 640, PixelFormat::PlanarRgb, DType::F16, Some(mem)).unwrap();
6354 gl.opengl
6358 .as_mut()
6359 .expect("GL backend present")
6360 .convert(&src, &mut gl_dst, Rotation::None, Flip::None, crop)
6361 .expect("fused NV12→PlanarF16 GL convert");
6362
6363 let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6364 backend: ComputeBackend::Cpu,
6365 ..Default::default()
6366 })
6367 .unwrap();
6368 let mut cpu_dst = TensorDyn::image(
6369 640,
6370 640,
6371 PixelFormat::PlanarRgb,
6372 DType::F16,
6373 Some(TensorMemory::Mem),
6374 )
6375 .unwrap();
6376 cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
6377 .expect("CPU reference convert");
6378
6379 let g = gl_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
6380 let c = cpu_dst.as_f16().unwrap().map().unwrap().as_slice().to_vec();
6381 assert_eq!(g.len(), c.len());
6382 let mut max_diff = 0.0f32;
6383 let mut max_at = 0usize;
6384 for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
6385 let d = (a.to_f32() - b.to_f32()).abs();
6386 if d > max_diff {
6387 max_diff = d;
6388 max_at = i;
6389 }
6390 }
6391 let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
6393 let (row, col) = (rem / 640, rem % 640);
6394 eprintln!(
6395 "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
6396 gl={} cpu={}",
6397 g[max_at].to_f32(),
6398 c[max_at].to_f32()
6399 );
6400 assert!(
6403 max_diff <= 4.0 / 255.0 + 1e-3,
6404 "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
6405 );
6406 }
6407
6408 #[test]
6418 #[cfg(feature = "opengl")]
6419 fn test_zero_copy_src_to_mem_dst_gl_direct() {
6420 let mut proc = match ImageProcessor::new() {
6421 Ok(p) if p.opengl.is_some() => p,
6422 _ => {
6423 eprintln!("SKIPPED: {} — GL backend unavailable", function!());
6424 return;
6425 }
6426 };
6427 if !edgefirst_tensor::is_gpu_buffer_available() {
6428 eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
6429 return;
6430 }
6431
6432 let src = TensorDyn::image(
6433 1280,
6434 720,
6435 PixelFormat::Rgba,
6436 DType::U8,
6437 Some(TensorMemory::Dma),
6438 )
6439 .unwrap();
6440 {
6441 let t = src.as_u8().unwrap();
6442 let mut m = t.map().unwrap();
6443 for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
6444 *b = ((i * 31) % 211) as u8;
6445 }
6446 }
6447 let mut gl_dst = TensorDyn::image(
6448 1280,
6449 720,
6450 PixelFormat::Bgra,
6451 DType::U8,
6452 Some(TensorMemory::Mem),
6453 )
6454 .unwrap();
6455 proc.opengl
6456 .as_mut()
6457 .expect("GL backend present")
6458 .convert(
6459 &src,
6460 &mut gl_dst,
6461 Rotation::None,
6462 Flip::None,
6463 Crop::no_crop(),
6464 )
6465 .expect("zero-copy src → heap dst GL convert");
6466
6467 let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
6468 backend: ComputeBackend::Cpu,
6469 ..Default::default()
6470 })
6471 .unwrap();
6472 let mut cpu_dst = TensorDyn::image(
6473 1280,
6474 720,
6475 PixelFormat::Bgra,
6476 DType::U8,
6477 Some(TensorMemory::Mem),
6478 )
6479 .unwrap();
6480 cpu.convert(
6481 &src,
6482 &mut cpu_dst,
6483 Rotation::None,
6484 Flip::None,
6485 Crop::no_crop(),
6486 )
6487 .expect("CPU reference convert");
6488
6489 let g = gl_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6490 let c = cpu_dst.as_u8().unwrap().map().unwrap().as_slice().to_vec();
6491 assert_eq!(g.len(), c.len());
6492 let max_diff = g
6493 .iter()
6494 .zip(c.iter())
6495 .map(|(a, b)| a.abs_diff(*b))
6496 .max()
6497 .unwrap();
6498 assert!(
6501 max_diff <= 2,
6502 "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
6503 );
6504 }
6505
6506 #[test]
6507 #[cfg(target_os = "linux")]
6508 fn test_yuyv_to_rgb_g2d() {
6509 if !is_g2d_available() {
6510 eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
6511 return;
6512 }
6513 if !is_dma_available() {
6514 eprintln!(
6515 "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6516 );
6517 return;
6518 }
6519
6520 let src = load_bytes_to_tensor(
6521 1280,
6522 720,
6523 PixelFormat::Yuyv,
6524 None,
6525 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6526 )
6527 .unwrap();
6528
6529 let g2d_dst = TensorDyn::image(
6530 1280,
6531 720,
6532 PixelFormat::Rgb,
6533 DType::U8,
6534 Some(TensorMemory::Dma),
6535 )
6536 .unwrap();
6537 let mut g2d_converter = G2DProcessor::new().unwrap();
6538
6539 let (result, src, g2d_dst) = convert_img(
6540 &mut g2d_converter,
6541 src,
6542 g2d_dst,
6543 Rotation::None,
6544 Flip::None,
6545 Crop::no_crop(),
6546 );
6547 result.unwrap();
6548
6549 let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
6550 let mut cpu_converter: CPUProcessor = CPUProcessor::new();
6551
6552 let (result, _src, cpu_dst) = convert_img(
6553 &mut cpu_converter,
6554 src,
6555 cpu_dst,
6556 Rotation::None,
6557 Flip::None,
6558 Crop::no_crop(),
6559 );
6560 result.unwrap();
6561
6562 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6568 }
6569
6570 #[test]
6571 #[cfg(target_os = "linux")]
6572 fn test_yuyv_to_yuyv_resize_g2d() {
6573 if !is_g2d_available() {
6574 eprintln!(
6575 "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
6576 );
6577 return;
6578 }
6579 if !is_dma_available() {
6580 eprintln!(
6581 "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6582 );
6583 return;
6584 }
6585
6586 let src = load_bytes_to_tensor(
6587 1280,
6588 720,
6589 PixelFormat::Yuyv,
6590 None,
6591 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6592 )
6593 .unwrap();
6594
6595 let g2d_dst = TensorDyn::image(
6596 600,
6597 400,
6598 PixelFormat::Yuyv,
6599 DType::U8,
6600 Some(TensorMemory::Dma),
6601 )
6602 .unwrap();
6603 let mut g2d_converter = G2DProcessor::new().unwrap();
6604
6605 let (result, src, g2d_dst) = convert_img(
6606 &mut g2d_converter,
6607 src,
6608 g2d_dst,
6609 Rotation::None,
6610 Flip::None,
6611 Crop::no_crop(),
6612 );
6613 result.unwrap();
6614
6615 let cpu_dst = TensorDyn::image(600, 400, PixelFormat::Yuyv, DType::U8, None).unwrap();
6616 let mut cpu_converter: CPUProcessor = CPUProcessor::new();
6617
6618 let (result, _src, cpu_dst) = convert_img(
6619 &mut cpu_converter,
6620 src,
6621 cpu_dst,
6622 Rotation::None,
6623 Flip::None,
6624 Crop::no_crop(),
6625 );
6626 result.unwrap();
6627
6628 eprintln!(
6635 "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
6636 CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
6637 );
6638 compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
6639 }
6640
6641 #[test]
6642 fn test_yuyv_to_rgba_resize_cpu() {
6643 let src = load_bytes_to_tensor(
6644 1280,
6645 720,
6646 PixelFormat::Yuyv,
6647 None,
6648 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6649 )
6650 .unwrap();
6651
6652 let (dst_width, dst_height) = (960, 540);
6653
6654 let dst =
6655 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
6656 let mut cpu_converter = CPUProcessor::new();
6657
6658 let (result, _src, dst) = convert_img(
6659 &mut cpu_converter,
6660 src,
6661 dst,
6662 Rotation::None,
6663 Flip::None,
6664 Crop::no_crop(),
6665 );
6666 result.unwrap();
6667
6668 let dst_target =
6669 TensorDyn::image(dst_width, dst_height, PixelFormat::Rgba, DType::U8, None).unwrap();
6670 let src_target = load_bytes_to_tensor(
6671 1280,
6672 720,
6673 PixelFormat::Rgba,
6674 None,
6675 &edgefirst_bench::testdata::read("camera720p.rgba"),
6676 )
6677 .unwrap();
6678 let (result, _src_target, dst_target) = convert_img(
6679 &mut cpu_converter,
6680 src_target,
6681 dst_target,
6682 Rotation::None,
6683 Flip::None,
6684 Crop::no_crop(),
6685 );
6686 result.unwrap();
6687
6688 compare_images(&dst, &dst_target, 0.98, function!());
6691 }
6692
6693 #[test]
6694 #[cfg(target_os = "linux")]
6695 fn test_yuyv_to_rgba_crop_flip_g2d() {
6696 if !is_g2d_available() {
6697 eprintln!(
6698 "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
6699 );
6700 return;
6701 }
6702 if !is_dma_available() {
6703 eprintln!(
6704 "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6705 );
6706 return;
6707 }
6708
6709 let src = load_bytes_to_tensor(
6710 1280,
6711 720,
6712 PixelFormat::Yuyv,
6713 Some(TensorMemory::Dma),
6714 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6715 )
6716 .unwrap();
6717
6718 let (dst_width, dst_height) = (640, 640);
6719
6720 let dst_g2d = TensorDyn::image(
6721 dst_width,
6722 dst_height,
6723 PixelFormat::Rgba,
6724 DType::U8,
6725 Some(TensorMemory::Dma),
6726 )
6727 .unwrap();
6728 let mut g2d_converter = G2DProcessor::new().unwrap();
6729 let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
6730
6731 let (result, src, dst_g2d) = convert_img(
6732 &mut g2d_converter,
6733 src,
6734 dst_g2d,
6735 Rotation::None,
6736 Flip::Horizontal,
6737 crop,
6738 );
6739 result.unwrap();
6740
6741 let dst_cpu = TensorDyn::image(
6742 dst_width,
6743 dst_height,
6744 PixelFormat::Rgba,
6745 DType::U8,
6746 Some(TensorMemory::Dma),
6747 )
6748 .unwrap();
6749 let mut cpu_converter = CPUProcessor::new();
6750
6751 let (result, _src, dst_cpu) = convert_img(
6752 &mut cpu_converter,
6753 src,
6754 dst_cpu,
6755 Rotation::None,
6756 Flip::Horizontal,
6757 crop,
6758 );
6759 result.unwrap();
6760 compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
6766 }
6767
6768 #[test]
6769 #[cfg(target_os = "linux")]
6770 #[cfg(feature = "opengl")]
6771 fn test_yuyv_to_rgba_crop_flip_opengl() {
6772 if !is_opengl_available() {
6773 eprintln!("SKIPPED: {} - OpenGL not available", function!());
6774 return;
6775 }
6776
6777 if !is_dma_available() {
6778 eprintln!(
6779 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
6780 function!()
6781 );
6782 return;
6783 }
6784
6785 let src = load_bytes_to_tensor(
6786 1280,
6787 720,
6788 PixelFormat::Yuyv,
6789 Some(TensorMemory::Dma),
6790 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6791 )
6792 .unwrap();
6793
6794 let (dst_width, dst_height) = (640, 640);
6795
6796 let dst_gl = TensorDyn::image(
6797 dst_width,
6798 dst_height,
6799 PixelFormat::Rgba,
6800 DType::U8,
6801 Some(TensorMemory::Dma),
6802 )
6803 .unwrap();
6804 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6805 let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
6806
6807 let (result, src, dst_gl) = convert_img(
6808 &mut gl_converter,
6809 src,
6810 dst_gl,
6811 Rotation::None,
6812 Flip::Horizontal,
6813 crop,
6814 );
6815 result.unwrap();
6816
6817 let dst_cpu = TensorDyn::image(
6818 dst_width,
6819 dst_height,
6820 PixelFormat::Rgba,
6821 DType::U8,
6822 Some(TensorMemory::Dma),
6823 )
6824 .unwrap();
6825 let mut cpu_converter = CPUProcessor::new();
6826
6827 let (result, _src, dst_cpu) = convert_img(
6828 &mut cpu_converter,
6829 src,
6830 dst_cpu,
6831 Rotation::None,
6832 Flip::Horizontal,
6833 crop,
6834 );
6835 result.unwrap();
6836 compare_images(&dst_gl, &dst_cpu, 0.98, function!());
6841 }
6842
6843 #[test]
6844 fn test_vyuy_to_rgba_cpu() {
6845 let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
6846 let src = TensorDyn::image(1280, 720, PixelFormat::Vyuy, DType::U8, None).unwrap();
6847 src.as_u8()
6848 .unwrap()
6849 .map()
6850 .unwrap()
6851 .as_mut_slice()
6852 .copy_from_slice(&file);
6853
6854 let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
6855 let mut cpu_converter = CPUProcessor::new();
6856
6857 let (result, _src, dst) = convert_img(
6858 &mut cpu_converter,
6859 src,
6860 dst,
6861 Rotation::None,
6862 Flip::None,
6863 Crop::no_crop(),
6864 );
6865 result.unwrap();
6866
6867 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
6868 target_image
6869 .as_u8()
6870 .unwrap()
6871 .map()
6872 .unwrap()
6873 .as_mut_slice()
6874 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6875
6876 compare_images(&dst, &target_image, 0.98, function!());
6879 }
6880
6881 #[test]
6882 fn test_vyuy_to_rgb_cpu() {
6883 let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
6884 let src = TensorDyn::image(1280, 720, PixelFormat::Vyuy, DType::U8, None).unwrap();
6885 src.as_u8()
6886 .unwrap()
6887 .map()
6888 .unwrap()
6889 .as_mut_slice()
6890 .copy_from_slice(&file);
6891
6892 let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
6893 let mut cpu_converter = CPUProcessor::new();
6894
6895 let (result, _src, dst) = convert_img(
6896 &mut cpu_converter,
6897 src,
6898 dst,
6899 Rotation::None,
6900 Flip::None,
6901 Crop::no_crop(),
6902 );
6903 result.unwrap();
6904
6905 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
6906 target_image
6907 .as_u8()
6908 .unwrap()
6909 .map()
6910 .unwrap()
6911 .as_mut_slice()
6912 .as_chunks_mut::<3>()
6913 .0
6914 .iter_mut()
6915 .zip(
6916 edgefirst_bench::testdata::read("camera720p.rgba")
6917 .as_chunks::<4>()
6918 .0,
6919 )
6920 .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
6921
6922 compare_images(&dst, &target_image, 0.98, function!());
6925 }
6926
6927 #[test]
6928 #[cfg(target_os = "linux")]
6929 #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
6930 fn test_vyuy_to_rgba_g2d() {
6931 if !is_g2d_available() {
6932 eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
6933 return;
6934 }
6935 if !is_dma_available() {
6936 eprintln!(
6937 "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6938 );
6939 return;
6940 }
6941
6942 let src = load_bytes_to_tensor(
6943 1280,
6944 720,
6945 PixelFormat::Vyuy,
6946 None,
6947 &edgefirst_bench::testdata::read("camera720p.vyuy"),
6948 )
6949 .unwrap();
6950
6951 let dst = TensorDyn::image(
6952 1280,
6953 720,
6954 PixelFormat::Rgba,
6955 DType::U8,
6956 Some(TensorMemory::Dma),
6957 )
6958 .unwrap();
6959 let mut g2d_converter = G2DProcessor::new().unwrap();
6960
6961 let (result, _src, dst) = convert_img(
6962 &mut g2d_converter,
6963 src,
6964 dst,
6965 Rotation::None,
6966 Flip::None,
6967 Crop::no_crop(),
6968 );
6969 match result {
6970 Err(Error::G2D(_)) => {
6971 eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
6972 return;
6973 }
6974 r => r.unwrap(),
6975 }
6976
6977 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
6978 target_image
6979 .as_u8()
6980 .unwrap()
6981 .map()
6982 .unwrap()
6983 .as_mut_slice()
6984 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6985
6986 compare_images(&dst, &target_image, 0.98, function!());
6990 }
6991
6992 #[test]
6993 #[cfg(target_os = "linux")]
6994 #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
6995 fn test_vyuy_to_rgb_g2d() {
6996 if !is_g2d_available() {
6997 eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
6998 return;
6999 }
7000 if !is_dma_available() {
7001 eprintln!(
7002 "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
7003 );
7004 return;
7005 }
7006
7007 let src = load_bytes_to_tensor(
7008 1280,
7009 720,
7010 PixelFormat::Vyuy,
7011 None,
7012 &edgefirst_bench::testdata::read("camera720p.vyuy"),
7013 )
7014 .unwrap();
7015
7016 let g2d_dst = TensorDyn::image(
7017 1280,
7018 720,
7019 PixelFormat::Rgb,
7020 DType::U8,
7021 Some(TensorMemory::Dma),
7022 )
7023 .unwrap();
7024 let mut g2d_converter = G2DProcessor::new().unwrap();
7025
7026 let (result, src, g2d_dst) = convert_img(
7027 &mut g2d_converter,
7028 src,
7029 g2d_dst,
7030 Rotation::None,
7031 Flip::None,
7032 Crop::no_crop(),
7033 );
7034 match result {
7035 Err(Error::G2D(_)) => {
7036 eprintln!(
7037 "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
7038 );
7039 return;
7040 }
7041 r => r.unwrap(),
7042 }
7043
7044 let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
7045 let mut cpu_converter: CPUProcessor = CPUProcessor::new();
7046
7047 let (result, _src, cpu_dst) = convert_img(
7048 &mut cpu_converter,
7049 src,
7050 cpu_dst,
7051 Rotation::None,
7052 Flip::None,
7053 Crop::no_crop(),
7054 );
7055 result.unwrap();
7056
7057 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
7063 }
7064
7065 #[test]
7066 #[cfg(target_os = "linux")]
7067 #[cfg(feature = "opengl")]
7068 fn test_vyuy_to_rgba_opengl() {
7069 if !is_opengl_available() {
7070 eprintln!("SKIPPED: {} - OpenGL not available", function!());
7071 return;
7072 }
7073 if !is_dma_available() {
7074 eprintln!(
7075 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7076 function!()
7077 );
7078 return;
7079 }
7080
7081 let src = load_bytes_to_tensor(
7082 1280,
7083 720,
7084 PixelFormat::Vyuy,
7085 Some(TensorMemory::Dma),
7086 &edgefirst_bench::testdata::read("camera720p.vyuy"),
7087 )
7088 .unwrap();
7089
7090 let dst = TensorDyn::image(
7091 1280,
7092 720,
7093 PixelFormat::Rgba,
7094 DType::U8,
7095 Some(TensorMemory::Dma),
7096 )
7097 .unwrap();
7098 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
7099
7100 let (result, _src, dst) = convert_img(
7101 &mut gl_converter,
7102 src,
7103 dst,
7104 Rotation::None,
7105 Flip::None,
7106 Crop::no_crop(),
7107 );
7108 match result {
7109 Err(Error::NotSupported(_)) => {
7110 eprintln!(
7111 "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
7112 function!()
7113 );
7114 return;
7115 }
7116 r => r.unwrap(),
7117 }
7118
7119 let target_image = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
7120 target_image
7121 .as_u8()
7122 .unwrap()
7123 .map()
7124 .unwrap()
7125 .as_mut_slice()
7126 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7127
7128 compare_images(&dst, &target_image, 0.98, function!());
7132 }
7133
7134 #[test]
7135 fn test_nv12_to_rgba_cpu() {
7136 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7137 let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7138 src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7139 .copy_from_slice(&file);
7140
7141 let dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None).unwrap();
7142 let mut cpu_converter = CPUProcessor::new();
7143
7144 let (result, _src, dst) = convert_img(
7145 &mut cpu_converter,
7146 src,
7147 dst,
7148 Rotation::None,
7149 Flip::None,
7150 Crop::no_crop(),
7151 );
7152 result.unwrap();
7153
7154 let target_image = crate::load_image_test_helper(
7155 &edgefirst_bench::testdata::read("zidane.jpg"),
7156 Some(PixelFormat::Rgba),
7157 None,
7158 )
7159 .unwrap();
7160
7161 compare_images(&dst, &target_image, 0.95, function!());
7166 }
7167
7168 #[test]
7169 fn test_nv12_odd_height_to_rgb_cpu() {
7170 let mut src =
7181 TensorDyn::image(8, 5, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
7182 assert_eq!(src.shape(), &[8, 8]);
7183 assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
7184 src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
7185 src.set_colorimetry(Some(
7189 edgefirst_tensor::Colorimetry::default()
7190 .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
7191 .with_range(edgefirst_tensor::ColorRange::Full),
7192 ));
7193
7194 let dst =
7195 TensorDyn::image(8, 5, PixelFormat::Rgb, DType::U8, Some(TensorMemory::Mem)).unwrap();
7196 let mut cpu_converter = CPUProcessor::new();
7197 let (result, _src, dst) = convert_img(
7198 &mut cpu_converter,
7199 src,
7200 dst,
7201 Rotation::None,
7202 Flip::None,
7203 Crop::no_crop(),
7204 );
7205 result.unwrap();
7206
7207 assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
7208 let map = dst.as_u8().unwrap().map().unwrap();
7209 for (i, &b) in map.as_slice().iter().enumerate() {
7210 assert!(
7211 (b as i16 - 128).abs() <= 2,
7212 "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
7213 );
7214 }
7215 }
7216
7217 #[test]
7218 fn test_nv24_to_rgb_cpu() {
7219 let mut src =
7225 TensorDyn::image(8, 4, PixelFormat::Nv24, DType::U8, Some(TensorMemory::Mem)).unwrap();
7226 assert_eq!(src.shape(), &[12, 8]);
7227 assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
7228 src.as_u8().unwrap().map().unwrap().as_mut_slice().fill(128);
7229 src.set_colorimetry(Some(
7232 edgefirst_tensor::Colorimetry::default()
7233 .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
7234 .with_range(edgefirst_tensor::ColorRange::Full),
7235 ));
7236
7237 let dst =
7238 TensorDyn::image(8, 4, PixelFormat::Rgb, DType::U8, Some(TensorMemory::Mem)).unwrap();
7239 let mut cpu_converter = CPUProcessor::new();
7240 let (result, _src, dst) = convert_img(
7241 &mut cpu_converter,
7242 src,
7243 dst,
7244 Rotation::None,
7245 Flip::None,
7246 Crop::no_crop(),
7247 );
7248 result.unwrap();
7249
7250 assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
7251 let map = dst.as_u8().unwrap().map().unwrap();
7252 for (i, &b) in map.as_slice().iter().enumerate() {
7253 assert!(
7254 (b as i16 - 128).abs() <= 2,
7255 "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
7256 );
7257 }
7258 }
7259
7260 #[test]
7261 fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
7262 fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
7270 let mut src =
7271 TensorDyn::image(8, 4, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem))
7272 .unwrap();
7273 assert_eq!(src.shape(), &[6, 8]);
7275 {
7276 let mut map = src.as_u8().unwrap().map().unwrap();
7277 let buf = map.as_mut_slice();
7278 buf[..32].fill(120); for px in buf[32..].chunks_exact_mut(2) {
7280 px[0] = 180; px[1] = 64; }
7283 }
7284 src.set_colorimetry(Some(
7287 edgefirst_tensor::Colorimetry::default()
7288 .with_encoding(enc)
7289 .with_range(edgefirst_tensor::ColorRange::Limited),
7290 ));
7291 let dst = TensorDyn::image(8, 4, PixelFormat::Rgb, DType::U8, Some(TensorMemory::Mem))
7292 .unwrap();
7293 let mut cpu = CPUProcessor::new();
7294 let (result, _src, dst) = convert_img(
7295 &mut cpu,
7296 src,
7297 dst,
7298 Rotation::None,
7299 Flip::None,
7300 Crop::no_crop(),
7301 );
7302 result.unwrap();
7303 let map = dst.as_u8().unwrap().map().unwrap();
7304 let s = map.as_slice();
7305 [s[0], s[1], s[2]]
7306 }
7307
7308 let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
7309 let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
7310 let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
7311
7312 assert_ne!(
7313 bt2020, bt601,
7314 "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
7315 );
7316 assert_ne!(
7317 bt2020, bt709,
7318 "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
7319 );
7320 assert_ne!(
7321 bt601, bt709,
7322 "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
7323 );
7324 }
7325
7326 #[test]
7327 fn test_nv12_to_rgb_cpu() {
7328 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7329 let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7330 src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7331 .copy_from_slice(&file);
7332
7333 let dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None).unwrap();
7334 let mut cpu_converter = CPUProcessor::new();
7335
7336 let (result, _src, dst) = convert_img(
7337 &mut cpu_converter,
7338 src,
7339 dst,
7340 Rotation::None,
7341 Flip::None,
7342 Crop::no_crop(),
7343 );
7344 result.unwrap();
7345
7346 let target_image = crate::load_image_test_helper(
7347 &edgefirst_bench::testdata::read("zidane.jpg"),
7348 Some(PixelFormat::Rgb),
7349 None,
7350 )
7351 .unwrap();
7352
7353 compare_images(&dst, &target_image, 0.95, function!());
7358 }
7359
7360 #[test]
7361 fn test_nv12_to_grey_cpu() {
7362 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7363 let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7364 src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7365 .copy_from_slice(&file);
7366
7367 let dst = TensorDyn::image(1280, 720, PixelFormat::Grey, DType::U8, None).unwrap();
7368 let mut cpu_converter = CPUProcessor::new();
7369
7370 let (result, _src, dst) = convert_img(
7371 &mut cpu_converter,
7372 src,
7373 dst,
7374 Rotation::None,
7375 Flip::None,
7376 Crop::no_crop(),
7377 );
7378 result.unwrap();
7379
7380 let target_image = crate::load_image_test_helper(
7381 &edgefirst_bench::testdata::read("zidane.jpg"),
7382 Some(PixelFormat::Grey),
7383 None,
7384 )
7385 .unwrap();
7386
7387 compare_images(&dst, &target_image, 0.95, function!());
7392 }
7393
7394 #[test]
7395 fn test_nv12_to_yuyv_cpu() {
7396 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
7397 let src = TensorDyn::image(1280, 720, PixelFormat::Nv12, DType::U8, None).unwrap();
7398 src.as_u8().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
7399 .copy_from_slice(&file);
7400
7401 let dst = TensorDyn::image(1280, 720, PixelFormat::Yuyv, DType::U8, None).unwrap();
7402 let mut cpu_converter = CPUProcessor::new();
7403
7404 let (result, _src, dst) = convert_img(
7405 &mut cpu_converter,
7406 src,
7407 dst,
7408 Rotation::None,
7409 Flip::None,
7410 Crop::no_crop(),
7411 );
7412 result.unwrap();
7413
7414 let target_image = crate::load_image_test_helper(
7415 &edgefirst_bench::testdata::read("zidane.jpg"),
7416 Some(PixelFormat::Rgb),
7417 None,
7418 )
7419 .unwrap();
7420
7421 compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
7426 }
7427
7428 #[test]
7429 fn test_cpu_resize_nv16() {
7430 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7431 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7432
7433 let cpu_nv16_dst = TensorDyn::image(640, 640, PixelFormat::Nv16, DType::U8, None).unwrap();
7434 let cpu_rgb_dst = TensorDyn::image(640, 640, PixelFormat::Rgb, DType::U8, None).unwrap();
7435 let mut cpu_converter = CPUProcessor::new();
7436 let crop = Crop::letterbox([255, 128, 0, 255]);
7437
7438 let (result, src, cpu_nv16_dst) = convert_img(
7439 &mut cpu_converter,
7440 src,
7441 cpu_nv16_dst,
7442 Rotation::None,
7443 Flip::None,
7444 crop,
7445 );
7446 result.unwrap();
7447
7448 let (result, _src, cpu_rgb_dst) = convert_img(
7449 &mut cpu_converter,
7450 src,
7451 cpu_rgb_dst,
7452 Rotation::None,
7453 Flip::None,
7454 crop,
7455 );
7456 result.unwrap();
7457 compare_images_convert_to_rgb(&cpu_nv16_dst, &cpu_rgb_dst, 0.99, function!());
7458 }
7459
7460 fn load_bytes_to_tensor(
7461 width: usize,
7462 height: usize,
7463 format: PixelFormat,
7464 memory: Option<TensorMemory>,
7465 bytes: &[u8],
7466 ) -> Result<TensorDyn, Error> {
7467 let src = TensorDyn::image(width, height, format, DType::U8, memory)?;
7468 src.as_u8()
7469 .unwrap()
7470 .map()?
7471 .as_mut_slice()
7472 .copy_from_slice(bytes);
7473 Ok(src)
7474 }
7475
7476 fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
7484 assert_eq!(img1.height(), img2.height(), "Heights differ");
7485 assert_eq!(img1.width(), img2.width(), "Widths differ");
7486 assert_eq!(
7487 img1.format().unwrap(),
7488 img2.format().unwrap(),
7489 "PixelFormat differ"
7490 );
7491 assert!(
7492 matches!(
7493 img1.format().unwrap(),
7494 PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
7495 ),
7496 "format must be Rgb or Rgba for comparison"
7497 );
7498
7499 let image1 = match img1.format().unwrap() {
7500 PixelFormat::Rgb => image::RgbImage::from_vec(
7501 img1.width().unwrap() as u32,
7502 img1.height().unwrap() as u32,
7503 img1.as_u8().unwrap().map().unwrap().to_vec(),
7504 )
7505 .unwrap(),
7506 PixelFormat::Rgba => image::RgbaImage::from_vec(
7507 img1.width().unwrap() as u32,
7508 img1.height().unwrap() as u32,
7509 img1.as_u8().unwrap().map().unwrap().to_vec(),
7510 )
7511 .unwrap()
7512 .convert(),
7513 PixelFormat::Grey => image::GrayImage::from_vec(
7514 img1.width().unwrap() as u32,
7515 img1.height().unwrap() as u32,
7516 img1.as_u8().unwrap().map().unwrap().to_vec(),
7517 )
7518 .unwrap()
7519 .convert(),
7520 PixelFormat::PlanarRgb => image::GrayImage::from_vec(
7521 img1.width().unwrap() as u32,
7522 (img1.height().unwrap() * 3) as u32,
7523 img1.as_u8().unwrap().map().unwrap().to_vec(),
7524 )
7525 .unwrap()
7526 .convert(),
7527 _ => return,
7528 };
7529
7530 let image2 = match img2.format().unwrap() {
7531 PixelFormat::Rgb => image::RgbImage::from_vec(
7532 img2.width().unwrap() as u32,
7533 img2.height().unwrap() as u32,
7534 img2.as_u8().unwrap().map().unwrap().to_vec(),
7535 )
7536 .unwrap(),
7537 PixelFormat::Rgba => image::RgbaImage::from_vec(
7538 img2.width().unwrap() as u32,
7539 img2.height().unwrap() as u32,
7540 img2.as_u8().unwrap().map().unwrap().to_vec(),
7541 )
7542 .unwrap()
7543 .convert(),
7544 PixelFormat::Grey => image::GrayImage::from_vec(
7545 img2.width().unwrap() as u32,
7546 img2.height().unwrap() as u32,
7547 img2.as_u8().unwrap().map().unwrap().to_vec(),
7548 )
7549 .unwrap()
7550 .convert(),
7551 PixelFormat::PlanarRgb => image::GrayImage::from_vec(
7552 img2.width().unwrap() as u32,
7553 (img2.height().unwrap() * 3) as u32,
7554 img2.as_u8().unwrap().map().unwrap().to_vec(),
7555 )
7556 .unwrap()
7557 .convert(),
7558 _ => return,
7559 };
7560
7561 let similarity = image_compare::rgb_similarity_structure(
7562 &image_compare::Algorithm::RootMeanSquared,
7563 &image1,
7564 &image2,
7565 )
7566 .expect("Image Comparison failed");
7567 if similarity.score < threshold {
7568 similarity
7571 .image
7572 .to_color_map()
7573 .save(format!("{name}.png"))
7574 .unwrap();
7575 panic!(
7576 "{name}: converted image and target image have similarity score too low: {} < {}",
7577 similarity.score, threshold
7578 )
7579 }
7580 }
7581
7582 fn compare_images_convert_to_rgb(
7583 img1: &TensorDyn,
7584 img2: &TensorDyn,
7585 threshold: f64,
7586 name: &str,
7587 ) {
7588 assert_eq!(img1.height(), img2.height(), "Heights differ");
7589 assert_eq!(img1.width(), img2.width(), "Widths differ");
7590
7591 let mut img_rgb1 = TensorDyn::image(
7592 img1.width().unwrap(),
7593 img1.height().unwrap(),
7594 PixelFormat::Rgb,
7595 DType::U8,
7596 Some(TensorMemory::Mem),
7597 )
7598 .unwrap();
7599 let mut img_rgb2 = TensorDyn::image(
7600 img1.width().unwrap(),
7601 img1.height().unwrap(),
7602 PixelFormat::Rgb,
7603 DType::U8,
7604 Some(TensorMemory::Mem),
7605 )
7606 .unwrap();
7607 let mut __cv = CPUProcessor::default();
7608 let r1 = __cv.convert(
7609 img1,
7610 &mut img_rgb1,
7611 crate::Rotation::None,
7612 crate::Flip::None,
7613 crate::Crop::default(),
7614 );
7615 let r2 = __cv.convert(
7616 img2,
7617 &mut img_rgb2,
7618 crate::Rotation::None,
7619 crate::Flip::None,
7620 crate::Crop::default(),
7621 );
7622 if r1.is_err() || r2.is_err() {
7623 let w = img1.width().unwrap() as u32;
7625 let data1 = img1.as_u8().unwrap().map().unwrap().to_vec();
7626 let data2 = img2.as_u8().unwrap().map().unwrap().to_vec();
7627 let h1 = (data1.len() as u32) / w;
7628 let h2 = (data2.len() as u32) / w;
7629 let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
7630 let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
7631 let similarity = image_compare::gray_similarity_structure(
7632 &image_compare::Algorithm::RootMeanSquared,
7633 &g1,
7634 &g2,
7635 )
7636 .expect("Image Comparison failed");
7637 if similarity.score < threshold {
7638 panic!(
7639 "{name}: converted image and target image have similarity score too low: {} < {}",
7640 similarity.score, threshold
7641 )
7642 }
7643 return;
7644 }
7645
7646 let image1 = image::RgbImage::from_vec(
7647 img_rgb1.width().unwrap() as u32,
7648 img_rgb1.height().unwrap() as u32,
7649 img_rgb1.as_u8().unwrap().map().unwrap().to_vec(),
7650 )
7651 .unwrap();
7652
7653 let image2 = image::RgbImage::from_vec(
7654 img_rgb2.width().unwrap() as u32,
7655 img_rgb2.height().unwrap() as u32,
7656 img_rgb2.as_u8().unwrap().map().unwrap().to_vec(),
7657 )
7658 .unwrap();
7659
7660 let similarity = image_compare::rgb_similarity_structure(
7661 &image_compare::Algorithm::RootMeanSquared,
7662 &image1,
7663 &image2,
7664 )
7665 .expect("Image Comparison failed");
7666 if similarity.score < threshold {
7667 similarity
7670 .image
7671 .to_color_map()
7672 .save(format!("{name}.png"))
7673 .unwrap();
7674 panic!(
7675 "{name}: converted image and target image have similarity score too low: {} < {}",
7676 similarity.score, threshold
7677 )
7678 }
7679 }
7680
7681 #[test]
7686 fn test_nv12_image_creation() {
7687 let width = 640;
7688 let height = 480;
7689 let img = TensorDyn::image(width, height, PixelFormat::Nv12, DType::U8, None).unwrap();
7690
7691 assert_eq!(img.width(), Some(width));
7692 assert_eq!(img.height(), Some(height));
7693 assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
7694 assert_eq!(img.as_u8().unwrap().shape(), &[height * 3 / 2, width]);
7696 }
7697
7698 #[test]
7699 fn test_nv12_channels() {
7700 let img = TensorDyn::image(640, 480, PixelFormat::Nv12, DType::U8, None).unwrap();
7701 assert_eq!(img.format().unwrap().channels(), 1);
7703 }
7704
7705 #[test]
7710 fn test_tensor_set_format_planar() {
7711 let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
7712 tensor.set_format(PixelFormat::PlanarRgb).unwrap();
7713 assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
7714 assert_eq!(tensor.width(), Some(640));
7715 assert_eq!(tensor.height(), Some(480));
7716 }
7717
7718 #[test]
7719 fn test_tensor_set_format_interleaved() {
7720 let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
7721 tensor.set_format(PixelFormat::Rgba).unwrap();
7722 assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
7723 assert_eq!(tensor.width(), Some(640));
7724 assert_eq!(tensor.height(), Some(480));
7725 }
7726
7727 #[test]
7728 fn test_tensordyn_image_rgb() {
7729 let img = TensorDyn::image(640, 480, PixelFormat::Rgb, DType::U8, None).unwrap();
7730 assert_eq!(img.width(), Some(640));
7731 assert_eq!(img.height(), Some(480));
7732 assert_eq!(img.format(), Some(PixelFormat::Rgb));
7733 }
7734
7735 #[test]
7736 fn test_tensordyn_image_planar_rgb() {
7737 let img = TensorDyn::image(640, 480, PixelFormat::PlanarRgb, DType::U8, None).unwrap();
7738 assert_eq!(img.width(), Some(640));
7739 assert_eq!(img.height(), Some(480));
7740 assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
7741 }
7742
7743 #[test]
7744 fn test_rgb_int8_format() {
7745 let img = TensorDyn::image(
7747 1280,
7748 720,
7749 PixelFormat::Rgb,
7750 DType::I8,
7751 Some(TensorMemory::Mem),
7752 )
7753 .unwrap();
7754 assert_eq!(img.width(), Some(1280));
7755 assert_eq!(img.height(), Some(720));
7756 assert_eq!(img.format(), Some(PixelFormat::Rgb));
7757 assert_eq!(img.dtype(), DType::I8);
7758 }
7759
7760 #[test]
7761 fn test_planar_rgb_int8_format() {
7762 let img = TensorDyn::image(
7763 1280,
7764 720,
7765 PixelFormat::PlanarRgb,
7766 DType::I8,
7767 Some(TensorMemory::Mem),
7768 )
7769 .unwrap();
7770 assert_eq!(img.width(), Some(1280));
7771 assert_eq!(img.height(), Some(720));
7772 assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
7773 assert_eq!(img.dtype(), DType::I8);
7774 }
7775
7776 #[test]
7777 fn test_rgb_from_tensor() {
7778 let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
7779 tensor.set_format(PixelFormat::Rgb).unwrap();
7780 let img = TensorDyn::from(tensor);
7781 assert_eq!(img.width(), Some(1280));
7782 assert_eq!(img.height(), Some(720));
7783 assert_eq!(img.format(), Some(PixelFormat::Rgb));
7784 }
7785
7786 #[test]
7787 fn test_planar_rgb_from_tensor() {
7788 let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
7789 tensor.set_format(PixelFormat::PlanarRgb).unwrap();
7790 let img = TensorDyn::from(tensor);
7791 assert_eq!(img.width(), Some(1280));
7792 assert_eq!(img.height(), Some(720));
7793 assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
7794 }
7795
7796 #[test]
7797 fn test_dtype_determines_int8() {
7798 let u8_img = TensorDyn::image(64, 64, PixelFormat::Rgb, DType::U8, None).unwrap();
7800 let i8_img = TensorDyn::image(64, 64, PixelFormat::Rgb, DType::I8, None).unwrap();
7801 assert_eq!(u8_img.dtype(), DType::U8);
7802 assert_eq!(i8_img.dtype(), DType::I8);
7803 }
7804
7805 #[test]
7806 fn test_pixel_layout_packed_vs_planar() {
7807 assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
7809 assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
7810 assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
7811 assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
7812 }
7813
7814 #[cfg(target_os = "linux")]
7819 #[cfg(feature = "opengl")]
7820 #[test]
7821 fn test_convert_pbo_to_pbo() {
7822 let mut converter = ImageProcessor::new().unwrap();
7823
7824 let is_pbo = converter
7826 .opengl
7827 .as_ref()
7828 .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
7829 if !is_pbo {
7830 eprintln!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
7831 return;
7832 }
7833
7834 let src_w = 640;
7835 let src_h = 480;
7836 let dst_w = 320;
7837 let dst_h = 240;
7838
7839 let pbo_src = converter
7841 .create_image(src_w, src_h, PixelFormat::Rgba, DType::U8, None)
7842 .unwrap();
7843 assert_eq!(
7844 pbo_src.as_u8().unwrap().memory(),
7845 TensorMemory::Pbo,
7846 "create_image should produce a PBO tensor"
7847 );
7848
7849 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
7851 let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
7852
7853 let mem_src = TensorDyn::image(
7855 src_w,
7856 src_h,
7857 PixelFormat::Rgba,
7858 DType::U8,
7859 Some(TensorMemory::Mem),
7860 )
7861 .unwrap();
7862 let (result, _jpeg_src, mem_src) = convert_img(
7863 &mut CPUProcessor::new(),
7864 jpeg_src,
7865 mem_src,
7866 Rotation::None,
7867 Flip::None,
7868 Crop::no_crop(),
7869 );
7870 result.unwrap();
7871
7872 {
7874 let src_data = mem_src.as_u8().unwrap().map().unwrap();
7875 let mut pbo_map = pbo_src.as_u8().unwrap().map().unwrap();
7876 pbo_map.copy_from_slice(&src_data);
7877 }
7878
7879 let pbo_dst = converter
7881 .create_image(dst_w, dst_h, PixelFormat::Rgba, DType::U8, None)
7882 .unwrap();
7883 assert_eq!(pbo_dst.as_u8().unwrap().memory(), TensorMemory::Pbo);
7884
7885 let mut pbo_dst = pbo_dst;
7887 let result = converter.convert(
7888 &pbo_src,
7889 &mut pbo_dst,
7890 Rotation::None,
7891 Flip::None,
7892 Crop::no_crop(),
7893 );
7894 result.unwrap();
7895
7896 let cpu_dst = TensorDyn::image(
7898 dst_w,
7899 dst_h,
7900 PixelFormat::Rgba,
7901 DType::U8,
7902 Some(TensorMemory::Mem),
7903 )
7904 .unwrap();
7905 let (result, _mem_src, cpu_dst) = convert_img(
7906 &mut CPUProcessor::new(),
7907 mem_src,
7908 cpu_dst,
7909 Rotation::None,
7910 Flip::None,
7911 Crop::no_crop(),
7912 );
7913 result.unwrap();
7914
7915 let pbo_dst_img = {
7916 let mut __t = pbo_dst.into_u8().unwrap();
7917 __t.set_format(PixelFormat::Rgba).unwrap();
7918 TensorDyn::from(__t)
7919 };
7920 compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
7921 log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
7922 }
7923
7924 #[test]
7925 fn test_image_bgra() {
7926 let img = TensorDyn::image(
7927 640,
7928 480,
7929 PixelFormat::Bgra,
7930 DType::U8,
7931 Some(edgefirst_tensor::TensorMemory::Mem),
7932 )
7933 .unwrap();
7934 assert_eq!(img.width(), Some(640));
7935 assert_eq!(img.height(), Some(480));
7936 assert_eq!(img.format().unwrap().channels(), 4);
7937 assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
7938 }
7939
7940 #[test]
7945 fn test_force_backend_cpu() {
7946 let _lock = acquire_env_lock();
7947 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
7948 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
7949 let converter = ImageProcessor::new().unwrap();
7950 assert!(converter.cpu.is_some());
7951 assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
7952 }
7953
7954 #[test]
7955 fn test_force_backend_invalid() {
7956 let _lock = acquire_env_lock();
7957 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
7958 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
7959 let result = ImageProcessor::new();
7960 assert!(
7961 matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
7962 "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
7963 );
7964 }
7965
7966 #[test]
7967 fn test_force_backend_unset() {
7968 let _lock = acquire_env_lock();
7969 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
7970 unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
7971 let converter = ImageProcessor::new().unwrap();
7972 assert!(converter.forced_backend.is_none());
7973 }
7974
7975 #[test]
7980 fn test_draw_proto_masks_no_cpu_returns_error() {
7981 let _lock = acquire_env_lock();
7983 let _guard = EnvGuard::snapshot(&[
7984 "EDGEFIRST_FORCE_BACKEND",
7985 "EDGEFIRST_DISABLE_GL",
7986 "EDGEFIRST_DISABLE_G2D",
7987 "EDGEFIRST_DISABLE_CPU",
7988 ]);
7989
7990 unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
7992 unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
7993 unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
7994
7995 let mut converter = ImageProcessor::new().unwrap();
7996 assert!(converter.cpu.is_none(), "CPU should be disabled");
7997
7998 let dst = TensorDyn::image(
7999 640,
8000 480,
8001 PixelFormat::Rgba,
8002 DType::U8,
8003 Some(TensorMemory::Mem),
8004 )
8005 .unwrap();
8006 let mut dst_dyn = dst;
8007 let det = [DetectBox {
8008 bbox: edgefirst_decoder::BoundingBox {
8009 xmin: 0.1,
8010 ymin: 0.1,
8011 xmax: 0.5,
8012 ymax: 0.5,
8013 },
8014 score: 0.9,
8015 label: 0,
8016 }];
8017 let proto_data = {
8018 use edgefirst_tensor::{Tensor, TensorDyn};
8019 let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
8020 let protos_t =
8021 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8022 ProtoData {
8023 mask_coefficients: TensorDyn::F32(coeff_t),
8024 protos: TensorDyn::F32(protos_t),
8025 layout: ProtoLayout::Nhwc,
8026 }
8027 };
8028 let result =
8029 converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
8030 assert!(
8031 matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
8032 "draw_proto_masks without CPU should return Internal error: {result:?}"
8033 );
8034 }
8035
8036 #[test]
8037 fn test_draw_proto_masks_cpu_fallback_works() {
8038 let _lock = acquire_env_lock();
8041 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
8042 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
8043 let mut converter = ImageProcessor::new().unwrap();
8044 assert!(converter.cpu.is_some());
8045
8046 let dst = TensorDyn::image(
8047 64,
8048 64,
8049 PixelFormat::Rgba,
8050 DType::U8,
8051 Some(TensorMemory::Mem),
8052 )
8053 .unwrap();
8054 let mut dst_dyn = dst;
8055 let det = [DetectBox {
8056 bbox: edgefirst_decoder::BoundingBox {
8057 xmin: 0.1,
8058 ymin: 0.1,
8059 xmax: 0.5,
8060 ymax: 0.5,
8061 },
8062 score: 0.9,
8063 label: 0,
8064 }];
8065 let proto_data = {
8066 use edgefirst_tensor::{Tensor, TensorDyn};
8067 let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
8068 let protos_t =
8069 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8070 ProtoData {
8071 mask_coefficients: TensorDyn::F32(coeff_t),
8072 protos: TensorDyn::F32(protos_t),
8073 layout: ProtoLayout::Nhwc,
8074 }
8075 };
8076 let result =
8077 converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
8078 assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
8079 }
8080
8081 fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
8113 use std::sync::{Mutex, OnceLock};
8114 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
8115 ENV_MUTEX
8116 .get_or_init(|| Mutex::new(()))
8117 .lock()
8118 .unwrap_or_else(|e| e.into_inner())
8119 }
8120
8121 struct EnvGuard {
8124 vars: Vec<(&'static str, Option<String>)>,
8125 }
8126
8127 impl EnvGuard {
8128 fn snapshot(names: &[&'static str]) -> Self {
8132 Self {
8133 vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
8134 }
8135 }
8136 }
8137
8138 impl Drop for EnvGuard {
8139 fn drop(&mut self) {
8140 for (k, v) in &self.vars {
8141 match v {
8142 Some(s) => unsafe { std::env::set_var(k, s) },
8143 None => unsafe { std::env::remove_var(k) },
8144 }
8145 }
8146 }
8147 }
8148
8149 fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
8153 let _lock = acquire_env_lock();
8154 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
8155 match value {
8156 Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
8157 None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
8158 }
8159 body()
8160 }
8161
8162 fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
8167 let dst = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, mem).unwrap();
8168 {
8169 use edgefirst_tensor::TensorMapTrait;
8170 let u8t = dst.as_u8().unwrap();
8171 let mut map = u8t.map().unwrap();
8172 for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
8173 *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
8174 }
8175 }
8176 dst
8177 }
8178
8179 fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
8181 let bg = TensorDyn::image(w, h, PixelFormat::Rgba, DType::U8, mem).unwrap();
8182 {
8183 use edgefirst_tensor::TensorMapTrait;
8184 let u8t = bg.as_u8().unwrap();
8185 let mut map = u8t.map().unwrap();
8186 for chunk in map.as_mut_slice().chunks_exact_mut(4) {
8187 chunk.copy_from_slice(&rgba);
8188 }
8189 }
8190 bg
8191 }
8192
8193 fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
8194 use edgefirst_tensor::TensorMapTrait;
8195 let w = dst.width().unwrap();
8196 let off = (y * w + x) * 4;
8197 let u8t = dst.as_u8().unwrap();
8198 let map = u8t.map().unwrap();
8199 let s = map.as_slice();
8200 [s[off], s[off + 1], s[off + 2], s[off + 3]]
8201 }
8202
8203 fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
8204 use edgefirst_tensor::TensorMapTrait;
8205 let u8t = dst.as_u8().unwrap();
8206 let map = u8t.map().unwrap();
8207 for (i, chunk) in map.as_slice().chunks_exact(4).enumerate() {
8208 assert_eq!(
8209 chunk, &expected,
8210 "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
8211 );
8212 }
8213 }
8214
8215 fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
8218 let mut dst = make_dirty_dst(64, 64, None);
8219 processor
8220 .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
8221 .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
8222 assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
8223
8224 let mut dst = make_dirty_dst(64, 64, None);
8225 let proto = {
8226 use edgefirst_tensor::{Tensor, TensorDyn};
8227 let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
8229 let protos_t =
8230 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8231 ProtoData {
8232 mask_coefficients: TensorDyn::F32(coeff_t),
8233 protos: TensorDyn::F32(protos_t),
8234 layout: ProtoLayout::Nhwc,
8235 }
8236 };
8237 processor
8238 .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
8239 .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
8240 assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
8241 }
8242
8243 fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
8246 let bg_color = [42, 99, 200, 255];
8247 let bg = make_bg(64, 64, None, bg_color);
8248 let overlay = MaskOverlay::new().with_background(&bg);
8249
8250 let mut dst = make_dirty_dst(64, 64, None);
8251 processor
8252 .draw_decoded_masks(&mut dst, &[], &[], overlay)
8253 .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
8254 assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
8255
8256 let mut dst = make_dirty_dst(64, 64, None);
8257 let proto = {
8258 use edgefirst_tensor::{Tensor, TensorDyn};
8259 let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
8261 let protos_t =
8262 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
8263 ProtoData {
8264 mask_coefficients: TensorDyn::F32(coeff_t),
8265 protos: TensorDyn::F32(protos_t),
8266 layout: ProtoLayout::Nhwc,
8267 }
8268 };
8269 processor
8270 .draw_proto_masks(&mut dst, &[], &proto, overlay)
8271 .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
8272 assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
8273 }
8274
8275 fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
8279 use edgefirst_decoder::Segmentation;
8280 use ndarray::Array3;
8281 processor
8282 .set_class_colors(&[[200, 80, 40, 255]])
8283 .expect("set_class_colors");
8284
8285 let detect = DetectBox {
8286 bbox: [0.25, 0.25, 0.75, 0.75].into(),
8287 score: 0.99,
8288 label: 0,
8289 };
8290 let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
8291 let seg = Segmentation {
8292 segmentation: seg_arr,
8293 xmin: 0.25,
8294 ymin: 0.25,
8295 xmax: 0.75,
8296 ymax: 0.75,
8297 };
8298
8299 let mut dst = make_dirty_dst(64, 64, None);
8300 processor
8301 .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
8302 .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
8303
8304 let corner = pixel_at(&dst, 2, 2);
8306 assert_eq!(
8307 corner,
8308 [0, 0, 0, 0],
8309 "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
8310 );
8311 let center = pixel_at(&dst, 32, 32);
8315 assert!(
8316 center != [0, 0, 0, 0],
8317 "{case}/decoded: center (32,32) was not coloured: {center:?}"
8318 );
8319 }
8320
8321 fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
8324 use edgefirst_decoder::Segmentation;
8325 use ndarray::Array3;
8326 processor
8327 .set_class_colors(&[[200, 80, 40, 255]])
8328 .expect("set_class_colors");
8329 let bg_color = [10, 20, 30, 255];
8330 let bg = make_bg(64, 64, None, bg_color);
8331
8332 let detect = DetectBox {
8333 bbox: [0.25, 0.25, 0.75, 0.75].into(),
8334 score: 0.99,
8335 label: 0,
8336 };
8337 let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
8338 let seg = Segmentation {
8339 segmentation: seg_arr,
8340 xmin: 0.25,
8341 ymin: 0.25,
8342 xmax: 0.75,
8343 ymax: 0.75,
8344 };
8345
8346 let overlay = MaskOverlay::new().with_background(&bg);
8347 let mut dst = make_dirty_dst(64, 64, None);
8348 processor
8349 .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
8350 .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
8351
8352 let corner = pixel_at(&dst, 2, 2);
8354 assert_eq!(
8355 corner, bg_color,
8356 "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
8357 );
8358 let center = pixel_at(&dst, 32, 32);
8361 assert!(
8362 center != bg_color,
8363 "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
8364 );
8365 }
8366
8367 fn run_all_scenarios(
8370 force_backend: Option<&'static str>,
8371 case: &'static str,
8372 require_dma_for_bg: bool,
8373 ) {
8374 if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
8375 eprintln!("SKIPPED: {case} — DMA not available on this host");
8376 return;
8377 }
8378 let processor_result = with_force_backend(force_backend, ImageProcessor::new);
8379 let mut processor = match processor_result {
8380 Ok(p) => p,
8381 Err(e) => {
8382 eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
8383 return;
8384 }
8385 };
8386 scenario_empty_no_bg(&mut processor, case);
8387 scenario_empty_with_bg(&mut processor, case);
8388 scenario_detect_no_bg(&mut processor, case);
8389 scenario_detect_with_bg(&mut processor, case);
8390 }
8391
8392 #[test]
8393 fn test_draw_masks_4_scenarios_cpu() {
8394 run_all_scenarios(Some("cpu"), "cpu", false);
8395 }
8396
8397 #[test]
8398 fn test_draw_masks_4_scenarios_auto() {
8399 run_all_scenarios(None, "auto", false);
8400 }
8401
8402 #[cfg(target_os = "linux")]
8403 #[cfg(feature = "opengl")]
8404 #[test]
8405 fn test_draw_masks_4_scenarios_opengl() {
8406 run_all_scenarios(Some("opengl"), "opengl", false);
8407 }
8408
8409 #[cfg(target_os = "linux")]
8414 #[test]
8415 fn test_draw_masks_zero_detection_g2d_forced() {
8416 if !edgefirst_tensor::is_dma_available() {
8417 eprintln!("SKIPPED: g2d forced — DMA not available on this host");
8418 return;
8419 }
8420 let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
8421 let mut processor = match processor_result {
8422 Ok(p) => p,
8423 Err(e) => {
8424 eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
8425 return;
8426 }
8427 };
8428
8429 let mut dst = TensorDyn::image(
8431 64,
8432 64,
8433 PixelFormat::Rgba,
8434 DType::U8,
8435 Some(TensorMemory::Dma),
8436 )
8437 .unwrap();
8438 {
8439 use edgefirst_tensor::TensorMapTrait;
8440 let u8t = dst.as_u8_mut().unwrap();
8441 let mut map = u8t.map().unwrap();
8442 map.as_mut_slice().fill(0xBB);
8443 }
8444 processor
8445 .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
8446 .expect("g2d empty+no-bg");
8447 assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
8448
8449 let bg_color = [7, 11, 13, 255];
8451 let bg = {
8452 let t = TensorDyn::image(
8453 64,
8454 64,
8455 PixelFormat::Rgba,
8456 DType::U8,
8457 Some(TensorMemory::Dma),
8458 )
8459 .unwrap();
8460 {
8461 use edgefirst_tensor::TensorMapTrait;
8462 let u8t = t.as_u8().unwrap();
8463 let mut map = u8t.map().unwrap();
8464 for chunk in map.as_mut_slice().chunks_exact_mut(4) {
8465 chunk.copy_from_slice(&bg_color);
8466 }
8467 }
8468 t
8469 };
8470 let mut dst = TensorDyn::image(
8471 64,
8472 64,
8473 PixelFormat::Rgba,
8474 DType::U8,
8475 Some(TensorMemory::Dma),
8476 )
8477 .unwrap();
8478 {
8479 use edgefirst_tensor::TensorMapTrait;
8480 let u8t = dst.as_u8_mut().unwrap();
8481 let mut map = u8t.map().unwrap();
8482 map.as_mut_slice().fill(0x55);
8483 }
8484 processor
8485 .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
8486 .expect("g2d empty+bg");
8487 assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
8488
8489 let detect = DetectBox {
8491 bbox: [0.25, 0.25, 0.75, 0.75].into(),
8492 score: 0.9,
8493 label: 0,
8494 };
8495 let mut dst = TensorDyn::image(
8496 64,
8497 64,
8498 PixelFormat::Rgba,
8499 DType::U8,
8500 Some(TensorMemory::Dma),
8501 )
8502 .unwrap();
8503 let err = processor
8504 .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
8505 .expect_err("g2d must reject detect-present draw_decoded_masks");
8506 assert!(
8507 matches!(err, Error::NotImplemented(_)),
8508 "g2d case3 wrong error: {err:?}"
8509 );
8510 }
8511
8512 #[test]
8513 fn test_set_format_then_cpu_convert() {
8514 let _lock = acquire_env_lock();
8517 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
8518 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
8519 let mut processor = ImageProcessor::new().unwrap();
8520
8521 let image = edgefirst_bench::testdata::read("zidane.jpg");
8523 let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
8524
8525 let mut dst =
8527 TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
8528 dst.set_format(PixelFormat::Rgb).unwrap();
8529
8530 processor
8532 .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
8533 .unwrap();
8534
8535 assert_eq!(dst.format(), Some(PixelFormat::Rgb));
8537 assert_eq!(dst.width(), Some(640));
8538 assert_eq!(dst.height(), Some(640));
8539 }
8540
8541 #[test]
8547 fn test_multiple_image_processors_same_thread() {
8548 let _lock = acquire_env_lock();
8551 let mut processors: Vec<ImageProcessor> = (0..4)
8552 .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
8553 .collect();
8554
8555 for proc in &mut processors {
8556 let src = proc
8557 .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
8558 .expect("create src failed");
8559 let mut dst = proc
8560 .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
8561 .expect("create dst failed");
8562 proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
8563 .expect("convert failed");
8564 assert_eq!(dst.width(), Some(64));
8565 assert_eq!(dst.height(), Some(64));
8566 }
8567 }
8568
8569 #[test]
8576 fn test_multiple_image_processors_separate_threads() {
8577 use std::sync::mpsc;
8578 use std::time::Duration;
8579
8580 if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
8590 eprintln!(
8591 "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
8592 GC7000UL concurrent-EGL-teardown double-free \
8593 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
8594 );
8595 return;
8596 }
8597
8598 const TIMEOUT: Duration = Duration::from_secs(60);
8599
8600 let _lock = acquire_env_lock();
8603
8604 let (tx, rx) = mpsc::channel::<()>();
8605
8606 std::thread::spawn(move || {
8607 let handles: Vec<_> = (0..4)
8608 .map(|i| {
8609 std::thread::spawn(move || {
8610 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8611 panic!("ImageProcessor::new() failed on thread {i}: {e}")
8612 });
8613 let src = proc
8614 .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
8615 .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
8616 let mut dst = proc
8617 .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
8618 .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
8619 proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
8620 .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
8621 assert_eq!(dst.width(), Some(64));
8622 assert_eq!(dst.height(), Some(64));
8623 })
8624 })
8625 .collect();
8626
8627 for (i, h) in handles.into_iter().enumerate() {
8628 h.join()
8629 .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
8630 }
8631
8632 let _ = tx.send(());
8633 });
8634
8635 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8636 panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
8637 });
8638 }
8639
8640 #[test]
8647 fn test_image_processors_concurrent_operations() {
8648 use std::sync::{mpsc, Arc, Barrier};
8649 use std::time::Duration;
8650
8651 const N: usize = 4;
8652 const ROUNDS: usize = 10;
8653 const TIMEOUT: Duration = Duration::from_secs(60);
8654
8655 let _lock = acquire_env_lock();
8658
8659 let (tx, rx) = mpsc::channel::<()>();
8660
8661 std::thread::spawn(move || {
8662 let barrier = Arc::new(Barrier::new(N));
8663
8664 let handles: Vec<_> = (0..N)
8665 .map(|i| {
8666 let barrier = Arc::clone(&barrier);
8667 std::thread::spawn(move || {
8668 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8669 panic!("ImageProcessor::new() failed on thread {i}: {e}")
8670 });
8671
8672 barrier.wait();
8674
8675 for round in 0..ROUNDS {
8677 let src = proc
8678 .create_image(128, 128, PixelFormat::Rgb, DType::U8, None)
8679 .unwrap_or_else(|e| {
8680 panic!("create src failed on thread {i} round {round}: {e}")
8681 });
8682 let mut dst = proc
8683 .create_image(64, 64, PixelFormat::Rgb, DType::U8, None)
8684 .unwrap_or_else(|e| {
8685 panic!("create dst failed on thread {i} round {round}: {e}")
8686 });
8687 proc.convert(
8688 &src,
8689 &mut dst,
8690 Rotation::None,
8691 Flip::None,
8692 Crop::default(),
8693 )
8694 .unwrap_or_else(|e| {
8695 panic!("convert failed on thread {i} round {round}: {e}")
8696 });
8697 assert_eq!(dst.width(), Some(64));
8698 assert_eq!(dst.height(), Some(64));
8699 }
8700 })
8701 })
8702 .collect();
8703
8704 for (i, h) in handles.into_iter().enumerate() {
8705 h.join()
8706 .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
8707 }
8708
8709 let _ = tx.send(());
8710 });
8711
8712 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8713 panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
8714 });
8715 }
8716
8717 #[test]
8735 fn test_parallel_processors_unique_outputs() {
8736 use std::sync::{mpsc, Arc, Barrier};
8737 use std::time::Duration;
8738
8739 const N: usize = 4;
8740 const ROUNDS: usize = 25;
8741 const TIMEOUT: Duration = Duration::from_secs(60);
8742
8743 if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
8744 eprintln!(
8745 "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
8746 GC7000UL concurrent-multi-processor driver abort \
8747 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
8748 );
8749 return;
8750 }
8751
8752 let _lock = acquire_env_lock();
8753 let (tx, rx) = mpsc::channel::<()>();
8754
8755 std::thread::spawn(move || {
8756 let barrier = Arc::new(Barrier::new(N));
8757 let handles: Vec<_> = (0..N)
8758 .map(|i| {
8759 let barrier = Arc::clone(&barrier);
8760 std::thread::spawn(move || {
8761 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8762 panic!("ImageProcessor::new() failed on thread {i}: {e}")
8763 });
8764 let (w, h) = (640usize, 480usize);
8766 let mem = if edgefirst_tensor::is_dma_available() {
8767 Some(TensorMemory::Dma)
8768 } else {
8769 Some(TensorMemory::Mem)
8770 };
8771 let src = proc
8772 .create_image(w, h, PixelFormat::Nv12, DType::U8, mem)
8773 .unwrap();
8774 {
8775 let t = src.as_u8().unwrap();
8776 let mut m = t.map().unwrap();
8777 let s = m.as_mut_slice();
8778 for (j, b) in s[..w * h].iter_mut().enumerate() {
8779 *b = ((i * 53 + j) % 200 + 16) as u8;
8780 }
8781 for b in &mut s[w * h..] {
8782 *b = (80 + i * 24) as u8;
8783 }
8784 }
8785 let lb = Crop::letterbox([114, 114, 114, 255]);
8786 let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
8787 let mut dst = proc
8788 .create_image(320, 320, PixelFormat::Rgba, DType::U8, mem)
8789 .unwrap();
8790 proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
8791 .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
8792 let t = dst.as_u8().unwrap();
8793 let m = t.map().unwrap();
8794 m.as_slice().to_vec()
8795 };
8796
8797 let oracle = convert_once(&mut proc);
8798 barrier.wait();
8799 for round in 0..ROUNDS {
8800 let out = convert_once(&mut proc);
8801 let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
8802 assert!(
8803 diffs == 0,
8804 "thread {i} round {round}: {diffs}/{} bytes diverged \
8805 from this processor's own oracle — cross-processor \
8806 GL state leakage under parallel execution",
8807 oracle.len()
8808 );
8809 }
8810 })
8811 })
8812 .collect();
8813
8814 for (i, h) in handles.into_iter().enumerate() {
8815 h.join()
8816 .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
8817 }
8818 let _ = tx.send(());
8819 });
8820
8821 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8822 panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
8823 });
8824 }
8825
8826 #[test]
8835 #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
8836 fn stress_parallel_processors_oracle() {
8837 use std::sync::{mpsc, Arc, Barrier};
8838 use std::time::Duration;
8839
8840 const N: usize = 4;
8841 const ROUNDS: usize = 200;
8842 const TIMEOUT: Duration = Duration::from_secs(600);
8843
8844 let _lock = acquire_env_lock();
8845 let (tx, rx) = mpsc::channel::<()>();
8846
8847 std::thread::spawn(move || {
8848 let barrier = Arc::new(Barrier::new(N));
8849 let handles: Vec<_> = (0..N)
8850 .map(|i| {
8851 let barrier = Arc::clone(&barrier);
8852 std::thread::spawn(move || {
8853 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
8854 panic!("ImageProcessor::new() failed on thread {i}: {e}")
8855 });
8856 let (w, h) = (1280usize, 720usize);
8857 let mem = if edgefirst_tensor::is_dma_available() {
8858 Some(TensorMemory::Dma)
8859 } else {
8860 Some(TensorMemory::Mem)
8861 };
8862
8863 let src = proc
8866 .create_image(w, h, PixelFormat::Nv12, DType::U8, mem)
8867 .unwrap();
8868 {
8869 let t = src.as_u8().unwrap();
8870 let mut m = t.map().unwrap();
8871 let s = m.as_mut_slice();
8872 for (j, b) in s[..w * h].iter_mut().enumerate() {
8873 *b = ((i * 37 + j) % 200 + 16) as u8;
8874 }
8875 for b in &mut s[w * h..] {
8876 *b = (96 + i * 16) as u8;
8877 }
8878 }
8879 let lb = Crop::letterbox([114, 114, 114, 255]);
8880
8881 let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
8882 let mut dst = proc
8883 .create_image(640, 640, PixelFormat::Rgb, DType::U8, mem)
8884 .unwrap();
8885 proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
8886 .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
8887 let t = dst.as_u8().unwrap();
8888 let m = t.map().unwrap();
8889 m.as_slice().to_vec()
8890 };
8891
8892 let oracle = convert_once(&mut proc);
8893 barrier.wait();
8894 for round in 0..ROUNDS {
8895 let out = convert_once(&mut proc);
8896 let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
8897 assert!(
8898 diffs == 0,
8899 "thread {i} round {round}: {diffs}/{} bytes diverged \
8900 from the pre-barrier oracle",
8901 oracle.len()
8902 );
8903 }
8904 })
8905 })
8906 .collect();
8907
8908 for (i, h) in handles.into_iter().enumerate() {
8909 h.join()
8910 .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
8911 }
8912 let _ = tx.send(());
8913 });
8914
8915 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
8916 panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
8917 });
8918 }
8919
8920 #[test]
8933 fn convert_f32_auto_never_errors_non_gl_combo() {
8934 const W: usize = 64;
8935 const H: usize = 64;
8936
8937 let src =
8940 TensorDyn::image(W, H, PixelFormat::Yuyv, DType::U8, Some(TensorMemory::Mem)).unwrap();
8941 {
8942 let mut map = src.as_u8().unwrap().map().unwrap();
8943 let data = map.as_mut_slice();
8944 for chunk in data.chunks_exact_mut(4) {
8945 chunk[0] = 128; chunk[1] = 128; chunk[2] = 160; chunk[3] = 128; }
8950 }
8951
8952 let mut dst =
8953 TensorDyn::image(W, H, PixelFormat::Rgb, DType::F32, Some(TensorMemory::Mem)).unwrap();
8954
8955 let mut proc = ImageProcessor::new().unwrap();
8956 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
8957 assert!(
8958 result.is_ok(),
8959 "auto-chain Yuyv→Rgb F32 must not error: {:?}",
8960 result.err()
8961 );
8962
8963 let map = dst.as_f32().unwrap().map().unwrap();
8965 let floats = map.as_slice();
8966 assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
8967 for (i, &v) in floats.iter().enumerate() {
8968 assert!(
8969 v.is_finite() && (0.0..=1.0).contains(&v),
8970 "output[{i}]={v} is not finite or not in [0,1]"
8971 );
8972 }
8973
8974 let first_non_zero = floats.iter().find(|&&v| v > 0.01);
8978 assert!(
8979 first_non_zero.is_some(),
8980 "all-zero output detected — CPU path likely did not write to the destination buffer"
8981 );
8982 let r0 = floats[0];
8985 assert!(
8986 (r0 - 0.502_f32).abs() < 0.05,
8987 "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
8988 );
8989 }
8990
8991 #[test]
8997 #[allow(clippy::needless_update)]
9003 fn convert_f16_forced_cpu_correct() {
9004 const W: usize = 16;
9005 const H: usize = 16;
9006 const TOL: f32 = 1.0 / 512.0; let src =
9010 TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9011 {
9012 let mut map = src.as_u8().unwrap().map().unwrap();
9013 let data = map.as_mut_slice();
9014 for y in 0..H {
9015 for x in 0..W {
9016 let i = y * W + x;
9017 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;
9021 }
9022 }
9023 }
9024
9025 let mut dst = TensorDyn::image(
9026 W,
9027 H,
9028 PixelFormat::PlanarRgb,
9029 DType::F16,
9030 Some(TensorMemory::Mem),
9031 )
9032 .unwrap();
9033
9034 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
9035 backend: ComputeBackend::Cpu,
9036 ..Default::default()
9037 })
9038 .unwrap();
9039 proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9040 .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
9041
9042 let src_map = src.as_u8().unwrap().map().unwrap();
9043 let src_bytes = src_map.as_slice();
9044 let dst_map = dst.as_f16().unwrap().map().unwrap();
9045 let dst_halfs = dst_map.as_slice();
9046
9047 let plane = W * H;
9048 assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
9049
9050 for y in 0..H {
9051 for x in 0..W {
9052 let i = y * W + x;
9053 let r_exp = src_bytes[i * 4] as f32 / 255.0;
9054 let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
9055 let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
9056
9057 let r_got = dst_halfs[i].to_f32();
9058 let g_got = dst_halfs[plane + i].to_f32();
9059 let b_got = dst_halfs[2 * plane + i].to_f32();
9060
9061 assert!(
9062 (r_got - r_exp).abs() <= TOL,
9063 "R plane ({x},{y}): got {r_got}, expected {r_exp}"
9064 );
9065 assert!(
9066 (g_got - g_exp).abs() <= TOL,
9067 "G plane ({x},{y}): got {g_got}, expected {g_exp}"
9068 );
9069 assert!(
9070 (b_got - b_exp).abs() <= TOL,
9071 "B plane ({x},{y}): got {b_got}, expected {b_exp}"
9072 );
9073
9074 if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
9076 assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
9077 }
9078 }
9079 }
9080 }
9081
9082 #[test]
9090 fn convert_f32_with_rotation_falls_back() {
9091 const W: usize = 16;
9092 const H: usize = 16;
9093
9094 let src =
9096 TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9097 {
9098 let mut map = src.as_u8().unwrap().map().unwrap();
9099 let data = map.as_mut_slice();
9100 for y in 0..H {
9101 for x in 0..W {
9102 let i = y * W + x;
9103 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;
9107 }
9108 }
9109 }
9110
9111 let mut dst = TensorDyn::image(
9113 H, W, PixelFormat::Rgb,
9116 DType::F32,
9117 Some(TensorMemory::Mem),
9118 )
9119 .unwrap();
9120
9121 let mut proc = ImageProcessor::new().unwrap();
9122 let result = proc.convert(
9123 &src,
9124 &mut dst,
9125 Rotation::Clockwise90,
9126 Flip::None,
9127 Crop::default(),
9128 );
9129 assert!(
9130 result.is_ok(),
9131 "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
9132 result.err()
9133 );
9134
9135 let map = dst.as_f32().unwrap().map().unwrap();
9136 let floats = map.as_slice();
9137 assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
9138 for (i, &v) in floats.iter().enumerate() {
9139 assert!(
9140 v.is_finite() && (0.0..=1.0).contains(&v),
9141 "output[{i}]={v} is not finite or not in [0,1]"
9142 );
9143 }
9144 }
9145
9146 #[test]
9153 #[cfg(all(target_os = "linux", feature = "opengl"))]
9154 fn convert_f16_gl_cpu_parity_identity() {
9155 if !is_opengl_available() {
9156 eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
9157 return;
9158 }
9159
9160 const W: usize = 16;
9161 const H: usize = 16;
9162 const TOL: f32 = 1.0 / 256.0; let src =
9166 TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9167 {
9168 let mut map = src.as_u8().unwrap().map().unwrap();
9169 let data = map.as_mut_slice();
9170 for y in 0..H {
9171 for x in 0..W {
9172 let i = y * W + x;
9173 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;
9177 }
9178 }
9179 }
9180
9181 let gl_result = {
9183 let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
9184 backend: ComputeBackend::OpenGl,
9185 ..Default::default()
9186 }) {
9187 Ok(p) => p,
9188 Err(e) => {
9189 eprintln!(
9190 "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
9191 );
9192 return;
9193 }
9194 };
9195
9196 if !gl_proc.supported_render_dtypes().f16 {
9197 eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
9198 return;
9199 }
9200
9201 let mut dst = TensorDyn::image(
9202 W,
9203 H,
9204 PixelFormat::PlanarRgb,
9205 DType::F16,
9206 Some(TensorMemory::Mem),
9207 )
9208 .unwrap();
9209 match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
9210 Ok(()) => dst,
9211 Err(e) => {
9212 eprintln!(
9213 "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
9214 );
9215 return;
9216 }
9217 }
9218 };
9219
9220 let cpu_result = {
9222 let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
9223 backend: ComputeBackend::Cpu,
9224 ..Default::default()
9225 })
9226 .unwrap();
9227 let mut dst = TensorDyn::image(
9228 W,
9229 H,
9230 PixelFormat::PlanarRgb,
9231 DType::F16,
9232 Some(TensorMemory::Mem),
9233 )
9234 .unwrap();
9235 cpu_proc
9236 .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
9237 .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
9238 dst
9239 };
9240
9241 let gl_map = gl_result.as_f16().unwrap().map().unwrap();
9243 let cpu_map = cpu_result.as_f16().unwrap().map().unwrap();
9244 let gl_halfs = gl_map.as_slice();
9245 let cpu_halfs = cpu_map.as_slice();
9246
9247 assert_eq!(
9248 gl_halfs.len(),
9249 cpu_halfs.len(),
9250 "GL and CPU output sizes differ"
9251 );
9252
9253 let plane = W * H;
9254 let channel_names = ["R", "G", "B"];
9255 for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
9256 let gl_v = gl_h.to_f32();
9257 let cpu_v = cpu_h.to_f32();
9258 let err = (gl_v - cpu_v).abs();
9259 let ch = channel_names[idx / plane];
9260 let pixel = idx % plane;
9261 assert!(
9262 err <= TOL,
9263 "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
9264 );
9265 }
9266 }
9267
9268 #[test]
9275 #[cfg(all(target_os = "linux", feature = "opengl"))]
9276 fn supported_render_dtypes_linux_smoke() {
9277 let proc = match ImageProcessor::new() {
9278 Ok(p) => p,
9279 Err(e) => {
9280 eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
9281 return;
9282 }
9283 };
9284 if proc.opengl.is_none() {
9285 eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
9286 return;
9287 }
9288 let support = proc.supported_render_dtypes();
9290 eprintln!(
9291 "supported_render_dtypes_linux_smoke: f16={} f32={}",
9292 support.f16, support.f32
9293 );
9294 }
9296
9297 #[test]
9306 fn convert_f16_pbo_non_4_aligned_width_falls_back() {
9307 const W: usize = 18; const H: usize = 16;
9309
9310 let src =
9312 TensorDyn::image(W, H, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem)).unwrap();
9313 {
9314 let mut map = src.as_u8().unwrap().map().unwrap();
9315 let data = map.as_mut_slice();
9316 for chunk in data.chunks_exact_mut(4) {
9317 chunk[0] = 128;
9318 chunk[1] = 64;
9319 chunk[2] = 200;
9320 chunk[3] = 255;
9321 }
9322 }
9323
9324 let mut dst = TensorDyn::image(
9327 W,
9328 H,
9329 PixelFormat::PlanarRgb,
9330 DType::F16,
9331 Some(TensorMemory::Mem),
9332 )
9333 .unwrap();
9334
9335 let mut proc = ImageProcessor::new().unwrap();
9338 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
9339 assert!(
9340 result.is_ok(),
9341 "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
9342 result.err()
9343 );
9344
9345 let map = dst.as_f16().unwrap().map().unwrap();
9347 let halfs = map.as_slice();
9348 assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
9349 for (i, h) in halfs.iter().enumerate() {
9350 let v = h.to_f32();
9351 assert!(
9352 v.is_finite() && (0.0..=1.0).contains(&v),
9353 "output[{i}]={v} is not finite or not in [0,1]"
9354 );
9355 }
9356 }
9357
9358 #[test]
9368 #[allow(clippy::needless_update)]
9371 fn convert_nv12_to_rgb_f32_cpu() {
9372 const W: usize = 16;
9373 const H: usize = 16; let src =
9377 TensorDyn::image(W, H, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
9378 {
9379 let mut map = src.as_u8().unwrap().map().unwrap();
9380 map.as_mut_slice().fill(128); }
9382
9383 let mut dst =
9384 TensorDyn::image(W, H, PixelFormat::Rgb, DType::F32, Some(TensorMemory::Mem)).unwrap();
9385
9386 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
9387 backend: ComputeBackend::Cpu,
9388 ..Default::default()
9389 })
9390 .unwrap();
9391
9392 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
9393 assert!(
9394 result.is_ok(),
9395 "forced-CPU NV12→Rgb F32 must not error: {:?}",
9396 result.err()
9397 );
9398
9399 let map = dst.as_f32().unwrap().map().unwrap();
9400 let floats = map.as_slice();
9401 assert_eq!(floats.len(), W * H * 3, "unexpected element count");
9402 for (i, &v) in floats.iter().enumerate() {
9403 assert!(
9404 v.is_finite() && (0.0..=1.0).contains(&v),
9405 "output[{i}]={v} is not finite or not in [0,1]"
9406 );
9407 }
9408 let non_zero = floats.iter().any(|&v| v > 0.01);
9410 assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
9411 }
9412
9413 #[test]
9417 #[allow(clippy::needless_update)]
9420 fn convert_nv12_to_planar_rgb_f16_cpu() {
9421 const W: usize = 16;
9422 const H: usize = 16;
9423
9424 let src =
9425 TensorDyn::image(W, H, PixelFormat::Nv12, DType::U8, Some(TensorMemory::Mem)).unwrap();
9426 {
9427 let mut map = src.as_u8().unwrap().map().unwrap();
9428 map.as_mut_slice().fill(128);
9429 }
9430
9431 let mut dst = TensorDyn::image(
9432 W,
9433 H,
9434 PixelFormat::PlanarRgb,
9435 DType::F16,
9436 Some(TensorMemory::Mem),
9437 )
9438 .unwrap();
9439
9440 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
9441 backend: ComputeBackend::Cpu,
9442 ..Default::default()
9443 })
9444 .unwrap();
9445
9446 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
9447 assert!(
9448 result.is_ok(),
9449 "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
9450 result.err()
9451 );
9452
9453 let map = dst.as_f16().unwrap().map().unwrap();
9454 let halfs = map.as_slice();
9455 assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
9456 for (i, h) in halfs.iter().enumerate() {
9457 let v = h.to_f32();
9458 assert!(
9459 v.is_finite() && (0.0..=1.0).contains(&v),
9460 "output[{i}]={v} is not finite or not in [0,1]"
9461 );
9462 }
9463 let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
9464 assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
9465 }
9466
9467 #[test]
9474 #[cfg(target_os = "linux")]
9475 fn create_image_f32_dma_rejected() {
9476 let proc = ImageProcessor::new().unwrap();
9477 let result = proc.create_image(
9478 64,
9479 64,
9480 PixelFormat::Rgb,
9481 DType::F32,
9482 Some(TensorMemory::Dma),
9483 );
9484 assert!(
9485 result.is_err(),
9486 "create_image(F32, Dma) must fail — no DRM fourcc for f32"
9487 );
9488 }
9489
9490 #[test]
9499 #[cfg(target_os = "linux")]
9500 fn import_image_carries_colorimetry() {
9501 use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
9502
9503 let expected = Colorimetry::default()
9504 .with_encoding(ColorEncoding::Bt709)
9505 .with_range(ColorRange::Limited);
9506
9507 if !is_dma_available() {
9508 let mut t =
9511 TensorDyn::image(8, 8, PixelFormat::Rgba, DType::U8, Some(TensorMemory::Mem))
9512 .expect("alloc");
9513 assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
9514 t.set_colorimetry(Some(expected));
9515 assert_eq!(
9516 t.colorimetry(),
9517 Some(expected),
9518 "set_colorimetry must round-trip"
9519 );
9520 eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
9521 return;
9522 }
9523
9524 use edgefirst_tensor::{PlaneDescriptor, Tensor};
9527
9528 let rgba_bytes = 64 * 64 * 4; let dma_tensor =
9530 Tensor::<u8>::new(&[rgba_bytes], Some(TensorMemory::Dma), Some("import_test"))
9531 .expect("dma alloc");
9532 let pd =
9533 PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
9534
9535 let proc = ImageProcessor::new().expect("ImageProcessor");
9536 let result = proc.import_image(
9537 pd,
9538 None,
9539 64,
9540 64,
9541 PixelFormat::Rgba,
9542 DType::U8,
9543 Some(expected),
9544 );
9545 let tensor = result.expect("import_image must succeed on DMA fd");
9546 assert_eq!(
9547 tensor.colorimetry(),
9548 Some(expected),
9549 "import_image must store the supplied colorimetry on the returned TensorDyn"
9550 );
9551 }
9552}