1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
88
89#[cfg(all(coverage, target_os = "linux"))]
93#[used]
94#[link_section = ".init_array"]
95static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
96 extern "C" fn ctor() {
97 edgefirst_tensor::covguard::install();
98 }
99 ctor
100};
101
102pub const GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES: usize = 64;
115
116pub fn align_width_for_gpu_pitch(width: usize, bpp: usize) -> usize {
154 if bpp == 0 || width == 0 {
155 return width;
156 }
157
158 let Some(lcm_alignment) = checked_num_integer_lcm(GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES, bpp)
167 else {
168 log::warn!(
169 "align_width_for_gpu_pitch: lcm({GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES}, {bpp}) \
170 overflows usize, returning unaligned width {width}"
171 );
172 return width;
173 };
174 if lcm_alignment == 0 {
175 return width;
176 }
177
178 debug_assert_eq!(lcm_alignment % bpp, 0);
179 let width_alignment = lcm_alignment / bpp;
180 if width_alignment == 0 {
181 return width;
182 }
183
184 let remainder = width % width_alignment;
185 if remainder == 0 {
186 return width;
187 }
188
189 let pad = width_alignment - remainder;
190 match width.checked_add(pad) {
191 Some(aligned) => aligned,
192 None => {
193 log::warn!(
194 "align_width_for_gpu_pitch: width {width} + pad {pad} overflows usize, \
195 returning unaligned (caller should use a smaller width or pre-aligned size)"
196 );
197 width
198 }
199 }
200}
201
202#[cfg(target_os = "linux")]
211pub(crate) fn align_pitch_bytes_to_gpu_alignment(min_pitch_bytes: usize) -> Option<usize> {
212 let alignment = GPU_DMA_BUF_PITCH_ALIGNMENT_BYTES;
213 if min_pitch_bytes == 0 {
214 return Some(0);
215 }
216 let remainder = min_pitch_bytes % alignment;
217 if remainder == 0 {
218 return Some(min_pitch_bytes);
219 }
220 min_pitch_bytes.checked_add(alignment - remainder)
221}
222
223fn checked_num_integer_lcm(a: usize, b: usize) -> Option<usize> {
226 if a == 0 || b == 0 {
227 return Some(0);
228 }
229 let g = num_integer_gcd(a, b);
230 (a / g).checked_mul(b)
233}
234
235fn num_integer_gcd(a: usize, b: usize) -> usize {
236 if b == 0 {
237 a
238 } else {
239 num_integer_gcd(b, a % b)
240 }
241}
242
243pub fn primary_plane_bpp(format: PixelFormat, elem: usize) -> Option<usize> {
259 use edgefirst_tensor::PixelLayout;
260 match format.layout() {
261 PixelLayout::Packed => Some(format.channels() * elem),
262 PixelLayout::Planar => Some(elem),
263 PixelLayout::SemiPlanar => Some(elem),
267 _ => None,
270 }
271}
272
273#[cfg(all(target_os = "linux", test))]
286pub(crate) fn padded_dma_pitch_for(
287 fmt: PixelFormat,
288 width: usize,
289 memory: &Option<TensorMemory>,
290) -> Option<usize> {
291 match memory {
301 Some(TensorMemory::DmaBuf) => {}
302 None if edgefirst_tensor::is_dma_available() => {}
303 _ => return None,
304 }
305 if fmt.layout() != PixelLayout::Packed {
309 return None;
310 }
311 let bpp = primary_plane_bpp(fmt, 1)?;
312 let natural = width.checked_mul(bpp)?;
313 let aligned = align_pitch_bytes_to_gpu_alignment(natural)?;
314 if aligned > natural {
315 Some(aligned)
316 } else {
317 None
318 }
319}
320
321pub use cpu::CPUProcessor;
322#[cfg(feature = "codec")]
323pub use edgefirst_codec as codec;
324
325#[cfg(test)]
326use edgefirst_tensor::ProtoLayout;
327#[doc(inline)]
328pub use edgefirst_tensor::Region;
329#[cfg(any(
330 test,
331 all(any(target_os = "linux", target_os = "windows"), feature = "opengl")
332))]
333use edgefirst_tensor::Tensor;
334use edgefirst_tensor::{
335 DType, PixelFormat, PixelLayout, TensorDyn, TensorMemory, TensorTrait as _,
336};
337use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
338use enum_dispatch::enum_dispatch;
339pub use error::{Error, Result};
340#[cfg(target_os = "linux")]
341pub use g2d::G2DProcessor;
342#[cfg(all(
343 any(
344 target_os = "linux",
345 target_os = "macos",
346 target_os = "ios",
347 target_os = "android",
348 target_os = "windows"
349 ),
350 feature = "opengl"
351))]
352pub use opengl_headless::EglDisplayKind;
353#[cfg(all(
354 any(
355 target_os = "linux",
356 target_os = "macos",
357 target_os = "ios",
358 target_os = "android",
359 target_os = "windows"
360 ),
361 feature = "opengl"
362))]
363pub use opengl_headless::GLProcessorThreaded;
364#[cfg(all(
365 any(
366 target_os = "linux",
367 target_os = "macos",
368 target_os = "ios",
369 target_os = "android",
370 target_os = "windows"
371 ),
372 feature = "opengl"
373))]
374pub use opengl_headless::Int8InterpolationMode;
375#[cfg(target_os = "linux")]
376#[cfg(feature = "opengl")]
377pub use opengl_headless::{probe_egl_displays, EglDisplayInfo};
378#[cfg(all(
382 any(
383 target_os = "linux",
384 target_os = "macos",
385 target_os = "ios",
386 target_os = "android",
387 target_os = "windows"
388 ),
389 feature = "opengl"
390))]
391pub use opengl_headless::{CacheStats, ConvertStats, GlCacheStats};
392use std::{fmt::Display, time::Instant};
393
394mod colorimetry;
395mod cpu;
396mod error;
397mod g2d;
398#[path = "gl/mod.rs"]
399mod opengl_headless;
400mod tiling;
401pub use tiling::{tile_grid, TilePlacement, TileSpec, TilingConfig};
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum Rotation {
408 None = 0,
409 Clockwise90 = 1,
410 Rotate180 = 2,
411 CounterClockwise90 = 3,
412}
413impl Rotation {
414 pub fn from_degrees_clockwise(angle: usize) -> Rotation {
427 match angle.rem_euclid(360) {
428 0 => Rotation::None,
429 90 => Rotation::Clockwise90,
430 180 => Rotation::Rotate180,
431 270 => Rotation::CounterClockwise90,
432 _ => panic!("rotation angle is not a multiple of 90"),
433 }
434 }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum Flip {
439 None = 0,
440 Vertical = 1,
441 Horizontal = 2,
442}
443
444#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
446pub enum ColorMode {
447 #[default]
452 Class,
453 Instance,
458 Track,
461}
462
463impl ColorMode {
464 #[inline]
466 pub fn index(self, idx: usize, label: usize) -> usize {
467 match self {
468 ColorMode::Class => label,
469 ColorMode::Instance | ColorMode::Track => idx,
470 }
471 }
472}
473
474#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
495pub enum MaskResolution {
496 #[default]
498 Proto,
499 Scaled {
503 width: u32,
505 height: u32,
507 },
508}
509
510#[derive(Debug, Clone, Copy)]
526pub struct MaskOverlay<'a> {
527 pub background: Option<&'a TensorDyn>,
531 pub opacity: f32,
532 pub letterbox: Option<[f32; 4]>,
542 pub color_mode: ColorMode,
543}
544
545impl Default for MaskOverlay<'_> {
546 fn default() -> Self {
547 Self {
548 background: None,
549 opacity: 1.0,
550 letterbox: None,
551 color_mode: ColorMode::Class,
552 }
553 }
554}
555
556impl<'a> MaskOverlay<'a> {
557 pub fn new() -> Self {
558 Self::default()
559 }
560
561 pub fn with_background(mut self, bg: &'a TensorDyn) -> Self {
569 self.background = Some(bg);
570 self
571 }
572
573 pub fn with_opacity(mut self, opacity: f32) -> Self {
574 self.opacity = opacity.clamp(0.0, 1.0);
575 self
576 }
577
578 pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
579 self.color_mode = mode;
580 self
581 }
582
583 pub fn with_letterbox_crop(
593 mut self,
594 crop: &Crop,
595 src_w: usize,
596 src_h: usize,
597 model_w: usize,
598 model_h: usize,
599 ) -> Self {
600 if let Ok(resolved) = crop.resolve(src_w, src_h, model_w, model_h) {
603 if let Some(r) = resolved.dst_rect {
604 self.letterbox = Some([
605 r.left as f32 / model_w as f32,
606 r.top as f32 / model_h as f32,
607 (r.left + r.width) as f32 / model_w as f32,
608 (r.top + r.height) as f32 / model_h as f32,
609 ]);
610 }
611 }
612 self
613 }
614}
615
616fn copy_segmentation(s: &Segmentation, bbox: [f32; 4]) -> Result<Segmentation> {
622 use edgefirst_tensor::TensorMapTrait as _;
623 let src = s
624 .segmentation
625 .as_typed::<u8>()
626 .ok_or_else(|| Error::InvalidShape("segmentation must be a U8 tensor".into()))?;
627 let map = src.map_read()?;
628 let dst = edgefirst_tensor::Tensor::<u8>::new(
635 s.segmentation.shape(),
636 Some(edgefirst_tensor::TensorMemory::Mem),
637 None,
638 )
639 .map_err(|e| Error::InvalidShape(format!("segmentation copy: {e}")))?;
640 {
641 let mut dmap = dst.map_mut()?;
642 dmap.as_mut_slice().copy_from_slice(map.as_slice());
643 }
644 Ok(Segmentation {
645 xmin: bbox[0],
646 ymin: bbox[1],
647 xmax: bbox[2],
648 ymax: bbox[3],
649 segmentation: dst.into(),
650 })
651}
652
653#[inline]
666fn unletter_bbox(bbox: DetectBox, lb: [f32; 4]) -> DetectBox {
667 DetectBox {
668 bbox: edgefirst_tensor::unletter_norm(bbox.bbox, lb),
669 ..bbox
670 }
671}
672
673#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
675pub enum Fit {
676 #[default]
678 Stretch,
679 Letterbox { pad: [u8; 4] },
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
690pub struct Crop {
691 pub source: Option<Region>,
693 pub fit: Fit,
695}
696
697impl Crop {
698 pub fn new() -> Self {
700 Self::default()
701 }
702
703 pub fn no_crop() -> Self {
705 Self::default()
706 }
707
708 pub fn letterbox(pad: [u8; 4]) -> Self {
711 Self {
712 source: None,
713 fit: Fit::Letterbox { pad },
714 }
715 }
716
717 pub fn with_source(mut self, source: Option<Region>) -> Self {
719 self.source = source;
720 self
721 }
722
723 pub fn with_fit(mut self, fit: Fit) -> Self {
725 self.fit = fit;
726 self
727 }
728
729 pub(crate) fn resolve(
735 &self,
736 src_w: usize,
737 src_h: usize,
738 dst_w: usize,
739 dst_h: usize,
740 ) -> Result<ResolvedCrop, Error> {
741 let src_rect = self.source.map(region_to_rect);
742 let (sw, sh) = match self.source {
745 Some(r) => (r.width, r.height),
746 None => (src_w, src_h),
747 };
748 let resolved = match self.fit {
749 Fit::Stretch => ResolvedCrop {
750 src_rect,
751 dst_rect: None,
752 dst_color: None,
753 },
754 Fit::Letterbox { pad } => ResolvedCrop {
755 src_rect,
756 dst_rect: Some(letterbox_rect(sw, sh, dst_w, dst_h)),
757 dst_color: Some(pad),
758 },
759 };
760 resolved.check_crop_dims(src_w, src_h, dst_w, dst_h)?;
761 Ok(resolved)
762 }
763
764 pub fn check_crop_dyn(
766 &self,
767 src: &edgefirst_tensor::TensorDyn,
768 dst: &edgefirst_tensor::TensorDyn,
769 ) -> Result<(), Error> {
770 self.resolve(
771 src.width().unwrap_or(0),
772 src.height().unwrap_or(0),
773 dst.width().unwrap_or(0),
774 dst.height().unwrap_or(0),
775 )
776 .map(|_| ())
777 }
778}
779
780#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
784pub(crate) struct ResolvedCrop {
785 pub(crate) src_rect: Option<Rect>,
786 pub(crate) dst_rect: Option<Rect>,
787 pub(crate) dst_color: Option<[u8; 4]>,
788}
789
790impl ResolvedCrop {
791 #[allow(dead_code)] pub(crate) fn no_crop() -> Self {
794 Self::default()
795 }
796
797 pub(crate) fn check_crop_dims(
799 &self,
800 src_w: usize,
801 src_h: usize,
802 dst_w: usize,
803 dst_h: usize,
804 ) -> Result<(), Error> {
805 let src_ok = self
806 .src_rect
807 .is_none_or(|r| r.left + r.width <= src_w && r.top + r.height <= src_h);
808 let dst_ok = self
809 .dst_rect
810 .is_none_or(|r| r.left + r.width <= dst_w && r.top + r.height <= dst_h);
811 match (src_ok, dst_ok) {
812 (true, true) => Ok(()),
813 (true, false) => Err(Error::CropInvalid(format!(
814 "Dest crop invalid: {:?}",
815 self.dst_rect
816 ))),
817 (false, true) => Err(Error::CropInvalid(format!(
818 "Src crop invalid: {:?}",
819 self.src_rect
820 ))),
821 (false, false) => Err(Error::CropInvalid(format!(
822 "Dest and Src crop invalid: {:?} {:?}",
823 self.dst_rect, self.src_rect
824 ))),
825 }
826 }
827}
828
829fn region_to_rect(r: Region) -> Rect {
831 Rect {
832 left: r.x,
833 top: r.y,
834 width: r.width,
835 height: r.height,
836 }
837}
838
839fn letterbox_rect(sw: usize, sh: usize, dw: usize, dh: usize) -> Rect {
843 if sw == 0 || sh == 0 {
844 return Rect::new(0, 0, dw, dh);
845 }
846 let src_aspect = sw as f64 / sh as f64;
847 let dst_aspect = dw as f64 / dh as f64;
848 let (new_w, new_h) = if src_aspect > dst_aspect {
849 (dw, ((dw as f64 / src_aspect).round() as usize).max(1))
850 } else {
851 (((dh as f64 * src_aspect).round() as usize).max(1), dh)
852 };
853 let left = dw.saturating_sub(new_w) / 2;
854 let top = dh.saturating_sub(new_h) / 2;
855 Rect::new(left, top, new_w, new_h)
856}
857
858#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub(crate) struct Rect {
864 pub left: usize,
865 pub top: usize,
866 pub width: usize,
867 pub height: usize,
868}
869
870impl Rect {
871 pub fn new(left: usize, top: usize, width: usize, height: usize) -> Self {
873 Self {
874 left,
875 top,
876 width,
877 height,
878 }
879 }
880}
881
882#[enum_dispatch(ImageProcessor)]
883pub trait ImageProcessorTrait {
884 fn convert(
921 &mut self,
922 src: &TensorDyn,
923 dst: &mut TensorDyn,
924 rotation: Rotation,
925 flip: Flip,
926 crop: Crop,
927 ) -> Result<()>;
928
929 fn draw_decoded_masks(
986 &mut self,
987 dst: &mut TensorDyn,
988 detect: &[DetectBox],
989 segmentation: &[Segmentation],
990 overlay: MaskOverlay<'_>,
991 ) -> Result<()>;
992
993 fn draw_proto_masks(
1013 &mut self,
1014 dst: &mut TensorDyn,
1015 detect: &[DetectBox],
1016 proto_data: &ProtoData,
1017 overlay: MaskOverlay<'_>,
1018 ) -> Result<()>;
1019
1020 fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()>;
1023
1024 fn convert_deferred(
1041 &mut self,
1042 src: &TensorDyn,
1043 dst: &mut TensorDyn,
1044 rotation: Rotation,
1045 flip: Flip,
1046 crop: Crop,
1047 ) -> Result<()> {
1048 self.convert(src, dst, rotation, flip, crop)
1049 }
1050
1051 fn flush(&mut self) -> Result<()> {
1058 Ok(())
1059 }
1060}
1061
1062#[derive(Debug, Clone, Default)]
1068pub struct ImageProcessorConfig {
1069 #[cfg(all(
1080 any(
1081 target_os = "linux",
1082 target_os = "macos",
1083 target_os = "ios",
1084 target_os = "android",
1085 target_os = "windows"
1086 ),
1087 feature = "opengl"
1088 ))]
1089 pub egl_display: Option<EglDisplayKind>,
1090
1091 pub backend: ComputeBackend,
1103
1104 pub colorimetry: ColorimetryMode,
1109
1110 pub egl_cache_capacity: Option<usize>,
1166}
1167
1168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1183pub enum ColorimetryMode {
1184 #[default]
1188 Fast,
1189 Exact,
1192}
1193
1194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1201pub enum ComputeBackend {
1202 #[default]
1204 Auto,
1205 Cpu,
1207 G2d,
1209 OpenGl,
1211}
1212
1213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219pub(crate) enum ForcedBackend {
1220 Cpu,
1221 G2d,
1222 OpenGl,
1223}
1224
1225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1246pub struct RenderDtypeSupport {
1247 pub f32: bool,
1252 pub f16: bool,
1258}
1259
1260#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
1274pub(crate) fn float_pbo_eligible(dtype: DType, support: RenderDtypeSupport) -> bool {
1275 match dtype {
1276 DType::F16 => support.f16,
1277 DType::F32 => support.f32,
1278 _ => false,
1279 }
1280}
1281
1282#[derive(Debug)]
1285pub struct ImageProcessor {
1286 pub cpu: Option<CPUProcessor>,
1289
1290 #[cfg(target_os = "linux")]
1291 pub g2d: Option<G2DProcessor>,
1295 #[cfg(target_os = "linux")]
1296 #[cfg(feature = "opengl")]
1297 pub opengl: Option<GLProcessorThreaded>,
1301 #[cfg(any(
1302 target_os = "macos",
1303 target_os = "ios",
1304 target_os = "android",
1305 target_os = "windows"
1306 ))]
1307 #[cfg(feature = "opengl")]
1308 pub opengl: Option<GLProcessorThreaded>,
1317
1318 pub(crate) forced_backend: Option<ForcedBackend>,
1320
1321 pub(crate) convert_fallbacks: std::sync::atomic::AtomicU64,
1327}
1328
1329unsafe impl Send for ImageProcessor {}
1330unsafe impl Sync for ImageProcessor {}
1331
1332impl ImageProcessor {
1333 pub fn new() -> Result<Self> {
1359 Self::with_config(ImageProcessorConfig::default())
1360 }
1361
1362 pub fn convert_fallback_count(&self) -> u64 {
1369 self.convert_fallbacks
1370 .load(std::sync::atomic::Ordering::Relaxed)
1371 }
1372
1373 pub fn compression_fallback_count(&self) -> u64 {
1382 edgefirst_tensor::compression_fallback_count()
1383 }
1384
1385 #[cfg(unix)]
1399 pub fn convert_with_fence(
1400 &mut self,
1401 src: &TensorDyn,
1402 dst: &mut TensorDyn,
1403 rotation: Rotation,
1404 flip: Flip,
1405 crop: Crop,
1406 ) -> Result<Option<std::os::fd::OwnedFd>> {
1407 #[cfg(any(
1408 target_os = "linux",
1409 target_os = "macos",
1410 target_os = "ios",
1411 target_os = "android",
1412 target_os = "windows"
1413 ))]
1414 #[cfg(feature = "opengl")]
1415 {
1416 let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
1417 if self.forced_backend.is_none() || gl_forced {
1418 if let Some(opengl) = self.opengl.as_mut() {
1419 match opengl.convert_with_fence(src, dst, rotation, flip, crop) {
1420 Ok(fd) => return Ok(fd),
1421 Err(e) if gl_forced => return Err(e),
1422 Err(e) => {
1423 log::debug!(
1427 "convert_with_fence: opengl declined, \
1428 falling back to the blocking chain: {e}"
1429 );
1430 }
1431 }
1432 } else if gl_forced {
1433 return Err(Error::ForcedBackendUnavailable("opengl".into()));
1434 }
1435 }
1436 }
1437 self.convert(src, dst, rotation, flip, crop)?;
1440 Ok(None)
1441 }
1442
1443 pub fn supported_render_dtypes(&self) -> RenderDtypeSupport {
1456 #[cfg(all(
1457 any(
1458 target_os = "macos",
1459 target_os = "ios",
1460 target_os = "android",
1461 target_os = "windows"
1462 ),
1463 feature = "opengl"
1464 ))]
1465 if let Some(gl) = self.opengl.as_ref() {
1466 return gl.supported_render_dtypes();
1467 }
1468 #[cfg(all(target_os = "linux", feature = "opengl"))]
1469 if let Some(gl) = self.opengl.as_ref() {
1470 return gl.supported_render_dtypes();
1471 }
1472 RenderDtypeSupport {
1473 f32: false,
1474 f16: false,
1475 }
1476 }
1477
1478 #[allow(unused_variables)]
1487 pub fn with_config(config: ImageProcessorConfig) -> Result<Self> {
1488 match config.backend {
1492 ComputeBackend::Cpu => {
1493 log::info!("ComputeBackend::Cpu — CPU only");
1494 return Ok(Self {
1495 cpu: Some(CPUProcessor::new()),
1496 #[cfg(target_os = "linux")]
1497 g2d: None,
1498 #[cfg(target_os = "linux")]
1499 #[cfg(feature = "opengl")]
1500 opengl: None,
1501 #[cfg(any(
1502 target_os = "macos",
1503 target_os = "ios",
1504 target_os = "android",
1505 target_os = "windows"
1506 ))]
1507 #[cfg(feature = "opengl")]
1508 opengl: None,
1509 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1510 forced_backend: None,
1511 });
1512 }
1513 ComputeBackend::G2d => {
1514 log::info!("ComputeBackend::G2d — G2D + CPU fallback");
1515 #[cfg(target_os = "linux")]
1516 {
1517 let g2d = match G2DProcessor::new() {
1518 Ok(g) => Some(g),
1519 Err(e) => {
1520 log::warn!("G2D requested but failed to initialize: {e:?}");
1521 None
1522 }
1523 };
1524 return Ok(Self {
1525 cpu: Some(CPUProcessor::new()),
1526 g2d,
1527 #[cfg(feature = "opengl")]
1528 opengl: None,
1529 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1530 forced_backend: None,
1531 });
1532 }
1533 #[cfg(not(target_os = "linux"))]
1534 {
1535 log::warn!("G2D requested but not available on this platform, using CPU");
1536 return Ok(Self {
1537 cpu: Some(CPUProcessor::new()),
1538 #[cfg(any(
1539 target_os = "macos",
1540 target_os = "ios",
1541 target_os = "android",
1542 target_os = "windows"
1543 ))]
1544 #[cfg(feature = "opengl")]
1545 opengl: None,
1546 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1547 forced_backend: None,
1548 });
1549 }
1550 }
1551 ComputeBackend::OpenGl => {
1552 log::info!("ComputeBackend::OpenGl — OpenGL + CPU fallback");
1553 #[cfg(target_os = "linux")]
1554 {
1555 #[cfg(feature = "opengl")]
1556 let opengl = match GLProcessorThreaded::with_cache_capacity(
1557 config.egl_display,
1558 config.egl_cache_capacity,
1559 ) {
1560 Ok(gl) => Some(gl),
1561 Err(e) => {
1562 log::warn!("OpenGL requested but failed to initialize: {e:?}");
1563 None
1564 }
1565 };
1566 return Ok(Self {
1567 cpu: Some(CPUProcessor::new()),
1568 g2d: None,
1569 #[cfg(feature = "opengl")]
1570 opengl,
1571 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1572 forced_backend: None,
1573 }
1574 .apply_colorimetry_mode(config.colorimetry));
1575 }
1576 #[cfg(any(target_os = "macos", target_os = "ios"))]
1577 {
1578 #[cfg(feature = "opengl")]
1579 let opengl = match GLProcessorThreaded::with_cache_capacity(
1580 config.egl_display,
1581 config.egl_cache_capacity,
1582 ) {
1583 Ok(gl) => Some(gl),
1584 Err(e) => {
1585 log::warn!(
1586 "OpenGL requested on macOS but ANGLE init failed: {e:?} \
1587 (install ANGLE via `brew install startergo/angle/angle` \
1588 and re-sign the dylibs — see README.md § macOS GPU \
1589 Acceleration). Falling back to CPU."
1590 );
1591 None
1592 }
1593 };
1594 return Ok(Self {
1595 cpu: Some(CPUProcessor::new()),
1596 #[cfg(feature = "opengl")]
1597 opengl,
1598 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1599 forced_backend: None,
1600 }
1601 .apply_colorimetry_mode(config.colorimetry));
1602 }
1603 #[cfg(target_os = "android")]
1604 {
1605 #[cfg(feature = "opengl")]
1606 let opengl = match GLProcessorThreaded::with_cache_capacity(
1607 config.egl_display,
1608 config.egl_cache_capacity,
1609 ) {
1610 Ok(gl) => Some(gl),
1611 Err(e) => {
1612 log::warn!(
1613 "OpenGL requested but native EGL init failed: {e:?}. \
1614 Falling back to CPU."
1615 );
1616 None
1617 }
1618 };
1619 return Ok(Self {
1620 cpu: Some(CPUProcessor::new()),
1621 #[cfg(feature = "opengl")]
1622 opengl,
1623 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1624 forced_backend: None,
1625 }
1626 .apply_colorimetry_mode(config.colorimetry));
1627 }
1628 #[cfg(target_os = "windows")]
1629 {
1630 #[cfg(feature = "opengl")]
1631 let opengl = match GLProcessorThreaded::with_cache_capacity(
1632 config.egl_display,
1633 config.egl_cache_capacity,
1634 ) {
1635 Ok(gl) => Some(gl),
1636 Err(e) => {
1637 log::warn!(
1638 "OpenGL requested on Windows but ANGLE/D3D11 init failed: {e:?} \
1639 (fetch ANGLE with `scripts/fetch-angle.sh` and set \
1640 EDGEFIRST_ANGLE_PATH to the directory containing libEGL.dll + \
1641 libGLESv2.dll, or place the two DLLs next to the executable — \
1642 see README.md § Windows GPU Acceleration). Falling back to CPU."
1643 );
1644 None
1645 }
1646 };
1647 return Ok(Self {
1648 cpu: Some(CPUProcessor::new()),
1649 #[cfg(feature = "opengl")]
1650 opengl,
1651 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1652 forced_backend: None,
1653 }
1654 .apply_colorimetry_mode(config.colorimetry));
1655 }
1656 #[cfg(not(any(
1657 target_os = "linux",
1658 target_os = "macos",
1659 target_os = "ios",
1660 target_os = "android",
1661 target_os = "windows"
1662 )))]
1663 {
1664 log::warn!("OpenGL requested but not available on this platform, using CPU");
1665 return Ok(Self {
1666 cpu: Some(CPUProcessor::new()),
1667 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1668 forced_backend: None,
1669 });
1670 }
1671 }
1672 ComputeBackend::Auto => { }
1673 }
1674
1675 if let Ok(val) = std::env::var("EDGEFIRST_FORCE_BACKEND") {
1680 let val_lower = val.to_lowercase();
1681 let forced = match val_lower.as_str() {
1682 "cpu" => ForcedBackend::Cpu,
1683 "g2d" => ForcedBackend::G2d,
1684 "opengl" => ForcedBackend::OpenGl,
1685 other => {
1686 return Err(Error::ForcedBackendUnavailable(format!(
1687 "unknown EDGEFIRST_FORCE_BACKEND value: {other:?} (expected cpu, g2d, or opengl)"
1688 )));
1689 }
1690 };
1691
1692 log::info!("EDGEFIRST_FORCE_BACKEND={val} — only initializing {val_lower} backend");
1693
1694 return match forced {
1695 ForcedBackend::Cpu => Ok(Self {
1696 cpu: Some(CPUProcessor::new()),
1697 #[cfg(target_os = "linux")]
1698 g2d: None,
1699 #[cfg(target_os = "linux")]
1700 #[cfg(feature = "opengl")]
1701 opengl: None,
1702 #[cfg(any(
1703 target_os = "macos",
1704 target_os = "ios",
1705 target_os = "android",
1706 target_os = "windows"
1707 ))]
1708 #[cfg(feature = "opengl")]
1709 opengl: None,
1710 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1711 forced_backend: Some(ForcedBackend::Cpu),
1712 }),
1713 ForcedBackend::G2d => {
1714 #[cfg(target_os = "linux")]
1715 {
1716 let g2d = G2DProcessor::new().map_err(|e| {
1717 Error::ForcedBackendUnavailable(format!(
1718 "g2d forced but failed to initialize: {e:?}"
1719 ))
1720 })?;
1721 Ok(Self {
1722 cpu: None,
1723 g2d: Some(g2d),
1724 #[cfg(feature = "opengl")]
1725 opengl: None,
1726 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1727 forced_backend: Some(ForcedBackend::G2d),
1728 })
1729 }
1730 #[cfg(not(target_os = "linux"))]
1731 {
1732 Err(Error::ForcedBackendUnavailable(
1733 "g2d backend is only available on Linux".into(),
1734 ))
1735 }
1736 }
1737 ForcedBackend::OpenGl => {
1738 #[cfg(target_os = "linux")]
1739 #[cfg(feature = "opengl")]
1740 {
1741 let opengl = GLProcessorThreaded::with_cache_capacity(
1742 config.egl_display,
1743 config.egl_cache_capacity,
1744 )
1745 .map_err(|e| {
1746 Error::ForcedBackendUnavailable(format!(
1747 "opengl forced but failed to initialize: {e:?}"
1748 ))
1749 })?;
1750 Ok(Self {
1751 cpu: None,
1752 g2d: None,
1753 opengl: Some(opengl),
1754 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1755 forced_backend: Some(ForcedBackend::OpenGl),
1756 }
1757 .apply_colorimetry_mode(config.colorimetry))
1758 }
1759 #[cfg(any(target_os = "macos", target_os = "ios"))]
1760 #[cfg(feature = "opengl")]
1761 {
1762 let opengl = GLProcessorThreaded::with_cache_capacity(
1763 config.egl_display,
1764 config.egl_cache_capacity,
1765 )
1766 .map_err(|e| {
1767 Error::ForcedBackendUnavailable(format!(
1768 "opengl forced on macOS but ANGLE init failed: {e:?}"
1769 ))
1770 })?;
1771 Ok(Self {
1772 cpu: None,
1773 opengl: Some(opengl),
1774 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1775 forced_backend: Some(ForcedBackend::OpenGl),
1776 }
1777 .apply_colorimetry_mode(config.colorimetry))
1778 }
1779 #[cfg(target_os = "android")]
1780 #[cfg(feature = "opengl")]
1781 {
1782 let opengl = GLProcessorThreaded::with_cache_capacity(
1783 config.egl_display,
1784 config.egl_cache_capacity,
1785 )
1786 .map_err(|e| {
1787 Error::ForcedBackendUnavailable(format!(
1788 "opengl forced but native EGL init failed: {e:?}"
1789 ))
1790 })?;
1791 Ok(Self {
1792 cpu: None,
1793 opengl: Some(opengl),
1794 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1795 forced_backend: Some(ForcedBackend::OpenGl),
1796 }
1797 .apply_colorimetry_mode(config.colorimetry))
1798 }
1799 #[cfg(target_os = "windows")]
1800 #[cfg(feature = "opengl")]
1801 {
1802 let opengl = GLProcessorThreaded::with_cache_capacity(
1803 config.egl_display,
1804 config.egl_cache_capacity,
1805 )
1806 .map_err(|e| {
1807 Error::ForcedBackendUnavailable(format!(
1808 "opengl forced on Windows but ANGLE/D3D11 init failed: {e:?}"
1809 ))
1810 })?;
1811 Ok(Self {
1812 cpu: None,
1813 opengl: Some(opengl),
1814 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1815 forced_backend: Some(ForcedBackend::OpenGl),
1816 }
1817 .apply_colorimetry_mode(config.colorimetry))
1818 }
1819 #[cfg(not(all(
1820 any(
1821 target_os = "linux",
1822 target_os = "macos",
1823 target_os = "ios",
1824 target_os = "android",
1825 target_os = "windows"
1826 ),
1827 feature = "opengl"
1828 )))]
1829 {
1830 Err(Error::ForcedBackendUnavailable(
1831 "opengl backend requires Linux, macOS, Android or Windows with the \
1832 'opengl' feature enabled"
1833 .into(),
1834 ))
1835 }
1836 }
1837 };
1838 }
1839
1840 #[cfg(target_os = "linux")]
1842 let g2d = if std::env::var("EDGEFIRST_DISABLE_G2D")
1843 .map(|x| x != "0" && x.to_lowercase() != "false")
1844 .unwrap_or(false)
1845 {
1846 log::debug!("EDGEFIRST_DISABLE_G2D is set");
1847 None
1848 } else {
1849 match G2DProcessor::new() {
1850 Ok(g2d_converter) => Some(g2d_converter),
1851 Err(err) => {
1852 log::warn!("Failed to initialize G2D converter: {err:?}");
1853 None
1854 }
1855 }
1856 };
1857
1858 #[cfg(target_os = "linux")]
1859 #[cfg(feature = "opengl")]
1860 let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1861 .map(|x| x != "0" && x.to_lowercase() != "false")
1862 .unwrap_or(false)
1863 {
1864 log::debug!("EDGEFIRST_DISABLE_GL is set");
1865 None
1866 } else {
1867 match GLProcessorThreaded::with_cache_capacity(
1868 config.egl_display,
1869 config.egl_cache_capacity,
1870 ) {
1871 Ok(gl_converter) => Some(gl_converter),
1872 Err(err) => {
1873 log::warn!("Failed to initialize GL converter: {err:?}");
1874 None
1875 }
1876 }
1877 };
1878
1879 #[cfg(any(
1880 target_os = "macos",
1881 target_os = "ios",
1882 target_os = "android",
1883 target_os = "windows"
1884 ))]
1885 #[cfg(feature = "opengl")]
1886 let opengl = if std::env::var("EDGEFIRST_DISABLE_GL")
1887 .map(|x| x != "0" && x.to_lowercase() != "false")
1888 .unwrap_or(false)
1889 {
1890 log::debug!("EDGEFIRST_DISABLE_GL is set");
1891 None
1892 } else {
1893 match GLProcessorThreaded::with_cache_capacity(
1894 config.egl_display,
1895 config.egl_cache_capacity,
1896 ) {
1897 Ok(gl_converter) => Some(gl_converter),
1898 Err(err) => {
1899 log::debug!(
1900 "GL backend unavailable: {err:?} \
1901 (CPU fallback will be used)"
1902 );
1903 None
1904 }
1905 }
1906 };
1907
1908 let cpu = if std::env::var("EDGEFIRST_DISABLE_CPU")
1909 .map(|x| x != "0" && x.to_lowercase() != "false")
1910 .unwrap_or(false)
1911 {
1912 log::debug!("EDGEFIRST_DISABLE_CPU is set");
1913 None
1914 } else {
1915 Some(CPUProcessor::new())
1916 };
1917 Ok(Self {
1918 cpu,
1919 #[cfg(target_os = "linux")]
1920 g2d,
1921 #[cfg(target_os = "linux")]
1922 #[cfg(feature = "opengl")]
1923 opengl,
1924 #[cfg(any(
1925 target_os = "macos",
1926 target_os = "ios",
1927 target_os = "android",
1928 target_os = "windows"
1929 ))]
1930 #[cfg(feature = "opengl")]
1931 opengl,
1932 convert_fallbacks: std::sync::atomic::AtomicU64::new(0),
1933 forced_backend: None,
1934 }
1935 .apply_colorimetry_mode(config.colorimetry))
1936 }
1937
1938 fn apply_colorimetry_mode(self, _mode: ColorimetryMode) -> Self {
1942 #[cfg(all(
1943 any(
1944 target_os = "linux",
1945 target_os = "macos",
1946 target_os = "ios",
1947 target_os = "android",
1948 target_os = "windows"
1949 ),
1950 feature = "opengl"
1951 ))]
1952 {
1953 let mut me = self;
1954 if let Err(e) = me.set_colorimetry_mode(_mode) {
1955 log::warn!("Failed to apply ColorimetryMode::{_mode:?}: {e:?}");
1956 }
1957 me
1958 }
1959 #[cfg(not(all(
1960 any(
1961 target_os = "linux",
1962 target_os = "macos",
1963 target_os = "ios",
1964 target_os = "android",
1965 target_os = "windows"
1966 ),
1967 feature = "opengl"
1968 )))]
1969 {
1970 let _ = _mode;
1971 self
1972 }
1973 }
1974
1975 #[cfg(all(
1980 any(
1981 target_os = "linux",
1982 target_os = "macos",
1983 target_os = "ios",
1984 target_os = "android",
1985 target_os = "windows"
1986 ),
1987 feature = "opengl"
1988 ))]
1989 pub fn set_colorimetry_mode(&mut self, mode: ColorimetryMode) -> Result<()> {
1990 if let Some(ref mut gl) = self.opengl {
1991 gl.set_colorimetry_mode(mode)?;
1992 }
1993 Ok(())
1994 }
1995
1996 #[cfg(all(
1999 any(
2000 target_os = "linux",
2001 target_os = "macos",
2002 target_os = "ios",
2003 target_os = "android",
2004 target_os = "windows"
2005 ),
2006 feature = "opengl"
2007 ))]
2008 pub fn set_int8_interpolation_mode(&mut self, mode: Int8InterpolationMode) -> Result<()> {
2009 if let Some(ref mut gl) = self.opengl {
2010 gl.set_int8_interpolation_mode(mode)?;
2011 }
2012 Ok(())
2013 }
2014
2015 pub fn create_image_desc(&self, desc: &edgefirst_tensor::ImageDesc) -> Result<TensorDyn> {
2026 if desc.compression().is_none() {
2027 return self.create_image(
2028 desc.width(),
2029 desc.height(),
2030 desc.format(),
2031 desc.dtype(),
2032 desc.memory(),
2033 desc.access(),
2034 );
2035 }
2036 Ok(TensorDyn::image_desc(desc)?)
2037 }
2038
2039 pub fn create_image(
2130 &self,
2131 width: usize,
2132 height: usize,
2133 format: PixelFormat,
2134 dtype: DType,
2135 memory: Option<TensorMemory>,
2136 access: edgefirst_tensor::CpuAccess,
2137 ) -> Result<TensorDyn> {
2138 #[cfg(target_os = "linux")]
2149 let dma_stride_bytes: Option<usize> = primary_plane_bpp(format, dtype.size())
2150 .and_then(|bpp| width.checked_mul(bpp))
2151 .and_then(align_pitch_bytes_to_gpu_alignment);
2152
2153 #[cfg(target_os = "linux")]
2157 let try_dma = || -> Result<TensorDyn> {
2158 let packed = format.layout() == edgefirst_tensor::PixelLayout::Packed;
2166 match dma_stride_bytes {
2167 Some(stride)
2168 if packed
2169 && primary_plane_bpp(format, dtype.size())
2170 .and_then(|bpp| width.checked_mul(bpp))
2171 .is_some_and(|natural| stride > natural) =>
2172 {
2173 log::debug!(
2174 "create_image: padding row stride for {format:?} {width}x{height} \
2175 from natural pitch to {stride} bytes for GPU alignment"
2176 );
2177 Ok(TensorDyn::image_with_stride(
2178 width,
2179 height,
2180 format,
2181 dtype,
2182 stride,
2183 Some(edgefirst_tensor::TensorMemory::DmaBuf),
2184 access,
2185 )?)
2186 }
2187 _ => Ok(TensorDyn::image(
2188 width,
2189 height,
2190 format,
2191 dtype,
2192 Some(edgefirst_tensor::TensorMemory::DmaBuf),
2193 access,
2194 )?),
2195 }
2196 };
2197
2198 match memory {
2205 #[cfg(target_os = "linux")]
2206 Some(TensorMemory::DmaBuf) => {
2207 if dtype == DType::F32 {
2209 return Err(Error::NotSupported(
2210 "F32 has no 32-bit-float DRM format for DMA-BUF; \
2211 use TensorMemory::Pbo for F32"
2212 .to_string(),
2213 ));
2214 }
2215 return try_dma();
2216 }
2217 Some(mem) => {
2218 return Ok(TensorDyn::image(
2219 width,
2220 height,
2221 format,
2222 dtype,
2223 Some(mem),
2224 access,
2225 )?);
2226 }
2227 None => {}
2228 }
2229
2230 #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
2239 #[cfg(feature = "opengl")]
2240 if let Some(gl) = self.opengl.as_ref() {
2241 let _ = gl; match TensorDyn::image(
2243 width,
2244 height,
2245 format,
2246 dtype,
2247 Some(edgefirst_tensor::TensorMemory::DmaBuf),
2248 access,
2249 ) {
2250 Ok(img) => return Ok(img),
2251 Err(e) => {
2252 log::debug!(
2256 "create_image: zero-copy Dma allocation declined \
2257 ({format:?}/{dtype:?} {width}x{height}): {e:?}; using fallback storage"
2258 );
2259 }
2260 }
2261 }
2262
2263 #[cfg(target_os = "linux")]
2266 {
2267 #[cfg(feature = "opengl")]
2268 let gl_uses_pbo = self
2269 .opengl
2270 .as_ref()
2271 .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
2272 #[cfg(not(feature = "opengl"))]
2273 let gl_uses_pbo = false;
2274
2275 if !gl_uses_pbo {
2276 if let Ok(img) = try_dma() {
2277 return Ok(img);
2278 }
2279 }
2280 }
2281
2282 #[cfg(any(target_os = "linux", target_os = "windows"))]
2286 #[cfg(feature = "opengl")]
2287 if dtype.size() == 1 {
2288 if let Some(gl) = &self.opengl {
2289 match gl.create_pbo_image(width, height, format) {
2290 Ok(t) => {
2291 if dtype == DType::I8 {
2292 debug_assert!(
2300 t.chroma().is_none(),
2301 "PBO i8 transmute requires chroma == None"
2302 );
2303 let t_i8: Tensor<i8> = unsafe { std::mem::transmute(t) };
2304 return Ok(TensorDyn::from(t_i8));
2305 }
2306 return Ok(TensorDyn::from(t));
2307 }
2308 Err(e) => log::debug!("PBO image creation failed, falling back to Mem: {e:?}"),
2309 }
2310 }
2311 }
2312
2313 #[cfg(any(target_os = "linux", target_os = "windows"))]
2316 #[cfg(feature = "opengl")]
2317 if float_pbo_eligible(dtype, self.supported_render_dtypes()) {
2318 if let Some(gl) = &self.opengl {
2319 match gl.create_pbo_image_dtype(width, height, format, dtype) {
2320 Ok(t) => return Ok(t),
2321 Err(e) => {
2322 log::debug!(
2323 "Float PBO image creation failed for {dtype:?}, \
2324 falling back to Mem: {e:?}"
2325 );
2326 }
2327 }
2328 }
2329 }
2330
2331 Ok(TensorDyn::image(
2333 width,
2334 height,
2335 format,
2336 dtype,
2337 Some(edgefirst_tensor::TensorMemory::Mem),
2338 access,
2339 )?)
2340 }
2341
2342 #[allow(clippy::too_many_arguments)]
2396 #[cfg(target_os = "linux")]
2397 pub fn import_image(
2398 &self,
2399 image: edgefirst_tensor::PlaneDescriptor,
2400 chroma: Option<edgefirst_tensor::PlaneDescriptor>,
2401 width: usize,
2402 height: usize,
2403 format: PixelFormat,
2404 dtype: DType,
2405 colorimetry: Option<edgefirst_tensor::Colorimetry>,
2406 ) -> Result<TensorDyn> {
2407 use edgefirst_tensor::{Tensor, TensorMemory};
2408
2409 let image_stride = image.stride();
2411 let image_offset = image.offset();
2412 let chroma_stride = chroma.as_ref().and_then(|c| c.stride());
2413 let chroma_offset = chroma.as_ref().and_then(|c| c.offset());
2414
2415 if let Some(chroma_pd) = chroma {
2416 if dtype != DType::U8 && dtype != DType::I8 {
2421 return Err(Error::NotSupported(format!(
2422 "multiplane import only supports U8/I8, got {dtype:?}"
2423 )));
2424 }
2425 if format.layout() != PixelLayout::SemiPlanar {
2426 return Err(Error::NotSupported(format!(
2427 "import_image with chroma requires a semi-planar format, got {format:?}"
2428 )));
2429 }
2430
2431 let chroma_h = match format {
2432 PixelFormat::Nv12 => {
2433 height.div_ceil(2)
2435 }
2436 PixelFormat::Nv16 => {
2439 return Err(Error::NotSupported(
2440 "multiplane NV16 is not yet supported; use contiguous NV16 instead".into(),
2441 ))
2442 }
2443 _ => {
2444 return Err(Error::NotSupported(format!(
2445 "unsupported semi-planar format: {format:?}"
2446 )))
2447 }
2448 };
2449
2450 let luma = Tensor::<u8>::from_fd(image.into_fd(), &[height, width], Some("luma"))?;
2451 if luma.memory() != TensorMemory::DmaBuf {
2452 return Err(Error::NotSupported(format!(
2453 "luma fd must be DMA-backed, got {:?}",
2454 luma.memory()
2455 )));
2456 }
2457
2458 let chroma_tensor =
2459 Tensor::<u8>::from_fd(chroma_pd.into_fd(), &[chroma_h, width], Some("chroma"))?;
2460 if chroma_tensor.memory() != TensorMemory::DmaBuf {
2461 return Err(Error::NotSupported(format!(
2462 "chroma fd must be DMA-backed, got {:?}",
2463 chroma_tensor.memory()
2464 )));
2465 }
2466
2467 let mut tensor = Tensor::<u8>::from_planes(luma, chroma_tensor, format)?;
2470
2471 if let Some(s) = image_stride {
2473 tensor.set_row_stride(s)?;
2474 }
2475 if let Some(o) = image_offset {
2476 tensor.set_plane_offset(o);
2477 }
2478
2479 if let Some(chroma_ref) = tensor.chroma_mut() {
2484 if let Some(s) = chroma_stride {
2485 if s < width {
2486 return Err(Error::InvalidShape(format!(
2487 "chroma stride {s} < minimum {width} for {format:?}"
2488 )));
2489 }
2490 chroma_ref.set_row_stride_unchecked(s);
2491 }
2492 if let Some(o) = chroma_offset {
2493 chroma_ref.set_plane_offset(o);
2494 }
2495 }
2496
2497 if dtype == DType::I8 {
2498 const {
2502 assert!(std::mem::size_of::<Tensor<u8>>() == std::mem::size_of::<Tensor<i8>>());
2503 assert!(
2504 std::mem::align_of::<Tensor<u8>>() == std::mem::align_of::<Tensor<i8>>()
2505 );
2506 }
2507 let tensor_i8: Tensor<i8> = unsafe { std::mem::transmute(tensor) };
2508 let mut dyn_tensor = TensorDyn::from(tensor_i8);
2509 dyn_tensor.set_colorimetry(colorimetry);
2510 return Ok(dyn_tensor);
2511 }
2512 let mut dyn_tensor = TensorDyn::from(tensor);
2513 dyn_tensor.set_colorimetry(colorimetry);
2514 Ok(dyn_tensor)
2515 } else {
2516 let shape = format.allocation_shape(width, height).ok_or_else(|| {
2521 Error::NotSupported(format!(
2522 "unsupported pixel format for import_image: {format:?}"
2523 ))
2524 })?;
2525 let tensor = TensorDyn::from_fd(image.into_fd(), &shape, dtype, None)?;
2526 if tensor.memory() != TensorMemory::DmaBuf {
2527 return Err(Error::NotSupported(format!(
2528 "import_image requires DMA-backed fd, got {:?}",
2529 tensor.memory()
2530 )));
2531 }
2532 let mut tensor = tensor.with_format(format)?;
2533 if let Some(s) = image_stride {
2534 tensor.set_row_stride(s)?;
2535 }
2536 if let Some(o) = image_offset {
2537 tensor.set_plane_offset(o);
2538 }
2539 tensor.set_colorimetry(colorimetry);
2540 Ok(tensor)
2541 }
2542 }
2543
2544 #[cfg(feature = "decode")]
2569 pub fn draw_masks(
2570 &mut self,
2571 decoder: &edgefirst_decoder::Decoder,
2572 outputs: &[&TensorDyn],
2573 dst: &mut TensorDyn,
2574 overlay: MaskOverlay<'_>,
2575 ) -> Result<Vec<DetectBox>> {
2576 let mut output_boxes = Vec::with_capacity(100);
2577
2578 let proto_result = decoder
2580 .decode_proto(outputs, &mut output_boxes)
2581 .map_err(|e| Error::Internal(format!("decode_proto: {e:#?}")))?;
2582
2583 if let Some(proto_data) = proto_result {
2584 self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2585 } else {
2586 let mut output_masks = Vec::with_capacity(100);
2588 decoder
2589 .decode(outputs, &mut output_boxes, &mut output_masks)
2590 .map_err(|e| Error::Internal(format!("decode: {e:#?}")))?;
2591 self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2592 }
2593 Ok(output_boxes)
2594 }
2595
2596 #[cfg(feature = "tracker")]
2604 pub fn draw_masks_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2605 &mut self,
2606 decoder: &edgefirst_decoder::Decoder,
2607 tracker: &mut TR,
2608 timestamp: u64,
2609 outputs: &[&TensorDyn],
2610 dst: &mut TensorDyn,
2611 overlay: MaskOverlay<'_>,
2612 ) -> Result<(Vec<DetectBox>, Vec<edgefirst_tracker::TrackInfo>)> {
2613 let mut output_boxes = Vec::with_capacity(100);
2614 let mut output_tracks = Vec::new();
2615
2616 let proto_result = decoder
2617 .decode_proto_tracked(
2618 tracker,
2619 timestamp,
2620 outputs,
2621 &mut output_boxes,
2622 &mut output_tracks,
2623 )
2624 .map_err(|e| Error::Internal(format!("decode_proto_tracked: {e:#?}")))?;
2625
2626 if let Some(proto_data) = proto_result {
2627 self.draw_proto_masks(dst, &output_boxes, &proto_data, overlay)?;
2628 } else {
2629 let mut output_masks = Vec::with_capacity(100);
2633 decoder
2634 .decode_tracked(
2635 tracker,
2636 timestamp,
2637 outputs,
2638 &mut output_boxes,
2639 &mut output_masks,
2640 &mut output_tracks,
2641 )
2642 .map_err(|e| Error::Internal(format!("decode_tracked: {e:#?}")))?;
2643 self.draw_decoded_masks(dst, &output_boxes, &output_masks, overlay)?;
2644 }
2645 Ok((output_boxes, output_tracks))
2646 }
2647
2648 pub fn materialize_masks(
2672 &mut self,
2673 detect: &[DetectBox],
2674 proto_data: &ProtoData,
2675 letterbox: Option<[f32; 4]>,
2676 resolution: MaskResolution,
2677 ) -> Result<Vec<Segmentation>> {
2678 let cpu = self.cpu.as_mut().ok_or(Error::NoConverter)?;
2679 match resolution {
2680 MaskResolution::Proto => cpu.materialize_segmentations(detect, proto_data, letterbox),
2681 MaskResolution::Scaled { width, height } => {
2682 cpu.materialize_scaled_segmentations(detect, proto_data, letterbox, width, height)
2683 }
2684 }
2685 }
2686}
2687
2688impl ImageProcessorTrait for ImageProcessor {
2689 fn convert(
2695 &mut self,
2696 src: &TensorDyn,
2697 dst: &mut TensorDyn,
2698 rotation: Rotation,
2699 flip: Flip,
2700 crop: Crop,
2701 ) -> Result<()> {
2702 let start = Instant::now();
2703 let src_fmt = src.format();
2704 let dst_fmt = dst.format();
2705 let _span = tracing::trace_span!(
2706 "image.convert",
2707 ?src_fmt,
2708 ?dst_fmt,
2709 src_memory = ?src.memory(),
2710 dst_memory = ?dst.memory(),
2711 ?rotation,
2712 ?flip,
2713 )
2714 .entered();
2715 log::trace!(
2716 "convert: {src_fmt:?}({:?}/{:?}) → {dst_fmt:?}({:?}/{:?}), \
2717 rotation={rotation:?}, flip={flip:?}, backend={:?}",
2718 src.dtype(),
2719 src.memory(),
2720 dst.dtype(),
2721 dst.memory(),
2722 self.forced_backend,
2723 );
2724
2725 if let Some(forced) = self.forced_backend {
2727 return match forced {
2728 ForcedBackend::Cpu => {
2729 if let Some(cpu) = self.cpu.as_mut() {
2730 let r = cpu.convert(src, dst, rotation, flip, crop);
2731 log::trace!(
2732 "convert: forced=cpu result={} ({:?})",
2733 if r.is_ok() { "ok" } else { "err" },
2734 start.elapsed()
2735 );
2736 return r;
2737 }
2738 Err(Error::ForcedBackendUnavailable("cpu".into()))
2739 }
2740 ForcedBackend::G2d => {
2741 #[cfg(target_os = "linux")]
2742 if let Some(g2d) = self.g2d.as_mut() {
2743 let r = g2d.convert(src, dst, rotation, flip, crop);
2744 log::trace!(
2745 "convert: forced=g2d result={} ({:?})",
2746 if r.is_ok() { "ok" } else { "err" },
2747 start.elapsed()
2748 );
2749 return r;
2750 }
2751 Err(Error::ForcedBackendUnavailable("g2d".into()))
2752 }
2753 ForcedBackend::OpenGl => {
2754 #[cfg(any(
2755 target_os = "linux",
2756 target_os = "macos",
2757 target_os = "ios",
2758 target_os = "android",
2759 target_os = "windows"
2760 ))]
2761 #[cfg(feature = "opengl")]
2762 if let Some(opengl) = self.opengl.as_mut() {
2763 let r = opengl.convert(src, dst, rotation, flip, crop);
2764 log::trace!(
2765 "convert: forced=opengl result={} ({:?})",
2766 if r.is_ok() { "ok" } else { "err" },
2767 start.elapsed()
2768 );
2769 return r;
2770 }
2771 Err(Error::ForcedBackendUnavailable("opengl".into()))
2772 }
2773 };
2774 }
2775
2776 #[cfg(any(
2778 target_os = "linux",
2779 target_os = "macos",
2780 target_os = "ios",
2781 target_os = "android",
2782 target_os = "windows"
2783 ))]
2784 #[cfg(feature = "opengl")]
2785 if let Some(opengl) = self.opengl.as_mut() {
2786 match opengl.convert(src, dst, rotation, flip, crop) {
2787 Ok(_) => {
2788 log::trace!(
2789 "convert: auto selected=opengl for {src_fmt:?}→{dst_fmt:?} ({:?})",
2790 start.elapsed()
2791 );
2792 return Ok(());
2793 }
2794 Err(e) => {
2795 self.convert_fallbacks
2796 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2797 log::debug!(
2798 "convert: auto opengl declined {src_fmt:?}@{:?}→{dst_fmt:?}@{:?}, \
2799 falling back toward G2D/CPU: {e}",
2800 src.memory(),
2801 dst.memory(),
2802 );
2803 }
2804 }
2805 }
2806
2807 #[cfg(target_os = "linux")]
2808 if let Some(g2d) = self.g2d.as_mut() {
2809 let src_is_yuv = src.format().is_some_and(|f| f.is_yuv());
2816 let dst_is_yuv = dst.format().is_some_and(|f| f.is_yuv());
2817 let g2d_eligible = if src_is_yuv || dst_is_yuv {
2818 let cm = if src_is_yuv {
2819 crate::colorimetry::effective_colorimetry(src)
2820 } else {
2821 crate::colorimetry::effective_colorimetry(dst)
2822 };
2823 crate::g2d::g2d_can_handle(&cm, true)
2824 } else {
2825 true
2826 };
2827 if !g2d_eligible {
2828 log::trace!(
2829 "convert: auto g2d skipped {src_fmt:?}→{dst_fmt:?} \
2830 (colorimetry not expressible: full-range/BT.2020)"
2831 );
2832 } else {
2833 match g2d.convert(src, dst, rotation, flip, crop) {
2834 Ok(_) => {
2835 log::trace!(
2836 "convert: auto selected=g2d for {src_fmt:?}→{dst_fmt:?} ({:?})",
2837 start.elapsed()
2838 );
2839 return Ok(());
2840 }
2841 Err(e) => {
2842 log::trace!("convert: auto g2d declined {src_fmt:?}→{dst_fmt:?}: {e}");
2843 }
2844 }
2845 }
2846 }
2847
2848 if let Some(cpu) = self.cpu.as_mut() {
2849 match cpu.convert(src, dst, rotation, flip, crop) {
2850 Ok(_) => {
2851 log::trace!(
2852 "convert: auto selected=cpu for {src_fmt:?}→{dst_fmt:?} ({:?})",
2853 start.elapsed()
2854 );
2855 return Ok(());
2856 }
2857 Err(e) => {
2858 log::trace!("convert: auto cpu failed {src_fmt:?}→{dst_fmt:?}: {e}");
2859 return Err(e);
2860 }
2861 }
2862 }
2863 Err(Error::NoConverter)
2864 }
2865
2866 fn convert_deferred(
2867 &mut self,
2868 src: &TensorDyn,
2869 dst: &mut TensorDyn,
2870 rotation: Rotation,
2871 flip: Flip,
2872 crop: Crop,
2873 ) -> Result<()> {
2874 #[cfg(any(
2880 target_os = "linux",
2881 target_os = "macos",
2882 target_os = "ios",
2883 target_os = "android",
2884 target_os = "windows"
2885 ))]
2886 #[cfg(feature = "opengl")]
2887 {
2888 let gl_forced = matches!(self.forced_backend, Some(ForcedBackend::OpenGl));
2889 if gl_forced || self.forced_backend.is_none() {
2890 if let Some(opengl) = self.opengl.as_mut() {
2891 match opengl.convert_deferred(src, dst, rotation, flip, crop) {
2892 Ok(()) => return Ok(()),
2893 Err(e) => {
2894 log::trace!("convert_deferred: gl declined: {e}; eager fallback");
2895 if gl_forced {
2898 return Err(e);
2899 }
2900 }
2901 }
2902 }
2903 }
2904 }
2905 self.convert(src, dst, rotation, flip, crop)
2906 }
2907
2908 fn flush(&mut self) -> Result<()> {
2909 let _span = tracing::trace_span!("image.flush").entered();
2910 #[cfg(any(
2913 target_os = "linux",
2914 target_os = "macos",
2915 target_os = "ios",
2916 target_os = "android",
2917 target_os = "windows"
2918 ))]
2919 #[cfg(feature = "opengl")]
2920 if let Some(opengl) = self.opengl.as_mut() {
2921 return opengl.flush();
2922 }
2923 Ok(())
2924 }
2925
2926 fn draw_decoded_masks(
2927 &mut self,
2928 dst: &mut TensorDyn,
2929 detect: &[DetectBox],
2930 segmentation: &[Segmentation],
2931 overlay: MaskOverlay<'_>,
2932 ) -> Result<()> {
2933 let _span = tracing::trace_span!(
2934 "image.draw_decoded_masks",
2935 n_detections = detect.len(),
2936 n_segmentations = segmentation.len(),
2937 )
2938 .entered();
2939 let start = Instant::now();
2940
2941 if let Some(bg) = overlay.background {
2942 if bg.aliases(dst) {
2943 return Err(Error::AliasedBuffers(
2944 "background must not reference the same buffer as dst".to_string(),
2945 ));
2946 }
2947 }
2948
2949 let lb_boxes: Vec<DetectBox>;
2952 let lb_segs: Vec<Segmentation>;
2953 let (detect, segmentation) = if let Some(lb) = overlay.letterbox {
2954 lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
2955 if segmentation.len() == lb_boxes.len() {
2958 lb_segs = segmentation
2965 .iter()
2966 .zip(lb_boxes.iter())
2967 .map(|(s, d)| copy_segmentation(s, d.bbox.into()))
2968 .collect::<Result<Vec<_>>>()?;
2969 (lb_boxes.as_slice(), lb_segs.as_slice())
2970 } else {
2971 (lb_boxes.as_slice(), segmentation)
2975 }
2976 } else {
2977 (detect, segmentation)
2978 };
2979 #[cfg(target_os = "linux")]
2980 let is_empty_frame = detect.is_empty() && segmentation.is_empty();
2981
2982 if let Some(forced) = self.forced_backend {
2984 return match forced {
2985 ForcedBackend::Cpu => {
2986 if let Some(cpu) = self.cpu.as_mut() {
2987 return cpu.draw_decoded_masks(dst, detect, segmentation, overlay);
2988 }
2989 Err(Error::ForcedBackendUnavailable("cpu".into()))
2990 }
2991 ForcedBackend::G2d => {
2992 #[cfg(target_os = "linux")]
2995 if let Some(g2d) = self.g2d.as_mut() {
2996 return g2d.draw_decoded_masks(dst, detect, segmentation, overlay);
2997 }
2998 Err(Error::ForcedBackendUnavailable("g2d".into()))
2999 }
3000 ForcedBackend::OpenGl => {
3001 #[cfg(target_os = "linux")]
3004 #[cfg(feature = "opengl")]
3005 if let Some(opengl) = self.opengl.as_mut() {
3006 return opengl.draw_decoded_masks(dst, detect, segmentation, overlay);
3007 }
3008 Err(Error::ForcedBackendUnavailable("opengl".into()))
3009 }
3010 };
3011 }
3012
3013 #[cfg(target_os = "linux")]
3019 if is_empty_frame {
3020 if let Some(g2d) = self.g2d.as_mut() {
3021 match g2d.draw_decoded_masks(dst, detect, segmentation, overlay) {
3022 Ok(_) => {
3023 log::trace!(
3024 "draw_decoded_masks empty frame via g2d in {:?}",
3025 start.elapsed()
3026 );
3027 return Ok(());
3028 }
3029 Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3030 }
3031 }
3032 }
3033
3034 #[cfg(target_os = "linux")]
3038 #[cfg(feature = "opengl")]
3039 if let Some(opengl) = self.opengl.as_mut() {
3040 log::trace!(
3041 "draw_decoded_masks started with opengl in {:?}",
3042 start.elapsed()
3043 );
3044 match opengl.draw_decoded_masks(dst, detect, segmentation, overlay) {
3045 Ok(_) => {
3046 log::trace!("draw_decoded_masks with opengl in {:?}", start.elapsed());
3047 return Ok(());
3048 }
3049 Err(e) => {
3050 log::trace!("draw_decoded_masks didn't work with opengl: {e:?}")
3051 }
3052 }
3053 }
3054
3055 log::trace!(
3056 "draw_decoded_masks started with cpu in {:?}",
3057 start.elapsed()
3058 );
3059 if let Some(cpu) = self.cpu.as_mut() {
3060 match cpu.draw_decoded_masks(dst, detect, segmentation, overlay) {
3061 Ok(_) => {
3062 log::trace!("draw_decoded_masks with cpu in {:?}", start.elapsed());
3063 return Ok(());
3064 }
3065 Err(e) => {
3066 log::trace!("draw_decoded_masks didn't work with cpu: {e:?}");
3067 return Err(e);
3068 }
3069 }
3070 }
3071 Err(Error::NoConverter)
3072 }
3073
3074 fn draw_proto_masks(
3075 &mut self,
3076 dst: &mut TensorDyn,
3077 detect: &[DetectBox],
3078 proto_data: &ProtoData,
3079 overlay: MaskOverlay<'_>,
3080 ) -> Result<()> {
3081 let start = Instant::now();
3082
3083 if let Some(bg) = overlay.background {
3084 if bg.aliases(dst) {
3085 return Err(Error::AliasedBuffers(
3086 "background must not reference the same buffer as dst".to_string(),
3087 ));
3088 }
3089 }
3090
3091 let lb_boxes: Vec<DetectBox>;
3097 let render_detect = if let Some(lb) = overlay.letterbox {
3098 lb_boxes = detect.iter().map(|&d| unletter_bbox(d, lb)).collect();
3099 lb_boxes.as_slice()
3100 } else {
3101 detect
3102 };
3103 #[cfg(target_os = "linux")]
3104 let is_empty_frame = detect.is_empty();
3105
3106 if let Some(forced) = self.forced_backend {
3108 return match forced {
3109 ForcedBackend::Cpu => {
3110 if let Some(cpu) = self.cpu.as_mut() {
3111 return cpu.draw_proto_masks(dst, render_detect, proto_data, overlay);
3112 }
3113 Err(Error::ForcedBackendUnavailable("cpu".into()))
3114 }
3115 ForcedBackend::G2d => {
3116 #[cfg(target_os = "linux")]
3117 if let Some(g2d) = self.g2d.as_mut() {
3118 return g2d.draw_proto_masks(dst, render_detect, proto_data, overlay);
3119 }
3120 Err(Error::ForcedBackendUnavailable("g2d".into()))
3121 }
3122 ForcedBackend::OpenGl => {
3123 #[cfg(target_os = "linux")]
3124 #[cfg(feature = "opengl")]
3125 if let Some(opengl) = self.opengl.as_mut() {
3126 return opengl.draw_proto_masks(dst, render_detect, proto_data, overlay);
3127 }
3128 Err(Error::ForcedBackendUnavailable("opengl".into()))
3129 }
3130 };
3131 }
3132
3133 #[cfg(target_os = "linux")]
3136 if is_empty_frame {
3137 if let Some(g2d) = self.g2d.as_mut() {
3138 match g2d.draw_proto_masks(dst, render_detect, proto_data, overlay) {
3139 Ok(_) => {
3140 log::trace!(
3141 "draw_proto_masks empty frame via g2d in {:?}",
3142 start.elapsed()
3143 );
3144 return Ok(());
3145 }
3146 Err(e) => log::trace!("g2d empty-frame path unavailable: {e:?}"),
3147 }
3148 }
3149 }
3150
3151 #[cfg(target_os = "linux")]
3160 #[cfg(feature = "opengl")]
3161 if let (Some(_), Some(_)) = (self.cpu.as_ref(), self.opengl.as_ref()) {
3162 let segmentation = match self.cpu.as_mut() {
3163 Some(cpu) => {
3164 log::trace!(
3165 "draw_proto_masks started with hybrid (cpu+opengl) in {:?}",
3166 start.elapsed()
3167 );
3168 cpu.materialize_segmentations(detect, proto_data, overlay.letterbox)?
3169 }
3170 None => unreachable!("cpu presence checked above"),
3171 };
3172 if let Some(opengl) = self.opengl.as_mut() {
3173 match opengl.draw_decoded_masks(dst, render_detect, &segmentation, overlay) {
3174 Ok(_) => {
3175 log::trace!(
3176 "draw_proto_masks with hybrid (cpu+opengl) in {:?}",
3177 start.elapsed()
3178 );
3179 return Ok(());
3180 }
3181 Err(e) => {
3182 log::trace!(
3183 "draw_proto_masks hybrid path failed, falling back to cpu: {e:?}"
3184 );
3185 }
3186 }
3187 }
3188 }
3189
3190 let Some(cpu) = self.cpu.as_mut() else {
3191 return Err(Error::Internal(
3192 "draw_proto_masks requires CPU backend for fallback path".into(),
3193 ));
3194 };
3195 log::trace!("draw_proto_masks started with cpu in {:?}", start.elapsed());
3196 cpu.draw_proto_masks(dst, render_detect, proto_data, overlay)
3197 }
3198
3199 fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<()> {
3200 let start = Instant::now();
3201
3202 if let Some(forced) = self.forced_backend {
3204 return match forced {
3205 ForcedBackend::Cpu => {
3206 if let Some(cpu) = self.cpu.as_mut() {
3207 return cpu.set_class_colors(colors);
3208 }
3209 Err(Error::ForcedBackendUnavailable("cpu".into()))
3210 }
3211 ForcedBackend::G2d => Err(Error::NotSupported(
3212 "g2d does not support set_class_colors".into(),
3213 )),
3214 ForcedBackend::OpenGl => {
3215 #[cfg(target_os = "linux")]
3216 #[cfg(feature = "opengl")]
3217 if let Some(opengl) = self.opengl.as_mut() {
3218 return opengl.set_class_colors(colors);
3219 }
3220 Err(Error::ForcedBackendUnavailable("opengl".into()))
3221 }
3222 };
3223 }
3224
3225 #[cfg(target_os = "linux")]
3228 #[cfg(feature = "opengl")]
3229 if let Some(opengl) = self.opengl.as_mut() {
3230 log::trace!("image started with opengl in {:?}", start.elapsed());
3231 match opengl.set_class_colors(colors) {
3232 Ok(_) => {
3233 log::trace!("colors set with opengl in {:?}", start.elapsed());
3234 return Ok(());
3235 }
3236 Err(e) => {
3237 log::trace!("colors didn't set with opengl: {e:?}")
3238 }
3239 }
3240 }
3241 log::trace!("image started with cpu in {:?}", start.elapsed());
3242 if let Some(cpu) = self.cpu.as_mut() {
3243 match cpu.set_class_colors(colors) {
3244 Ok(_) => {
3245 log::trace!("colors set with cpu in {:?}", start.elapsed());
3246 return Ok(());
3247 }
3248 Err(e) => {
3249 log::trace!("colors didn't set with cpu: {e:?}");
3250 return Err(e);
3251 }
3252 }
3253 }
3254 Err(Error::NoConverter)
3255 }
3256}
3257
3258#[cfg(all(test, feature = "codec"))]
3268pub(crate) fn load_image_test_helper(
3269 image: &[u8],
3270 format: Option<PixelFormat>,
3271 memory: Option<TensorMemory>,
3272) -> Result<TensorDyn> {
3273 use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
3274
3275 let info = peek_info(image)?;
3279 let native_fmt = info.format;
3280 let w = info.width;
3281 let h = info.height;
3282
3283 let mut decoder = ImageDecoder::new();
3284
3285 #[cfg(target_os = "linux")]
3288 let native_src = {
3289 if let Some(aligned_pitch) = padded_dma_pitch_for(native_fmt, w, &memory) {
3290 let mut dma = Tensor::<u8>::image_with_stride(
3291 w,
3292 h,
3293 native_fmt,
3294 aligned_pitch,
3295 Some(TensorMemory::DmaBuf),
3296 edgefirst_tensor::CpuAccess::ReadWrite,
3297 )?;
3298 dma.load_image(&mut decoder, image)?;
3299 TensorDyn::from(dma)
3300 } else {
3301 let mut img = Tensor::<u8>::image(
3302 w,
3303 h,
3304 native_fmt,
3305 memory,
3306 edgefirst_tensor::CpuAccess::ReadWrite,
3307 )?;
3308 img.load_image(&mut decoder, image)?;
3309 TensorDyn::from(img)
3310 }
3311 };
3312 #[cfg(not(target_os = "linux"))]
3313 let native_src = {
3314 let mut img = Tensor::<u8>::image(
3315 w,
3316 h,
3317 native_fmt,
3318 memory,
3319 edgefirst_tensor::CpuAccess::ReadWrite,
3320 )?;
3321 img.load_image(&mut decoder, image)?;
3322 TensorDyn::from(img)
3323 };
3324
3325 match format {
3329 Some(f) if f != native_fmt => {
3330 let mut dst = TensorDyn::image(
3331 w,
3332 h,
3333 f,
3334 DType::U8,
3335 memory,
3336 edgefirst_tensor::CpuAccess::ReadWrite,
3337 )?;
3338 #[allow(clippy::needless_update)]
3345 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
3346 backend: ComputeBackend::Cpu,
3347 ..Default::default()
3348 })?;
3349 proc.convert(
3350 &native_src,
3351 &mut dst,
3352 Rotation::None,
3353 Flip::None,
3354 Crop::default(),
3355 )?;
3356 Ok(dst)
3357 }
3358 _ => Ok(native_src),
3359 }
3360}
3361
3362pub fn save_jpeg(tensor: &TensorDyn, path: impl AsRef<std::path::Path>, quality: u8) -> Result<()> {
3366 let t = tensor.as_typed::<u8>().ok_or(Error::UnsupportedFormat(
3367 "save_jpeg requires u8 tensor".to_string(),
3368 ))?;
3369 let fmt = t.format().ok_or(Error::NotAnImage)?;
3370 if fmt.layout() != PixelLayout::Packed {
3371 return Err(Error::NotImplemented(
3372 "Saving planar images is not supported".to_string(),
3373 ));
3374 }
3375
3376 let colour = match fmt {
3377 PixelFormat::Rgb => jpeg_encoder::ColorType::Rgb,
3378 PixelFormat::Rgba => jpeg_encoder::ColorType::Rgba,
3379 _ => {
3380 return Err(Error::NotImplemented(
3381 "Unsupported image format for saving".to_string(),
3382 ));
3383 }
3384 };
3385
3386 let w = t.width().ok_or(Error::NotAnImage)?;
3387 let h = t.height().ok_or(Error::NotAnImage)?;
3388 let encoder = jpeg_encoder::Encoder::new_file(path, quality)?;
3389 let tensor_map = t.map_read()?;
3390
3391 encoder.encode(&tensor_map, w as u16, h as u16, colour)?;
3392
3393 Ok(())
3394}
3395
3396pub(crate) struct FunctionTimer<T: Display> {
3397 name: T,
3398 start: std::time::Instant,
3399}
3400
3401impl<T: Display> FunctionTimer<T> {
3402 pub fn new(name: T) -> Self {
3403 Self {
3404 name,
3405 start: std::time::Instant::now(),
3406 }
3407 }
3408}
3409
3410impl<T: Display> Drop for FunctionTimer<T> {
3411 fn drop(&mut self) {
3412 log::trace!("{} elapsed: {:?}", self.name, self.start.elapsed())
3413 }
3414}
3415
3416const DEFAULT_COLORS: [[f32; 4]; 20] = [
3417 [0., 1., 0., 0.7],
3418 [1., 0.5568628, 0., 0.7],
3419 [0.25882353, 0.15294118, 0.13333333, 0.7],
3420 [0.8, 0.7647059, 0.78039216, 0.7],
3421 [0.3137255, 0.3137255, 0.3137255, 0.7],
3422 [0.1411765, 0.3098039, 0.1215686, 0.7],
3423 [1., 0.95686275, 0.5137255, 0.7],
3424 [0.3529412, 0.32156863, 0., 0.7],
3425 [0.4235294, 0.6235294, 0.6509804, 0.7],
3426 [0.5098039, 0.5098039, 0.7294118, 0.7],
3427 [0.00784314, 0.18823529, 0.29411765, 0.7],
3428 [0.0, 0.2706, 1.0, 0.7],
3429 [0.0, 0.0, 0.0, 0.7],
3430 [0.0, 0.5, 0.0, 0.7],
3431 [1.0, 0.0, 0.0, 0.7],
3432 [0.0, 0.0, 1.0, 0.7],
3433 [1.0, 0.5, 0.5, 0.7],
3434 [0.1333, 0.5451, 0.1333, 0.7],
3435 [0.1176, 0.4118, 0.8235, 0.7],
3436 [1., 1., 1., 0.7],
3437];
3438
3439const fn denorm<const M: usize, const N: usize>(a: [[f32; M]; N]) -> [[u8; M]; N] {
3440 let mut result = [[0; M]; N];
3441 let mut i = 0;
3442 while i < N {
3443 let mut j = 0;
3444 while j < M {
3445 result[i][j] = (a[i][j] * 255.0).round() as u8;
3446 j += 1;
3447 }
3448 i += 1;
3449 }
3450 result
3451}
3452
3453const DEFAULT_COLORS_U8: [[u8; 4]; 20] = denorm(DEFAULT_COLORS);
3454
3455#[cfg(test)]
3456#[cfg_attr(coverage_nightly, coverage(off))]
3457mod alignment_tests {
3458 use super::*;
3459
3460 #[test]
3461 fn align_width_rgba8_common_widths() {
3462 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); }
3473
3474 #[test]
3475 fn align_width_rgb888_packed() {
3476 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] {
3483 let padded = align_width_for_gpu_pitch(w, 3);
3484 assert!(padded >= w);
3485 assert_eq!((padded * 3) % 64, 0);
3486 assert_eq!((padded * 3) % 3, 0);
3487 }
3488 }
3489
3490 #[test]
3491 fn align_width_grey_u8() {
3492 assert_eq!(align_width_for_gpu_pitch(64, 1), 64);
3494 assert_eq!(align_width_for_gpu_pitch(640, 1), 640);
3495 assert_eq!(align_width_for_gpu_pitch(1, 1), 64);
3496 assert_eq!(align_width_for_gpu_pitch(65, 1), 128);
3497 }
3498
3499 #[test]
3500 fn align_width_zero_inputs() {
3501 assert_eq!(align_width_for_gpu_pitch(0, 4), 0);
3502 assert_eq!(align_width_for_gpu_pitch(640, 0), 640);
3503 }
3504
3505 #[test]
3506 fn align_width_never_returns_smaller_than_input() {
3507 for &bpp in &[1usize, 2, 3, 4, 8] {
3511 for &w in &[
3512 1usize,
3513 17,
3514 64,
3515 65,
3516 100,
3517 1280,
3518 1281,
3519 1920,
3520 3004,
3521 3072,
3522 3840,
3523 usize::MAX / 8,
3524 usize::MAX / 4,
3525 usize::MAX / 2,
3526 usize::MAX - 1,
3527 usize::MAX,
3528 ] {
3529 let aligned = align_width_for_gpu_pitch(w, bpp);
3530 assert!(
3531 aligned >= w,
3532 "align_width_for_gpu_pitch({w}, {bpp}) = {aligned} < {w}"
3533 );
3534 }
3535 }
3536 }
3537
3538 #[test]
3539 fn align_width_overflow_returns_unaligned_not_smaller() {
3540 let aligned_extreme = usize::MAX - 15; assert_eq!(
3546 align_width_for_gpu_pitch(aligned_extreme, 4),
3547 aligned_extreme
3548 );
3549 let misaligned_extreme = usize::MAX - 1;
3552 let result = align_width_for_gpu_pitch(misaligned_extreme, 4);
3553 assert!(
3554 result == misaligned_extreme || result >= misaligned_extreme,
3555 "extreme misaligned width must not be rounded down to {result}"
3556 );
3557 }
3558
3559 #[test]
3560 fn checked_lcm_basic_and_overflow() {
3561 assert_eq!(checked_num_integer_lcm(64, 4), Some(64));
3562 assert_eq!(checked_num_integer_lcm(64, 3), Some(192));
3563 assert_eq!(checked_num_integer_lcm(64, 1), Some(64));
3564 assert_eq!(checked_num_integer_lcm(0, 4), Some(0));
3565 assert_eq!(checked_num_integer_lcm(64, 0), Some(0));
3566 assert_eq!(
3568 checked_num_integer_lcm(usize::MAX, usize::MAX - 1),
3569 None,
3570 "coprime extreme values must overflow detect, not panic"
3571 );
3572 }
3573
3574 #[test]
3575 fn primary_plane_bpp_known_formats() {
3576 assert_eq!(primary_plane_bpp(PixelFormat::Rgba, 1), Some(4));
3578 assert_eq!(primary_plane_bpp(PixelFormat::Bgra, 1), Some(4));
3579 assert_eq!(primary_plane_bpp(PixelFormat::Rgb, 1), Some(3));
3580 assert_eq!(primary_plane_bpp(PixelFormat::Grey, 1), Some(1));
3581 assert_eq!(primary_plane_bpp(PixelFormat::Nv12, 1), Some(1));
3583 }
3584}
3585
3586#[cfg(test)]
3587#[cfg_attr(coverage_nightly, coverage(off))]
3588#[allow(deprecated)]
3589mod image_tests {
3590 use super::*;
3591 use crate::{CPUProcessor, Rotation};
3592 #[cfg(target_os = "linux")]
3593 use edgefirst_tensor::is_dma_available;
3594 use edgefirst_tensor::{TensorMapTrait, TensorMemory, TensorTrait};
3595 use image::buffer::ConvertBuffer;
3596
3597 fn convert_img(
3603 proc: &mut dyn ImageProcessorTrait,
3604 src: TensorDyn,
3605 dst: TensorDyn,
3606 rotation: Rotation,
3607 flip: Flip,
3608 crop: Crop,
3609 ) -> (Result<()>, TensorDyn, TensorDyn) {
3610 let src_fourcc = src.format().unwrap();
3611 let dst_fourcc = dst.format().unwrap();
3612 let src_dyn = src;
3613 let mut dst_dyn = dst;
3614 let result = proc.convert(&src_dyn, &mut dst_dyn, rotation, flip, crop);
3615 let src_back = {
3616 let mut __t = src_dyn.into_u8().unwrap();
3617 __t.set_format(src_fourcc).unwrap();
3618 TensorDyn::from(__t)
3619 };
3620 let dst_back = {
3621 let mut __t = dst_dyn.into_u8().unwrap();
3622 __t.set_format(dst_fourcc).unwrap();
3623 TensorDyn::from(__t)
3624 };
3625 (result, src_back, dst_back)
3626 }
3627
3628 #[ctor::ctor(unsafe)]
3629 fn init() {
3630 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
3631 }
3632
3633 macro_rules! function {
3634 () => {{
3635 fn f() {}
3636 fn type_name_of<T>(_: T) -> &'static str {
3637 std::any::type_name::<T>()
3638 }
3639 let name = type_name_of(f);
3640
3641 match &name[..name.len() - 3].rfind(':') {
3643 Some(pos) => &name[pos + 1..name.len() - 3],
3644 None => &name[..name.len() - 3],
3645 }
3646 }};
3647 }
3648
3649 #[test]
3662 fn batch_view_dst_tiles_match_standalone() {
3663 let mut proc = match ImageProcessor::new() {
3664 Ok(p) => p,
3665 Err(e) => {
3666 eprintln!(
3667 "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3668 function!()
3669 );
3670 return;
3671 }
3672 };
3673 let n = 3usize;
3674 let (w, h) = (32usize, 24usize);
3675 let colors: [[u8; 4]; 3] = [[210, 40, 40, 255], [40, 210, 40, 255], [40, 40, 210, 255]];
3676 let make_src = |c: [u8; 4]| -> TensorDyn {
3677 let bytes: Vec<u8> = c.iter().copied().cycle().take(w * h * 4).collect();
3678 load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3679 };
3680 let parent = match TensorDyn::image(
3683 w,
3684 n * h,
3685 PixelFormat::Rgba,
3686 DType::U8,
3687 Some(TensorMemory::DmaBuf),
3688 edgefirst_tensor::CpuAccess::ReadWrite,
3689 ) {
3690 Ok(d) => d,
3691 Err(e) => {
3692 eprintln!(
3693 "SKIPPED: {} — tall DMA destination alloc failed ({e:?})",
3694 function!()
3695 );
3696 return;
3697 }
3698 };
3699
3700 for (i, &c) in colors.iter().enumerate().take(n) {
3702 let mut tile = parent.view(Region::new(0, i * h, w, h)).unwrap();
3703 proc.convert_deferred(
3704 &make_src(c),
3705 &mut tile,
3706 Rotation::None,
3707 Flip::None,
3708 Crop::no_crop(),
3709 )
3710 .unwrap_or_else(|e| panic!("convert_deferred tile {i}: {e:?}"));
3711 }
3712 proc.flush().unwrap();
3713
3714 for (i, &c) in colors.iter().enumerate().take(n) {
3715 let mut solo = TensorDyn::image(
3717 w,
3718 h,
3719 PixelFormat::Rgba,
3720 DType::U8,
3721 Some(TensorMemory::DmaBuf),
3722 edgefirst_tensor::CpuAccess::ReadWrite,
3723 )
3724 .unwrap();
3725 proc.convert(
3726 &make_src(c),
3727 &mut solo,
3728 Rotation::None,
3729 Flip::None,
3730 Crop::no_crop(),
3731 )
3732 .unwrap();
3733
3734 let band = parent.view(Region::new(0, i * h, w, h)).unwrap();
3735 let band_bytes = band
3736 .as_typed::<u8>()
3737 .unwrap()
3738 .map()
3739 .unwrap()
3740 .as_slice()
3741 .to_vec();
3742 let solo_bytes = solo
3743 .as_typed::<u8>()
3744 .unwrap()
3745 .map()
3746 .unwrap()
3747 .as_slice()
3748 .to_vec();
3749 assert_eq!(
3750 band_bytes, solo_bytes,
3751 "tile {i}: band differs from standalone convert (placement or sibling wipe)"
3752 );
3753 assert!(
3754 band_bytes.as_chunks::<4>().0.iter().all(|&p| p == c),
3755 "tile {i}: band is not the expected solid color {c:?} (sibling wipe?)"
3756 );
3757 }
3758 }
3759
3760 #[cfg(test)]
3764 fn gradient_frame(w: usize, h: usize) -> TensorDyn {
3765 let mut bytes = vec![0u8; w * h * 4];
3766 for y in 0..h {
3767 for x in 0..w {
3768 let i = (y * w + x) * 4;
3769 bytes[i] = x as u8;
3770 bytes[i + 1] = y as u8;
3771 bytes[i + 2] = (x ^ y) as u8;
3772 bytes[i + 3] = 255;
3773 }
3774 }
3775 load_bytes_to_tensor(w, h, PixelFormat::Rgba, Some(TensorMemory::Mem), &bytes).unwrap()
3776 }
3777
3778 #[test]
3782 fn tile_into_cpu_distinct_content_parity() {
3783 let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3784 backend: ComputeBackend::Cpu,
3785 ..Default::default()
3786 }) {
3787 Ok(p) => p,
3788 Err(e) => {
3789 eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3790 return;
3791 }
3792 };
3793 let (fw, fh) = (96usize, 64usize);
3794 let src = gradient_frame(fw, fh);
3795 let cfg = TilingConfig::new(32, 32).with_overlap(0.0); let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3797 assert_eq!(n, 6);
3798
3799 let mut parent = proc
3800 .alloc_tile_batch(
3801 n,
3802 &cfg,
3803 PixelFormat::Rgba,
3804 DType::U8,
3805 Some(TensorMemory::Mem),
3806 edgefirst_tensor::CpuAccess::ReadWrite,
3807 )
3808 .unwrap();
3809 let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3810 assert_eq!(placements.len(), n);
3811
3812 for p in &placements {
3813 let source = Region::new(
3814 p.origin.0 as usize,
3815 p.origin.1 as usize,
3816 p.crop_size.0 as usize,
3817 p.crop_size.1 as usize,
3818 );
3819 let mut solo = TensorDyn::image(
3820 32,
3821 32,
3822 PixelFormat::Rgba,
3823 DType::U8,
3824 Some(TensorMemory::Mem),
3825 edgefirst_tensor::CpuAccess::ReadWrite,
3826 )
3827 .unwrap();
3828 proc.convert(
3829 &src,
3830 &mut solo,
3831 Rotation::None,
3832 Flip::None,
3833 Crop::default()
3834 .with_source(Some(source))
3835 .with_fit(Fit::Stretch),
3836 )
3837 .unwrap();
3838 let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3839 let band_bytes = band
3840 .as_typed::<u8>()
3841 .unwrap()
3842 .map()
3843 .unwrap()
3844 .as_slice()
3845 .to_vec();
3846 let solo_bytes = solo
3847 .as_typed::<u8>()
3848 .unwrap()
3849 .map()
3850 .unwrap()
3851 .as_slice()
3852 .to_vec();
3853 assert_eq!(
3854 band_bytes, solo_bytes,
3855 "tile {} band differs from standalone crop-convert",
3856 p.index
3857 );
3858 }
3859 }
3860
3861 #[test]
3864 fn tile_one_matches_tile_into_band() {
3865 let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
3866 backend: ComputeBackend::Cpu,
3867 ..Default::default()
3868 }) {
3869 Ok(p) => p,
3870 Err(e) => {
3871 eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
3872 return;
3873 }
3874 };
3875 let (fw, fh) = (96usize, 64usize);
3876 let src = gradient_frame(fw, fh);
3877 let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3878 let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3879
3880 let mut parent = proc
3881 .alloc_tile_batch(
3882 n,
3883 &cfg,
3884 PixelFormat::Rgba,
3885 DType::U8,
3886 Some(TensorMemory::Mem),
3887 edgefirst_tensor::CpuAccess::ReadWrite,
3888 )
3889 .unwrap();
3890 proc.tile_into(&src, &mut parent, &cfg).unwrap();
3891
3892 let plan = proc.plan_tiles(fw, fh, &cfg).unwrap();
3893 for p in &plan {
3894 let mut slot = TensorDyn::image(
3895 32,
3896 32,
3897 PixelFormat::Rgba,
3898 DType::U8,
3899 Some(TensorMemory::Mem),
3900 edgefirst_tensor::CpuAccess::ReadWrite,
3901 )
3902 .unwrap();
3903 proc.tile_one(&src, &mut slot, p, &cfg).unwrap();
3904 proc.flush().unwrap();
3905 let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3906 let slot_bytes = slot
3907 .as_typed::<u8>()
3908 .unwrap()
3909 .map()
3910 .unwrap()
3911 .as_slice()
3912 .to_vec();
3913 let band_bytes = band
3914 .as_typed::<u8>()
3915 .unwrap()
3916 .map()
3917 .unwrap()
3918 .as_slice()
3919 .to_vec();
3920 assert_eq!(
3921 slot_bytes, band_bytes,
3922 "tile {} stream != batch band",
3923 p.index
3924 );
3925 }
3926 }
3927
3928 #[test]
3935 fn tile_into_auto_dma_parity() {
3936 let mut proc = match ImageProcessor::new() {
3937 Ok(p) => p,
3938 Err(e) => {
3939 eprintln!(
3940 "SKIPPED: {} — ImageProcessor init failed ({e:?})",
3941 function!()
3942 );
3943 return;
3944 }
3945 };
3946 let (fw, fh) = (96usize, 64usize);
3947 let src = gradient_frame(fw, fh);
3948 let cfg = TilingConfig::new(32, 32).with_overlap(0.0);
3949 let n = tile_grid(fh, fw, 32, 32, 0.0).len();
3950
3951 let mut parent = match proc.alloc_tile_batch(
3952 n,
3953 &cfg,
3954 PixelFormat::Rgba,
3955 DType::U8,
3956 Some(TensorMemory::DmaBuf),
3957 edgefirst_tensor::CpuAccess::ReadWrite,
3958 ) {
3959 Ok(p) => p,
3960 Err(e) => {
3961 eprintln!(
3962 "SKIPPED: {} — tall DMA parent alloc failed ({e:?})",
3963 function!()
3964 );
3965 return;
3966 }
3967 };
3968 let placements = proc.tile_into(&src, &mut parent, &cfg).unwrap();
3969
3970 for p in &placements {
3971 let source = Region::new(
3972 p.origin.0 as usize,
3973 p.origin.1 as usize,
3974 p.crop_size.0 as usize,
3975 p.crop_size.1 as usize,
3976 );
3977 let mut solo = TensorDyn::image(
3978 32,
3979 32,
3980 PixelFormat::Rgba,
3981 DType::U8,
3982 Some(TensorMemory::DmaBuf),
3983 edgefirst_tensor::CpuAccess::ReadWrite,
3984 )
3985 .unwrap();
3986 proc.convert(
3987 &src,
3988 &mut solo,
3989 Rotation::None,
3990 Flip::None,
3991 Crop::default()
3992 .with_source(Some(source))
3993 .with_fit(Fit::Stretch),
3994 )
3995 .unwrap();
3996 let band = parent.view(Region::new(0, p.index * 32, 32, 32)).unwrap();
3997 compare_images(
4003 &band,
4004 &solo,
4005 0.98,
4006 &format!("{}_tile{}", function!(), p.index),
4007 );
4008 }
4009 }
4010
4011 #[test]
4013 fn tile_into_undersized_dst_errors() {
4014 let mut proc = match ImageProcessor::with_config(ImageProcessorConfig {
4015 backend: ComputeBackend::Cpu,
4016 ..Default::default()
4017 }) {
4018 Ok(p) => p,
4019 Err(e) => {
4020 eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4021 return;
4022 }
4023 };
4024 let (fw, fh) = (96usize, 64usize);
4025 let src = gradient_frame(fw, fh);
4026 let cfg = TilingConfig::new(32, 32).with_overlap(0.0); let mut small = TensorDyn::image(
4029 32,
4030 2 * 32,
4031 PixelFormat::Rgba,
4032 DType::U8,
4033 Some(TensorMemory::Mem),
4034 edgefirst_tensor::CpuAccess::ReadWrite,
4035 )
4036 .unwrap();
4037 let r = proc.tile_into(&src, &mut small, &cfg);
4038 assert!(
4039 matches!(r, Err(Error::InvalidShape(_))),
4040 "expected InvalidShape, got {r:?}"
4041 );
4042 }
4043
4044 #[test]
4046 fn tiling_alloc_rejects_invalid_config() {
4047 let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4048 backend: ComputeBackend::Cpu,
4049 ..Default::default()
4050 }) {
4051 Ok(p) => p,
4052 Err(e) => {
4053 eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4054 return;
4055 }
4056 };
4057 let bad = TilingConfig::new(0, 640);
4058 assert!(proc.plan_tiles(1920, 1080, &bad).is_err());
4059 assert!(proc
4060 .alloc_tile_batch(
4061 4,
4062 &bad,
4063 PixelFormat::Rgba,
4064 DType::U8,
4065 Some(TensorMemory::Mem),
4066 edgefirst_tensor::CpuAccess::ReadWrite,
4067 )
4068 .is_err());
4069 }
4070
4071 #[test]
4072 fn plan_tiles_metadata_4k() {
4073 let proc = match ImageProcessor::with_config(ImageProcessorConfig {
4074 backend: ComputeBackend::Cpu,
4075 ..Default::default()
4076 }) {
4077 Ok(p) => p,
4078 Err(e) => {
4079 eprintln!("SKIPPED: {} — CPU init failed ({e:?})", function!());
4080 return;
4081 }
4082 };
4083 let cfg = TilingConfig::new(640, 640).with_overlap(0.2);
4084 let plan = proc.plan_tiles(3840, 2160, &cfg).unwrap();
4085 assert_eq!(plan.len(), 32);
4086 assert!(plan.iter().all(|p| p.count == 32));
4087 assert!(plan.iter().all(|p| p.crop_size == (640.0, 640.0)));
4088 assert!(plan.iter().all(|p| p.letterbox.is_none())); assert!(plan.iter().all(|p| p.frame_dims == (3840.0, 2160.0)));
4090 assert_eq!(plan[0].origin, (0.0, 0.0));
4091 }
4092
4093 #[test]
4094 fn test_invalid_crop() {
4095 let src = TensorDyn::image(
4096 100,
4097 100,
4098 PixelFormat::Rgb,
4099 DType::U8,
4100 None,
4101 edgefirst_tensor::CpuAccess::ReadWrite,
4102 )
4103 .unwrap();
4104 let dst = TensorDyn::image(
4105 100,
4106 100,
4107 PixelFormat::Rgb,
4108 DType::U8,
4109 None,
4110 edgefirst_tensor::CpuAccess::ReadWrite,
4111 )
4112 .unwrap();
4113
4114 let crop = Crop::new().with_source(Some(Region::new(50, 50, 60, 60)));
4116 assert!(matches!(
4117 crop.check_crop_dyn(&src, &dst),
4118 Err(Error::CropInvalid(_))
4119 ));
4120
4121 let crop = Crop::new().with_source(Some(Region::new(0, 0, 10, 10)));
4123 assert!(crop.check_crop_dyn(&src, &dst).is_ok());
4124
4125 assert!(Crop::letterbox([0, 0, 0, 255])
4127 .check_crop_dyn(&src, &dst)
4128 .is_ok());
4129 }
4130
4131 #[test]
4132 fn test_invalid_tensor_format() -> Result<(), Error> {
4133 let mut tensor = Tensor::<u8>::new(&[720, 1280, 4, 1], None, None)?;
4135 let result = tensor.set_format(PixelFormat::Rgb);
4136 assert!(result.is_err(), "4D tensor should reject set_format");
4137
4138 let mut tensor = Tensor::<u8>::new(&[720, 1280, 4], None, None)?;
4140 let result = tensor.set_format(PixelFormat::Rgb);
4141 assert!(result.is_err(), "4-channel tensor should reject RGB format");
4142
4143 Ok(())
4144 }
4145
4146 #[test]
4147 fn test_invalid_image_file() -> Result<(), Error> {
4148 let result = crate::load_image_test_helper(&[123; 5000], None, None);
4149 assert!(
4150 matches!(result, Err(Error::Codec(_))),
4151 "unrecognised bytes should surface as Error::Codec, got {result:?}"
4152 );
4153 Ok(())
4154 }
4155
4156 #[test]
4157 fn test_invalid_jpeg_format() -> Result<(), Error> {
4158 let result = crate::load_image_test_helper(&[123; 5000], Some(PixelFormat::Yuyv), None);
4159 assert!(
4162 matches!(result, Err(Error::Codec(_))),
4163 "Yuyv target with garbage bytes should surface as Error::Codec, got {result:?}"
4164 );
4165 Ok(())
4166 }
4167
4168 #[test]
4169 fn test_load_resize_save() {
4170 let file = edgefirst_bench::testdata::read("zidane.jpg");
4171 let img = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4172 assert_eq!(img.width(), Some(1280));
4173 assert_eq!(img.height(), Some(720));
4174
4175 let dst = TensorDyn::image(
4176 640,
4177 360,
4178 PixelFormat::Rgba,
4179 DType::U8,
4180 None,
4181 edgefirst_tensor::CpuAccess::ReadWrite,
4182 )
4183 .unwrap();
4184 let mut converter = CPUProcessor::new();
4185 let (result, _img, dst) = convert_img(
4186 &mut converter,
4187 img,
4188 dst,
4189 Rotation::None,
4190 Flip::None,
4191 Crop::no_crop(),
4192 );
4193 result.unwrap();
4194 assert_eq!(dst.width(), Some(640));
4195 assert_eq!(dst.height(), Some(360));
4196
4197 crate::save_jpeg(&dst, "zidane_resized.jpg", 80).unwrap();
4198
4199 let file = std::fs::read("zidane_resized.jpg").unwrap();
4200 let img = crate::load_image_test_helper(&file, None, None).unwrap();
4203 assert_eq!(img.width(), Some(640));
4204 assert_eq!(img.height(), Some(360));
4205 assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
4206 }
4207
4208 #[test]
4209 fn test_from_tensor_planar() -> Result<(), Error> {
4210 let mut tensor = Tensor::new(&[3, 720, 1280], None, None)?;
4211 tensor
4212 .map()?
4213 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.8bps"));
4214 let planar = {
4215 tensor
4216 .set_format(PixelFormat::PlanarRgb)
4217 .map_err(|e| crate::Error::Internal(e.to_string()))?;
4218 TensorDyn::from(tensor)
4219 };
4220
4221 let rbga = load_bytes_to_tensor(
4222 1280,
4223 720,
4224 PixelFormat::Rgba,
4225 None,
4226 &edgefirst_bench::testdata::read("camera720p.rgba"),
4227 )?;
4228 compare_images_convert_to_rgb(&planar, &rbga, 0.98, function!());
4229
4230 Ok(())
4231 }
4232
4233 #[test]
4234 fn test_from_tensor_invalid_format() {
4235 assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4238 }
4239
4240 #[test]
4241 #[should_panic(expected = "Failed to save planar RGB image")]
4242 fn test_save_planar() {
4243 let planar_img = load_bytes_to_tensor(
4244 1280,
4245 720,
4246 PixelFormat::PlanarRgb,
4247 None,
4248 &edgefirst_bench::testdata::read("camera720p.8bps"),
4249 )
4250 .unwrap();
4251
4252 let save_path = "/tmp/planar_rgb.jpg";
4253 crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save planar RGB image");
4254 }
4255
4256 #[test]
4257 #[should_panic(expected = "Failed to save YUYV image")]
4258 fn test_save_yuyv() {
4259 let planar_img = load_bytes_to_tensor(
4260 1280,
4261 720,
4262 PixelFormat::Yuyv,
4263 None,
4264 &edgefirst_bench::testdata::read("camera720p.yuyv"),
4265 )
4266 .unwrap();
4267
4268 let save_path = "/tmp/yuyv.jpg";
4269 crate::save_jpeg(&planar_img, save_path, 90).expect("Failed to save YUYV image");
4270 }
4271
4272 #[test]
4273 fn test_rotation_angle() {
4274 assert_eq!(Rotation::from_degrees_clockwise(0), Rotation::None);
4275 assert_eq!(Rotation::from_degrees_clockwise(90), Rotation::Clockwise90);
4276 assert_eq!(Rotation::from_degrees_clockwise(180), Rotation::Rotate180);
4277 assert_eq!(
4278 Rotation::from_degrees_clockwise(270),
4279 Rotation::CounterClockwise90
4280 );
4281 assert_eq!(Rotation::from_degrees_clockwise(360), Rotation::None);
4282 assert_eq!(Rotation::from_degrees_clockwise(450), Rotation::Clockwise90);
4283 assert_eq!(Rotation::from_degrees_clockwise(540), Rotation::Rotate180);
4284 assert_eq!(
4285 Rotation::from_degrees_clockwise(630),
4286 Rotation::CounterClockwise90
4287 );
4288 }
4289
4290 #[test]
4291 #[should_panic(expected = "rotation angle is not a multiple of 90")]
4292 fn test_rotation_angle_panic() {
4293 Rotation::from_degrees_clockwise(361);
4294 }
4295
4296 #[test]
4297 fn test_disable_env_var() -> Result<(), Error> {
4298 let _lock = acquire_env_lock();
4301
4302 let _guard = EnvGuard::snapshot(&[
4305 "EDGEFIRST_FORCE_BACKEND",
4306 "EDGEFIRST_DISABLE_GL",
4307 "EDGEFIRST_DISABLE_G2D",
4308 "EDGEFIRST_DISABLE_CPU",
4309 ]);
4310
4311 unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
4314
4315 #[cfg(target_os = "linux")]
4316 {
4317 unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4318 let converter = ImageProcessor::new()?;
4319 assert!(converter.g2d.is_none());
4320 unsafe { std::env::remove_var("EDGEFIRST_DISABLE_G2D") };
4321 }
4322
4323 #[cfg(any(target_os = "linux", target_os = "windows"))]
4324 #[cfg(feature = "opengl")]
4325 {
4326 unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4327 let converter = ImageProcessor::new()?;
4328 assert!(converter.opengl.is_none());
4329 unsafe { std::env::remove_var("EDGEFIRST_DISABLE_GL") };
4330 }
4331
4332 unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4333 let converter = ImageProcessor::new()?;
4334 assert!(converter.cpu.is_none());
4335 unsafe { std::env::remove_var("EDGEFIRST_DISABLE_CPU") };
4336
4337 unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
4339 unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
4340 unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
4341 let mut converter = ImageProcessor::new()?;
4342
4343 let src = TensorDyn::image(
4344 1280,
4345 720,
4346 PixelFormat::Rgba,
4347 DType::U8,
4348 None,
4349 edgefirst_tensor::CpuAccess::ReadWrite,
4350 )?;
4351 let dst = TensorDyn::image(
4352 640,
4353 360,
4354 PixelFormat::Rgba,
4355 DType::U8,
4356 None,
4357 edgefirst_tensor::CpuAccess::ReadWrite,
4358 )?;
4359 let (result, _src, _dst) = convert_img(
4360 &mut converter,
4361 src,
4362 dst,
4363 Rotation::None,
4364 Flip::None,
4365 Crop::no_crop(),
4366 );
4367 assert!(matches!(result, Err(Error::NoConverter)));
4368 Ok(())
4370 }
4371
4372 #[test]
4373 fn test_unsupported_conversion() {
4374 let src = TensorDyn::image(
4375 1280,
4376 720,
4377 PixelFormat::Nv12,
4378 DType::U8,
4379 None,
4380 edgefirst_tensor::CpuAccess::ReadWrite,
4381 )
4382 .unwrap();
4383 let dst = TensorDyn::image(
4384 640,
4385 360,
4386 PixelFormat::Nv12,
4387 DType::U8,
4388 None,
4389 edgefirst_tensor::CpuAccess::ReadWrite,
4390 )
4391 .unwrap();
4392 let mut converter = ImageProcessor::new().unwrap();
4393 let (result, _src, _dst) = convert_img(
4394 &mut converter,
4395 src,
4396 dst,
4397 Rotation::None,
4398 Flip::None,
4399 Crop::no_crop(),
4400 );
4401 log::debug!("result: {:?}", result);
4402 assert!(matches!(
4403 result,
4404 Err(Error::NotSupported(e)) if e.starts_with("Conversion from NV12 to NV12")
4405 ));
4406 }
4407
4408 #[test]
4409 fn test_load_grey() {
4410 let grey_img = crate::load_image_test_helper(
4414 &edgefirst_bench::testdata::read("grey.jpg"),
4415 Some(PixelFormat::Rgba),
4416 None,
4417 )
4418 .unwrap();
4419 assert_eq!(grey_img.width(), Some(1024));
4420 assert_eq!(grey_img.height(), Some(681));
4421
4422 let grey_but_rgb = crate::load_image_test_helper(
4428 &edgefirst_bench::testdata::read("grey-rgb.jpg"),
4429 Some(PixelFormat::Rgba),
4430 None,
4431 )
4432 .expect("odd-height colour JPEG should decode to NV12 and convert to RGBA");
4433 assert_eq!(grey_but_rgb.width(), Some(1024));
4434 assert_eq!(grey_but_rgb.height(), Some(681));
4435 }
4436
4437 #[test]
4438 fn test_new_nv12() {
4439 let nv12 = TensorDyn::image(
4440 1280,
4441 720,
4442 PixelFormat::Nv12,
4443 DType::U8,
4444 None,
4445 edgefirst_tensor::CpuAccess::ReadWrite,
4446 )
4447 .unwrap();
4448 assert_eq!(nv12.height(), Some(720));
4449 assert_eq!(nv12.width(), Some(1280));
4450 assert_eq!(nv12.format().unwrap(), PixelFormat::Nv12);
4451 assert_eq!(nv12.format().unwrap().channels(), 1);
4453 assert!(nv12.format().is_some_and(
4454 |f| f.layout() == PixelLayout::Planar || f.layout() == PixelLayout::SemiPlanar
4455 ))
4456 }
4457
4458 #[test]
4459 #[cfg(any(target_os = "linux", target_os = "windows"))]
4460 fn test_new_image_converter() {
4461 let dst_width = 640;
4462 let dst_height = 360;
4463 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4464 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4465
4466 let mut converter = ImageProcessor::new().unwrap();
4467 let converter_dst = converter
4468 .create_image(
4469 dst_width,
4470 dst_height,
4471 PixelFormat::Rgba,
4472 DType::U8,
4473 None,
4474 edgefirst_tensor::CpuAccess::ReadWrite,
4475 )
4476 .unwrap();
4477 let (result, src, converter_dst) = convert_img(
4478 &mut converter,
4479 src,
4480 converter_dst,
4481 Rotation::None,
4482 Flip::None,
4483 Crop::no_crop(),
4484 );
4485 result.unwrap();
4486
4487 let cpu_dst = TensorDyn::image(
4488 dst_width,
4489 dst_height,
4490 PixelFormat::Rgba,
4491 DType::U8,
4492 None,
4493 edgefirst_tensor::CpuAccess::ReadWrite,
4494 )
4495 .unwrap();
4496 let mut cpu_converter = CPUProcessor::new();
4497 let (result, _src, cpu_dst) = convert_img(
4498 &mut cpu_converter,
4499 src,
4500 cpu_dst,
4501 Rotation::None,
4502 Flip::None,
4503 Crop::no_crop(),
4504 );
4505 result.unwrap();
4506
4507 compare_images(&converter_dst, &cpu_dst, 0.98, function!());
4508 }
4509
4510 #[test]
4511 #[cfg(any(target_os = "linux", target_os = "windows"))]
4512 fn test_create_image_dtype_i8() {
4513 let mut converter = ImageProcessor::new().unwrap();
4514
4515 let dst = converter
4517 .create_image(
4518 320,
4519 240,
4520 PixelFormat::Rgb,
4521 DType::I8,
4522 None,
4523 edgefirst_tensor::CpuAccess::ReadWrite,
4524 )
4525 .unwrap();
4526 assert_eq!(dst.dtype(), DType::I8);
4527 assert!(dst.width() == Some(320));
4528 assert!(dst.height() == Some(240));
4529 assert_eq!(dst.format(), Some(PixelFormat::Rgb));
4530
4531 let dst_u8 = converter
4533 .create_image(
4534 320,
4535 240,
4536 PixelFormat::Rgb,
4537 DType::U8,
4538 None,
4539 edgefirst_tensor::CpuAccess::ReadWrite,
4540 )
4541 .unwrap();
4542 assert_eq!(dst_u8.dtype(), DType::U8);
4543
4544 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4546 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4547 let mut dst_i8 = converter
4548 .create_image(
4549 320,
4550 240,
4551 PixelFormat::Rgb,
4552 DType::I8,
4553 None,
4554 edgefirst_tensor::CpuAccess::ReadWrite,
4555 )
4556 .unwrap();
4557 converter
4558 .convert(
4559 &src,
4560 &mut dst_i8,
4561 Rotation::None,
4562 Flip::None,
4563 Crop::no_crop(),
4564 )
4565 .unwrap();
4566 }
4567
4568 #[test]
4569 #[cfg(target_os = "linux")]
4570 fn test_create_image_nv12_dma_non_aligned_width() {
4571 let converter = ImageProcessor::new().unwrap();
4577
4578 let result = converter.create_image(
4580 100,
4581 64,
4582 PixelFormat::Nv12,
4583 DType::U8,
4584 Some(TensorMemory::DmaBuf),
4585 edgefirst_tensor::CpuAccess::ReadWrite,
4586 );
4587
4588 match result {
4589 Ok(img) => {
4590 assert_eq!(img.width(), Some(100));
4591 assert_eq!(img.height(), Some(64));
4592 assert_eq!(img.format(), Some(PixelFormat::Nv12));
4593 if let Some(stride) = img.row_stride() {
4594 assert!(
4595 stride >= 100,
4596 "NV12 row_stride {stride} must be >= the logical width (100)",
4597 );
4598 }
4599 }
4600 Err(e) => {
4601 eprintln!("SKIPPED: create_image NV12 DMA non-aligned width: {e}");
4603 }
4604 }
4605 }
4606
4607 #[test]
4608 #[ignore] fn test_crop_skip() {
4612 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4613 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4614
4615 let mut converter = ImageProcessor::new().unwrap();
4616 let converter_dst = converter
4617 .create_image(
4618 1280,
4619 720,
4620 PixelFormat::Rgba,
4621 DType::U8,
4622 None,
4623 edgefirst_tensor::CpuAccess::ReadWrite,
4624 )
4625 .unwrap();
4626 let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 640)));
4627 let (result, src, converter_dst) = convert_img(
4628 &mut converter,
4629 src,
4630 converter_dst,
4631 Rotation::None,
4632 Flip::None,
4633 crop,
4634 );
4635 result.unwrap();
4636
4637 let cpu_dst = TensorDyn::image(
4638 1280,
4639 720,
4640 PixelFormat::Rgba,
4641 DType::U8,
4642 None,
4643 edgefirst_tensor::CpuAccess::ReadWrite,
4644 )
4645 .unwrap();
4646 let mut cpu_converter = CPUProcessor::new();
4647 let (result, _src, cpu_dst) = convert_img(
4648 &mut cpu_converter,
4649 src,
4650 cpu_dst,
4651 Rotation::None,
4652 Flip::None,
4653 crop,
4654 );
4655 result.unwrap();
4656
4657 compare_images(&converter_dst, &cpu_dst, 0.99999, function!());
4658 }
4659
4660 #[test]
4661 fn test_invalid_pixel_format() {
4662 assert!(PixelFormat::from_fourcc(u32::from_le_bytes(*b"TEST")).is_none());
4665 }
4666
4667 #[cfg(target_os = "linux")]
4669 static G2D_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4670
4671 #[cfg(target_os = "linux")]
4672 fn is_g2d_available() -> bool {
4673 *G2D_AVAILABLE.get_or_init(|| G2DProcessor::new().is_ok())
4674 }
4675
4676 #[cfg(any(
4682 target_os = "linux",
4683 target_os = "macos",
4684 target_os = "ios",
4685 target_os = "android",
4686 target_os = "windows"
4687 ))]
4688 #[cfg(feature = "opengl")]
4689 static GL_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4690
4691 #[cfg(any(
4692 target_os = "linux",
4693 target_os = "macos",
4694 target_os = "ios",
4695 target_os = "android",
4696 target_os = "windows"
4697 ))]
4698 #[cfg(feature = "opengl")]
4699 fn is_opengl_available() -> bool {
4701 *GL_AVAILABLE.get_or_init(|| GLProcessorThreaded::new(None).is_ok())
4702 }
4703
4704 #[test]
4716 #[cfg(feature = "opengl")]
4717 fn gl_backend_available_canary() {
4718 let require_gl = std::env::var("HAL_TEST_REQUIRE_GL").is_ok_and(|v| v == "1");
4719 if !require_gl {
4720 eprintln!(
4721 "SKIPPED: {} — HAL_TEST_REQUIRE_GL is not set to 1",
4722 function!()
4723 );
4724 return;
4725 }
4726 #[cfg(target_os = "macos")]
4727 if std::env::var_os("HAL_TEST_ALLOW_DLOPEN_ANGLE").is_none() {
4728 eprintln!(
4729 "SKIPPED: {} — ANGLE dlopen gate closed (coverage pass 1)",
4730 function!()
4731 );
4732 return;
4733 }
4734 GLProcessorThreaded::new(None).expect(
4735 "HAL_TEST_REQUIRE_GL=1 but the GL backend failed to initialize — \
4736 check the ANGLE install/re-sign step and binary entitlements \
4737 (macOS) or the EGL stack (Linux)",
4738 );
4739 }
4740
4741 #[test]
4742 fn test_load_jpeg_with_exif() {
4743 use edgefirst_codec::peek_info;
4744
4745 let file = edgefirst_bench::testdata::read("zidane_rotated_exif.jpg").to_vec();
4750 let info = peek_info(&file).unwrap();
4751 assert_eq!(info.rotation_degrees, 90);
4752 assert!(!info.flip_horizontal);
4753
4754 let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4755 assert_eq!(loaded.width(), Some(1280));
4757 assert_eq!(loaded.height(), Some(720));
4758
4759 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4762 let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4763
4764 let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4765 let (dst_width, dst_height) = (cpu_src.height().unwrap(), cpu_src.width().unwrap());
4766
4767 let cpu_dst = TensorDyn::image(
4768 dst_width,
4769 dst_height,
4770 PixelFormat::Rgba,
4771 DType::U8,
4772 None,
4773 edgefirst_tensor::CpuAccess::ReadWrite,
4774 )
4775 .unwrap();
4776 let mut cpu_converter = CPUProcessor::new();
4777
4778 let loaded_rotated = TensorDyn::image(
4781 dst_width,
4782 dst_height,
4783 PixelFormat::Rgba,
4784 DType::U8,
4785 None,
4786 edgefirst_tensor::CpuAccess::ReadWrite,
4787 )
4788 .unwrap();
4789 let (r0, _loaded, loaded_rotated) = convert_img(
4790 &mut cpu_converter,
4791 loaded,
4792 loaded_rotated,
4793 rotation,
4794 Flip::None,
4795 Crop::no_crop(),
4796 );
4797 r0.unwrap();
4798
4799 let (result, _cpu_src, cpu_dst) = convert_img(
4800 &mut cpu_converter,
4801 cpu_src,
4802 cpu_dst,
4803 rotation,
4804 Flip::None,
4805 Crop::no_crop(),
4806 );
4807 result.unwrap();
4808
4809 compare_images(&loaded_rotated, &cpu_dst, 0.98, function!());
4810 }
4811
4812 #[test]
4813 fn test_load_png_with_exif() {
4814 use edgefirst_codec::peek_info;
4815
4816 let file = edgefirst_bench::testdata::read("zidane_rotated_exif_180.png").to_vec();
4819 let info = peek_info(&file).unwrap();
4820 assert_eq!(info.rotation_degrees, 180);
4821 assert!(!info.flip_horizontal);
4822
4823 let loaded = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4824 assert_eq!(loaded.height(), Some(720));
4826 assert_eq!(loaded.width(), Some(1280));
4827
4828 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
4833 let cpu_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
4834
4835 let rotation = Rotation::from_degrees_clockwise(info.rotation_degrees as usize);
4836 let cpu_dst = TensorDyn::image(
4837 1280,
4838 720,
4839 PixelFormat::Rgba,
4840 DType::U8,
4841 None,
4842 edgefirst_tensor::CpuAccess::ReadWrite,
4843 )
4844 .unwrap();
4845 let mut cpu_converter = CPUProcessor::new();
4846
4847 let (result, _cpu_src, cpu_dst) = convert_img(
4848 &mut cpu_converter,
4849 cpu_src,
4850 cpu_dst,
4851 rotation,
4852 Flip::None,
4853 Crop::no_crop(),
4854 );
4855 result.unwrap();
4856
4857 let loaded_rotated = TensorDyn::image(
4860 1280,
4861 720,
4862 PixelFormat::Rgba,
4863 DType::U8,
4864 None,
4865 edgefirst_tensor::CpuAccess::ReadWrite,
4866 )
4867 .unwrap();
4868 let (r0, _loaded, loaded_rotated) = convert_img(
4869 &mut cpu_converter,
4870 loaded,
4871 loaded_rotated,
4872 rotation,
4873 Flip::None,
4874 Crop::no_crop(),
4875 );
4876 r0.unwrap();
4877
4878 compare_images(&loaded_rotated, &cpu_dst, 0.95, function!());
4883 }
4884
4885 #[cfg(target_os = "linux")]
4891 fn make_rgb_jpeg(width: u32, height: u32) -> Vec<u8> {
4892 let mut bytes = Vec::with_capacity((width * height * 3) as usize);
4893 for y in 0..height {
4894 for x in 0..width {
4895 bytes.push(((x + y) & 0xFF) as u8);
4896 bytes.push(((x.wrapping_mul(3)) & 0xFF) as u8);
4897 bytes.push(((y.wrapping_mul(5)) & 0xFF) as u8);
4898 }
4899 }
4900 let mut out = Vec::new();
4901 let encoder = jpeg_encoder::Encoder::new(&mut out, 85);
4902 encoder
4903 .encode(
4904 &bytes,
4905 width as u16,
4906 height as u16,
4907 jpeg_encoder::ColorType::Rgb,
4908 )
4909 .expect("jpeg-encoder must succeed on trivial input");
4910 out
4911 }
4912
4913 #[test]
4922 #[cfg(target_os = "linux")]
4923 #[cfg(feature = "opengl")]
4924 fn test_convert_rgba_non_4_aligned_width_end_to_end() {
4925 use edgefirst_tensor::is_dma_available;
4926 if !is_dma_available() {
4927 eprintln!(
4928 "SKIPPED: test_convert_rgba_non_4_aligned_width_end_to_end — DMA not available"
4929 );
4930 return;
4931 }
4932 let jpeg = make_rgb_jpeg(375, 333);
4936 let src_gl = crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
4937 assert_eq!(src_gl.width(), Some(375));
4938 let stride = src_gl.row_stride().unwrap();
4940 assert_eq!(stride, 1536, "expected padded pitch 1536, got {stride}");
4941
4942 let mut gl_proc = ImageProcessor::new().unwrap();
4944 let gl_dst = gl_proc
4945 .create_image(
4946 640,
4947 640,
4948 PixelFormat::Rgba,
4949 DType::U8,
4950 None,
4951 edgefirst_tensor::CpuAccess::ReadWrite,
4952 )
4953 .unwrap();
4954 let (r_gl, _src_gl, gl_dst) = convert_img(
4955 &mut gl_proc,
4956 src_gl,
4957 gl_dst,
4958 Rotation::None,
4959 Flip::None,
4960 Crop::no_crop(),
4961 );
4962 r_gl.expect("GL-backed convert must succeed for 375x333 Rgba src");
4963
4964 let src_cpu =
4969 crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), Some(TensorMemory::Mem))
4970 .unwrap();
4971 let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
4972 backend: ComputeBackend::Cpu,
4973 ..Default::default()
4974 })
4975 .unwrap();
4976 let cpu_dst = TensorDyn::image(
4977 640,
4978 640,
4979 PixelFormat::Rgba,
4980 DType::U8,
4981 Some(TensorMemory::Mem),
4982 edgefirst_tensor::CpuAccess::ReadWrite,
4983 )
4984 .unwrap();
4985 let (r_cpu, _src_cpu, cpu_dst) = convert_img(
4986 &mut cpu_proc,
4987 src_cpu,
4988 cpu_dst,
4989 Rotation::None,
4990 Flip::None,
4991 Crop::no_crop(),
4992 );
4993 r_cpu.unwrap();
4994
4995 compare_images(&gl_dst, &cpu_dst, 0.95, function!());
4999 }
5000
5001 #[test]
5008 #[cfg(target_os = "linux")]
5009 fn test_load_jpeg_rgba_non_aligned_pitch_padded_dma() {
5010 use edgefirst_tensor::is_dma_available;
5011 if !is_dma_available() {
5012 eprintln!(
5013 "SKIPPED: test_load_jpeg_rgba_non_aligned_pitch_padded_dma — DMA not available"
5014 );
5015 return;
5016 }
5017 for &w in &[500u32, 612, 428] {
5021 let jpeg = make_rgb_jpeg(w, 333);
5022 let loaded =
5023 crate::load_image_test_helper(&jpeg, Some(PixelFormat::Rgba), None).unwrap();
5024 let natural = (w as usize) * 4;
5025 let aligned = crate::align_pitch_bytes_to_gpu_alignment(natural).unwrap();
5026 assert!(
5027 aligned > natural,
5028 "test sanity: width {w} should be unaligned"
5029 );
5030 let stride = loaded
5031 .row_stride()
5032 .expect("padded DMA path must set an explicit row_stride — regression if None");
5033 assert_eq!(
5034 stride, aligned,
5035 "width {w}: expected padded stride {aligned}, got {stride} \
5036 (regression: pitch-padding branch skipped?)"
5037 );
5038 let eff = loaded.effective_row_stride().unwrap();
5039 assert_eq!(
5040 eff, aligned,
5041 "effective_row_stride must match stored stride"
5042 );
5043 assert_eq!(loaded.width(), Some(w as usize));
5044 assert_eq!(loaded.height(), Some(333));
5045 }
5046 }
5047
5048 #[test]
5057 #[cfg(target_os = "linux")]
5058 fn test_padded_dma_pitch_for_respects_memory_choice() {
5059 use edgefirst_tensor::{is_dma_available, TensorMemory};
5060
5061 let unaligned_w = 500;
5064
5065 assert_eq!(
5067 crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Mem),),
5068 None,
5069 "Mem must never trigger DMA padding"
5070 );
5071 assert_eq!(
5072 crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &Some(TensorMemory::Shm),),
5073 None,
5074 "Shm must never trigger DMA padding"
5075 );
5076
5077 assert_eq!(
5082 crate::padded_dma_pitch_for(
5083 PixelFormat::Rgba,
5084 unaligned_w,
5085 &Some(TensorMemory::DmaBuf),
5086 ),
5087 Some(2048),
5088 "explicit Dma must pad regardless of runtime DMA availability"
5089 );
5090
5091 let none_result = crate::padded_dma_pitch_for(PixelFormat::Rgba, unaligned_w, &None);
5095 if is_dma_available() {
5096 assert_eq!(
5097 none_result,
5098 Some(2048),
5099 "memory=None + DMA available → pad (will route through DMA)"
5100 );
5101 } else {
5102 assert_eq!(
5103 none_result, None,
5104 "memory=None + DMA unavailable → must NOT pad (would force \
5105 image_with_stride into a DMA-only allocation that fails). \
5106 Regression: padded_dma_pitch_for ignored is_dma_available()."
5107 );
5108 }
5109 }
5110
5111 fn make_grey_png(width: u32, height: u32) -> Vec<u8> {
5115 let mut bytes = Vec::with_capacity((width * height) as usize);
5116 for y in 0..height {
5117 for x in 0..width {
5118 bytes.push(((x + y) & 0xFF) as u8);
5119 }
5120 }
5121 let img = image::GrayImage::from_vec(width, height, bytes).unwrap();
5122 let mut buf = Vec::new();
5123 img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
5124 .unwrap();
5125 buf
5126 }
5127
5128 #[test]
5133 #[cfg(target_os = "linux")]
5134 fn test_load_png_grey_misaligned_width_dma() {
5135 use edgefirst_tensor::is_dma_available;
5136 if !is_dma_available() {
5137 eprintln!("SKIPPED: test_load_png_grey_misaligned_width_dma — DMA not available");
5138 return;
5139 }
5140 let png = make_grey_png(612, 388);
5141 let loaded = crate::load_image_test_helper(&png, Some(PixelFormat::Grey), None).unwrap();
5142 assert_eq!(loaded.width(), Some(612));
5143 assert_eq!(loaded.height(), Some(388));
5144 assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5145
5146 let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5149 let stride = loaded.row_stride().unwrap_or(612);
5150 assert!(stride >= 612);
5151 let bytes: &[u8] = ↦
5152 for y in 0..388usize {
5153 for x in 0..612usize {
5154 let expected = ((x + y) & 0xFF) as u8;
5155 let got = bytes[y * stride + x];
5156 assert_eq!(
5157 got, expected,
5158 "grey png mismatch at ({x},{y}): got {got} expected {expected}"
5159 );
5160 }
5161 }
5162 }
5163
5164 #[test]
5168 fn test_load_png_grey_mem() {
5169 use edgefirst_tensor::TensorMemory;
5170 let png = make_grey_png(612, 100);
5171 let loaded =
5172 crate::load_image_test_helper(&png, Some(PixelFormat::Grey), Some(TensorMemory::Mem))
5173 .unwrap();
5174 assert_eq!(loaded.width(), Some(612));
5175 assert_eq!(loaded.height(), Some(100));
5176 assert_eq!(loaded.format(), Some(PixelFormat::Grey));
5177 let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5178 let bytes: &[u8] = ↦
5179 assert_eq!(bytes.len(), 612 * 100);
5181 for y in 0..100 {
5182 for x in 0..612 {
5183 assert_eq!(bytes[y * 612 + x], ((x + y) & 0xFF) as u8);
5184 }
5185 }
5186 }
5187
5188 #[test]
5192 fn test_load_png_grey_to_rgb_mem() {
5193 use edgefirst_tensor::TensorMemory;
5194 let png = make_grey_png(620, 240);
5195 let loaded =
5196 crate::load_image_test_helper(&png, Some(PixelFormat::Rgb), Some(TensorMemory::Mem))
5197 .unwrap();
5198 assert_eq!(loaded.width(), Some(620));
5199 assert_eq!(loaded.height(), Some(240));
5200 assert_eq!(loaded.format(), Some(PixelFormat::Rgb));
5201
5202 let map = loaded.as_typed::<u8>().unwrap().map().unwrap();
5204 let bytes: &[u8] = ↦
5205 for (x, y) in [(0usize, 0usize), (100, 50), (619, 239)] {
5206 let expected = ((x + y) & 0xFF) as u8;
5207 let off = (y * 620 + x) * 3;
5208 assert_eq!(bytes[off], expected, "R@{x},{y}");
5209 assert_eq!(bytes[off + 1], expected, "G@{x},{y}");
5210 assert_eq!(bytes[off + 2], expected, "B@{x},{y}");
5211 }
5212 }
5213
5214 #[test]
5215 #[cfg(target_os = "linux")]
5216 fn test_g2d_resize() {
5217 if !is_g2d_available() {
5218 eprintln!("SKIPPED: test_g2d_resize - G2D library (libg2d.so.2) not available");
5219 return;
5220 }
5221 if !is_dma_available() {
5222 eprintln!(
5223 "SKIPPED: test_g2d_resize - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5224 );
5225 return;
5226 }
5227
5228 let dst_width = 640;
5229 let dst_height = 360;
5230 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5231 let src = crate::load_image_test_helper(
5232 &file,
5233 Some(PixelFormat::Rgba),
5234 Some(TensorMemory::DmaBuf),
5235 )
5236 .unwrap();
5237
5238 let g2d_dst = TensorDyn::image(
5239 dst_width,
5240 dst_height,
5241 PixelFormat::Rgba,
5242 DType::U8,
5243 Some(TensorMemory::DmaBuf),
5244 edgefirst_tensor::CpuAccess::ReadWrite,
5245 )
5246 .unwrap();
5247 let mut g2d_converter = G2DProcessor::new().unwrap();
5248 let (result, src, g2d_dst) = convert_img(
5249 &mut g2d_converter,
5250 src,
5251 g2d_dst,
5252 Rotation::None,
5253 Flip::None,
5254 Crop::no_crop(),
5255 );
5256 result.unwrap();
5257
5258 let cpu_dst = TensorDyn::image(
5259 dst_width,
5260 dst_height,
5261 PixelFormat::Rgba,
5262 DType::U8,
5263 None,
5264 edgefirst_tensor::CpuAccess::ReadWrite,
5265 )
5266 .unwrap();
5267 let mut cpu_converter = CPUProcessor::new();
5268 let (result, _src, cpu_dst) = convert_img(
5269 &mut cpu_converter,
5270 src,
5271 cpu_dst,
5272 Rotation::None,
5273 Flip::None,
5274 Crop::no_crop(),
5275 );
5276 result.unwrap();
5277
5278 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5284 }
5285
5286 #[test]
5287 #[cfg(any(
5288 target_os = "linux",
5289 target_os = "macos",
5290 target_os = "ios",
5291 target_os = "android",
5292 target_os = "windows"
5293 ))]
5294 #[cfg(feature = "opengl")]
5295 fn test_opengl_resize() {
5296 if !is_opengl_available() {
5297 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5298 return;
5299 }
5300
5301 let dst_width = 640;
5302 let dst_height = 360;
5303 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5304 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5305
5306 let cpu_dst = TensorDyn::image(
5307 dst_width,
5308 dst_height,
5309 PixelFormat::Rgba,
5310 DType::U8,
5311 None,
5312 edgefirst_tensor::CpuAccess::ReadWrite,
5313 )
5314 .unwrap();
5315 let mut cpu_converter = CPUProcessor::new();
5316 let (result, src, cpu_dst) = convert_img(
5317 &mut cpu_converter,
5318 src,
5319 cpu_dst,
5320 Rotation::None,
5321 Flip::None,
5322 Crop::no_crop(),
5323 );
5324 result.unwrap();
5325
5326 let mut src = src;
5327 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5328
5329 for _ in 0..5 {
5330 let gl_dst = TensorDyn::image(
5331 dst_width,
5332 dst_height,
5333 PixelFormat::Rgba,
5334 DType::U8,
5335 None,
5336 edgefirst_tensor::CpuAccess::ReadWrite,
5337 )
5338 .unwrap();
5339 let (result, src_back, gl_dst) = convert_img(
5340 &mut gl_converter,
5341 src,
5342 gl_dst,
5343 Rotation::None,
5344 Flip::None,
5345 Crop::no_crop(),
5346 );
5347 result.unwrap();
5348 src = src_back;
5349
5350 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5351 }
5352 }
5353
5354 #[test]
5355 #[cfg(any(
5356 target_os = "linux",
5357 target_os = "macos",
5358 target_os = "ios",
5359 target_os = "android",
5360 target_os = "windows"
5361 ))]
5362 #[cfg(feature = "opengl")]
5363 fn test_opengl_10_threads() {
5364 if !is_opengl_available() {
5365 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5366 return;
5367 }
5368
5369 let handles: Vec<_> = (0..10)
5370 .map(|i| {
5371 std::thread::Builder::new()
5372 .name(format!("Thread {i}"))
5373 .spawn(test_opengl_resize)
5374 .unwrap()
5375 })
5376 .collect();
5377 handles.into_iter().for_each(|h| {
5378 if let Err(e) = h.join() {
5379 std::panic::resume_unwind(e)
5380 }
5381 });
5382 }
5383
5384 #[test]
5385 #[cfg(any(
5386 target_os = "linux",
5387 target_os = "macos",
5388 target_os = "ios",
5389 target_os = "android",
5390 target_os = "windows"
5391 ))]
5392 #[cfg(feature = "opengl")]
5393 fn test_opengl_grey() {
5394 if !is_opengl_available() {
5395 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5396 return;
5397 }
5398
5399 let img = crate::load_image_test_helper(
5400 &edgefirst_bench::testdata::read("grey.jpg"),
5401 Some(PixelFormat::Grey),
5402 None,
5403 )
5404 .unwrap();
5405
5406 let gl_dst = TensorDyn::image(
5407 640,
5408 640,
5409 PixelFormat::Grey,
5410 DType::U8,
5411 None,
5412 edgefirst_tensor::CpuAccess::ReadWrite,
5413 )
5414 .unwrap();
5415 let cpu_dst = TensorDyn::image(
5416 640,
5417 640,
5418 PixelFormat::Grey,
5419 DType::U8,
5420 None,
5421 edgefirst_tensor::CpuAccess::ReadWrite,
5422 )
5423 .unwrap();
5424
5425 let mut converter = CPUProcessor::new();
5426
5427 let (result, img, cpu_dst) = convert_img(
5428 &mut converter,
5429 img,
5430 cpu_dst,
5431 Rotation::None,
5432 Flip::None,
5433 Crop::no_crop(),
5434 );
5435 result.unwrap();
5436
5437 let mut gl = GLProcessorThreaded::new(None).unwrap();
5438 let (result, _img, gl_dst) = convert_img(
5439 &mut gl,
5440 img,
5441 gl_dst,
5442 Rotation::None,
5443 Flip::None,
5444 Crop::no_crop(),
5445 );
5446 result.unwrap();
5447
5448 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5449 }
5450
5451 #[test]
5452 #[cfg(target_os = "linux")]
5453 fn test_g2d_src_crop() {
5454 if !is_g2d_available() {
5455 eprintln!("SKIPPED: test_g2d_src_crop - G2D library (libg2d.so.2) not available");
5456 return;
5457 }
5458 if !is_dma_available() {
5459 eprintln!(
5460 "SKIPPED: test_g2d_src_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5461 );
5462 return;
5463 }
5464
5465 let dst_width = 640;
5466 let dst_height = 640;
5467 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5468 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5469
5470 let cpu_dst = TensorDyn::image(
5471 dst_width,
5472 dst_height,
5473 PixelFormat::Rgba,
5474 DType::U8,
5475 None,
5476 edgefirst_tensor::CpuAccess::ReadWrite,
5477 )
5478 .unwrap();
5479 let mut cpu_converter = CPUProcessor::new();
5480 let crop = Crop::new().with_source(Some(Region::new(0, 0, 640, 360)));
5481 let (result, src, cpu_dst) = convert_img(
5482 &mut cpu_converter,
5483 src,
5484 cpu_dst,
5485 Rotation::None,
5486 Flip::None,
5487 crop,
5488 );
5489 result.unwrap();
5490
5491 let g2d_dst = TensorDyn::image(
5492 dst_width,
5493 dst_height,
5494 PixelFormat::Rgba,
5495 DType::U8,
5496 None,
5497 edgefirst_tensor::CpuAccess::ReadWrite,
5498 )
5499 .unwrap();
5500 let mut g2d_converter = G2DProcessor::new().unwrap();
5501 let (result, _src, g2d_dst) = convert_img(
5502 &mut g2d_converter,
5503 src,
5504 g2d_dst,
5505 Rotation::None,
5506 Flip::None,
5507 crop,
5508 );
5509 result.unwrap();
5510
5511 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5517 }
5518
5519 #[test]
5520 #[cfg(target_os = "linux")]
5521 fn test_g2d_dst_crop() {
5522 if !is_g2d_available() {
5523 eprintln!("SKIPPED: test_g2d_dst_crop - G2D library (libg2d.so.2) not available");
5524 return;
5525 }
5526 if !is_dma_available() {
5527 eprintln!(
5528 "SKIPPED: test_g2d_dst_crop - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5529 );
5530 return;
5531 }
5532
5533 let dst_width = 640;
5534 let dst_height = 640;
5535 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5536 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5537
5538 let cpu_dst = TensorDyn::image(
5539 dst_width,
5540 dst_height,
5541 PixelFormat::Rgba,
5542 DType::U8,
5543 None,
5544 edgefirst_tensor::CpuAccess::ReadWrite,
5545 )
5546 .unwrap();
5547 let mut cpu_converter = CPUProcessor::new();
5548 let crop = Crop::new();
5549 let (result, src, cpu_dst) = convert_img(
5550 &mut cpu_converter,
5551 src,
5552 cpu_dst,
5553 Rotation::None,
5554 Flip::None,
5555 crop,
5556 );
5557 result.unwrap();
5558
5559 let g2d_dst = TensorDyn::image(
5560 dst_width,
5561 dst_height,
5562 PixelFormat::Rgba,
5563 DType::U8,
5564 None,
5565 edgefirst_tensor::CpuAccess::ReadWrite,
5566 )
5567 .unwrap();
5568 let mut g2d_converter = G2DProcessor::new().unwrap();
5569 let (result, _src, g2d_dst) = convert_img(
5570 &mut g2d_converter,
5571 src,
5572 g2d_dst,
5573 Rotation::None,
5574 Flip::None,
5575 crop,
5576 );
5577 result.unwrap();
5578
5579 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
5585 }
5586
5587 #[test]
5588 #[cfg(target_os = "linux")]
5589 fn test_g2d_all_rgba() {
5590 if !is_g2d_available() {
5591 eprintln!("SKIPPED: test_g2d_all_rgba - G2D library (libg2d.so.2) not available");
5592 return;
5593 }
5594 if !is_dma_available() {
5595 eprintln!(
5596 "SKIPPED: test_g2d_all_rgba - DMA memory allocation not available (permission denied or no DMA-BUF support)"
5597 );
5598 return;
5599 }
5600
5601 let dst_width = 640;
5602 let dst_height = 640;
5603 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5604 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5605 let src_dyn = src;
5606
5607 let mut cpu_dst = TensorDyn::image(
5608 dst_width,
5609 dst_height,
5610 PixelFormat::Rgba,
5611 DType::U8,
5612 None,
5613 edgefirst_tensor::CpuAccess::ReadWrite,
5614 )
5615 .unwrap();
5616 let mut cpu_converter = CPUProcessor::new();
5617 let mut g2d_dst = TensorDyn::image(
5618 dst_width,
5619 dst_height,
5620 PixelFormat::Rgba,
5621 DType::U8,
5622 None,
5623 edgefirst_tensor::CpuAccess::ReadWrite,
5624 )
5625 .unwrap();
5626 let mut g2d_converter = G2DProcessor::new().unwrap();
5627
5628 let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5629
5630 for rot in [
5631 Rotation::None,
5632 Rotation::Clockwise90,
5633 Rotation::Rotate180,
5634 Rotation::CounterClockwise90,
5635 ] {
5636 cpu_dst
5637 .as_typed::<u8>()
5638 .unwrap()
5639 .map()
5640 .unwrap()
5641 .as_mut_slice()
5642 .fill(114);
5643 g2d_dst
5644 .as_typed::<u8>()
5645 .unwrap()
5646 .map()
5647 .unwrap()
5648 .as_mut_slice()
5649 .fill(114);
5650 for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5651 let mut cpu_dst_dyn = cpu_dst;
5652 cpu_converter
5653 .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5654 .unwrap();
5655 cpu_dst = {
5656 let mut __t = cpu_dst_dyn.into_u8().unwrap();
5657 __t.set_format(PixelFormat::Rgba).unwrap();
5658 TensorDyn::from(__t)
5659 };
5660
5661 let mut g2d_dst_dyn = g2d_dst;
5662 g2d_converter
5663 .convert(&src_dyn, &mut g2d_dst_dyn, Rotation::None, Flip::None, crop)
5664 .unwrap();
5665 g2d_dst = {
5666 let mut __t = g2d_dst_dyn.into_u8().unwrap();
5667 __t.set_format(PixelFormat::Rgba).unwrap();
5668 TensorDyn::from(__t)
5669 };
5670
5671 compare_images(
5672 &g2d_dst,
5673 &cpu_dst,
5674 0.98,
5675 &format!("{} {:?} {:?}", function!(), rot, flip),
5676 );
5677 }
5678 }
5679 }
5680
5681 #[test]
5682 #[cfg(any(
5683 target_os = "linux",
5684 target_os = "macos",
5685 target_os = "ios",
5686 target_os = "android",
5687 target_os = "windows"
5688 ))]
5689 #[cfg(feature = "opengl")]
5690 fn test_opengl_src_crop() {
5691 if !is_opengl_available() {
5692 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5693 return;
5694 }
5695
5696 let dst_width = 640;
5697 let dst_height = 360;
5698 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5699 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5700 let crop = Crop::new().with_source(Some(Region::new(320, 180, 1280 - 320, 720 - 180)));
5701
5702 let cpu_dst = TensorDyn::image(
5703 dst_width,
5704 dst_height,
5705 PixelFormat::Rgba,
5706 DType::U8,
5707 None,
5708 edgefirst_tensor::CpuAccess::ReadWrite,
5709 )
5710 .unwrap();
5711 let mut cpu_converter = CPUProcessor::new();
5712 let (result, src, cpu_dst) = convert_img(
5713 &mut cpu_converter,
5714 src,
5715 cpu_dst,
5716 Rotation::None,
5717 Flip::None,
5718 crop,
5719 );
5720 result.unwrap();
5721
5722 let gl_dst = TensorDyn::image(
5723 dst_width,
5724 dst_height,
5725 PixelFormat::Rgba,
5726 DType::U8,
5727 None,
5728 edgefirst_tensor::CpuAccess::ReadWrite,
5729 )
5730 .unwrap();
5731 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5732 let (result, _src, gl_dst) = convert_img(
5733 &mut gl_converter,
5734 src,
5735 gl_dst,
5736 Rotation::None,
5737 Flip::None,
5738 crop,
5739 );
5740 result.unwrap();
5741
5742 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5743 }
5744
5745 #[test]
5746 #[cfg(any(
5747 target_os = "linux",
5748 target_os = "macos",
5749 target_os = "ios",
5750 target_os = "android",
5751 target_os = "windows"
5752 ))]
5753 #[cfg(feature = "opengl")]
5754 fn test_opengl_dst_crop() {
5755 if !is_opengl_available() {
5756 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5757 return;
5758 }
5759
5760 let dst_width = 640;
5761 let dst_height = 640;
5762 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5763 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5764
5765 let cpu_dst = TensorDyn::image(
5766 dst_width,
5767 dst_height,
5768 PixelFormat::Rgba,
5769 DType::U8,
5770 None,
5771 edgefirst_tensor::CpuAccess::ReadWrite,
5772 )
5773 .unwrap();
5774 let mut cpu_converter = CPUProcessor::new();
5775 let crop = Crop::new();
5776 let (result, src, cpu_dst) = convert_img(
5777 &mut cpu_converter,
5778 src,
5779 cpu_dst,
5780 Rotation::None,
5781 Flip::None,
5782 crop,
5783 );
5784 result.unwrap();
5785
5786 let gl_dst = TensorDyn::image(
5787 dst_width,
5788 dst_height,
5789 PixelFormat::Rgba,
5790 DType::U8,
5791 None,
5792 edgefirst_tensor::CpuAccess::ReadWrite,
5793 )
5794 .unwrap();
5795 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5796 let (result, _src, gl_dst) = convert_img(
5797 &mut gl_converter,
5798 src,
5799 gl_dst,
5800 Rotation::None,
5801 Flip::None,
5802 crop,
5803 );
5804 result.unwrap();
5805
5806 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
5807 }
5808
5809 #[test]
5810 #[cfg(any(
5811 target_os = "linux",
5812 target_os = "macos",
5813 target_os = "ios",
5814 target_os = "android",
5815 target_os = "windows"
5816 ))]
5817 #[cfg(feature = "opengl")]
5818 fn test_opengl_all_rgba() {
5819 if !is_opengl_available() {
5820 eprintln!("SKIPPED: {} - OpenGL not available", function!());
5821 return;
5822 }
5823
5824 let dst_width = 640;
5825 let dst_height = 640;
5826 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5827
5828 let mut cpu_converter = CPUProcessor::new();
5829
5830 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
5831
5832 let mut mem = vec![None, Some(TensorMemory::Mem)];
5836 if edgefirst_tensor::is_shm_available() {
5837 mem.push(Some(TensorMemory::Shm));
5838 }
5839 if edgefirst_tensor::is_gpu_buffer_available() {
5840 mem.push(Some(TensorMemory::DmaBuf));
5841 }
5842 let crop = Crop::new().with_source(Some(Region::new(50, 120, 1024, 576)));
5843 for m in mem {
5844 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), m).unwrap();
5845 let src_dyn = src;
5846
5847 for rot in [
5848 Rotation::None,
5849 Rotation::Clockwise90,
5850 Rotation::Rotate180,
5851 Rotation::CounterClockwise90,
5852 ] {
5853 for flip in [Flip::None, Flip::Horizontal, Flip::Vertical] {
5854 let cpu_dst = TensorDyn::image(
5855 dst_width,
5856 dst_height,
5857 PixelFormat::Rgba,
5858 DType::U8,
5859 m,
5860 edgefirst_tensor::CpuAccess::ReadWrite,
5861 )
5862 .unwrap();
5863 let gl_dst = TensorDyn::image(
5864 dst_width,
5865 dst_height,
5866 PixelFormat::Rgba,
5867 DType::U8,
5868 m,
5869 edgefirst_tensor::CpuAccess::ReadWrite,
5870 )
5871 .unwrap();
5872 cpu_dst
5873 .as_typed::<u8>()
5874 .unwrap()
5875 .map()
5876 .unwrap()
5877 .as_mut_slice()
5878 .fill(114);
5879 gl_dst
5880 .as_typed::<u8>()
5881 .unwrap()
5882 .map()
5883 .unwrap()
5884 .as_mut_slice()
5885 .fill(114);
5886
5887 let mut cpu_dst_dyn = cpu_dst;
5888 cpu_converter
5889 .convert(&src_dyn, &mut cpu_dst_dyn, Rotation::None, Flip::None, crop)
5890 .unwrap();
5891 let cpu_dst = {
5892 let mut __t = cpu_dst_dyn.into_u8().unwrap();
5893 __t.set_format(PixelFormat::Rgba).unwrap();
5894 TensorDyn::from(__t)
5895 };
5896
5897 let mut gl_dst_dyn = gl_dst;
5898 gl_converter
5899 .convert(&src_dyn, &mut gl_dst_dyn, Rotation::None, Flip::None, crop)
5900 .map_err(|e| {
5901 log::error!("error mem {m:?} rot {rot:?} error: {e:?}");
5902 e
5903 })
5904 .unwrap();
5905 let gl_dst = {
5906 let mut __t = gl_dst_dyn.into_u8().unwrap();
5907 __t.set_format(PixelFormat::Rgba).unwrap();
5908 TensorDyn::from(__t)
5909 };
5910
5911 compare_images(
5912 &gl_dst,
5913 &cpu_dst,
5914 0.98,
5915 &format!("{} {:?} {:?}", function!(), rot, flip),
5916 );
5917 }
5918 }
5919 }
5920 }
5921
5922 #[test]
5923 #[cfg(target_os = "linux")]
5924 fn test_cpu_rotate() {
5925 for rot in [
5926 Rotation::Clockwise90,
5927 Rotation::Rotate180,
5928 Rotation::CounterClockwise90,
5929 ] {
5930 test_cpu_rotate_(rot);
5931 }
5932 }
5933
5934 #[cfg(target_os = "linux")]
5935 fn test_cpu_rotate_(rot: Rotation) {
5936 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
5940
5941 let unchanged_src =
5942 crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5943 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
5944
5945 let (dst_width, dst_height) = match rot {
5946 Rotation::None | Rotation::Rotate180 => (src.width().unwrap(), src.height().unwrap()),
5947 Rotation::Clockwise90 | Rotation::CounterClockwise90 => {
5948 (src.height().unwrap(), src.width().unwrap())
5949 }
5950 };
5951
5952 let cpu_dst = TensorDyn::image(
5953 dst_width,
5954 dst_height,
5955 PixelFormat::Rgba,
5956 DType::U8,
5957 None,
5958 edgefirst_tensor::CpuAccess::ReadWrite,
5959 )
5960 .unwrap();
5961 let mut cpu_converter = CPUProcessor::new();
5962
5963 let (result, src, cpu_dst) = convert_img(
5966 &mut cpu_converter,
5967 src,
5968 cpu_dst,
5969 rot,
5970 Flip::None,
5971 Crop::no_crop(),
5972 );
5973 result.unwrap();
5974
5975 let (result, cpu_dst, src) = convert_img(
5976 &mut cpu_converter,
5977 cpu_dst,
5978 src,
5979 rot,
5980 Flip::None,
5981 Crop::no_crop(),
5982 );
5983 result.unwrap();
5984
5985 let (result, src, cpu_dst) = convert_img(
5986 &mut cpu_converter,
5987 src,
5988 cpu_dst,
5989 rot,
5990 Flip::None,
5991 Crop::no_crop(),
5992 );
5993 result.unwrap();
5994
5995 let (result, _cpu_dst, src) = convert_img(
5996 &mut cpu_converter,
5997 cpu_dst,
5998 src,
5999 rot,
6000 Flip::None,
6001 Crop::no_crop(),
6002 );
6003 result.unwrap();
6004
6005 compare_images(&src, &unchanged_src, 0.98, function!());
6006 }
6007
6008 #[test]
6009 #[cfg(any(
6010 target_os = "linux",
6011 target_os = "macos",
6012 target_os = "ios",
6013 target_os = "android",
6014 target_os = "windows"
6015 ))]
6016 #[cfg(feature = "opengl")]
6017 fn test_opengl_rotate() {
6018 if !is_opengl_available() {
6019 eprintln!("SKIPPED: {} - OpenGL not available", function!());
6020 return;
6021 }
6022
6023 let size = (1280, 720);
6024 let mut mem = vec![None, Some(TensorMemory::Mem)];
6026 if edgefirst_tensor::is_shm_available() {
6027 mem.push(Some(TensorMemory::Shm));
6028 }
6029 if edgefirst_tensor::is_gpu_buffer_available() {
6030 mem.push(Some(TensorMemory::DmaBuf));
6031 }
6032 for m in mem {
6033 for rot in [
6034 Rotation::Clockwise90,
6035 Rotation::Rotate180,
6036 Rotation::CounterClockwise90,
6037 ] {
6038 test_opengl_rotate_(size, rot, m);
6039 }
6040 }
6041 }
6042
6043 #[cfg(any(
6044 target_os = "linux",
6045 target_os = "macos",
6046 target_os = "ios",
6047 target_os = "android",
6048 target_os = "windows"
6049 ))]
6050 #[cfg(feature = "opengl")]
6051 fn test_opengl_rotate_(
6052 size: (usize, usize),
6053 rot: Rotation,
6054 tensor_memory: Option<TensorMemory>,
6055 ) {
6056 let (dst_width, dst_height) = match rot {
6057 Rotation::None | Rotation::Rotate180 => size,
6058 Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
6059 };
6060
6061 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6062 let src =
6063 crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), tensor_memory).unwrap();
6064
6065 let cpu_dst = TensorDyn::image(
6066 dst_width,
6067 dst_height,
6068 PixelFormat::Rgba,
6069 DType::U8,
6070 None,
6071 edgefirst_tensor::CpuAccess::ReadWrite,
6072 )
6073 .unwrap();
6074 let mut cpu_converter = CPUProcessor::new();
6075
6076 let (result, mut src, cpu_dst) = convert_img(
6077 &mut cpu_converter,
6078 src,
6079 cpu_dst,
6080 rot,
6081 Flip::None,
6082 Crop::no_crop(),
6083 );
6084 result.unwrap();
6085
6086 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6087
6088 for _ in 0..5 {
6089 let gl_dst = TensorDyn::image(
6090 dst_width,
6091 dst_height,
6092 PixelFormat::Rgba,
6093 DType::U8,
6094 tensor_memory,
6095 edgefirst_tensor::CpuAccess::ReadWrite,
6096 )
6097 .unwrap();
6098 let (result, src_back, gl_dst) = convert_img(
6099 &mut gl_converter,
6100 src,
6101 gl_dst,
6102 rot,
6103 Flip::None,
6104 Crop::no_crop(),
6105 );
6106 result.unwrap();
6107 src = src_back;
6108 compare_images(&gl_dst, &cpu_dst, 0.98, function!());
6109 }
6110 }
6111
6112 #[test]
6113 #[cfg(target_os = "linux")]
6114 fn test_g2d_rotate() {
6115 if !is_g2d_available() {
6116 eprintln!("SKIPPED: test_g2d_rotate - G2D library (libg2d.so.2) not available");
6117 return;
6118 }
6119 if !is_dma_available() {
6120 eprintln!(
6121 "SKIPPED: test_g2d_rotate - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6122 );
6123 return;
6124 }
6125
6126 let size = (1280, 720);
6127 for rot in [
6128 Rotation::Clockwise90,
6129 Rotation::Rotate180,
6130 Rotation::CounterClockwise90,
6131 ] {
6132 test_g2d_rotate_(size, rot);
6133 }
6134 }
6135
6136 #[cfg(target_os = "linux")]
6137 fn test_g2d_rotate_(size: (usize, usize), rot: Rotation) {
6138 let (dst_width, dst_height) = match rot {
6139 Rotation::None | Rotation::Rotate180 => size,
6140 Rotation::Clockwise90 | Rotation::CounterClockwise90 => (size.1, size.0),
6141 };
6142
6143 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
6144 let src = crate::load_image_test_helper(
6145 &file,
6146 Some(PixelFormat::Rgba),
6147 Some(TensorMemory::DmaBuf),
6148 )
6149 .unwrap();
6150
6151 let cpu_dst = TensorDyn::image(
6152 dst_width,
6153 dst_height,
6154 PixelFormat::Rgba,
6155 DType::U8,
6156 None,
6157 edgefirst_tensor::CpuAccess::ReadWrite,
6158 )
6159 .unwrap();
6160 let mut cpu_converter = CPUProcessor::new();
6161
6162 let (result, src, cpu_dst) = convert_img(
6163 &mut cpu_converter,
6164 src,
6165 cpu_dst,
6166 rot,
6167 Flip::None,
6168 Crop::no_crop(),
6169 );
6170 result.unwrap();
6171
6172 let g2d_dst = TensorDyn::image(
6173 dst_width,
6174 dst_height,
6175 PixelFormat::Rgba,
6176 DType::U8,
6177 Some(TensorMemory::DmaBuf),
6178 edgefirst_tensor::CpuAccess::ReadWrite,
6179 )
6180 .unwrap();
6181 let mut g2d_converter = G2DProcessor::new().unwrap();
6182
6183 let (result, _src, g2d_dst) = convert_img(
6184 &mut g2d_converter,
6185 src,
6186 g2d_dst,
6187 rot,
6188 Flip::None,
6189 Crop::no_crop(),
6190 );
6191 result.unwrap();
6192
6193 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
6199 }
6200
6201 #[test]
6202 fn test_rgba_to_yuyv_resize_cpu() {
6203 let src = load_bytes_to_tensor(
6204 1280,
6205 720,
6206 PixelFormat::Rgba,
6207 None,
6208 &edgefirst_bench::testdata::read("camera720p.rgba"),
6209 )
6210 .unwrap();
6211
6212 let (dst_width, dst_height) = (640, 360);
6213
6214 let dst = TensorDyn::image(
6215 dst_width,
6216 dst_height,
6217 PixelFormat::Yuyv,
6218 DType::U8,
6219 None,
6220 edgefirst_tensor::CpuAccess::ReadWrite,
6221 )
6222 .unwrap();
6223
6224 let dst_through_yuyv = TensorDyn::image(
6225 dst_width,
6226 dst_height,
6227 PixelFormat::Rgba,
6228 DType::U8,
6229 None,
6230 edgefirst_tensor::CpuAccess::ReadWrite,
6231 )
6232 .unwrap();
6233 let dst_direct = TensorDyn::image(
6234 dst_width,
6235 dst_height,
6236 PixelFormat::Rgba,
6237 DType::U8,
6238 None,
6239 edgefirst_tensor::CpuAccess::ReadWrite,
6240 )
6241 .unwrap();
6242
6243 let mut cpu_converter = CPUProcessor::new();
6244
6245 let (result, src, dst) = convert_img(
6246 &mut cpu_converter,
6247 src,
6248 dst,
6249 Rotation::None,
6250 Flip::None,
6251 Crop::no_crop(),
6252 );
6253 result.unwrap();
6254
6255 let (result, _dst, dst_through_yuyv) = convert_img(
6256 &mut cpu_converter,
6257 dst,
6258 dst_through_yuyv,
6259 Rotation::None,
6260 Flip::None,
6261 Crop::no_crop(),
6262 );
6263 result.unwrap();
6264
6265 let (result, _src, dst_direct) = convert_img(
6266 &mut cpu_converter,
6267 src,
6268 dst_direct,
6269 Rotation::None,
6270 Flip::None,
6271 Crop::no_crop(),
6272 );
6273 result.unwrap();
6274
6275 compare_images(&dst_through_yuyv, &dst_direct, 0.98, function!());
6276 }
6277
6278 #[test]
6279 #[cfg(any(
6280 target_os = "linux",
6281 target_os = "macos",
6282 target_os = "ios",
6283 target_os = "android",
6284 target_os = "windows"
6285 ))]
6286 #[cfg(feature = "opengl")]
6287 #[ignore = "opengl doesn't support rendering to PixelFormat::Yuyv texture"]
6288 fn test_rgba_to_yuyv_resize_opengl() {
6289 if !is_opengl_available() {
6290 eprintln!("SKIPPED: {} - OpenGL not available", function!());
6291 return;
6292 }
6293
6294 if !edgefirst_tensor::is_gpu_buffer_available() {
6295 eprintln!(
6296 "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
6297 function!()
6298 );
6299 return;
6300 }
6301
6302 let src = load_bytes_to_tensor(
6303 1280,
6304 720,
6305 PixelFormat::Rgba,
6306 None,
6307 &edgefirst_bench::testdata::read("camera720p.rgba"),
6308 )
6309 .unwrap();
6310
6311 let (dst_width, dst_height) = (640, 360);
6312
6313 let dst = TensorDyn::image(
6314 dst_width,
6315 dst_height,
6316 PixelFormat::Yuyv,
6317 DType::U8,
6318 Some(TensorMemory::DmaBuf),
6319 edgefirst_tensor::CpuAccess::ReadWrite,
6320 )
6321 .unwrap();
6322
6323 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6324
6325 let (result, src, dst) = convert_img(
6326 &mut gl_converter,
6327 src,
6328 dst,
6329 Rotation::None,
6330 Flip::None,
6331 Crop::letterbox([255, 255, 255, 255]),
6332 );
6333 result.unwrap();
6334
6335 std::fs::write(
6336 "rgba_to_yuyv_opengl.yuyv",
6337 dst.as_typed::<u8>().unwrap().map().unwrap().as_slice(),
6338 )
6339 .unwrap();
6340 let cpu_dst = TensorDyn::image(
6341 dst_width,
6342 dst_height,
6343 PixelFormat::Yuyv,
6344 DType::U8,
6345 Some(TensorMemory::DmaBuf),
6346 edgefirst_tensor::CpuAccess::ReadWrite,
6347 )
6348 .unwrap();
6349 let (result, _src, cpu_dst) = convert_img(
6350 &mut CPUProcessor::new(),
6351 src,
6352 cpu_dst,
6353 Rotation::None,
6354 Flip::None,
6355 Crop::no_crop(),
6356 );
6357 result.unwrap();
6358
6359 compare_images_convert_to_rgb(&dst, &cpu_dst, 0.98, function!());
6360 }
6361
6362 #[test]
6363 #[cfg(target_os = "linux")]
6364 fn test_rgba_to_yuyv_resize_g2d() {
6365 if !is_g2d_available() {
6366 eprintln!(
6367 "SKIPPED: test_rgba_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
6368 );
6369 return;
6370 }
6371 if !is_dma_available() {
6372 eprintln!(
6373 "SKIPPED: test_rgba_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6374 );
6375 return;
6376 }
6377
6378 let src = load_bytes_to_tensor(
6379 1280,
6380 720,
6381 PixelFormat::Rgba,
6382 Some(TensorMemory::DmaBuf),
6383 &edgefirst_bench::testdata::read("camera720p.rgba"),
6384 )
6385 .unwrap();
6386
6387 let (dst_width, dst_height) = (1280, 720);
6388
6389 let cpu_dst = TensorDyn::image(
6390 dst_width,
6391 dst_height,
6392 PixelFormat::Yuyv,
6393 DType::U8,
6394 Some(TensorMemory::DmaBuf),
6395 edgefirst_tensor::CpuAccess::ReadWrite,
6396 )
6397 .unwrap();
6398
6399 let g2d_dst = TensorDyn::image(
6400 dst_width,
6401 dst_height,
6402 PixelFormat::Yuyv,
6403 DType::U8,
6404 Some(TensorMemory::DmaBuf),
6405 edgefirst_tensor::CpuAccess::ReadWrite,
6406 )
6407 .unwrap();
6408
6409 let mut g2d_converter = G2DProcessor::new().unwrap();
6410 let crop = Crop::new();
6411
6412 g2d_dst
6413 .as_typed::<u8>()
6414 .unwrap()
6415 .map()
6416 .unwrap()
6417 .as_mut_slice()
6418 .fill(128);
6419 let (result, src, g2d_dst) = convert_img(
6420 &mut g2d_converter,
6421 src,
6422 g2d_dst,
6423 Rotation::None,
6424 Flip::None,
6425 crop,
6426 );
6427 result.unwrap();
6428
6429 let cpu_dst_img = cpu_dst;
6430 cpu_dst_img
6431 .as_typed::<u8>()
6432 .unwrap()
6433 .map()
6434 .unwrap()
6435 .as_mut_slice()
6436 .fill(128);
6437 let (result, _src, cpu_dst) = convert_img(
6438 &mut CPUProcessor::new(),
6439 src,
6440 cpu_dst_img,
6441 Rotation::None,
6442 Flip::None,
6443 crop,
6444 );
6445 result.unwrap();
6446
6447 compare_images_convert_to_rgb(&cpu_dst, &g2d_dst, 0.98, function!());
6448 }
6449
6450 #[test]
6451 fn test_yuyv_to_rgba_cpu() {
6452 let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6453 let src = TensorDyn::image(
6454 1280,
6455 720,
6456 PixelFormat::Yuyv,
6457 DType::U8,
6458 None,
6459 edgefirst_tensor::CpuAccess::ReadWrite,
6460 )
6461 .unwrap();
6462 src.as_typed::<u8>()
6463 .unwrap()
6464 .map()
6465 .unwrap()
6466 .as_mut_slice()
6467 .copy_from_slice(&file);
6468
6469 let dst = TensorDyn::image(
6470 1280,
6471 720,
6472 PixelFormat::Rgba,
6473 DType::U8,
6474 None,
6475 edgefirst_tensor::CpuAccess::ReadWrite,
6476 )
6477 .unwrap();
6478 let mut cpu_converter = CPUProcessor::new();
6479
6480 let (result, _src, dst) = convert_img(
6481 &mut cpu_converter,
6482 src,
6483 dst,
6484 Rotation::None,
6485 Flip::None,
6486 Crop::no_crop(),
6487 );
6488 result.unwrap();
6489
6490 let target_image = TensorDyn::image(
6491 1280,
6492 720,
6493 PixelFormat::Rgba,
6494 DType::U8,
6495 None,
6496 edgefirst_tensor::CpuAccess::ReadWrite,
6497 )
6498 .unwrap();
6499 target_image
6500 .as_typed::<u8>()
6501 .unwrap()
6502 .map()
6503 .unwrap()
6504 .as_mut_slice()
6505 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6506
6507 compare_images(&dst, &target_image, 0.98, function!());
6510 }
6511
6512 #[test]
6513 fn test_yuyv_to_rgb_cpu() {
6514 let file = edgefirst_bench::testdata::read("camera720p.yuyv").to_vec();
6515 let src = TensorDyn::image(
6516 1280,
6517 720,
6518 PixelFormat::Yuyv,
6519 DType::U8,
6520 None,
6521 edgefirst_tensor::CpuAccess::ReadWrite,
6522 )
6523 .unwrap();
6524 src.as_typed::<u8>()
6525 .unwrap()
6526 .map()
6527 .unwrap()
6528 .as_mut_slice()
6529 .copy_from_slice(&file);
6530
6531 let dst = TensorDyn::image(
6532 1280,
6533 720,
6534 PixelFormat::Rgb,
6535 DType::U8,
6536 None,
6537 edgefirst_tensor::CpuAccess::ReadWrite,
6538 )
6539 .unwrap();
6540 let mut cpu_converter = CPUProcessor::new();
6541
6542 let (result, _src, dst) = convert_img(
6543 &mut cpu_converter,
6544 src,
6545 dst,
6546 Rotation::None,
6547 Flip::None,
6548 Crop::no_crop(),
6549 );
6550 result.unwrap();
6551
6552 let target_image = TensorDyn::image(
6553 1280,
6554 720,
6555 PixelFormat::Rgb,
6556 DType::U8,
6557 None,
6558 edgefirst_tensor::CpuAccess::ReadWrite,
6559 )
6560 .unwrap();
6561 target_image
6562 .as_typed::<u8>()
6563 .unwrap()
6564 .map()
6565 .unwrap()
6566 .as_mut_slice()
6567 .as_chunks_mut::<3>()
6568 .0
6569 .iter_mut()
6570 .zip(
6571 edgefirst_bench::testdata::read("camera720p.rgba")
6572 .as_chunks::<4>()
6573 .0,
6574 )
6575 .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
6576
6577 compare_images(&dst, &target_image, 0.98, function!());
6580 }
6581
6582 #[test]
6583 #[cfg(target_os = "linux")]
6584 fn test_yuyv_to_rgba_g2d() {
6585 if !is_g2d_available() {
6586 eprintln!("SKIPPED: test_yuyv_to_rgba_g2d - G2D library (libg2d.so.2) not available");
6587 return;
6588 }
6589 if !is_dma_available() {
6590 eprintln!(
6591 "SKIPPED: test_yuyv_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
6592 );
6593 return;
6594 }
6595
6596 let src = load_bytes_to_tensor(
6597 1280,
6598 720,
6599 PixelFormat::Yuyv,
6600 None,
6601 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6602 )
6603 .unwrap();
6604
6605 let dst = TensorDyn::image(
6606 1280,
6607 720,
6608 PixelFormat::Rgba,
6609 DType::U8,
6610 Some(TensorMemory::DmaBuf),
6611 edgefirst_tensor::CpuAccess::ReadWrite,
6612 )
6613 .unwrap();
6614 let mut g2d_converter = G2DProcessor::new().unwrap();
6615
6616 let (result, _src, dst) = convert_img(
6617 &mut g2d_converter,
6618 src,
6619 dst,
6620 Rotation::None,
6621 Flip::None,
6622 Crop::no_crop(),
6623 );
6624 result.unwrap();
6625
6626 let target_image = TensorDyn::image(
6627 1280,
6628 720,
6629 PixelFormat::Rgba,
6630 DType::U8,
6631 None,
6632 edgefirst_tensor::CpuAccess::ReadWrite,
6633 )
6634 .unwrap();
6635 target_image
6636 .as_typed::<u8>()
6637 .unwrap()
6638 .map()
6639 .unwrap()
6640 .as_mut_slice()
6641 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6642
6643 compare_images(&dst, &target_image, 0.98, function!());
6647 }
6648
6649 #[test]
6650 #[cfg(any(
6651 target_os = "linux",
6652 target_os = "macos",
6653 target_os = "ios",
6654 target_os = "android",
6655 target_os = "windows"
6656 ))]
6657 #[cfg(feature = "opengl")]
6658 fn test_yuyv_to_rgba_opengl() {
6659 if !is_opengl_available() {
6660 eprintln!("SKIPPED: {} - OpenGL not available", function!());
6661 return;
6662 }
6663 if !edgefirst_tensor::is_gpu_buffer_available() {
6666 eprintln!(
6667 "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
6668 function!()
6669 );
6670 return;
6671 }
6672
6673 let src = load_bytes_to_tensor(
6674 1280,
6675 720,
6676 PixelFormat::Yuyv,
6677 Some(TensorMemory::DmaBuf),
6678 &edgefirst_bench::testdata::read("camera720p.yuyv"),
6679 )
6680 .unwrap();
6681
6682 let dst = TensorDyn::image(
6683 1280,
6684 720,
6685 PixelFormat::Rgba,
6686 DType::U8,
6687 Some(TensorMemory::DmaBuf),
6688 edgefirst_tensor::CpuAccess::ReadWrite,
6689 )
6690 .unwrap();
6691 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
6692
6693 let (result, _src, dst) = convert_img(
6694 &mut gl_converter,
6695 src,
6696 dst,
6697 Rotation::None,
6698 Flip::None,
6699 Crop::no_crop(),
6700 );
6701 if let Err(crate::Error::NotSupported(msg)) = &result {
6704 eprintln!("SKIPPED: {} — {msg}", function!());
6705 return;
6706 }
6707 result.unwrap();
6708
6709 let target_image = TensorDyn::image(
6710 1280,
6711 720,
6712 PixelFormat::Rgba,
6713 DType::U8,
6714 None,
6715 edgefirst_tensor::CpuAccess::ReadWrite,
6716 )
6717 .unwrap();
6718 target_image
6719 .as_typed::<u8>()
6720 .unwrap()
6721 .map()
6722 .unwrap()
6723 .as_mut_slice()
6724 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
6725
6726 compare_images(&dst, &target_image, 0.98, function!());
6730 }
6731
6732 #[test]
6742 #[cfg(target_os = "macos")]
6743 #[cfg(feature = "opengl")]
6744 fn test_grey_r8_iosurface_to_rgba_opengl_macos() {
6745 let mut proc = match GLProcessorThreaded::new(None) {
6746 Ok(p) => p,
6747 Err(e) => {
6748 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
6749 return;
6750 }
6751 };
6752
6753 let (w, h) = (16usize, 16usize);
6754 let src = TensorDyn::image(
6755 w,
6756 h,
6757 PixelFormat::Grey,
6758 DType::U8,
6759 Some(TensorMemory::DmaBuf),
6760 edgefirst_tensor::CpuAccess::ReadWrite,
6761 )
6762 .expect("GREY IOSurface (R8/L008) should allocate — proves the FourCC mapping");
6763 {
6765 let su8 = src.as_typed::<u8>().unwrap();
6766 let stride = src
6767 .as_typed::<u8>()
6768 .unwrap()
6769 .effective_row_stride()
6770 .unwrap();
6771 let mut m = su8.map().unwrap();
6772 let buf = m.as_mut_slice();
6773 for y in 0..h {
6774 for x in 0..w {
6775 buf[y * stride + x] = ((x * 13 + y * 7) & 0xff) as u8;
6776 }
6777 }
6778 }
6779
6780 let dst = TensorDyn::image(
6781 w,
6782 h,
6783 PixelFormat::Rgba,
6784 DType::U8,
6785 Some(TensorMemory::DmaBuf),
6786 edgefirst_tensor::CpuAccess::ReadWrite,
6787 )
6788 .unwrap();
6789 let (result, src_back, dst) = convert_img(
6790 &mut proc,
6791 src,
6792 dst,
6793 Rotation::None,
6794 Flip::None,
6795 Crop::no_crop(),
6796 );
6797 result.expect("GREY(R8 IOSurface) → RGBA must convert on ANGLE (R8 binding works)");
6798
6799 let src_stride = src_back
6800 .as_typed::<u8>()
6801 .unwrap()
6802 .effective_row_stride()
6803 .unwrap();
6804 let src_map = src_back.as_typed::<u8>().unwrap().map().unwrap();
6805 let sbytes = src_map.as_slice();
6806 let dst_stride = dst
6807 .as_typed::<u8>()
6808 .unwrap()
6809 .effective_row_stride()
6810 .unwrap();
6811 let dst_map = dst.as_typed::<u8>().unwrap().map().unwrap();
6812 let dbytes = dst_map.as_slice();
6813 for y in 0..h {
6814 for x in 0..w {
6815 let yv = sbytes[y * src_stride + x] as i16;
6816 let p = y * dst_stride + x * 4;
6817 for c in 0..3 {
6818 assert!(
6819 (dbytes[p + c] as i16 - yv).abs() <= 2,
6820 "pixel ({x},{y}) ch{c} = {} expected ~{yv} (GREY→RGB identity)",
6821 dbytes[p + c]
6822 );
6823 }
6824 }
6825 }
6826 }
6827
6828 #[test]
6834 #[cfg(target_os = "macos")]
6835 #[cfg(feature = "opengl")]
6836 fn test_nv12_to_planar_f16_two_pass_opengl_macos() {
6837 let mut gpu = match GLProcessorThreaded::new(None) {
6838 Ok(p) => p,
6839 Err(e) => {
6840 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6841 return;
6842 }
6843 };
6844 let (w, h) = (64usize, 64usize);
6845 let src = match TensorDyn::image(
6846 w,
6847 h,
6848 PixelFormat::Nv12,
6849 DType::U8,
6850 Some(TensorMemory::DmaBuf),
6851 edgefirst_tensor::CpuAccess::ReadWrite,
6852 ) {
6853 Ok(t) => t,
6854 Err(e) => {
6855 eprintln!("SKIPPED: {} — NV12 IOSurface alloc: {e:?}", function!());
6856 return;
6857 }
6858 };
6859 src.as_typed::<u8>()
6860 .unwrap()
6861 .map()
6862 .unwrap()
6863 .as_mut_slice()
6864 .fill(128); let dst = match TensorDyn::image(
6867 w,
6868 h,
6869 PixelFormat::PlanarRgb,
6870 DType::F16,
6871 Some(TensorMemory::DmaBuf),
6872 edgefirst_tensor::CpuAccess::ReadWrite,
6873 ) {
6874 Ok(t) => t,
6875 Err(e) => {
6876 eprintln!("SKIPPED: {} — F16 PlanarRgb IOSurface: {e:?}", function!());
6877 return;
6878 }
6879 };
6880 let mut dst = dst;
6881 if let Err(e) = ImageProcessorTrait::convert(
6883 &mut gpu,
6884 &src,
6885 &mut dst,
6886 Rotation::None,
6887 Flip::None,
6888 Crop::no_crop(),
6889 ) {
6890 eprintln!(
6894 "SKIPPED: {} — NV12→PlanarRgb F16 not available ({e:?})",
6895 function!()
6896 );
6897 return;
6898 }
6899 let dt = dst.as_typed::<half::f16>().expect("dst is F16 PlanarRgb");
6900 let map = dt.map().unwrap();
6901 let vals = map.as_slice();
6902 let mut checked = 0usize;
6905 for &v in vals.iter() {
6906 let f = f32::from(v);
6907 assert!(
6908 (0.40..=0.60).contains(&f),
6909 "planar F16 value {f} not ~0.5 for neutral-grey NV12"
6910 );
6911 checked += 1;
6912 }
6913 assert!(
6914 checked >= w * h * 3,
6915 "expected >= 3 planes of samples, got {checked}"
6916 );
6917 }
6918
6919 #[test]
6927 #[cfg(target_os = "macos")]
6928 #[cfg(feature = "opengl")]
6929 fn test_nv12_to_planar_f16_two_pass_pool_opengl_macos() {
6930 let mut gpu = match GLProcessorThreaded::new(None) {
6931 Ok(p) => p,
6932 Err(e) => {
6933 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
6934 return;
6935 }
6936 };
6937 let (fw, fh) = (96usize, 64usize);
6939 let (pool_w, pool_h) = (256usize, 768usize);
6940 let (model_w, model_h) = (128usize, 128usize);
6941
6942 let mut src = match TensorDyn::image(
6943 pool_w,
6944 pool_h,
6945 PixelFormat::Grey,
6946 DType::U8,
6947 Some(TensorMemory::DmaBuf),
6948 edgefirst_tensor::CpuAccess::ReadWrite,
6949 ) {
6950 Ok(t) => t,
6951 Err(e) => {
6952 eprintln!("SKIPPED: {} — R8 pool alloc: {e:?}", function!());
6953 return;
6954 }
6955 };
6956 src.configure_image(fw, fh, PixelFormat::Nv12)
6957 .unwrap_or_else(|e| panic!("configure_image NV12 on pool: {e}"));
6958 let stride = src
6959 .as_typed::<u8>()
6960 .unwrap()
6961 .effective_row_stride()
6962 .unwrap();
6963 src.as_typed::<u8>()
6964 .unwrap()
6965 .map()
6966 .unwrap()
6967 .as_mut_slice()
6968 .fill(128); let mut dst = match TensorDyn::image(
6971 model_w,
6972 model_h,
6973 PixelFormat::PlanarRgb,
6974 DType::F16,
6975 Some(TensorMemory::DmaBuf),
6976 edgefirst_tensor::CpuAccess::ReadWrite,
6977 ) {
6978 Ok(t) => t,
6979 Err(e) => {
6980 eprintln!("SKIPPED: {} — F16 PlanarRgb dst: {e:?}", function!());
6981 return;
6982 }
6983 };
6984
6985 let _ = model_w;
6987 let crop = Crop::new()
6988 .with_source(Some(Region::new(0, 0, fw, fh)))
6989 .with_fit(Fit::Letterbox {
6990 pad: [0, 0, 0, 255],
6991 });
6992 if let Err(e) =
6993 ImageProcessorTrait::convert(&mut gpu, &src, &mut dst, Rotation::None, Flip::None, crop)
6994 {
6995 eprintln!(
6996 "SKIPPED: {} — NV12→PlanarRgb F16 unavailable ({e:?})",
6997 function!()
6998 );
6999 return;
7000 }
7001 let _ = stride;
7002 let dt = dst.as_typed::<half::f16>().expect("dst F16");
7005 let map = dt.map().unwrap();
7006 let any_half = map.as_slice().iter().any(|&v| {
7007 let f = f32::from(v);
7008 (0.40..=0.60).contains(&f)
7009 });
7010 assert!(any_half, "expected ~0.5 grey samples in the letterbox band");
7011 }
7012
7013 #[test]
7019 #[cfg(target_os = "macos")]
7020 #[cfg(feature = "opengl")]
7021 fn test_nv12_to_planar_f16_cross_thread_opengl_macos() {
7022 use std::sync::mpsc;
7023 let mut proc = match ImageProcessor::new() {
7026 Ok(p) => p,
7027 Err(e) => {
7028 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
7029 return;
7030 }
7031 };
7032 let (fw, fh) = (96usize, 64usize);
7033 let mut src = match TensorDyn::image(
7034 256,
7035 768,
7036 PixelFormat::Grey,
7037 DType::U8,
7038 Some(TensorMemory::DmaBuf),
7039 edgefirst_tensor::CpuAccess::ReadWrite,
7040 ) {
7041 Ok(t) => t,
7042 Err(e) => {
7043 eprintln!("SKIPPED: {} — pool: {e:?}", function!());
7044 return;
7045 }
7046 };
7047 src.configure_image(fw, fh, PixelFormat::Nv12).unwrap();
7048 src.as_typed::<u8>()
7049 .unwrap()
7050 .map()
7051 .unwrap()
7052 .as_mut_slice()
7053 .fill(128);
7054 let mut dst = match TensorDyn::image(
7055 128,
7056 128,
7057 PixelFormat::PlanarRgb,
7058 DType::F16,
7059 Some(TensorMemory::DmaBuf),
7060 edgefirst_tensor::CpuAccess::ReadWrite,
7061 ) {
7062 Ok(t) => t,
7063 Err(e) => {
7064 eprintln!("SKIPPED: {} — dst: {e:?}", function!());
7065 return;
7066 }
7067 };
7068 let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
7069
7070 let (tx, rx) = mpsc::channel::<bool>();
7073 let worker = std::thread::spawn(move || {
7074 let _ = ImageProcessorTrait::convert(
7075 &mut proc,
7076 &src,
7077 &mut dst,
7078 Rotation::None,
7079 Flip::None,
7080 crop,
7081 );
7082 let _ = tx.send(true);
7083 });
7084 match rx.recv_timeout(std::time::Duration::from_secs(20)) {
7085 Ok(_) => { let _ = worker.join(); }
7086 Err(_) => panic!(
7087 "cross-thread NV12→PlanarRgb convert HUNG (>20s) — reproduces the orchestrator deadlock"
7088 ),
7089 }
7090 }
7091
7092 #[test]
7099 #[cfg(target_os = "macos")]
7100 #[cfg(feature = "opengl")]
7101 fn test_nv_to_planar_f16_varying_sizes_no_leak_opengl_macos() {
7102 let mut gpu = match GLProcessorThreaded::new(None) {
7103 Ok(p) => p,
7104 Err(e) => {
7105 eprintln!("SKIPPED: {} — init failed ({e:?})", function!());
7106 return;
7107 }
7108 };
7109 let (max_w, max_h) = (640usize, 640usize);
7112 let depth = 4usize;
7113 let mut srcs = Vec::new();
7114 let mut dsts = Vec::new();
7115 for _ in 0..depth {
7116 srcs.push(
7117 match TensorDyn::image(
7118 max_w,
7119 max_h * 3,
7120 PixelFormat::Grey,
7121 DType::U8,
7122 Some(TensorMemory::DmaBuf),
7123 edgefirst_tensor::CpuAccess::ReadWrite,
7124 ) {
7125 Ok(t) => t,
7126 Err(e) => {
7127 eprintln!("SKIPPED: {} — pool: {e:?}", function!());
7128 return;
7129 }
7130 },
7131 );
7132 dsts.push(
7133 match TensorDyn::image(
7134 640,
7135 640,
7136 PixelFormat::PlanarRgb,
7137 DType::F16,
7138 Some(TensorMemory::DmaBuf),
7139 edgefirst_tensor::CpuAccess::ReadWrite,
7140 ) {
7141 Ok(t) => t,
7142 Err(e) => {
7143 eprintln!("SKIPPED: {} — dst: {e:?}", function!());
7144 return;
7145 }
7146 },
7147 );
7148 }
7149 let sizes = [
7151 (640, 480),
7152 (500, 375),
7153 (640, 427),
7154 (333, 500),
7155 (480, 640),
7156 (612, 612),
7157 (428, 640),
7158 (576, 432),
7159 ];
7160 let mut first_ms = 0f64;
7161 let mut last_ms = 0f64;
7162 let iters = 40usize;
7163 for i in 0..iters {
7164 let (fw, fh) = sizes[i % sizes.len()];
7165 let src = &mut srcs[i % depth];
7166 let dst = &mut dsts[i % depth];
7167 src.configure_image(fw, fh, PixelFormat::Nv24).unwrap();
7168 src.as_typed::<u8>()
7169 .unwrap()
7170 .map()
7171 .unwrap()
7172 .as_mut_slice()
7173 .fill(128);
7174 let crop = Crop::new().with_source(Some(Region::new(0, 0, fw, fh)));
7175 let t0 = std::time::Instant::now();
7176 ImageProcessorTrait::convert(&mut gpu, src, dst, Rotation::None, Flip::None, crop)
7177 .unwrap_or_else(|e| panic!("convert iter {i} ({fw}×{fh}): {e}"));
7178 let ms = t0.elapsed().as_secs_f64() * 1e3;
7179 if i == 2 {
7180 first_ms = ms;
7181 }
7182 if i == iters - 1 {
7183 last_ms = ms;
7184 }
7185 }
7186 eprintln!("first={first_ms:.2}ms last={last_ms:.2}ms");
7187 assert!(
7188 last_ms < first_ms * 5.0 + 5.0,
7189 "convert latency ran away: first {first_ms:.2}ms → last {last_ms:.2}ms (intermediate/pbuffer leak)"
7190 );
7191 }
7192
7193 #[test]
7200 #[cfg(target_os = "macos")]
7201 #[cfg(feature = "opengl")]
7202 fn test_nv12_nv16_nv24_to_rgba_opengl_macos() {
7203 let mut gpu = match GLProcessorThreaded::new(None) {
7204 Ok(p) => p,
7205 Err(e) => {
7206 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7207 return;
7208 }
7209 };
7210 let mut cpu = CPUProcessor::new();
7211
7212 let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
7224 for y in 0..h {
7225 for x in 0..w {
7226 buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
7227 }
7228 }
7229 let (cw, ch, uv_grid_rows) = match fmt {
7230 PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
7231 PixelFormat::Nv16 => (w / 2, h, 1usize),
7232 _ => (w, h, 2usize), };
7234 let uv_plane = h * stride;
7235 for cy in 0..ch {
7236 for cx in 0..cw {
7237 let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
7238 buf[off] = ((cx * 11 + 30) & 0xff) as u8;
7239 buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
7240 }
7241 }
7242 };
7243
7244 for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
7245 for (w, h) in [
7246 (16usize, 16usize), (15, 16), (16, 15), ] {
7250 let mem = TensorDyn::image(
7251 w,
7252 h,
7253 fmt,
7254 DType::U8,
7255 None,
7256 edgefirst_tensor::CpuAccess::ReadWrite,
7257 )
7258 .unwrap();
7259 let mem_stride = mem
7260 .as_typed::<u8>()
7261 .unwrap()
7262 .effective_row_stride()
7263 .unwrap();
7264 fill(
7265 mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7266 mem_stride,
7267 fmt,
7268 w,
7269 h,
7270 );
7271 let cpu_dst = TensorDyn::image(
7272 w,
7273 h,
7274 PixelFormat::Rgba,
7275 DType::U8,
7276 None,
7277 edgefirst_tensor::CpuAccess::ReadWrite,
7278 )
7279 .unwrap();
7280 let (r, _s, cpu_dst) = convert_img(
7281 &mut cpu,
7282 mem,
7283 cpu_dst,
7284 Rotation::None,
7285 Flip::None,
7286 Crop::no_crop(),
7287 );
7288 r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
7289
7290 let ios = TensorDyn::image(
7291 w,
7292 h,
7293 fmt,
7294 DType::U8,
7295 Some(TensorMemory::DmaBuf),
7296 edgefirst_tensor::CpuAccess::ReadWrite,
7297 )
7298 .unwrap_or_else(|e| panic!("{fmt:?} {w}x{h} IOSurface alloc: {e}"));
7299 let ios_stride = ios
7300 .as_typed::<u8>()
7301 .unwrap()
7302 .effective_row_stride()
7303 .unwrap();
7304 fill(
7305 ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7306 ios_stride,
7307 fmt,
7308 w,
7309 h,
7310 );
7311 let gpu_dst = TensorDyn::image(
7312 w,
7313 h,
7314 PixelFormat::Rgba,
7315 DType::U8,
7316 Some(TensorMemory::DmaBuf),
7317 edgefirst_tensor::CpuAccess::ReadWrite,
7318 )
7319 .unwrap();
7320 let (r, _s, gpu_dst) = convert_img(
7321 &mut gpu,
7322 ios,
7323 gpu_dst,
7324 Rotation::None,
7325 Flip::None,
7326 Crop::no_crop(),
7327 );
7328 r.unwrap_or_else(|e| panic!("GPU {fmt:?}->{w}x{h}->RGBA on ANGLE: {e}"));
7329
7330 let cs = cpu_dst
7331 .as_typed::<u8>()
7332 .unwrap()
7333 .effective_row_stride()
7334 .unwrap();
7335 let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7336 let cb = cmap.as_slice();
7337 let gs = gpu_dst
7338 .as_typed::<u8>()
7339 .unwrap()
7340 .effective_row_stride()
7341 .unwrap();
7342 let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7343 let gb = gmap.as_slice();
7344 let mut max_d = 0i16;
7345 for y in 0..h {
7346 for x in 0..w {
7347 for c in 0..3 {
7348 let cv = cb[y * cs + x * 4 + c] as i16;
7349 let gv = gb[y * gs + x * 4 + c] as i16;
7350 max_d = max_d.max((cv - gv).abs());
7351 }
7352 }
7353 }
7354 assert!(
7355 max_d <= 3,
7356 "{fmt:?} {w}x{h}: GPU vs CPU RGBA max channel diff {max_d} > 3"
7357 );
7358 }
7359 }
7360 }
7361
7362 #[test]
7370 #[cfg(target_os = "macos")]
7371 #[cfg(feature = "opengl")]
7372 fn test_nv_to_rgba_larger_pool_surface_opengl_macos() {
7373 let mut gpu = match GLProcessorThreaded::new(None) {
7374 Ok(p) => p,
7375 Err(e) => {
7376 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7377 return;
7378 }
7379 };
7380 let mut cpu = CPUProcessor::new();
7381 let (pool_w, pool_h) = (256usize, 256usize);
7384
7385 let fill = |buf: &mut [u8], stride: usize, fmt: PixelFormat, w: usize, h: usize| {
7389 for y in 0..h {
7390 for x in 0..w {
7391 buf[y * stride + x] = ((x * 9 + y * 5) & 0xff) as u8;
7392 }
7393 }
7394 let (cw, ch, uv_grid_rows) = match fmt {
7395 PixelFormat::Nv12 => (w / 2, h / 2, 1usize),
7396 PixelFormat::Nv16 => (w / 2, h, 1usize),
7397 _ => (w, h, 2usize), };
7399 let uv_plane = h * stride;
7400 for cy in 0..ch {
7401 for cx in 0..cw {
7402 let off = uv_plane + cy * uv_grid_rows * stride + cx * 2;
7403 buf[off] = ((cx * 11 + 30) & 0xff) as u8;
7404 buf[off + 1] = ((cy * 7 + 200) & 0xff) as u8;
7405 }
7406 }
7407 };
7408
7409 for fmt in [PixelFormat::Nv12, PixelFormat::Nv16, PixelFormat::Nv24] {
7410 for (w, h) in [
7411 (40usize, 24usize), (15, 16), (16, 15), ] {
7415 let ew = w.next_multiple_of(2);
7418
7419 let mem = TensorDyn::image(
7421 w,
7422 h,
7423 fmt,
7424 DType::U8,
7425 None,
7426 edgefirst_tensor::CpuAccess::ReadWrite,
7427 )
7428 .unwrap();
7429 let mem_stride = mem
7430 .as_typed::<u8>()
7431 .unwrap()
7432 .effective_row_stride()
7433 .unwrap();
7434 fill(
7435 mem.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7436 mem_stride,
7437 fmt,
7438 w,
7439 h,
7440 );
7441 let cpu_dst = TensorDyn::image(
7442 w,
7443 h,
7444 PixelFormat::Rgba,
7445 DType::U8,
7446 None,
7447 edgefirst_tensor::CpuAccess::ReadWrite,
7448 )
7449 .unwrap();
7450 let (r, _s, cpu_dst) = convert_img(
7451 &mut cpu,
7452 mem,
7453 cpu_dst,
7454 Rotation::None,
7455 Flip::None,
7456 Crop::no_crop(),
7457 );
7458 r.unwrap_or_else(|e| panic!("CPU {fmt:?}->{w}x{h}->RGBA: {e}"));
7459
7460 let mut ios = match TensorDyn::image(
7464 pool_w,
7465 pool_h,
7466 PixelFormat::Grey,
7467 DType::U8,
7468 Some(TensorMemory::DmaBuf),
7469 edgefirst_tensor::CpuAccess::ReadWrite,
7470 ) {
7471 Ok(t) => t,
7472 Err(e) => {
7473 eprintln!("SKIPPED: {} — R8 pool IOSurface alloc: {e:?}", function!());
7474 return;
7475 }
7476 };
7477 ios.configure_image(w, h, fmt)
7478 .unwrap_or_else(|e| panic!("configure_image {fmt:?} {w}x{h} on pool: {e}"));
7479 let ios_stride = ios
7480 .as_typed::<u8>()
7481 .unwrap()
7482 .effective_row_stride()
7483 .unwrap();
7484 assert!(
7485 ios_stride > ew,
7486 "{fmt:?} {w}x{h}: pool stride {ios_stride} should exceed even width {ew} \
7487 (test must exercise padding)"
7488 );
7489 fill(
7490 ios.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice(),
7491 ios_stride,
7492 fmt,
7493 w,
7494 h,
7495 );
7496
7497 let gpu_dst = TensorDyn::image(
7498 w,
7499 h,
7500 PixelFormat::Rgba,
7501 DType::U8,
7502 Some(TensorMemory::DmaBuf),
7503 edgefirst_tensor::CpuAccess::ReadWrite,
7504 )
7505 .unwrap();
7506 let (r, _s, gpu_dst) = convert_img(
7507 &mut gpu,
7508 ios,
7509 gpu_dst,
7510 Rotation::None,
7511 Flip::None,
7512 Crop::no_crop(),
7513 );
7514 r.unwrap_or_else(|e| {
7515 panic!("GPU {fmt:?}->{w}x{h}->RGBA (pool surface) on ANGLE: {e}")
7516 });
7517
7518 let cs = cpu_dst
7519 .as_typed::<u8>()
7520 .unwrap()
7521 .effective_row_stride()
7522 .unwrap();
7523 let cmap = cpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7524 let cb = cmap.as_slice();
7525 let gs = gpu_dst
7526 .as_typed::<u8>()
7527 .unwrap()
7528 .effective_row_stride()
7529 .unwrap();
7530 let gmap = gpu_dst.as_typed::<u8>().unwrap().map().unwrap();
7531 let gb = gmap.as_slice();
7532 let mut max_d = 0i16;
7533 for y in 0..h {
7534 for x in 0..w {
7535 for c in 0..3 {
7536 let cv = cb[y * cs + x * 4 + c] as i16;
7537 let gv = gb[y * gs + x * 4 + c] as i16;
7538 max_d = max_d.max((cv - gv).abs());
7539 }
7540 }
7541 }
7542 assert!(
7543 max_d <= 3,
7544 "{fmt:?} {w}x{h}: GPU(pool surface) vs CPU RGBA max channel diff {max_d} > 3"
7545 );
7546 }
7547 }
7548 }
7549
7550 #[test]
7551 #[cfg(target_os = "macos")]
7552 #[cfg(feature = "opengl")]
7553 fn test_yuyv_to_rgba_opengl_macos() {
7554 let mut proc = match GLProcessorThreaded::new(None) {
7555 Ok(p) => p,
7556 Err(e) => {
7557 eprintln!(
7558 "SKIPPED: {} — GL engine init failed ({e:?}). \
7559 Install ANGLE via `brew install startergo/angle/angle` \
7560 and re-sign per README.md § macOS GPU Acceleration to \
7561 run this test.",
7562 function!()
7563 );
7564 return;
7565 }
7566 };
7567
7568 let src = load_bytes_to_tensor(
7569 1280,
7570 720,
7571 PixelFormat::Yuyv,
7572 Some(TensorMemory::DmaBuf),
7573 &edgefirst_bench::testdata::read("camera720p.yuyv"),
7574 )
7575 .unwrap();
7576
7577 let dst = TensorDyn::image(
7578 1280,
7579 720,
7580 PixelFormat::Rgba,
7581 DType::U8,
7582 Some(TensorMemory::DmaBuf),
7583 edgefirst_tensor::CpuAccess::ReadWrite,
7584 )
7585 .unwrap();
7586
7587 let (result, _src, dst) = convert_img(
7588 &mut proc,
7589 src,
7590 dst,
7591 Rotation::None,
7592 Flip::None,
7593 Crop::no_crop(),
7594 );
7595 result.unwrap();
7596
7597 let target_image = TensorDyn::image(
7598 1280,
7599 720,
7600 PixelFormat::Rgba,
7601 DType::U8,
7602 None,
7603 edgefirst_tensor::CpuAccess::ReadWrite,
7604 )
7605 .unwrap();
7606 target_image
7607 .as_typed::<u8>()
7608 .unwrap()
7609 .map()
7610 .unwrap()
7611 .as_mut_slice()
7612 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
7613
7614 compare_images(&dst, &target_image, 0.98, function!());
7619 }
7620
7621 #[test]
7640 #[cfg(target_os = "macos")]
7641 #[cfg(feature = "opengl")]
7642 fn test_yuyv_to_rgba_opengl_macos_multi_resolution() {
7643 let mut proc = match GLProcessorThreaded::new(None) {
7644 Ok(p) => p,
7645 Err(e) => {
7646 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7647 return;
7648 }
7649 };
7650
7651 for (w, h) in [(64usize, 32usize), (3840, 2160)] {
7652 let bytes_per_row = w * 2;
7655 let mut yuyv = vec![0u8; bytes_per_row * h];
7656 for chunk in yuyv.as_chunks_mut::<4>().0 {
7657 chunk[0] = 128; chunk[1] = 128; chunk[2] = 128; chunk[3] = 128; }
7662
7663 let src =
7664 load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
7665 .unwrap();
7666
7667 let dst = TensorDyn::image(
7668 w,
7669 h,
7670 PixelFormat::Rgba,
7671 DType::U8,
7672 Some(TensorMemory::DmaBuf),
7673 edgefirst_tensor::CpuAccess::ReadWrite,
7674 )
7675 .unwrap();
7676
7677 let (result, _src, dst) = convert_img(
7678 &mut proc,
7679 src,
7680 dst,
7681 Rotation::None,
7682 Flip::None,
7683 Crop::no_crop(),
7684 );
7685 result.expect("GL convert should succeed at this resolution");
7686
7687 let dst_u8 = dst.as_typed::<u8>().unwrap();
7692 let dst_map = dst_u8.map().unwrap();
7693 let dst_bytes = dst_map.as_slice();
7694 assert_eq!(dst_bytes.len(), w * h * 4, "RGBA byte count");
7695 for px in dst_bytes.as_chunks::<4>().0 {
7696 for (i, &c) in px[..3].iter().enumerate() {
7697 assert!(
7698 (120..=140).contains(&c),
7699 "{}: channel {i} = {c} (expected ~128 ±12) at {w}×{h}",
7700 function!(),
7701 );
7702 }
7703 assert_eq!(px[3], 255, "alpha must be 1.0");
7704 }
7705 }
7706 }
7707
7708 #[test]
7718 #[cfg(target_os = "macos")]
7719 #[cfg(feature = "opengl")]
7720 fn test_macos_gl_pbuffer_cache_reuses_surfaces() {
7721 let mut proc = match GLProcessorThreaded::new(None) {
7722 Ok(p) => p,
7723 Err(e) => {
7724 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7725 return;
7726 }
7727 };
7728
7729 let mut yuyv = vec![0u8; 64 * 32 * 2];
7731 for chunk in yuyv.as_chunks_mut::<4>().0 {
7732 chunk[0] = 200;
7733 chunk[1] = 100;
7734 chunk[2] = 200;
7735 chunk[3] = 156;
7736 }
7737 let src =
7738 load_bytes_to_tensor(64, 32, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
7739 .unwrap();
7740 let dst = TensorDyn::image(
7741 64,
7742 32,
7743 PixelFormat::Rgba,
7744 DType::U8,
7745 Some(TensorMemory::DmaBuf),
7746 edgefirst_tensor::CpuAccess::ReadWrite,
7747 )
7748 .unwrap();
7749
7750 let (r1, src, dst) = convert_img(
7751 &mut proc,
7752 src,
7753 dst,
7754 Rotation::None,
7755 Flip::None,
7756 Crop::no_crop(),
7757 );
7758 r1.unwrap();
7759 let first: Vec<u8> = dst
7760 .as_typed::<u8>()
7761 .unwrap()
7762 .map()
7763 .unwrap()
7764 .as_slice()
7765 .to_vec();
7766
7767 let (r2, _src, dst) = convert_img(
7768 &mut proc,
7769 src,
7770 dst,
7771 Rotation::None,
7772 Flip::None,
7773 Crop::no_crop(),
7774 );
7775 r2.unwrap();
7776 let second: Vec<u8> = dst
7777 .as_typed::<u8>()
7778 .unwrap()
7779 .map()
7780 .unwrap()
7781 .as_slice()
7782 .to_vec();
7783
7784 assert_eq!(first, second, "cache-hit conversion must be deterministic");
7785 }
7786
7787 #[test]
7795 #[cfg(target_os = "macos")]
7796 #[cfg(feature = "opengl")]
7797 fn test_macos_gl_pbuffer_cache_steady_state() {
7798 let mut proc = match GLProcessorThreaded::new(None) {
7799 Ok(p) => p,
7800 Err(e) => {
7801 eprintln!("SKIPPED: {} — GL engine init failed ({e:?})", function!());
7802 return;
7803 }
7804 };
7805
7806 let (w, h) = (64usize, 32usize);
7807 const POOL: usize = 3;
7808 const FRAMES: usize = 100;
7809
7810 let yuyv = vec![128u8; w * h * 2];
7811 let pool: Vec<TensorDyn> = (0..POOL)
7812 .map(|_| {
7813 load_bytes_to_tensor(w, h, PixelFormat::Yuyv, Some(TensorMemory::DmaBuf), &yuyv)
7814 .unwrap()
7815 })
7816 .collect();
7817 let mut dst = TensorDyn::image(
7818 w,
7819 h,
7820 PixelFormat::Rgba,
7821 DType::U8,
7822 Some(TensorMemory::DmaBuf),
7823 edgefirst_tensor::CpuAccess::ReadWrite,
7824 )
7825 .unwrap();
7826
7827 for src in pool.iter().cycle().take(POOL * 2) {
7829 proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7830 .unwrap();
7831 }
7832 let warm = proc.egl_cache_stats().unwrap();
7833
7834 for src in pool.iter().cycle().take(FRAMES) {
7835 proc.convert(src, &mut dst, Rotation::None, Flip::None, Crop::no_crop())
7836 .unwrap();
7837 }
7838 let steady = proc.egl_cache_stats().unwrap();
7839
7840 assert_eq!(
7841 warm.total_misses(),
7842 steady.total_misses(),
7843 "steady-state loop created new imports (warm {warm:?}, steady {steady:?})"
7844 );
7845 let hits = |s: &GlCacheStats| s.src.hits + s.dst.hits + s.nv_r8.hits;
7846 assert!(
7847 hits(&steady) - hits(&warm) >= FRAMES as u64,
7848 "expected at least {FRAMES} import-cache hits over the loop, got {}",
7849 hits(&steady) - hits(&warm)
7850 );
7851 }
7852
7853 #[test]
7865 #[cfg(target_os = "macos")]
7866 #[cfg(feature = "opengl")]
7867 fn test_macos_gl_f16_planar_is_gl_backed() {
7868 let mut proc = ImageProcessor::new().expect("ImageProcessor");
7869 let Some(ref gl) = proc.opengl else {
7870 eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7871 return;
7872 };
7873 if !gl.supported_render_dtypes().f16 {
7874 eprintln!(
7875 "SKIPPED: {} — configuration lacks F16 color-buffer support",
7876 function!()
7877 );
7878 return;
7879 }
7880 let stats_before = gl.egl_cache_stats().expect("cache stats");
7881
7882 let src = TensorDyn::image(
7883 1280,
7884 720,
7885 PixelFormat::Nv12,
7886 DType::U8,
7887 Some(TensorMemory::DmaBuf),
7888 edgefirst_tensor::CpuAccess::ReadWrite,
7889 )
7890 .unwrap();
7891 {
7892 let t = src.as_typed::<u8>().unwrap();
7893 let mut m = t.map().unwrap();
7894 for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7895 *b = ((i * 31) % 211) as u8;
7896 }
7897 }
7898 let mut dst = TensorDyn::image(
7899 640,
7900 640,
7901 PixelFormat::PlanarRgb,
7902 DType::F16,
7903 Some(TensorMemory::DmaBuf),
7904 edgefirst_tensor::CpuAccess::ReadWrite,
7905 )
7906 .unwrap();
7907
7908 proc.convert(
7909 &src,
7910 &mut dst,
7911 Rotation::None,
7912 Flip::None,
7913 Crop::letterbox([114, 114, 114, 255]),
7914 )
7915 .expect("F16 capability reported but the NV12→PlanarF16 convert failed");
7916 let stats_after = proc
7917 .opengl
7918 .as_ref()
7919 .expect("GL backend present")
7920 .egl_cache_stats()
7921 .expect("cache stats");
7922 assert!(
7927 stats_after.total_misses() >= stats_before.total_misses() + 2,
7928 "convert succeeded but the GL engine did not import both the \
7929 source and the F16 destination — the work did not (fully) run \
7930 on the GL backend (silent CPU fallback); misses before={} after={}",
7931 stats_before.total_misses(),
7932 stats_after.total_misses()
7933 );
7934 }
7935
7936 #[test]
7942 #[cfg(feature = "opengl")]
7943 fn test_nv12_to_planar_f16_fused_engine_vs_cpu() {
7944 let mut gl = match ImageProcessor::with_config(ImageProcessorConfig {
7945 backend: ComputeBackend::OpenGl,
7946 ..Default::default()
7947 }) {
7948 Ok(p) if p.opengl.is_some() => p,
7949 _ => {
7950 eprintln!("SKIPPED: {} — GL backend unavailable", function!());
7951 return;
7952 }
7953 };
7954 if !gl
7955 .opengl
7956 .as_ref()
7957 .map(|g| g.supported_render_dtypes().f16)
7958 .unwrap_or(false)
7959 {
7960 eprintln!("SKIPPED: {} — no F16 render support", function!());
7961 return;
7962 }
7963 let mem = if edgefirst_tensor::is_gpu_buffer_available() {
7964 TensorMemory::DmaBuf
7965 } else {
7966 eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
7967 return;
7968 };
7969
7970 let src = TensorDyn::image(
7971 1280,
7972 720,
7973 PixelFormat::Nv12,
7974 DType::U8,
7975 Some(mem),
7976 edgefirst_tensor::CpuAccess::ReadWrite,
7977 )
7978 .unwrap();
7979 {
7980 let t = src.as_typed::<u8>().unwrap();
7986 let mut m = t.map().unwrap();
7987 let buf = m.as_mut_slice();
7988 let (w, h) = (1280usize, 720usize);
7989 for y in 0..h {
7990 for x in 0..w {
7991 buf[y * w + x] = ((x * 255) / w) as u8; }
7993 }
7994 for y in 0..(h / 2) {
7995 for x in 0..(w / 2) {
7996 let o = h * w + y * w + 2 * x;
7997 buf[o] = ((y * 255) / (h / 2)) as u8; buf[o + 1] = (((x + y) * 255) / (w / 2 + h / 2)) as u8; }
8000 }
8001 }
8002 let crop = Crop::letterbox([114, 114, 114, 255]);
8003 let mut gl_dst = TensorDyn::image(
8004 640,
8005 640,
8006 PixelFormat::PlanarRgb,
8007 DType::F16,
8008 Some(mem),
8009 edgefirst_tensor::CpuAccess::ReadWrite,
8010 )
8011 .unwrap();
8012 match gl.opengl.as_mut().expect("GL backend present").convert(
8019 &src,
8020 &mut gl_dst,
8021 Rotation::None,
8022 Flip::None,
8023 crop,
8024 ) {
8025 Ok(()) => {}
8026 Err(crate::Error::NotSupported(msg)) => {
8027 eprintln!("SKIPPED: {} — {msg}", function!());
8028 return;
8029 }
8030 Err(e) => panic!("fused NV12→PlanarF16 GL convert: {e}"),
8031 }
8032
8033 let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
8034 backend: ComputeBackend::Cpu,
8035 ..Default::default()
8036 })
8037 .unwrap();
8038 let mut cpu_dst = TensorDyn::image(
8039 640,
8040 640,
8041 PixelFormat::PlanarRgb,
8042 DType::F16,
8043 Some(TensorMemory::Mem),
8044 edgefirst_tensor::CpuAccess::ReadWrite,
8045 )
8046 .unwrap();
8047 cpu.convert(&src, &mut cpu_dst, Rotation::None, Flip::None, crop)
8048 .expect("CPU reference convert");
8049
8050 let g = gl_dst
8051 .as_typed::<half::f16>()
8052 .unwrap()
8053 .map()
8054 .unwrap()
8055 .as_slice()
8056 .to_vec();
8057 let c = cpu_dst
8058 .as_typed::<half::f16>()
8059 .unwrap()
8060 .map()
8061 .unwrap()
8062 .as_slice()
8063 .to_vec();
8064 assert_eq!(g.len(), c.len());
8065 let mut max_diff = 0.0f32;
8066 let mut max_at = 0usize;
8067 for (i, (a, b)) in g.iter().zip(c.iter()).enumerate() {
8068 let d = (a.to_f32() - b.to_f32()).abs();
8069 if d > max_diff {
8070 max_diff = d;
8071 max_at = i;
8072 }
8073 }
8074 let (plane, rem) = (max_at / (640 * 640), max_at % (640 * 640));
8076 let (row, col) = (rem / 640, rem % 640);
8077 eprintln!(
8078 "fused-vs-cpu: max_diff={max_diff} at plane={plane} row={row} col={col} \
8079 gl={} cpu={}",
8080 g[max_at].to_f32(),
8081 c[max_at].to_f32()
8082 );
8083 assert!(
8086 max_diff <= 4.0 / 255.0 + 1e-3,
8087 "fused NV12→PlanarF16 diverges from CPU reference: max_diff={max_diff}"
8088 );
8089 }
8090
8091 #[test]
8101 #[cfg(feature = "opengl")]
8102 fn test_zero_copy_src_to_mem_dst_gl_direct() {
8103 let mut proc = match ImageProcessor::new() {
8104 Ok(p) if p.opengl.is_some() => p,
8105 _ => {
8106 eprintln!("SKIPPED: {} — GL backend unavailable", function!());
8107 return;
8108 }
8109 };
8110 if !edgefirst_tensor::is_gpu_buffer_available() {
8111 eprintln!("SKIPPED: {} — no zero-copy buffers", function!());
8112 return;
8113 }
8114
8115 let src = TensorDyn::image(
8116 1280,
8117 720,
8118 PixelFormat::Rgba,
8119 DType::U8,
8120 Some(TensorMemory::DmaBuf),
8121 edgefirst_tensor::CpuAccess::ReadWrite,
8122 )
8123 .unwrap();
8124 {
8125 let t = src.as_typed::<u8>().unwrap();
8126 let mut m = t.map().unwrap();
8127 for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
8128 *b = ((i * 31) % 211) as u8;
8129 }
8130 }
8131 let mut gl_dst = TensorDyn::image(
8132 1280,
8133 720,
8134 PixelFormat::Bgra,
8135 DType::U8,
8136 Some(TensorMemory::Mem),
8137 edgefirst_tensor::CpuAccess::ReadWrite,
8138 )
8139 .unwrap();
8140 proc.opengl
8141 .as_mut()
8142 .expect("GL backend present")
8143 .convert(
8144 &src,
8145 &mut gl_dst,
8146 Rotation::None,
8147 Flip::None,
8148 Crop::no_crop(),
8149 )
8150 .expect("zero-copy src → heap dst GL convert");
8151
8152 let mut cpu = ImageProcessor::with_config(ImageProcessorConfig {
8153 backend: ComputeBackend::Cpu,
8154 ..Default::default()
8155 })
8156 .unwrap();
8157 let mut cpu_dst = TensorDyn::image(
8158 1280,
8159 720,
8160 PixelFormat::Bgra,
8161 DType::U8,
8162 Some(TensorMemory::Mem),
8163 edgefirst_tensor::CpuAccess::ReadWrite,
8164 )
8165 .unwrap();
8166 cpu.convert(
8167 &src,
8168 &mut cpu_dst,
8169 Rotation::None,
8170 Flip::None,
8171 Crop::no_crop(),
8172 )
8173 .expect("CPU reference convert");
8174
8175 let g = gl_dst
8176 .as_typed::<u8>()
8177 .unwrap()
8178 .map()
8179 .unwrap()
8180 .as_slice()
8181 .to_vec();
8182 let c = cpu_dst
8183 .as_typed::<u8>()
8184 .unwrap()
8185 .map()
8186 .unwrap()
8187 .as_slice()
8188 .to_vec();
8189 assert_eq!(g.len(), c.len());
8190 let max_diff = g
8191 .iter()
8192 .zip(c.iter())
8193 .map(|(a, b)| a.abs_diff(*b))
8194 .max()
8195 .unwrap();
8196 assert!(
8199 max_diff <= 2,
8200 "zero-copy src → heap dst diverges from CPU reference: max_diff={max_diff}"
8201 );
8202 }
8203
8204 #[test]
8205 #[cfg(target_os = "linux")]
8206 fn test_yuyv_to_rgb_g2d() {
8207 if !is_g2d_available() {
8208 eprintln!("SKIPPED: test_yuyv_to_rgb_g2d - G2D library (libg2d.so.2) not available");
8209 return;
8210 }
8211 if !is_dma_available() {
8212 eprintln!(
8213 "SKIPPED: test_yuyv_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8214 );
8215 return;
8216 }
8217
8218 let src = load_bytes_to_tensor(
8219 1280,
8220 720,
8221 PixelFormat::Yuyv,
8222 None,
8223 &edgefirst_bench::testdata::read("camera720p.yuyv"),
8224 )
8225 .unwrap();
8226
8227 let g2d_dst = TensorDyn::image(
8228 1280,
8229 720,
8230 PixelFormat::Rgb,
8231 DType::U8,
8232 Some(TensorMemory::DmaBuf),
8233 edgefirst_tensor::CpuAccess::ReadWrite,
8234 )
8235 .unwrap();
8236 let mut g2d_converter = G2DProcessor::new().unwrap();
8237
8238 let (result, src, g2d_dst) = convert_img(
8239 &mut g2d_converter,
8240 src,
8241 g2d_dst,
8242 Rotation::None,
8243 Flip::None,
8244 Crop::no_crop(),
8245 );
8246 result.unwrap();
8247
8248 let cpu_dst = TensorDyn::image(
8249 1280,
8250 720,
8251 PixelFormat::Rgb,
8252 DType::U8,
8253 None,
8254 edgefirst_tensor::CpuAccess::ReadWrite,
8255 )
8256 .unwrap();
8257 let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8258
8259 let (result, _src, cpu_dst) = convert_img(
8260 &mut cpu_converter,
8261 src,
8262 cpu_dst,
8263 Rotation::None,
8264 Flip::None,
8265 Crop::no_crop(),
8266 );
8267 result.unwrap();
8268
8269 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8275 }
8276
8277 #[test]
8278 #[cfg(target_os = "linux")]
8279 fn test_yuyv_to_yuyv_resize_g2d() {
8280 if !is_g2d_available() {
8281 eprintln!(
8282 "SKIPPED: test_yuyv_to_yuyv_resize_g2d - G2D library (libg2d.so.2) not available"
8283 );
8284 return;
8285 }
8286 if !is_dma_available() {
8287 eprintln!(
8288 "SKIPPED: test_yuyv_to_yuyv_resize_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8289 );
8290 return;
8291 }
8292
8293 let src = load_bytes_to_tensor(
8294 1280,
8295 720,
8296 PixelFormat::Yuyv,
8297 None,
8298 &edgefirst_bench::testdata::read("camera720p.yuyv"),
8299 )
8300 .unwrap();
8301
8302 let g2d_dst = TensorDyn::image(
8303 600,
8304 400,
8305 PixelFormat::Yuyv,
8306 DType::U8,
8307 Some(TensorMemory::DmaBuf),
8308 edgefirst_tensor::CpuAccess::ReadWrite,
8309 )
8310 .unwrap();
8311 let mut g2d_converter = G2DProcessor::new().unwrap();
8312
8313 let (result, src, g2d_dst) = convert_img(
8314 &mut g2d_converter,
8315 src,
8316 g2d_dst,
8317 Rotation::None,
8318 Flip::None,
8319 Crop::no_crop(),
8320 );
8321 result.unwrap();
8322
8323 let cpu_dst = TensorDyn::image(
8324 600,
8325 400,
8326 PixelFormat::Yuyv,
8327 DType::U8,
8328 None,
8329 edgefirst_tensor::CpuAccess::ReadWrite,
8330 )
8331 .unwrap();
8332 let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8333
8334 let (result, _src, cpu_dst) = convert_img(
8335 &mut cpu_converter,
8336 src,
8337 cpu_dst,
8338 Rotation::None,
8339 Flip::None,
8340 Crop::no_crop(),
8341 );
8342 result.unwrap();
8343
8344 eprintln!(
8351 "WARNING: G2D has poor colorimetry support — YUYV resize diverges from the \
8352 CPU reference (~0.85 similarity); threshold held at 0.85, not 0.95."
8353 );
8354 compare_images_convert_to_rgb(&g2d_dst, &cpu_dst, 0.85, function!());
8355 }
8356
8357 #[test]
8358 fn test_yuyv_to_rgba_resize_cpu() {
8359 let src = load_bytes_to_tensor(
8360 1280,
8361 720,
8362 PixelFormat::Yuyv,
8363 None,
8364 &edgefirst_bench::testdata::read("camera720p.yuyv"),
8365 )
8366 .unwrap();
8367
8368 let (dst_width, dst_height) = (960, 540);
8369
8370 let dst = TensorDyn::image(
8371 dst_width,
8372 dst_height,
8373 PixelFormat::Rgba,
8374 DType::U8,
8375 None,
8376 edgefirst_tensor::CpuAccess::ReadWrite,
8377 )
8378 .unwrap();
8379 let mut cpu_converter = CPUProcessor::new();
8380
8381 let (result, _src, dst) = convert_img(
8382 &mut cpu_converter,
8383 src,
8384 dst,
8385 Rotation::None,
8386 Flip::None,
8387 Crop::no_crop(),
8388 );
8389 result.unwrap();
8390
8391 let dst_target = TensorDyn::image(
8392 dst_width,
8393 dst_height,
8394 PixelFormat::Rgba,
8395 DType::U8,
8396 None,
8397 edgefirst_tensor::CpuAccess::ReadWrite,
8398 )
8399 .unwrap();
8400 let src_target = load_bytes_to_tensor(
8401 1280,
8402 720,
8403 PixelFormat::Rgba,
8404 None,
8405 &edgefirst_bench::testdata::read("camera720p.rgba"),
8406 )
8407 .unwrap();
8408 let (result, _src_target, dst_target) = convert_img(
8409 &mut cpu_converter,
8410 src_target,
8411 dst_target,
8412 Rotation::None,
8413 Flip::None,
8414 Crop::no_crop(),
8415 );
8416 result.unwrap();
8417
8418 compare_images(&dst, &dst_target, 0.98, function!());
8421 }
8422
8423 #[test]
8424 #[cfg(target_os = "linux")]
8425 fn test_yuyv_to_rgba_crop_flip_g2d() {
8426 if !is_g2d_available() {
8427 eprintln!(
8428 "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - G2D library (libg2d.so.2) not available"
8429 );
8430 return;
8431 }
8432 if !is_dma_available() {
8433 eprintln!(
8434 "SKIPPED: test_yuyv_to_rgba_crop_flip_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8435 );
8436 return;
8437 }
8438
8439 let src = load_bytes_to_tensor(
8440 1280,
8441 720,
8442 PixelFormat::Yuyv,
8443 Some(TensorMemory::DmaBuf),
8444 &edgefirst_bench::testdata::read("camera720p.yuyv"),
8445 )
8446 .unwrap();
8447
8448 let (dst_width, dst_height) = (640, 640);
8449
8450 let dst_g2d = TensorDyn::image(
8451 dst_width,
8452 dst_height,
8453 PixelFormat::Rgba,
8454 DType::U8,
8455 Some(TensorMemory::DmaBuf),
8456 edgefirst_tensor::CpuAccess::ReadWrite,
8457 )
8458 .unwrap();
8459 let mut g2d_converter = G2DProcessor::new().unwrap();
8460 let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
8461
8462 let (result, src, dst_g2d) = convert_img(
8463 &mut g2d_converter,
8464 src,
8465 dst_g2d,
8466 Rotation::None,
8467 Flip::Horizontal,
8468 crop,
8469 );
8470 result.unwrap();
8471
8472 let dst_cpu = TensorDyn::image(
8473 dst_width,
8474 dst_height,
8475 PixelFormat::Rgba,
8476 DType::U8,
8477 Some(TensorMemory::DmaBuf),
8478 edgefirst_tensor::CpuAccess::ReadWrite,
8479 )
8480 .unwrap();
8481 let mut cpu_converter = CPUProcessor::new();
8482
8483 let (result, _src, dst_cpu) = convert_img(
8484 &mut cpu_converter,
8485 src,
8486 dst_cpu,
8487 Rotation::None,
8488 Flip::Horizontal,
8489 crop,
8490 );
8491 result.unwrap();
8492 compare_images(&dst_g2d, &dst_cpu, 0.98, function!());
8498 }
8499
8500 #[test]
8501 #[cfg(any(
8502 target_os = "linux",
8503 target_os = "macos",
8504 target_os = "ios",
8505 target_os = "android",
8506 target_os = "windows"
8507 ))]
8508 #[cfg(feature = "opengl")]
8509 fn test_yuyv_to_rgba_crop_flip_opengl() {
8510 if !is_opengl_available() {
8511 eprintln!("SKIPPED: {} - OpenGL not available", function!());
8512 return;
8513 }
8514
8515 if !edgefirst_tensor::is_gpu_buffer_available() {
8516 eprintln!(
8517 "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8518 function!()
8519 );
8520 return;
8521 }
8522
8523 let src = load_bytes_to_tensor(
8524 1280,
8525 720,
8526 PixelFormat::Yuyv,
8527 Some(TensorMemory::DmaBuf),
8528 &edgefirst_bench::testdata::read("camera720p.yuyv"),
8529 )
8530 .unwrap();
8531
8532 let (dst_width, dst_height) = (640, 640);
8533
8534 let dst_gl = TensorDyn::image(
8535 dst_width,
8536 dst_height,
8537 PixelFormat::Rgba,
8538 DType::U8,
8539 Some(TensorMemory::DmaBuf),
8540 edgefirst_tensor::CpuAccess::ReadWrite,
8541 )
8542 .unwrap();
8543 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8544 let crop = Crop::new().with_source(Some(Region::new(20, 15, 400, 300)));
8545
8546 let (result, src, dst_gl) = convert_img(
8547 &mut gl_converter,
8548 src,
8549 dst_gl,
8550 Rotation::None,
8551 Flip::Horizontal,
8552 crop,
8553 );
8554 if let Err(crate::Error::NotSupported(msg)) = &result {
8556 eprintln!("SKIPPED: {} — {msg}", function!());
8557 return;
8558 }
8559 result.unwrap();
8560
8561 let dst_cpu = TensorDyn::image(
8562 dst_width,
8563 dst_height,
8564 PixelFormat::Rgba,
8565 DType::U8,
8566 Some(TensorMemory::DmaBuf),
8567 edgefirst_tensor::CpuAccess::ReadWrite,
8568 )
8569 .unwrap();
8570 let mut cpu_converter = CPUProcessor::new();
8571
8572 let (result, _src, dst_cpu) = convert_img(
8573 &mut cpu_converter,
8574 src,
8575 dst_cpu,
8576 Rotation::None,
8577 Flip::Horizontal,
8578 crop,
8579 );
8580 result.unwrap();
8581 compare_images(&dst_gl, &dst_cpu, 0.98, function!());
8586 }
8587
8588 #[test]
8589 fn test_vyuy_to_rgba_cpu() {
8590 let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8591 let src = TensorDyn::image(
8592 1280,
8593 720,
8594 PixelFormat::Vyuy,
8595 DType::U8,
8596 None,
8597 edgefirst_tensor::CpuAccess::ReadWrite,
8598 )
8599 .unwrap();
8600 src.as_typed::<u8>()
8601 .unwrap()
8602 .map()
8603 .unwrap()
8604 .as_mut_slice()
8605 .copy_from_slice(&file);
8606
8607 let dst = TensorDyn::image(
8608 1280,
8609 720,
8610 PixelFormat::Rgba,
8611 DType::U8,
8612 None,
8613 edgefirst_tensor::CpuAccess::ReadWrite,
8614 )
8615 .unwrap();
8616 let mut cpu_converter = CPUProcessor::new();
8617
8618 let (result, _src, dst) = convert_img(
8619 &mut cpu_converter,
8620 src,
8621 dst,
8622 Rotation::None,
8623 Flip::None,
8624 Crop::no_crop(),
8625 );
8626 result.unwrap();
8627
8628 let target_image = TensorDyn::image(
8629 1280,
8630 720,
8631 PixelFormat::Rgba,
8632 DType::U8,
8633 None,
8634 edgefirst_tensor::CpuAccess::ReadWrite,
8635 )
8636 .unwrap();
8637 target_image
8638 .as_typed::<u8>()
8639 .unwrap()
8640 .map()
8641 .unwrap()
8642 .as_mut_slice()
8643 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8644
8645 compare_images(&dst, &target_image, 0.98, function!());
8648 }
8649
8650 #[test]
8651 fn test_vyuy_to_rgb_cpu() {
8652 let file = edgefirst_bench::testdata::read("camera720p.vyuy").to_vec();
8653 let src = TensorDyn::image(
8654 1280,
8655 720,
8656 PixelFormat::Vyuy,
8657 DType::U8,
8658 None,
8659 edgefirst_tensor::CpuAccess::ReadWrite,
8660 )
8661 .unwrap();
8662 src.as_typed::<u8>()
8663 .unwrap()
8664 .map()
8665 .unwrap()
8666 .as_mut_slice()
8667 .copy_from_slice(&file);
8668
8669 let dst = TensorDyn::image(
8670 1280,
8671 720,
8672 PixelFormat::Rgb,
8673 DType::U8,
8674 None,
8675 edgefirst_tensor::CpuAccess::ReadWrite,
8676 )
8677 .unwrap();
8678 let mut cpu_converter = CPUProcessor::new();
8679
8680 let (result, _src, dst) = convert_img(
8681 &mut cpu_converter,
8682 src,
8683 dst,
8684 Rotation::None,
8685 Flip::None,
8686 Crop::no_crop(),
8687 );
8688 result.unwrap();
8689
8690 let target_image = TensorDyn::image(
8691 1280,
8692 720,
8693 PixelFormat::Rgb,
8694 DType::U8,
8695 None,
8696 edgefirst_tensor::CpuAccess::ReadWrite,
8697 )
8698 .unwrap();
8699 target_image
8700 .as_typed::<u8>()
8701 .unwrap()
8702 .map()
8703 .unwrap()
8704 .as_mut_slice()
8705 .as_chunks_mut::<3>()
8706 .0
8707 .iter_mut()
8708 .zip(
8709 edgefirst_bench::testdata::read("camera720p.rgba")
8710 .as_chunks::<4>()
8711 .0,
8712 )
8713 .for_each(|(dst, src)| *dst = [src[0], src[1], src[2]]);
8714
8715 compare_images(&dst, &target_image, 0.98, function!());
8718 }
8719
8720 #[test]
8721 #[cfg(target_os = "linux")]
8722 #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8723 fn test_vyuy_to_rgba_g2d() {
8724 if !is_g2d_available() {
8725 eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D library (libg2d.so.2) not available");
8726 return;
8727 }
8728 if !is_dma_available() {
8729 eprintln!(
8730 "SKIPPED: test_vyuy_to_rgba_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8731 );
8732 return;
8733 }
8734
8735 let src = load_bytes_to_tensor(
8736 1280,
8737 720,
8738 PixelFormat::Vyuy,
8739 None,
8740 &edgefirst_bench::testdata::read("camera720p.vyuy"),
8741 )
8742 .unwrap();
8743
8744 let dst = TensorDyn::image(
8745 1280,
8746 720,
8747 PixelFormat::Rgba,
8748 DType::U8,
8749 Some(TensorMemory::DmaBuf),
8750 edgefirst_tensor::CpuAccess::ReadWrite,
8751 )
8752 .unwrap();
8753 let mut g2d_converter = G2DProcessor::new().unwrap();
8754
8755 let (result, _src, dst) = convert_img(
8756 &mut g2d_converter,
8757 src,
8758 dst,
8759 Rotation::None,
8760 Flip::None,
8761 Crop::no_crop(),
8762 );
8763 match result {
8764 Err(Error::G2D(_)) => {
8765 eprintln!("SKIPPED: test_vyuy_to_rgba_g2d - G2D does not support PixelFormat::Vyuy format");
8766 return;
8767 }
8768 r => r.unwrap(),
8769 }
8770
8771 let target_image = TensorDyn::image(
8772 1280,
8773 720,
8774 PixelFormat::Rgba,
8775 DType::U8,
8776 None,
8777 edgefirst_tensor::CpuAccess::ReadWrite,
8778 )
8779 .unwrap();
8780 target_image
8781 .as_typed::<u8>()
8782 .unwrap()
8783 .map()
8784 .unwrap()
8785 .as_mut_slice()
8786 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8787
8788 compare_images(&dst, &target_image, 0.98, function!());
8792 }
8793
8794 #[test]
8795 #[cfg(target_os = "linux")]
8796 #[ignore = "G2D does not support VYUY; re-enable when hardware support is added"]
8797 fn test_vyuy_to_rgb_g2d() {
8798 if !is_g2d_available() {
8799 eprintln!("SKIPPED: test_vyuy_to_rgb_g2d - G2D library (libg2d.so.2) not available");
8800 return;
8801 }
8802 if !is_dma_available() {
8803 eprintln!(
8804 "SKIPPED: test_vyuy_to_rgb_g2d - DMA memory allocation not available (permission denied or no DMA-BUF support)"
8805 );
8806 return;
8807 }
8808
8809 let src = load_bytes_to_tensor(
8810 1280,
8811 720,
8812 PixelFormat::Vyuy,
8813 None,
8814 &edgefirst_bench::testdata::read("camera720p.vyuy"),
8815 )
8816 .unwrap();
8817
8818 let g2d_dst = TensorDyn::image(
8819 1280,
8820 720,
8821 PixelFormat::Rgb,
8822 DType::U8,
8823 Some(TensorMemory::DmaBuf),
8824 edgefirst_tensor::CpuAccess::ReadWrite,
8825 )
8826 .unwrap();
8827 let mut g2d_converter = G2DProcessor::new().unwrap();
8828
8829 let (result, src, g2d_dst) = convert_img(
8830 &mut g2d_converter,
8831 src,
8832 g2d_dst,
8833 Rotation::None,
8834 Flip::None,
8835 Crop::no_crop(),
8836 );
8837 match result {
8838 Err(Error::G2D(_)) => {
8839 eprintln!(
8840 "SKIPPED: test_vyuy_to_rgb_g2d - G2D does not support PixelFormat::Vyuy format"
8841 );
8842 return;
8843 }
8844 r => r.unwrap(),
8845 }
8846
8847 let cpu_dst = TensorDyn::image(
8848 1280,
8849 720,
8850 PixelFormat::Rgb,
8851 DType::U8,
8852 None,
8853 edgefirst_tensor::CpuAccess::ReadWrite,
8854 )
8855 .unwrap();
8856 let mut cpu_converter: CPUProcessor = CPUProcessor::new();
8857
8858 let (result, _src, cpu_dst) = convert_img(
8859 &mut cpu_converter,
8860 src,
8861 cpu_dst,
8862 Rotation::None,
8863 Flip::None,
8864 Crop::no_crop(),
8865 );
8866 result.unwrap();
8867
8868 compare_images(&g2d_dst, &cpu_dst, 0.98, function!());
8874 }
8875
8876 #[test]
8877 #[cfg(any(
8878 target_os = "linux",
8879 target_os = "macos",
8880 target_os = "ios",
8881 target_os = "android",
8882 target_os = "windows"
8883 ))]
8884 #[cfg(feature = "opengl")]
8885 fn test_vyuy_to_rgba_opengl() {
8886 if !is_opengl_available() {
8887 eprintln!("SKIPPED: {} - OpenGL not available", function!());
8888 return;
8889 }
8890 if !edgefirst_tensor::is_gpu_buffer_available() {
8891 eprintln!(
8892 "SKIPPED: {} - zero-copy GPU buffer (DMA-BUF / IOSurface) not available on this host",
8893 function!()
8894 );
8895 return;
8896 }
8897
8898 let src = match load_bytes_to_tensor(
8902 1280,
8903 720,
8904 PixelFormat::Vyuy,
8905 Some(TensorMemory::DmaBuf),
8906 &edgefirst_bench::testdata::read("camera720p.vyuy"),
8907 ) {
8908 Ok(src) => src,
8909 Err(e) => {
8910 eprintln!(
8911 "SKIPPED: {} - zero-copy VYUY source not allocatable on this platform ({e})",
8912 function!()
8913 );
8914 return;
8915 }
8916 };
8917
8918 let dst = TensorDyn::image(
8919 1280,
8920 720,
8921 PixelFormat::Rgba,
8922 DType::U8,
8923 Some(TensorMemory::DmaBuf),
8924 edgefirst_tensor::CpuAccess::ReadWrite,
8925 )
8926 .unwrap();
8927 let mut gl_converter = GLProcessorThreaded::new(None).unwrap();
8928
8929 let (result, _src, dst) = convert_img(
8930 &mut gl_converter,
8931 src,
8932 dst,
8933 Rotation::None,
8934 Flip::None,
8935 Crop::no_crop(),
8936 );
8937 match result {
8938 Err(Error::NotSupported(_)) => {
8939 eprintln!(
8940 "SKIPPED: {} - OpenGL does not support PixelFormat::Vyuy DMA format",
8941 function!()
8942 );
8943 return;
8944 }
8945 r => r.unwrap(),
8946 }
8947
8948 let target_image = TensorDyn::image(
8949 1280,
8950 720,
8951 PixelFormat::Rgba,
8952 DType::U8,
8953 None,
8954 edgefirst_tensor::CpuAccess::ReadWrite,
8955 )
8956 .unwrap();
8957 target_image
8958 .as_typed::<u8>()
8959 .unwrap()
8960 .map()
8961 .unwrap()
8962 .as_mut_slice()
8963 .copy_from_slice(&edgefirst_bench::testdata::read("camera720p.rgba"));
8964
8965 compare_images(&dst, &target_image, 0.98, function!());
8969 }
8970
8971 #[test]
8972 fn test_nv12_to_rgba_cpu() {
8973 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
8974 let src = TensorDyn::image(
8975 1280,
8976 720,
8977 PixelFormat::Nv12,
8978 DType::U8,
8979 None,
8980 edgefirst_tensor::CpuAccess::ReadWrite,
8981 )
8982 .unwrap();
8983 src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
8984 .copy_from_slice(&file);
8985
8986 let dst = TensorDyn::image(
8987 1280,
8988 720,
8989 PixelFormat::Rgba,
8990 DType::U8,
8991 None,
8992 edgefirst_tensor::CpuAccess::ReadWrite,
8993 )
8994 .unwrap();
8995 let mut cpu_converter = CPUProcessor::new();
8996
8997 let (result, _src, dst) = convert_img(
8998 &mut cpu_converter,
8999 src,
9000 dst,
9001 Rotation::None,
9002 Flip::None,
9003 Crop::no_crop(),
9004 );
9005 result.unwrap();
9006
9007 let target_image = crate::load_image_test_helper(
9008 &edgefirst_bench::testdata::read("zidane.jpg"),
9009 Some(PixelFormat::Rgba),
9010 None,
9011 )
9012 .unwrap();
9013
9014 compare_images(&dst, &target_image, 0.95, function!());
9019 }
9020
9021 #[test]
9022 fn test_nv12_odd_height_to_rgb_cpu() {
9023 let mut src = TensorDyn::image(
9034 8,
9035 5,
9036 PixelFormat::Nv12,
9037 DType::U8,
9038 Some(TensorMemory::Mem),
9039 edgefirst_tensor::CpuAccess::ReadWrite,
9040 )
9041 .unwrap();
9042 assert_eq!(src.shape(), &[8, 8]);
9043 assert_eq!((src.width(), src.height()), (Some(8), Some(5)));
9044 src.as_typed::<u8>()
9045 .unwrap()
9046 .map()
9047 .unwrap()
9048 .as_mut_slice()
9049 .fill(128);
9050 src.set_colorimetry(Some(
9054 edgefirst_tensor::Colorimetry::default()
9055 .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
9056 .with_range(edgefirst_tensor::ColorRange::Full),
9057 ));
9058
9059 let dst = TensorDyn::image(
9060 8,
9061 5,
9062 PixelFormat::Rgb,
9063 DType::U8,
9064 Some(TensorMemory::Mem),
9065 edgefirst_tensor::CpuAccess::ReadWrite,
9066 )
9067 .unwrap();
9068 let mut cpu_converter = CPUProcessor::new();
9069 let (result, _src, dst) = convert_img(
9070 &mut cpu_converter,
9071 src,
9072 dst,
9073 Rotation::None,
9074 Flip::None,
9075 Crop::no_crop(),
9076 );
9077 result.unwrap();
9078
9079 assert_eq!((dst.width(), dst.height()), (Some(8), Some(5)));
9080 let map = dst.as_typed::<u8>().unwrap().map().unwrap();
9081 for (i, &b) in map.as_slice().iter().enumerate() {
9082 assert!(
9083 (b as i16 - 128).abs() <= 2,
9084 "pixel byte {i} = {b}, expected ~128 for neutral-grey NV12"
9085 );
9086 }
9087 }
9088
9089 #[test]
9090 fn test_nv24_to_rgb_cpu() {
9091 let mut src = TensorDyn::image(
9097 8,
9098 4,
9099 PixelFormat::Nv24,
9100 DType::U8,
9101 Some(TensorMemory::Mem),
9102 edgefirst_tensor::CpuAccess::ReadWrite,
9103 )
9104 .unwrap();
9105 assert_eq!(src.shape(), &[12, 8]);
9106 assert_eq!((src.width(), src.height()), (Some(8), Some(4)));
9107 src.as_typed::<u8>()
9108 .unwrap()
9109 .map()
9110 .unwrap()
9111 .as_mut_slice()
9112 .fill(128);
9113 src.set_colorimetry(Some(
9116 edgefirst_tensor::Colorimetry::default()
9117 .with_encoding(edgefirst_tensor::ColorEncoding::Bt601)
9118 .with_range(edgefirst_tensor::ColorRange::Full),
9119 ));
9120
9121 let dst = TensorDyn::image(
9122 8,
9123 4,
9124 PixelFormat::Rgb,
9125 DType::U8,
9126 Some(TensorMemory::Mem),
9127 edgefirst_tensor::CpuAccess::ReadWrite,
9128 )
9129 .unwrap();
9130 let mut cpu_converter = CPUProcessor::new();
9131 let (result, _src, dst) = convert_img(
9132 &mut cpu_converter,
9133 src,
9134 dst,
9135 Rotation::None,
9136 Flip::None,
9137 Crop::no_crop(),
9138 );
9139 result.unwrap();
9140
9141 assert_eq!((dst.width(), dst.height()), (Some(8), Some(4)));
9142 let map = dst.as_typed::<u8>().unwrap().map().unwrap();
9143 for (i, &b) in map.as_slice().iter().enumerate() {
9144 assert!(
9145 (b as i16 - 128).abs() <= 2,
9146 "pixel byte {i} = {b}, expected ~128 for neutral-grey NV24"
9147 );
9148 }
9149 }
9150
9151 #[test]
9152 fn cpu_nv12_to_rgb_respects_tagged_bt2020() {
9153 fn decode_tagged(enc: edgefirst_tensor::ColorEncoding) -> [u8; 3] {
9161 let mut src = TensorDyn::image(
9162 8,
9163 4,
9164 PixelFormat::Nv12,
9165 DType::U8,
9166 Some(TensorMemory::Mem),
9167 edgefirst_tensor::CpuAccess::ReadWrite,
9168 )
9169 .unwrap();
9170 assert_eq!(src.shape(), &[6, 8]);
9172 {
9173 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
9174 let buf = map.as_mut_slice();
9175 buf[..32].fill(120); for px in buf[32..].as_chunks_mut::<2>().0 {
9177 px[0] = 180; px[1] = 64; }
9180 }
9181 src.set_colorimetry(Some(
9184 edgefirst_tensor::Colorimetry::default()
9185 .with_encoding(enc)
9186 .with_range(edgefirst_tensor::ColorRange::Limited),
9187 ));
9188 let dst = TensorDyn::image(
9189 8,
9190 4,
9191 PixelFormat::Rgb,
9192 DType::U8,
9193 Some(TensorMemory::Mem),
9194 edgefirst_tensor::CpuAccess::ReadWrite,
9195 )
9196 .unwrap();
9197 let mut cpu = CPUProcessor::new();
9198 let (result, _src, dst) = convert_img(
9199 &mut cpu,
9200 src,
9201 dst,
9202 Rotation::None,
9203 Flip::None,
9204 Crop::no_crop(),
9205 );
9206 result.unwrap();
9207 let map = dst.as_typed::<u8>().unwrap().map().unwrap();
9208 let s = map.as_slice();
9209 [s[0], s[1], s[2]]
9210 }
9211
9212 let bt601 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt601);
9213 let bt709 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt709);
9214 let bt2020 = decode_tagged(edgefirst_tensor::ColorEncoding::Bt2020);
9215
9216 assert_ne!(
9217 bt2020, bt601,
9218 "BT.2020 must decode differently from BT.601 ({bt2020:?} vs {bt601:?})"
9219 );
9220 assert_ne!(
9221 bt2020, bt709,
9222 "BT.2020 must decode differently from BT.709 ({bt2020:?} vs {bt709:?})"
9223 );
9224 assert_ne!(
9225 bt601, bt709,
9226 "BT.601 must decode differently from BT.709 ({bt601:?} vs {bt709:?})"
9227 );
9228 }
9229
9230 #[test]
9231 fn test_nv12_to_rgb_cpu() {
9232 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
9233 let src = TensorDyn::image(
9234 1280,
9235 720,
9236 PixelFormat::Nv12,
9237 DType::U8,
9238 None,
9239 edgefirst_tensor::CpuAccess::ReadWrite,
9240 )
9241 .unwrap();
9242 src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
9243 .copy_from_slice(&file);
9244
9245 let dst = TensorDyn::image(
9246 1280,
9247 720,
9248 PixelFormat::Rgb,
9249 DType::U8,
9250 None,
9251 edgefirst_tensor::CpuAccess::ReadWrite,
9252 )
9253 .unwrap();
9254 let mut cpu_converter = CPUProcessor::new();
9255
9256 let (result, _src, dst) = convert_img(
9257 &mut cpu_converter,
9258 src,
9259 dst,
9260 Rotation::None,
9261 Flip::None,
9262 Crop::no_crop(),
9263 );
9264 result.unwrap();
9265
9266 let target_image = crate::load_image_test_helper(
9267 &edgefirst_bench::testdata::read("zidane.jpg"),
9268 Some(PixelFormat::Rgb),
9269 None,
9270 )
9271 .unwrap();
9272
9273 compare_images(&dst, &target_image, 0.95, function!());
9278 }
9279
9280 #[test]
9281 fn test_nv12_to_grey_cpu() {
9282 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
9283 let src = TensorDyn::image(
9284 1280,
9285 720,
9286 PixelFormat::Nv12,
9287 DType::U8,
9288 None,
9289 edgefirst_tensor::CpuAccess::ReadWrite,
9290 )
9291 .unwrap();
9292 src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
9293 .copy_from_slice(&file);
9294
9295 let dst = TensorDyn::image(
9296 1280,
9297 720,
9298 PixelFormat::Grey,
9299 DType::U8,
9300 None,
9301 edgefirst_tensor::CpuAccess::ReadWrite,
9302 )
9303 .unwrap();
9304 let mut cpu_converter = CPUProcessor::new();
9305
9306 let (result, _src, dst) = convert_img(
9307 &mut cpu_converter,
9308 src,
9309 dst,
9310 Rotation::None,
9311 Flip::None,
9312 Crop::no_crop(),
9313 );
9314 result.unwrap();
9315
9316 let target_image = crate::load_image_test_helper(
9317 &edgefirst_bench::testdata::read("zidane.jpg"),
9318 Some(PixelFormat::Grey),
9319 None,
9320 )
9321 .unwrap();
9322
9323 compare_images(&dst, &target_image, 0.95, function!());
9328 }
9329
9330 #[test]
9331 fn test_nv12_to_yuyv_cpu() {
9332 let file = edgefirst_bench::testdata::read("zidane.nv12").to_vec();
9333 let src = TensorDyn::image(
9334 1280,
9335 720,
9336 PixelFormat::Nv12,
9337 DType::U8,
9338 None,
9339 edgefirst_tensor::CpuAccess::ReadWrite,
9340 )
9341 .unwrap();
9342 src.as_typed::<u8>().unwrap().map().unwrap().as_mut_slice()[0..(1280 * 720 * 3 / 2)]
9343 .copy_from_slice(&file);
9344
9345 let dst = TensorDyn::image(
9346 1280,
9347 720,
9348 PixelFormat::Yuyv,
9349 DType::U8,
9350 None,
9351 edgefirst_tensor::CpuAccess::ReadWrite,
9352 )
9353 .unwrap();
9354 let mut cpu_converter = CPUProcessor::new();
9355
9356 let (result, _src, dst) = convert_img(
9357 &mut cpu_converter,
9358 src,
9359 dst,
9360 Rotation::None,
9361 Flip::None,
9362 Crop::no_crop(),
9363 );
9364 result.unwrap();
9365
9366 let target_image = crate::load_image_test_helper(
9367 &edgefirst_bench::testdata::read("zidane.jpg"),
9368 Some(PixelFormat::Rgb),
9369 None,
9370 )
9371 .unwrap();
9372
9373 compare_images_convert_to_rgb(&dst, &target_image, 0.95, function!());
9378 }
9379
9380 #[test]
9381 fn test_cpu_resize_nv16() {
9382 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
9383 let src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
9384
9385 let cpu_nv16_dst = TensorDyn::image(
9386 640,
9387 640,
9388 PixelFormat::Nv16,
9389 DType::U8,
9390 None,
9391 edgefirst_tensor::CpuAccess::ReadWrite,
9392 )
9393 .unwrap();
9394 let cpu_rgb_dst = TensorDyn::image(
9395 640,
9396 640,
9397 PixelFormat::Rgb,
9398 DType::U8,
9399 None,
9400 edgefirst_tensor::CpuAccess::ReadWrite,
9401 )
9402 .unwrap();
9403 let mut cpu_converter = CPUProcessor::new();
9404 let crop = Crop::letterbox([255, 128, 0, 255]);
9405
9406 let (result, src, cpu_nv16_dst) = convert_img(
9407 &mut cpu_converter,
9408 src,
9409 cpu_nv16_dst,
9410 Rotation::None,
9411 Flip::None,
9412 crop,
9413 );
9414 result.unwrap();
9415
9416 let (result, _src, cpu_rgb_dst) = convert_img(
9417 &mut cpu_converter,
9418 src,
9419 cpu_rgb_dst,
9420 Rotation::None,
9421 Flip::None,
9422 crop,
9423 );
9424 result.unwrap();
9425 compare_images_convert_to_rgb(
9426 &cpu_nv16_dst,
9427 &cpu_rgb_dst,
9428 yuv_threshold(0.99),
9429 function!(),
9430 );
9431 }
9432
9433 fn yuv_threshold(balanced: f64) -> f64 {
9450 #[cfg(target_arch = "aarch64")]
9451 {
9452 if !std::arch::is_aarch64_feature_detected!("rdm") {
9453 return balanced - 0.01;
9454 }
9455 }
9456 balanced
9457 }
9458
9459 fn load_bytes_to_tensor(
9460 width: usize,
9461 height: usize,
9462 format: PixelFormat,
9463 memory: Option<TensorMemory>,
9464 bytes: &[u8],
9465 ) -> Result<TensorDyn, Error> {
9466 let src = TensorDyn::image(
9467 width,
9468 height,
9469 format,
9470 DType::U8,
9471 memory,
9472 edgefirst_tensor::CpuAccess::ReadWrite,
9473 )?;
9474 src.as_typed::<u8>()
9475 .unwrap()
9476 .map()?
9477 .as_mut_slice()
9478 .copy_from_slice(bytes);
9479 Ok(src)
9480 }
9481
9482 fn compare_images(img1: &TensorDyn, img2: &TensorDyn, threshold: f64, name: &str) {
9490 assert_eq!(img1.height(), img2.height(), "Heights differ");
9491 assert_eq!(img1.width(), img2.width(), "Widths differ");
9492 assert_eq!(
9493 img1.format().unwrap(),
9494 img2.format().unwrap(),
9495 "PixelFormat differ"
9496 );
9497 assert!(
9498 matches!(
9499 img1.format().unwrap(),
9500 PixelFormat::Rgb | PixelFormat::Rgba | PixelFormat::Grey | PixelFormat::PlanarRgb
9501 ),
9502 "format must be Rgb or Rgba for comparison"
9503 );
9504
9505 let image1 = match img1.format().unwrap() {
9506 PixelFormat::Rgb => image::RgbImage::from_vec(
9507 img1.width().unwrap() as u32,
9508 img1.height().unwrap() as u32,
9509 img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9510 )
9511 .unwrap(),
9512 PixelFormat::Rgba => image::RgbaImage::from_vec(
9513 img1.width().unwrap() as u32,
9514 img1.height().unwrap() as u32,
9515 img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9516 )
9517 .unwrap()
9518 .convert(),
9519 PixelFormat::Grey => image::GrayImage::from_vec(
9520 img1.width().unwrap() as u32,
9521 img1.height().unwrap() as u32,
9522 img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9523 )
9524 .unwrap()
9525 .convert(),
9526 PixelFormat::PlanarRgb => image::GrayImage::from_vec(
9527 img1.width().unwrap() as u32,
9528 (img1.height().unwrap() * 3) as u32,
9529 img1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9530 )
9531 .unwrap()
9532 .convert(),
9533 _ => return,
9534 };
9535
9536 let image2 = match img2.format().unwrap() {
9537 PixelFormat::Rgb => image::RgbImage::from_vec(
9538 img2.width().unwrap() as u32,
9539 img2.height().unwrap() as u32,
9540 img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9541 )
9542 .unwrap(),
9543 PixelFormat::Rgba => image::RgbaImage::from_vec(
9544 img2.width().unwrap() as u32,
9545 img2.height().unwrap() as u32,
9546 img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9547 )
9548 .unwrap()
9549 .convert(),
9550 PixelFormat::Grey => image::GrayImage::from_vec(
9551 img2.width().unwrap() as u32,
9552 img2.height().unwrap() as u32,
9553 img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9554 )
9555 .unwrap()
9556 .convert(),
9557 PixelFormat::PlanarRgb => image::GrayImage::from_vec(
9558 img2.width().unwrap() as u32,
9559 (img2.height().unwrap() * 3) as u32,
9560 img2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9561 )
9562 .unwrap()
9563 .convert(),
9564 _ => return,
9565 };
9566
9567 let similarity = image_compare::rgb_similarity_structure(
9568 &image_compare::Algorithm::RootMeanSquared,
9569 &image1,
9570 &image2,
9571 )
9572 .expect("Image Comparison failed");
9573 if similarity.score < threshold {
9574 similarity
9577 .image
9578 .to_color_map()
9579 .save(format!("{name}.png"))
9580 .unwrap();
9581 panic!(
9582 "{name}: converted image and target image have similarity score too low: {} < {}",
9583 similarity.score, threshold
9584 )
9585 }
9586 }
9587
9588 fn compare_images_convert_to_rgb(
9589 img1: &TensorDyn,
9590 img2: &TensorDyn,
9591 threshold: f64,
9592 name: &str,
9593 ) {
9594 assert_eq!(img1.height(), img2.height(), "Heights differ");
9595 assert_eq!(img1.width(), img2.width(), "Widths differ");
9596
9597 let mut img_rgb1 = TensorDyn::image(
9598 img1.width().unwrap(),
9599 img1.height().unwrap(),
9600 PixelFormat::Rgb,
9601 DType::U8,
9602 Some(TensorMemory::Mem),
9603 edgefirst_tensor::CpuAccess::ReadWrite,
9604 )
9605 .unwrap();
9606 let mut img_rgb2 = TensorDyn::image(
9607 img1.width().unwrap(),
9608 img1.height().unwrap(),
9609 PixelFormat::Rgb,
9610 DType::U8,
9611 Some(TensorMemory::Mem),
9612 edgefirst_tensor::CpuAccess::ReadWrite,
9613 )
9614 .unwrap();
9615 let mut __cv = CPUProcessor::default();
9616 let r1 = __cv.convert(
9617 img1,
9618 &mut img_rgb1,
9619 crate::Rotation::None,
9620 crate::Flip::None,
9621 crate::Crop::default(),
9622 );
9623 let r2 = __cv.convert(
9624 img2,
9625 &mut img_rgb2,
9626 crate::Rotation::None,
9627 crate::Flip::None,
9628 crate::Crop::default(),
9629 );
9630 if r1.is_err() || r2.is_err() {
9631 let w = img1.width().unwrap() as u32;
9633 let data1 = img1.as_typed::<u8>().unwrap().map().unwrap().to_vec();
9634 let data2 = img2.as_typed::<u8>().unwrap().map().unwrap().to_vec();
9635 let h1 = (data1.len() as u32) / w;
9636 let h2 = (data2.len() as u32) / w;
9637 let g1 = image::GrayImage::from_vec(w, h1, data1).unwrap();
9638 let g2 = image::GrayImage::from_vec(w, h2, data2).unwrap();
9639 let similarity = image_compare::gray_similarity_structure(
9640 &image_compare::Algorithm::RootMeanSquared,
9641 &g1,
9642 &g2,
9643 )
9644 .expect("Image Comparison failed");
9645 if similarity.score < threshold {
9646 panic!(
9647 "{name}: converted image and target image have similarity score too low: {} < {}",
9648 similarity.score, threshold
9649 )
9650 }
9651 return;
9652 }
9653
9654 let image1 = image::RgbImage::from_vec(
9655 img_rgb1.width().unwrap() as u32,
9656 img_rgb1.height().unwrap() as u32,
9657 img_rgb1.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9658 )
9659 .unwrap();
9660
9661 let image2 = image::RgbImage::from_vec(
9662 img_rgb2.width().unwrap() as u32,
9663 img_rgb2.height().unwrap() as u32,
9664 img_rgb2.as_typed::<u8>().unwrap().map().unwrap().to_vec(),
9665 )
9666 .unwrap();
9667
9668 let similarity = image_compare::rgb_similarity_structure(
9669 &image_compare::Algorithm::RootMeanSquared,
9670 &image1,
9671 &image2,
9672 )
9673 .expect("Image Comparison failed");
9674 if similarity.score < threshold {
9675 similarity
9678 .image
9679 .to_color_map()
9680 .save(format!("{name}.png"))
9681 .unwrap();
9682 panic!(
9683 "{name}: converted image and target image have similarity score too low: {} < {}",
9684 similarity.score, threshold
9685 )
9686 }
9687 }
9688
9689 #[test]
9694 fn test_nv12_image_creation() {
9695 let width = 640;
9696 let height = 480;
9697 let img = TensorDyn::image(
9698 width,
9699 height,
9700 PixelFormat::Nv12,
9701 DType::U8,
9702 None,
9703 edgefirst_tensor::CpuAccess::ReadWrite,
9704 )
9705 .unwrap();
9706
9707 assert_eq!(img.width(), Some(width));
9708 assert_eq!(img.height(), Some(height));
9709 assert_eq!(img.format().unwrap(), PixelFormat::Nv12);
9710 assert_eq!(
9712 img.as_typed::<u8>().unwrap().shape(),
9713 &[height * 3 / 2, width]
9714 );
9715 }
9716
9717 #[test]
9718 fn test_nv12_channels() {
9719 let img = TensorDyn::image(
9720 640,
9721 480,
9722 PixelFormat::Nv12,
9723 DType::U8,
9724 None,
9725 edgefirst_tensor::CpuAccess::ReadWrite,
9726 )
9727 .unwrap();
9728 assert_eq!(img.format().unwrap().channels(), 1);
9730 }
9731
9732 #[test]
9737 fn test_tensor_set_format_planar() {
9738 let mut tensor = Tensor::<u8>::new(&[3, 480, 640], None, None).unwrap();
9739 tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9740 assert_eq!(tensor.format(), Some(PixelFormat::PlanarRgb));
9741 assert_eq!(tensor.width(), Some(640));
9742 assert_eq!(tensor.height(), Some(480));
9743 }
9744
9745 #[test]
9746 fn test_tensor_set_format_interleaved() {
9747 let mut tensor = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
9748 tensor.set_format(PixelFormat::Rgba).unwrap();
9749 assert_eq!(tensor.format(), Some(PixelFormat::Rgba));
9750 assert_eq!(tensor.width(), Some(640));
9751 assert_eq!(tensor.height(), Some(480));
9752 }
9753
9754 #[test]
9755 fn test_tensordyn_image_rgb() {
9756 let img = TensorDyn::image(
9757 640,
9758 480,
9759 PixelFormat::Rgb,
9760 DType::U8,
9761 None,
9762 edgefirst_tensor::CpuAccess::ReadWrite,
9763 )
9764 .unwrap();
9765 assert_eq!(img.width(), Some(640));
9766 assert_eq!(img.height(), Some(480));
9767 assert_eq!(img.format(), Some(PixelFormat::Rgb));
9768 }
9769
9770 #[test]
9771 fn test_tensordyn_image_planar_rgb() {
9772 let img = TensorDyn::image(
9773 640,
9774 480,
9775 PixelFormat::PlanarRgb,
9776 DType::U8,
9777 None,
9778 edgefirst_tensor::CpuAccess::ReadWrite,
9779 )
9780 .unwrap();
9781 assert_eq!(img.width(), Some(640));
9782 assert_eq!(img.height(), Some(480));
9783 assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9784 }
9785
9786 #[test]
9787 fn test_rgb_int8_format() {
9788 let img = TensorDyn::image(
9790 1280,
9791 720,
9792 PixelFormat::Rgb,
9793 DType::I8,
9794 Some(TensorMemory::Mem),
9795 edgefirst_tensor::CpuAccess::ReadWrite,
9796 )
9797 .unwrap();
9798 assert_eq!(img.width(), Some(1280));
9799 assert_eq!(img.height(), Some(720));
9800 assert_eq!(img.format(), Some(PixelFormat::Rgb));
9801 assert_eq!(img.dtype(), DType::I8);
9802 }
9803
9804 #[test]
9805 fn test_planar_rgb_int8_format() {
9806 let img = TensorDyn::image(
9807 1280,
9808 720,
9809 PixelFormat::PlanarRgb,
9810 DType::I8,
9811 Some(TensorMemory::Mem),
9812 edgefirst_tensor::CpuAccess::ReadWrite,
9813 )
9814 .unwrap();
9815 assert_eq!(img.width(), Some(1280));
9816 assert_eq!(img.height(), Some(720));
9817 assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9818 assert_eq!(img.dtype(), DType::I8);
9819 }
9820
9821 #[test]
9822 fn test_rgb_from_tensor() {
9823 let mut tensor = Tensor::<u8>::new(&[720, 1280, 3], None, None).unwrap();
9824 tensor.set_format(PixelFormat::Rgb).unwrap();
9825 let img = TensorDyn::from(tensor);
9826 assert_eq!(img.width(), Some(1280));
9827 assert_eq!(img.height(), Some(720));
9828 assert_eq!(img.format(), Some(PixelFormat::Rgb));
9829 }
9830
9831 #[test]
9832 fn test_planar_rgb_from_tensor() {
9833 let mut tensor = Tensor::<u8>::new(&[3, 720, 1280], None, None).unwrap();
9834 tensor.set_format(PixelFormat::PlanarRgb).unwrap();
9835 let img = TensorDyn::from(tensor);
9836 assert_eq!(img.width(), Some(1280));
9837 assert_eq!(img.height(), Some(720));
9838 assert_eq!(img.format(), Some(PixelFormat::PlanarRgb));
9839 }
9840
9841 #[test]
9842 fn test_dtype_determines_int8() {
9843 let u8_img = TensorDyn::image(
9845 64,
9846 64,
9847 PixelFormat::Rgb,
9848 DType::U8,
9849 None,
9850 edgefirst_tensor::CpuAccess::ReadWrite,
9851 )
9852 .unwrap();
9853 let i8_img = TensorDyn::image(
9854 64,
9855 64,
9856 PixelFormat::Rgb,
9857 DType::I8,
9858 None,
9859 edgefirst_tensor::CpuAccess::ReadWrite,
9860 )
9861 .unwrap();
9862 assert_eq!(u8_img.dtype(), DType::U8);
9863 assert_eq!(i8_img.dtype(), DType::I8);
9864 }
9865
9866 #[test]
9867 fn test_pixel_layout_packed_vs_planar() {
9868 assert_eq!(PixelFormat::Rgb.layout(), PixelLayout::Packed);
9870 assert_eq!(PixelFormat::Rgba.layout(), PixelLayout::Packed);
9871 assert_eq!(PixelFormat::PlanarRgb.layout(), PixelLayout::Planar);
9872 assert_eq!(PixelFormat::Nv12.layout(), PixelLayout::SemiPlanar);
9873 }
9874
9875 #[cfg(target_os = "linux")]
9886 #[cfg(feature = "opengl")]
9887 #[test]
9888 fn pbo_reports_why_it_cannot_pin() {
9889 use edgefirst_tensor::{Error, TensorTrait};
9890
9891 let converter = ImageProcessor::new().unwrap();
9892 let is_pbo = converter
9893 .opengl
9894 .as_ref()
9895 .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
9896 if !is_pbo {
9897 use std::io::Write;
9902 let _ = writeln!(
9903 &mut std::io::stderr(),
9904 "SKIPPED: pbo_reports_why_it_cannot_pin - transfer backend is not PBO"
9905 );
9906 return;
9907 }
9908
9909 let t = converter
9910 .create_image(
9911 64,
9912 64,
9913 PixelFormat::Rgba,
9914 DType::U8,
9915 None,
9916 edgefirst_tensor::CpuAccess::ReadWrite,
9917 )
9918 .unwrap();
9919 assert_eq!(t.as_typed::<u8>().unwrap().memory(), TensorMemory::Pbo);
9920 let t = t.as_typed::<u8>().unwrap();
9921
9922 match t.pin_host(edgefirst_tensor::CpuAccess::ReadWrite) {
9923 Err(Error::NotImplemented(m)) => {
9924 assert!(
9925 m.contains("glMapBufferRange"),
9926 "must name the constraint: {m}"
9927 );
9928 assert!(m.contains("map()"), "must point at the supported API: {m}");
9929 }
9930 other => panic!("expected an explained NotImplemented, got {other:?}"),
9931 }
9932 assert!(matches!(
9934 t.sync_for_cpu(edgefirst_tensor::CpuAccess::Read),
9935 Err(Error::NotImplemented(_))
9936 ));
9937
9938 let g = t.map().expect("map must work where pin cannot");
9940 assert!(!g.as_slice().is_empty());
9941 }
9942
9943 #[cfg(target_os = "linux")]
9947 #[cfg(feature = "opengl")]
9948 #[test]
9949 fn test_convert_pbo_to_pbo() {
9950 let mut converter = ImageProcessor::new().unwrap();
9951
9952 let is_pbo = converter
9954 .opengl
9955 .as_ref()
9956 .is_some_and(|gl| gl.transfer_backend() == opengl_headless::TransferBackend::Pbo);
9957 if !is_pbo {
9958 log::warn!("Skipping test_convert_pbo_to_pbo: backend is not PBO");
9959 return;
9960 }
9961
9962 let src_w = 640;
9963 let src_h = 480;
9964 let dst_w = 320;
9965 let dst_h = 240;
9966
9967 let pbo_src = converter
9969 .create_image(
9970 src_w,
9971 src_h,
9972 PixelFormat::Rgba,
9973 DType::U8,
9974 None,
9975 edgefirst_tensor::CpuAccess::ReadWrite,
9976 )
9977 .unwrap();
9978 assert_eq!(
9979 pbo_src.as_typed::<u8>().unwrap().memory(),
9980 TensorMemory::Pbo,
9981 "create_image should produce a PBO tensor"
9982 );
9983
9984 let file = edgefirst_bench::testdata::read("zidane.jpg").to_vec();
9986 let jpeg_src = crate::load_image_test_helper(&file, Some(PixelFormat::Rgba), None).unwrap();
9987
9988 let mem_src = TensorDyn::image(
9990 src_w,
9991 src_h,
9992 PixelFormat::Rgba,
9993 DType::U8,
9994 Some(TensorMemory::Mem),
9995 edgefirst_tensor::CpuAccess::ReadWrite,
9996 )
9997 .unwrap();
9998 let (result, _jpeg_src, mem_src) = convert_img(
9999 &mut CPUProcessor::new(),
10000 jpeg_src,
10001 mem_src,
10002 Rotation::None,
10003 Flip::None,
10004 Crop::no_crop(),
10005 );
10006 result.unwrap();
10007
10008 {
10010 let src_data = mem_src.as_typed::<u8>().unwrap().map().unwrap();
10011 let mut pbo_map = pbo_src.as_typed::<u8>().unwrap().map().unwrap();
10012 pbo_map.copy_from_slice(&src_data);
10013 }
10014
10015 let pbo_dst = converter
10017 .create_image(
10018 dst_w,
10019 dst_h,
10020 PixelFormat::Rgba,
10021 DType::U8,
10022 None,
10023 edgefirst_tensor::CpuAccess::ReadWrite,
10024 )
10025 .unwrap();
10026 assert_eq!(
10027 pbo_dst.as_typed::<u8>().unwrap().memory(),
10028 TensorMemory::Pbo
10029 );
10030
10031 let mut pbo_dst = pbo_dst;
10033 let result = converter.convert(
10034 &pbo_src,
10035 &mut pbo_dst,
10036 Rotation::None,
10037 Flip::None,
10038 Crop::no_crop(),
10039 );
10040 result.unwrap();
10041
10042 let cpu_dst = TensorDyn::image(
10044 dst_w,
10045 dst_h,
10046 PixelFormat::Rgba,
10047 DType::U8,
10048 Some(TensorMemory::Mem),
10049 edgefirst_tensor::CpuAccess::ReadWrite,
10050 )
10051 .unwrap();
10052 let (result, _mem_src, cpu_dst) = convert_img(
10053 &mut CPUProcessor::new(),
10054 mem_src,
10055 cpu_dst,
10056 Rotation::None,
10057 Flip::None,
10058 Crop::no_crop(),
10059 );
10060 result.unwrap();
10061
10062 let pbo_dst_img = {
10063 let mut __t = pbo_dst.into_u8().unwrap();
10064 __t.set_format(PixelFormat::Rgba).unwrap();
10065 TensorDyn::from(__t)
10066 };
10067 compare_images(&pbo_dst_img, &cpu_dst, 0.95, function!());
10068 log::info!("test_convert_pbo_to_pbo: PASS — PBO-to-PBO convert matches CPU reference");
10069 }
10070
10071 #[test]
10072 fn test_image_bgra() {
10073 let img = TensorDyn::image(
10074 640,
10075 480,
10076 PixelFormat::Bgra,
10077 DType::U8,
10078 Some(edgefirst_tensor::TensorMemory::Mem),
10079 edgefirst_tensor::CpuAccess::ReadWrite,
10080 )
10081 .unwrap();
10082 assert_eq!(img.width(), Some(640));
10083 assert_eq!(img.height(), Some(480));
10084 assert_eq!(img.format().unwrap().channels(), 4);
10085 assert_eq!(img.format().unwrap(), PixelFormat::Bgra);
10086 }
10087
10088 #[test]
10093 fn test_force_backend_cpu() {
10094 let _lock = acquire_env_lock();
10095 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10096 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10097 let converter = ImageProcessor::new().unwrap();
10098 assert!(converter.cpu.is_some());
10099 assert_eq!(converter.forced_backend, Some(ForcedBackend::Cpu));
10100 }
10101
10102 #[test]
10103 fn test_force_backend_invalid() {
10104 let _lock = acquire_env_lock();
10105 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10106 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "invalid") };
10107 let result = ImageProcessor::new();
10108 assert!(
10109 matches!(&result, Err(Error::ForcedBackendUnavailable(s)) if s.contains("unknown")),
10110 "invalid backend value should return ForcedBackendUnavailable error: {result:?}"
10111 );
10112 }
10113
10114 #[test]
10115 fn test_force_backend_unset() {
10116 let _lock = acquire_env_lock();
10117 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10118 unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") };
10119 let converter = ImageProcessor::new().unwrap();
10120 assert!(converter.forced_backend.is_none());
10121 }
10122
10123 #[test]
10128 fn test_draw_proto_masks_no_cpu_returns_error() {
10129 let _lock = acquire_env_lock();
10131 let _guard = EnvGuard::snapshot(&[
10132 "EDGEFIRST_FORCE_BACKEND",
10133 "EDGEFIRST_DISABLE_GL",
10134 "EDGEFIRST_DISABLE_G2D",
10135 "EDGEFIRST_DISABLE_CPU",
10136 ]);
10137
10138 unsafe { std::env::set_var("EDGEFIRST_DISABLE_CPU", "1") };
10140 unsafe { std::env::set_var("EDGEFIRST_DISABLE_GL", "1") };
10141 unsafe { std::env::set_var("EDGEFIRST_DISABLE_G2D", "1") };
10142
10143 let mut converter = ImageProcessor::new().unwrap();
10144 assert!(converter.cpu.is_none(), "CPU should be disabled");
10145
10146 let dst = TensorDyn::image(
10147 640,
10148 480,
10149 PixelFormat::Rgba,
10150 DType::U8,
10151 Some(TensorMemory::Mem),
10152 edgefirst_tensor::CpuAccess::ReadWrite,
10153 )
10154 .unwrap();
10155 let mut dst_dyn = dst;
10156 let det = [DetectBox {
10157 bbox: edgefirst_tensor::BoundingBox {
10158 xmin: 0.1,
10159 ymin: 0.1,
10160 xmax: 0.5,
10161 ymax: 0.5,
10162 },
10163 score: 0.9,
10164 label: 0,
10165 }];
10166 let proto_data = {
10167 use edgefirst_tensor::{Tensor, TensorDyn};
10168 let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
10169 let protos_t =
10170 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10171 ProtoData {
10172 mask_coefficients: TensorDyn::F32(coeff_t),
10173 protos: TensorDyn::F32(protos_t),
10174 layout: ProtoLayout::Nhwc,
10175 }
10176 };
10177 let result =
10178 converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
10179 assert!(
10180 matches!(&result, Err(Error::Internal(s)) if s.contains("CPU backend")),
10181 "draw_proto_masks without CPU should return Internal error: {result:?}"
10182 );
10183 }
10184
10185 #[test]
10186 fn test_draw_proto_masks_cpu_fallback_works() {
10187 let _lock = acquire_env_lock();
10190 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10191 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10192 let mut converter = ImageProcessor::new().unwrap();
10193 assert!(converter.cpu.is_some());
10194
10195 let dst = TensorDyn::image(
10196 64,
10197 64,
10198 PixelFormat::Rgba,
10199 DType::U8,
10200 Some(TensorMemory::Mem),
10201 edgefirst_tensor::CpuAccess::ReadWrite,
10202 )
10203 .unwrap();
10204 let mut dst_dyn = dst;
10205 let det = [DetectBox {
10206 bbox: edgefirst_tensor::BoundingBox {
10207 xmin: 0.1,
10208 ymin: 0.1,
10209 xmax: 0.5,
10210 ymax: 0.5,
10211 },
10212 score: 0.9,
10213 label: 0,
10214 }];
10215 let proto_data = {
10216 use edgefirst_tensor::{Tensor, TensorDyn};
10217 let coeff_t = Tensor::<f32>::from_slice(&[0.5_f32; 4], &[1, 4]).unwrap();
10218 let protos_t =
10219 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10220 ProtoData {
10221 mask_coefficients: TensorDyn::F32(coeff_t),
10222 protos: TensorDyn::F32(protos_t),
10223 layout: ProtoLayout::Nhwc,
10224 }
10225 };
10226 let result =
10227 converter.draw_proto_masks(&mut dst_dyn, &det, &proto_data, Default::default());
10228 assert!(result.is_ok(), "CPU fallback path should work: {result:?}");
10229 }
10230
10231 fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
10263 use std::sync::{Mutex, OnceLock};
10264 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
10265 ENV_MUTEX
10266 .get_or_init(|| Mutex::new(()))
10267 .lock()
10268 .unwrap_or_else(|e| e.into_inner())
10269 }
10270
10271 struct EnvGuard {
10274 vars: Vec<(&'static str, Option<String>)>,
10275 }
10276
10277 impl EnvGuard {
10278 fn snapshot(names: &[&'static str]) -> Self {
10282 Self {
10283 vars: names.iter().map(|&k| (k, std::env::var(k).ok())).collect(),
10284 }
10285 }
10286 }
10287
10288 impl Drop for EnvGuard {
10289 fn drop(&mut self) {
10290 for (k, v) in &self.vars {
10291 match v {
10292 Some(s) => unsafe { std::env::set_var(k, s) },
10293 None => unsafe { std::env::remove_var(k) },
10294 }
10295 }
10296 }
10297 }
10298
10299 fn with_force_backend<R>(value: Option<&str>, body: impl FnOnce() -> R) -> R {
10303 let _lock = acquire_env_lock();
10304 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10305 match value {
10306 Some(v) => unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", v) },
10307 None => unsafe { std::env::remove_var("EDGEFIRST_FORCE_BACKEND") },
10308 }
10309 body()
10310 }
10311
10312 fn make_dirty_dst(w: usize, h: usize, mem: Option<TensorMemory>) -> TensorDyn {
10317 let dst = TensorDyn::image(
10318 w,
10319 h,
10320 PixelFormat::Rgba,
10321 DType::U8,
10322 mem,
10323 edgefirst_tensor::CpuAccess::ReadWrite,
10324 )
10325 .unwrap();
10326 {
10327 use edgefirst_tensor::TensorMapTrait;
10328 let u8t = dst.as_typed::<u8>().unwrap();
10329 let mut map = u8t.map().unwrap();
10330 for (i, b) in map.as_mut_slice().iter_mut().enumerate() {
10331 *b = 0xA0u8.wrapping_add((i as u8) & 0x3F);
10332 }
10333 }
10334 dst
10335 }
10336
10337 fn make_bg(w: usize, h: usize, mem: Option<TensorMemory>, rgba: [u8; 4]) -> TensorDyn {
10339 let bg = TensorDyn::image(
10340 w,
10341 h,
10342 PixelFormat::Rgba,
10343 DType::U8,
10344 mem,
10345 edgefirst_tensor::CpuAccess::ReadWrite,
10346 )
10347 .unwrap();
10348 {
10349 use edgefirst_tensor::TensorMapTrait;
10350 let u8t = bg.as_typed::<u8>().unwrap();
10351 let mut map = u8t.map().unwrap();
10352 for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
10353 chunk.copy_from_slice(&rgba);
10354 }
10355 }
10356 bg
10357 }
10358
10359 fn pixel_at(dst: &TensorDyn, x: usize, y: usize) -> [u8; 4] {
10360 use edgefirst_tensor::TensorMapTrait;
10361 let w = dst.width().unwrap();
10362 let off = (y * w + x) * 4;
10363 let u8t = dst.as_typed::<u8>().unwrap();
10364 let map = u8t.map().unwrap();
10365 let s = map.as_slice();
10366 [s[off], s[off + 1], s[off + 2], s[off + 3]]
10367 }
10368
10369 fn assert_every_pixel_eq(dst: &TensorDyn, expected: [u8; 4], case: &str) {
10370 use edgefirst_tensor::TensorMapTrait;
10371 let u8t = dst.as_typed::<u8>().unwrap();
10372 let map = u8t.map().unwrap();
10373 for (i, chunk) in map.as_slice().as_chunks::<4>().0.iter().enumerate() {
10374 assert_eq!(
10375 chunk, &expected,
10376 "{case}: pixel idx {i} = {chunk:?}, expected {expected:?}"
10377 );
10378 }
10379 }
10380
10381 fn scenario_empty_no_bg(processor: &mut ImageProcessor, case: &str) {
10384 let mut dst = make_dirty_dst(64, 64, None);
10385 processor
10386 .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
10387 .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+no-bg failed: {e:?}"));
10388 assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/decoded"));
10389
10390 let mut dst = make_dirty_dst(64, 64, None);
10391 let proto = {
10392 use edgefirst_tensor::{Tensor, TensorDyn};
10393 let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
10395 let protos_t =
10396 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10397 ProtoData {
10398 mask_coefficients: TensorDyn::F32(coeff_t),
10399 protos: TensorDyn::F32(protos_t),
10400 layout: ProtoLayout::Nhwc,
10401 }
10402 };
10403 processor
10404 .draw_proto_masks(&mut dst, &[], &proto, MaskOverlay::default())
10405 .unwrap_or_else(|e| panic!("{case}/proto_masks empty+no-bg failed: {e:?}"));
10406 assert_every_pixel_eq(&dst, [0, 0, 0, 0], &format!("{case}/proto"));
10407 }
10408
10409 fn scenario_empty_with_bg(processor: &mut ImageProcessor, case: &str) {
10412 let bg_color = [42, 99, 200, 255];
10413 let bg = make_bg(64, 64, None, bg_color);
10414 let overlay = MaskOverlay::new().with_background(&bg);
10415
10416 let mut dst = make_dirty_dst(64, 64, None);
10417 processor
10418 .draw_decoded_masks(&mut dst, &[], &[], overlay)
10419 .unwrap_or_else(|e| panic!("{case}/decoded_masks empty+bg failed: {e:?}"));
10420 assert_every_pixel_eq(&dst, bg_color, &format!("{case}/decoded bg blit"));
10421
10422 let mut dst = make_dirty_dst(64, 64, None);
10423 let proto = {
10424 use edgefirst_tensor::{Tensor, TensorDyn};
10425 let coeff_t = Tensor::<f32>::from_slice(&[0.0_f32; 4], &[1, 4]).unwrap();
10427 let protos_t =
10428 Tensor::<f32>::from_slice(&vec![0.0_f32; 8 * 8 * 4], &[8, 8, 4]).unwrap();
10429 ProtoData {
10430 mask_coefficients: TensorDyn::F32(coeff_t),
10431 protos: TensorDyn::F32(protos_t),
10432 layout: ProtoLayout::Nhwc,
10433 }
10434 };
10435 processor
10436 .draw_proto_masks(&mut dst, &[], &proto, overlay)
10437 .unwrap_or_else(|e| panic!("{case}/proto_masks empty+bg failed: {e:?}"));
10438 assert_every_pixel_eq(&dst, bg_color, &format!("{case}/proto bg blit"));
10439 }
10440
10441 fn scenario_detect_no_bg(processor: &mut ImageProcessor, case: &str) {
10445 use edgefirst_tensor::Segmentation;
10446 use ndarray::Array3;
10447 processor
10448 .set_class_colors(&[[200, 80, 40, 255]])
10449 .expect("set_class_colors");
10450
10451 let detect = DetectBox {
10452 bbox: [0.25, 0.25, 0.75, 0.75].into(),
10453 score: 0.99,
10454 label: 0,
10455 };
10456 let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
10457 let seg = Segmentation {
10458 segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
10459 .unwrap()
10460 .into(),
10461 xmin: 0.25,
10462 ymin: 0.25,
10463 xmax: 0.75,
10464 ymax: 0.75,
10465 };
10466
10467 let mut dst = make_dirty_dst(64, 64, None);
10468 processor
10469 .draw_decoded_masks(&mut dst, &[detect], &[seg], MaskOverlay::default())
10470 .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+no-bg failed: {e:?}"));
10471
10472 let corner = pixel_at(&dst, 2, 2);
10474 assert_eq!(
10475 corner,
10476 [0, 0, 0, 0],
10477 "{case}/decoded: corner (2,2) leaked dirty pattern: {corner:?}"
10478 );
10479 let center = pixel_at(&dst, 32, 32);
10483 assert!(
10484 center != [0, 0, 0, 0],
10485 "{case}/decoded: center (32,32) was not coloured: {center:?}"
10486 );
10487 }
10488
10489 fn scenario_detect_with_bg(processor: &mut ImageProcessor, case: &str) {
10492 use edgefirst_tensor::Segmentation;
10493 use ndarray::Array3;
10494 processor
10495 .set_class_colors(&[[200, 80, 40, 255]])
10496 .expect("set_class_colors");
10497 let bg_color = [10, 20, 30, 255];
10498 let bg = make_bg(64, 64, None, bg_color);
10499
10500 let detect = DetectBox {
10501 bbox: [0.25, 0.25, 0.75, 0.75].into(),
10502 score: 0.99,
10503 label: 0,
10504 };
10505 let seg_arr = Array3::from_shape_fn((4, 4, 1), |_| 255u8);
10506 let seg = Segmentation {
10507 segmentation: edgefirst_tensor::Tensor::from_arrayview3(seg_arr.view())
10508 .unwrap()
10509 .into(),
10510 xmin: 0.25,
10511 ymin: 0.25,
10512 xmax: 0.75,
10513 ymax: 0.75,
10514 };
10515
10516 let overlay = MaskOverlay::new().with_background(&bg);
10517 let mut dst = make_dirty_dst(64, 64, None);
10518 processor
10519 .draw_decoded_masks(&mut dst, &[detect], &[seg], overlay)
10520 .unwrap_or_else(|e| panic!("{case}/decoded_masks detect+bg failed: {e:?}"));
10521
10522 let corner = pixel_at(&dst, 2, 2);
10524 assert_eq!(
10525 corner, bg_color,
10526 "{case}/decoded: corner (2,2) should show bg {bg_color:?} got {corner:?}"
10527 );
10528 let center = pixel_at(&dst, 32, 32);
10531 assert!(
10532 center != bg_color,
10533 "{case}/decoded: center (32,32) should differ from bg {bg_color:?}, got {center:?}"
10534 );
10535 }
10536
10537 fn run_all_scenarios(
10540 force_backend: Option<&'static str>,
10541 case: &'static str,
10542 require_dma_for_bg: bool,
10543 ) {
10544 if require_dma_for_bg && !edgefirst_tensor::is_dma_available() {
10545 eprintln!("SKIPPED: {case} — DMA not available on this host");
10546 return;
10547 }
10548 let processor_result = with_force_backend(force_backend, ImageProcessor::new);
10549 let mut processor = match processor_result {
10550 Ok(p) => p,
10551 Err(e) => {
10552 eprintln!("SKIPPED: {case} — backend init failed: {e:?}");
10553 return;
10554 }
10555 };
10556 scenario_empty_no_bg(&mut processor, case);
10557 scenario_empty_with_bg(&mut processor, case);
10558 scenario_detect_no_bg(&mut processor, case);
10559 scenario_detect_with_bg(&mut processor, case);
10560 }
10561
10562 #[test]
10563 fn test_draw_masks_4_scenarios_cpu() {
10564 run_all_scenarios(Some("cpu"), "cpu", false);
10565 }
10566
10567 #[test]
10568 fn test_draw_masks_4_scenarios_auto() {
10569 run_all_scenarios(None, "auto", false);
10570 }
10571
10572 #[cfg(target_os = "linux")]
10573 #[cfg(feature = "opengl")]
10574 #[test]
10575 fn test_draw_masks_4_scenarios_opengl() {
10576 run_all_scenarios(Some("opengl"), "opengl", false);
10577 }
10578
10579 #[cfg(target_os = "linux")]
10584 #[test]
10585 fn test_draw_masks_zero_detection_g2d_forced() {
10586 if !edgefirst_tensor::is_dma_available() {
10587 eprintln!("SKIPPED: g2d forced — DMA not available on this host");
10588 return;
10589 }
10590 let processor_result = with_force_backend(Some("g2d"), ImageProcessor::new);
10591 let mut processor = match processor_result {
10592 Ok(p) => p,
10593 Err(e) => {
10594 eprintln!("SKIPPED: g2d forced — init failed: {e:?}");
10595 return;
10596 }
10597 };
10598
10599 let mut dst = TensorDyn::image(
10601 64,
10602 64,
10603 PixelFormat::Rgba,
10604 DType::U8,
10605 Some(TensorMemory::DmaBuf),
10606 edgefirst_tensor::CpuAccess::ReadWrite,
10607 )
10608 .unwrap();
10609 {
10610 use edgefirst_tensor::TensorMapTrait;
10611 let u8t = dst.as_typed_mut::<u8>().unwrap();
10612 let mut map = u8t.map().unwrap();
10613 map.as_mut_slice().fill(0xBB);
10614 }
10615 processor
10616 .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::default())
10617 .expect("g2d empty+no-bg");
10618 assert_every_pixel_eq(&dst, [0, 0, 0, 0], "g2d/case1 cleared");
10619
10620 let bg_color = [7, 11, 13, 255];
10622 let bg = {
10623 let t = TensorDyn::image(
10624 64,
10625 64,
10626 PixelFormat::Rgba,
10627 DType::U8,
10628 Some(TensorMemory::DmaBuf),
10629 edgefirst_tensor::CpuAccess::ReadWrite,
10630 )
10631 .unwrap();
10632 {
10633 use edgefirst_tensor::TensorMapTrait;
10634 let u8t = t.as_typed::<u8>().unwrap();
10635 let mut map = u8t.map().unwrap();
10636 for chunk in map.as_mut_slice().as_chunks_mut::<4>().0 {
10637 chunk.copy_from_slice(&bg_color);
10638 }
10639 }
10640 t
10641 };
10642 let mut dst = TensorDyn::image(
10643 64,
10644 64,
10645 PixelFormat::Rgba,
10646 DType::U8,
10647 Some(TensorMemory::DmaBuf),
10648 edgefirst_tensor::CpuAccess::ReadWrite,
10649 )
10650 .unwrap();
10651 {
10652 use edgefirst_tensor::TensorMapTrait;
10653 let u8t = dst.as_typed_mut::<u8>().unwrap();
10654 let mut map = u8t.map().unwrap();
10655 map.as_mut_slice().fill(0x55);
10656 }
10657 processor
10658 .draw_decoded_masks(&mut dst, &[], &[], MaskOverlay::new().with_background(&bg))
10659 .expect("g2d empty+bg");
10660 assert_every_pixel_eq(&dst, bg_color, "g2d/case2 bg blit");
10661
10662 let detect = DetectBox {
10664 bbox: [0.25, 0.25, 0.75, 0.75].into(),
10665 score: 0.9,
10666 label: 0,
10667 };
10668 let mut dst = TensorDyn::image(
10669 64,
10670 64,
10671 PixelFormat::Rgba,
10672 DType::U8,
10673 Some(TensorMemory::DmaBuf),
10674 edgefirst_tensor::CpuAccess::ReadWrite,
10675 )
10676 .unwrap();
10677 let err = processor
10678 .draw_decoded_masks(&mut dst, &[detect], &[], MaskOverlay::default())
10679 .expect_err("g2d must reject detect-present draw_decoded_masks");
10680 assert!(
10681 matches!(err, Error::NotImplemented(_)),
10682 "g2d case3 wrong error: {err:?}"
10683 );
10684 }
10685
10686 #[test]
10687 fn test_set_format_then_cpu_convert() {
10688 let _lock = acquire_env_lock();
10691 let _guard = EnvGuard::snapshot(&["EDGEFIRST_FORCE_BACKEND"]);
10692 unsafe { std::env::set_var("EDGEFIRST_FORCE_BACKEND", "cpu") };
10693 let mut processor = ImageProcessor::new().unwrap();
10694
10695 let image = edgefirst_bench::testdata::read("zidane.jpg");
10697 let src = load_image_test_helper(&image, Some(PixelFormat::Rgba), None).unwrap();
10698
10699 let mut dst =
10701 TensorDyn::new(&[640, 640, 3], DType::U8, Some(TensorMemory::Mem), None).unwrap();
10702 dst.set_format(PixelFormat::Rgb).unwrap();
10703
10704 processor
10706 .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10707 .unwrap();
10708
10709 assert_eq!(dst.format(), Some(PixelFormat::Rgb));
10711 assert_eq!(dst.width(), Some(640));
10712 assert_eq!(dst.height(), Some(640));
10713 }
10714
10715 #[test]
10721 fn test_multiple_image_processors_same_thread() {
10722 let _lock = acquire_env_lock();
10725 let mut processors: Vec<ImageProcessor> = (0..4)
10726 .map(|_| ImageProcessor::new().expect("ImageProcessor::new() failed"))
10727 .collect();
10728
10729 for proc in &mut processors {
10730 let src = proc
10731 .create_image(
10732 128,
10733 128,
10734 PixelFormat::Rgb,
10735 DType::U8,
10736 None,
10737 edgefirst_tensor::CpuAccess::ReadWrite,
10738 )
10739 .expect("create src failed");
10740 let mut dst = proc
10741 .create_image(
10742 64,
10743 64,
10744 PixelFormat::Rgb,
10745 DType::U8,
10746 None,
10747 edgefirst_tensor::CpuAccess::ReadWrite,
10748 )
10749 .expect("create dst failed");
10750 proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10751 .expect("convert failed");
10752 assert_eq!(dst.width(), Some(64));
10753 assert_eq!(dst.height(), Some(64));
10754 }
10755 }
10756
10757 #[test]
10764 fn test_multiple_image_processors_separate_threads() {
10765 use std::sync::mpsc;
10766 use std::time::Duration;
10767
10768 if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10778 eprintln!(
10779 "SKIPPED: test_multiple_image_processors_separate_threads — known Vivante \
10780 GC7000UL concurrent-EGL-teardown double-free \
10781 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10782 );
10783 return;
10784 }
10785
10786 const TIMEOUT: Duration = Duration::from_secs(60);
10787
10788 let _lock = acquire_env_lock();
10791
10792 let (tx, rx) = mpsc::channel::<()>();
10793
10794 std::thread::spawn(move || {
10795 let handles: Vec<_> = (0..4)
10796 .map(|i| {
10797 std::thread::spawn(move || {
10798 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10799 panic!("ImageProcessor::new() failed on thread {i}: {e}")
10800 });
10801 let src = proc
10802 .create_image(
10803 128,
10804 128,
10805 PixelFormat::Rgb,
10806 DType::U8,
10807 None,
10808 edgefirst_tensor::CpuAccess::ReadWrite,
10809 )
10810 .unwrap_or_else(|e| panic!("create src failed on thread {i}: {e}"));
10811 let mut dst = proc
10812 .create_image(
10813 64,
10814 64,
10815 PixelFormat::Rgb,
10816 DType::U8,
10817 None,
10818 edgefirst_tensor::CpuAccess::ReadWrite,
10819 )
10820 .unwrap_or_else(|e| panic!("create dst failed on thread {i}: {e}"));
10821 proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
10822 .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
10823 assert_eq!(dst.width(), Some(64));
10824 assert_eq!(dst.height(), Some(64));
10825 })
10826 })
10827 .collect();
10828
10829 for (i, h) in handles.into_iter().enumerate() {
10830 h.join()
10831 .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10832 }
10833
10834 let _ = tx.send(());
10835 });
10836
10837 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10838 panic!("test_multiple_image_processors_separate_threads timed out after {TIMEOUT:?}")
10839 });
10840 }
10841
10842 #[test]
10849 fn test_image_processors_concurrent_operations() {
10850 use std::sync::{mpsc, Arc, Barrier};
10851 use std::time::Duration;
10852
10853 const N: usize = 4;
10854 const ROUNDS: usize = 10;
10855 const TIMEOUT: Duration = Duration::from_secs(60);
10856
10857 let _lock = acquire_env_lock();
10860
10861 let (tx, rx) = mpsc::channel::<()>();
10862
10863 std::thread::spawn(move || {
10864 let barrier = Arc::new(Barrier::new(N));
10865
10866 let handles: Vec<_> = (0..N)
10867 .map(|i| {
10868 let barrier = Arc::clone(&barrier);
10869 std::thread::spawn(move || {
10870 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10871 panic!("ImageProcessor::new() failed on thread {i}: {e}")
10872 });
10873
10874 barrier.wait();
10876
10877 for round in 0..ROUNDS {
10879 let src = proc
10880 .create_image(
10881 128,
10882 128,
10883 PixelFormat::Rgb,
10884 DType::U8,
10885 None,
10886 edgefirst_tensor::CpuAccess::ReadWrite,
10887 )
10888 .unwrap_or_else(|e| {
10889 panic!("create src failed on thread {i} round {round}: {e}")
10890 });
10891 let mut dst = proc
10892 .create_image(
10893 64,
10894 64,
10895 PixelFormat::Rgb,
10896 DType::U8,
10897 None,
10898 edgefirst_tensor::CpuAccess::ReadWrite,
10899 )
10900 .unwrap_or_else(|e| {
10901 panic!("create dst failed on thread {i} round {round}: {e}")
10902 });
10903 proc.convert(
10904 &src,
10905 &mut dst,
10906 Rotation::None,
10907 Flip::None,
10908 Crop::default(),
10909 )
10910 .unwrap_or_else(|e| {
10911 panic!("convert failed on thread {i} round {round}: {e}")
10912 });
10913 assert_eq!(dst.width(), Some(64));
10914 assert_eq!(dst.height(), Some(64));
10915 }
10916 })
10917 })
10918 .collect();
10919
10920 for (i, h) in handles.into_iter().enumerate() {
10921 h.join()
10922 .unwrap_or_else(|e| panic!("thread {i} panicked: {e:?}"));
10923 }
10924
10925 let _ = tx.send(());
10926 });
10927
10928 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
10929 panic!("test_image_processors_concurrent_operations timed out after {TIMEOUT:?}")
10930 });
10931 }
10932
10933 #[test]
10951 fn test_parallel_processors_unique_outputs() {
10952 use std::sync::{mpsc, Arc, Barrier};
10953 use std::time::Duration;
10954
10955 const N: usize = 4;
10956 const ROUNDS: usize = 25;
10957 const TIMEOUT: Duration = Duration::from_secs(60);
10958
10959 if std::env::var_os("EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS").is_some() {
10960 eprintln!(
10961 "SKIPPED: test_parallel_processors_unique_outputs — known Vivante \
10962 GC7000UL concurrent-multi-processor driver abort \
10963 (EDGEFIRST_SKIP_VIVANTE_KNOWN_BUGS set)"
10964 );
10965 return;
10966 }
10967
10968 let _lock = acquire_env_lock();
10969 let (tx, rx) = mpsc::channel::<()>();
10970
10971 std::thread::spawn(move || {
10972 let barrier = Arc::new(Barrier::new(N));
10973 let handles: Vec<_> = (0..N)
10974 .map(|i| {
10975 let barrier = Arc::clone(&barrier);
10976 std::thread::spawn(move || {
10977 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
10978 panic!("ImageProcessor::new() failed on thread {i}: {e}")
10979 });
10980 let (w, h) = (640usize, 480usize);
10982 let mem = if edgefirst_tensor::is_dma_available() {
10983 Some(TensorMemory::DmaBuf)
10984 } else {
10985 Some(TensorMemory::Mem)
10986 };
10987 let src = proc
10988 .create_image(
10989 w,
10990 h,
10991 PixelFormat::Nv12,
10992 DType::U8,
10993 mem,
10994 edgefirst_tensor::CpuAccess::ReadWrite,
10995 )
10996 .unwrap();
10997 {
10998 let t = src.as_typed::<u8>().unwrap();
10999 let mut m = t.map().unwrap();
11000 let s = m.as_mut_slice();
11001 for (j, b) in s[..w * h].iter_mut().enumerate() {
11002 *b = ((i * 53 + j) % 200 + 16) as u8;
11003 }
11004 for b in &mut s[w * h..] {
11005 *b = (80 + i * 24) as u8;
11006 }
11007 }
11008 let lb = Crop::letterbox([114, 114, 114, 255]);
11009 let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
11010 let mut dst = proc
11011 .create_image(
11012 320,
11013 320,
11014 PixelFormat::Rgba,
11015 DType::U8,
11016 mem,
11017 edgefirst_tensor::CpuAccess::ReadWrite,
11018 )
11019 .unwrap();
11020 proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
11021 .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
11022 let t = dst.as_typed::<u8>().unwrap();
11023 let m = t.map().unwrap();
11024 m.as_slice().to_vec()
11025 };
11026
11027 let oracle = convert_once(&mut proc);
11028 barrier.wait();
11029 for round in 0..ROUNDS {
11030 let out = convert_once(&mut proc);
11031 let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
11032 assert!(
11033 diffs == 0,
11034 "thread {i} round {round}: {diffs}/{} bytes diverged \
11035 from this processor's own oracle — cross-processor \
11036 GL state leakage under parallel execution",
11037 oracle.len()
11038 );
11039 }
11040 })
11041 })
11042 .collect();
11043
11044 for (i, h) in handles.into_iter().enumerate() {
11045 h.join()
11046 .unwrap_or_else(|e| panic!("parallel thread {i} panicked: {e:?}"));
11047 }
11048 let _ = tx.send(());
11049 });
11050
11051 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
11052 panic!("test_parallel_processors_unique_outputs timed out after {TIMEOUT:?}")
11053 });
11054 }
11055
11056 #[test]
11065 #[ignore = "heavy on-demand GL-parallelism stressor; run explicitly on boards"]
11066 fn stress_parallel_processors_oracle() {
11067 use std::sync::{mpsc, Arc, Barrier};
11068 use std::time::Duration;
11069
11070 const N: usize = 4;
11071 const ROUNDS: usize = 200;
11072 const TIMEOUT: Duration = Duration::from_secs(600);
11073
11074 let _lock = acquire_env_lock();
11075 let (tx, rx) = mpsc::channel::<()>();
11076
11077 std::thread::spawn(move || {
11078 let barrier = Arc::new(Barrier::new(N));
11079 let handles: Vec<_> = (0..N)
11080 .map(|i| {
11081 let barrier = Arc::clone(&barrier);
11082 std::thread::spawn(move || {
11083 let mut proc = ImageProcessor::new().unwrap_or_else(|e| {
11084 panic!("ImageProcessor::new() failed on thread {i}: {e}")
11085 });
11086 let (w, h) = (1280usize, 720usize);
11087 let mem = if edgefirst_tensor::is_dma_available() {
11088 Some(TensorMemory::DmaBuf)
11089 } else {
11090 Some(TensorMemory::Mem)
11091 };
11092
11093 let src = proc
11096 .create_image(
11097 w,
11098 h,
11099 PixelFormat::Nv12,
11100 DType::U8,
11101 mem,
11102 edgefirst_tensor::CpuAccess::ReadWrite,
11103 )
11104 .unwrap();
11105 {
11106 let t = src.as_typed::<u8>().unwrap();
11107 let mut m = t.map().unwrap();
11108 let s = m.as_mut_slice();
11109 for (j, b) in s[..w * h].iter_mut().enumerate() {
11110 *b = ((i * 37 + j) % 200 + 16) as u8;
11111 }
11112 for b in &mut s[w * h..] {
11113 *b = (96 + i * 16) as u8;
11114 }
11115 }
11116 let lb = Crop::letterbox([114, 114, 114, 255]);
11117
11118 let convert_once = |proc: &mut ImageProcessor| -> Vec<u8> {
11119 let mut dst = proc
11120 .create_image(
11121 640,
11122 640,
11123 PixelFormat::Rgb,
11124 DType::U8,
11125 mem,
11126 edgefirst_tensor::CpuAccess::ReadWrite,
11127 )
11128 .unwrap();
11129 proc.convert(&src, &mut dst, Rotation::None, Flip::None, lb)
11130 .unwrap_or_else(|e| panic!("convert failed on thread {i}: {e}"));
11131 let t = dst.as_typed::<u8>().unwrap();
11132 let m = t.map().unwrap();
11133 m.as_slice().to_vec()
11134 };
11135
11136 let oracle = convert_once(&mut proc);
11137 barrier.wait();
11138 for round in 0..ROUNDS {
11139 let out = convert_once(&mut proc);
11140 let diffs = oracle.iter().zip(&out).filter(|(a, b)| a != b).count();
11141 assert!(
11142 diffs == 0,
11143 "thread {i} round {round}: {diffs}/{} bytes diverged \
11144 from the pre-barrier oracle",
11145 oracle.len()
11146 );
11147 }
11148 })
11149 })
11150 .collect();
11151
11152 for (i, h) in handles.into_iter().enumerate() {
11153 h.join()
11154 .unwrap_or_else(|e| panic!("stressor thread {i} panicked: {e:?}"));
11155 }
11156 let _ = tx.send(());
11157 });
11158
11159 rx.recv_timeout(TIMEOUT).unwrap_or_else(|_| {
11160 panic!("stress_parallel_processors_oracle timed out after {TIMEOUT:?}")
11161 });
11162 }
11163
11164 #[test]
11177 fn convert_f32_auto_never_errors_non_gl_combo() {
11178 const W: usize = 64;
11179 const H: usize = 64;
11180
11181 let src = TensorDyn::image(
11184 W,
11185 H,
11186 PixelFormat::Yuyv,
11187 DType::U8,
11188 Some(TensorMemory::Mem),
11189 edgefirst_tensor::CpuAccess::ReadWrite,
11190 )
11191 .unwrap();
11192 {
11193 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11194 let data = map.as_mut_slice();
11195 for chunk in data.as_chunks_mut::<4>().0 {
11196 chunk[0] = 128; chunk[1] = 128; chunk[2] = 160; chunk[3] = 128; }
11201 }
11202
11203 let mut dst = TensorDyn::image(
11204 W,
11205 H,
11206 PixelFormat::Rgb,
11207 DType::F32,
11208 Some(TensorMemory::Mem),
11209 edgefirst_tensor::CpuAccess::ReadWrite,
11210 )
11211 .unwrap();
11212
11213 let mut proc = ImageProcessor::new().unwrap();
11214 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11215 assert!(
11216 result.is_ok(),
11217 "auto-chain Yuyv→Rgb F32 must not error: {:?}",
11218 result.err()
11219 );
11220
11221 let map = dst.as_typed::<f32>().unwrap().map().unwrap();
11223 let floats = map.as_slice();
11224 assert_eq!(floats.len(), W * H * 3, "unexpected output element count");
11225 for (i, &v) in floats.iter().enumerate() {
11226 assert!(
11227 v.is_finite() && (0.0..=1.0).contains(&v),
11228 "output[{i}]={v} is not finite or not in [0,1]"
11229 );
11230 }
11231
11232 let first_non_zero = floats.iter().find(|&&v| v > 0.01);
11236 assert!(
11237 first_non_zero.is_some(),
11238 "all-zero output detected — CPU path likely did not write to the destination buffer"
11239 );
11240 let r0 = floats[0];
11243 assert!(
11244 (r0 - 0.502_f32).abs() < 0.05,
11245 "first pixel R={r0} expected ≈0.502 (Y=128 neutral grey from YUYV source)"
11246 );
11247 }
11248
11249 #[test]
11255 #[allow(clippy::needless_update)]
11261 fn convert_f16_forced_cpu_correct() {
11262 const W: usize = 16;
11263 const H: usize = 16;
11264 const TOL: f32 = 1.0 / 512.0; let src = TensorDyn::image(
11268 W,
11269 H,
11270 PixelFormat::Rgba,
11271 DType::U8,
11272 Some(TensorMemory::Mem),
11273 edgefirst_tensor::CpuAccess::ReadWrite,
11274 )
11275 .unwrap();
11276 {
11277 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11278 let data = map.as_mut_slice();
11279 for y in 0..H {
11280 for x in 0..W {
11281 let i = y * W + x;
11282 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;
11286 }
11287 }
11288 }
11289
11290 let mut dst = TensorDyn::image(
11291 W,
11292 H,
11293 PixelFormat::PlanarRgb,
11294 DType::F16,
11295 Some(TensorMemory::Mem),
11296 edgefirst_tensor::CpuAccess::ReadWrite,
11297 )
11298 .unwrap();
11299
11300 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11301 backend: ComputeBackend::Cpu,
11302 ..Default::default()
11303 })
11304 .unwrap();
11305 proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
11306 .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
11307
11308 let src_map = src.as_typed::<u8>().unwrap().map().unwrap();
11309 let src_bytes = src_map.as_slice();
11310 let dst_map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
11311 let dst_halfs = dst_map.as_slice();
11312
11313 let plane = W * H;
11314 assert_eq!(dst_halfs.len(), plane * 3, "wrong output element count");
11315
11316 for y in 0..H {
11317 for x in 0..W {
11318 let i = y * W + x;
11319 let r_exp = src_bytes[i * 4] as f32 / 255.0;
11320 let g_exp = src_bytes[i * 4 + 1] as f32 / 255.0;
11321 let b_exp = src_bytes[i * 4 + 2] as f32 / 255.0;
11322
11323 let r_got = dst_halfs[i].to_f32();
11324 let g_got = dst_halfs[plane + i].to_f32();
11325 let b_got = dst_halfs[2 * plane + i].to_f32();
11326
11327 assert!(
11328 (r_got - r_exp).abs() <= TOL,
11329 "R plane ({x},{y}): got {r_got}, expected {r_exp}"
11330 );
11331 assert!(
11332 (g_got - g_exp).abs() <= TOL,
11333 "G plane ({x},{y}): got {g_got}, expected {g_exp}"
11334 );
11335 assert!(
11336 (b_got - b_exp).abs() <= TOL,
11337 "B plane ({x},{y}): got {b_got}, expected {b_exp}"
11338 );
11339
11340 if src_bytes[i * 4] != src_bytes[i * 4 + 1] {
11342 assert_ne!(r_got, g_got, "R and G planes must differ at ({x},{y})");
11343 }
11344 }
11345 }
11346 }
11347
11348 #[test]
11356 fn convert_f32_with_rotation_falls_back() {
11357 const W: usize = 16;
11358 const H: usize = 16;
11359
11360 let src = TensorDyn::image(
11362 W,
11363 H,
11364 PixelFormat::Rgba,
11365 DType::U8,
11366 Some(TensorMemory::Mem),
11367 edgefirst_tensor::CpuAccess::ReadWrite,
11368 )
11369 .unwrap();
11370 {
11371 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11372 let data = map.as_mut_slice();
11373 for y in 0..H {
11374 for x in 0..W {
11375 let i = y * W + x;
11376 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;
11380 }
11381 }
11382 }
11383
11384 let mut dst = TensorDyn::image(
11386 H, W, PixelFormat::Rgb,
11389 DType::F32,
11390 Some(TensorMemory::Mem),
11391 edgefirst_tensor::CpuAccess::ReadWrite,
11392 )
11393 .unwrap();
11394
11395 let mut proc = ImageProcessor::new().unwrap();
11396 let result = proc.convert(
11397 &src,
11398 &mut dst,
11399 Rotation::Clockwise90,
11400 Flip::None,
11401 Crop::default(),
11402 );
11403 assert!(
11404 result.is_ok(),
11405 "auto-chain Rgba→Rgb F32 with Rot90 must not error: {:?}",
11406 result.err()
11407 );
11408
11409 let map = dst.as_typed::<f32>().unwrap().map().unwrap();
11410 let floats = map.as_slice();
11411 assert_eq!(floats.len(), H * W * 3, "unexpected output element count");
11412 for (i, &v) in floats.iter().enumerate() {
11413 assert!(
11414 v.is_finite() && (0.0..=1.0).contains(&v),
11415 "output[{i}]={v} is not finite or not in [0,1]"
11416 );
11417 }
11418 }
11419
11420 #[test]
11427 #[cfg(all(
11428 any(
11429 target_os = "linux",
11430 target_os = "macos",
11431 target_os = "ios",
11432 target_os = "android",
11433 target_os = "windows"
11434 ),
11435 feature = "opengl"
11436 ))]
11437 fn convert_f16_gl_cpu_parity_identity() {
11438 if !is_opengl_available() {
11439 eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - OpenGL not available");
11440 return;
11441 }
11442
11443 const W: usize = 16;
11444 const H: usize = 16;
11445 const TOL: f32 = 1.0 / 256.0; let src = TensorDyn::image(
11449 W,
11450 H,
11451 PixelFormat::Rgba,
11452 DType::U8,
11453 Some(TensorMemory::Mem),
11454 edgefirst_tensor::CpuAccess::ReadWrite,
11455 )
11456 .unwrap();
11457 {
11458 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11459 let data = map.as_mut_slice();
11460 for y in 0..H {
11461 for x in 0..W {
11462 let i = y * W + x;
11463 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;
11467 }
11468 }
11469 }
11470
11471 let gl_result = {
11473 let mut gl_proc = match ImageProcessor::with_config(ImageProcessorConfig {
11474 backend: ComputeBackend::OpenGl,
11475 ..Default::default()
11476 }) {
11477 Ok(p) => p,
11478 Err(e) => {
11479 eprintln!(
11480 "SKIPPED: convert_f16_gl_cpu_parity_identity - GL backend unavailable: {e}"
11481 );
11482 return;
11483 }
11484 };
11485
11486 if !gl_proc.supported_render_dtypes().f16 {
11487 eprintln!("SKIPPED: convert_f16_gl_cpu_parity_identity - F16 render not supported");
11488 return;
11489 }
11490
11491 let mut dst = TensorDyn::image(
11492 W,
11493 H,
11494 PixelFormat::PlanarRgb,
11495 DType::F16,
11496 Some(TensorMemory::Mem),
11497 edgefirst_tensor::CpuAccess::ReadWrite,
11498 )
11499 .unwrap();
11500 match gl_proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default()) {
11501 Ok(()) => dst,
11502 Err(e) => {
11503 eprintln!(
11504 "SKIPPED: convert_f16_gl_cpu_parity_identity - GL convert failed: {e}"
11505 );
11506 return;
11507 }
11508 }
11509 };
11510
11511 let cpu_result = {
11513 let mut cpu_proc = ImageProcessor::with_config(ImageProcessorConfig {
11514 backend: ComputeBackend::Cpu,
11515 ..Default::default()
11516 })
11517 .unwrap();
11518 let mut dst = TensorDyn::image(
11519 W,
11520 H,
11521 PixelFormat::PlanarRgb,
11522 DType::F16,
11523 Some(TensorMemory::Mem),
11524 edgefirst_tensor::CpuAccess::ReadWrite,
11525 )
11526 .unwrap();
11527 cpu_proc
11528 .convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default())
11529 .expect("forced-CPU Rgba→PlanarRgb F16 must not error");
11530 dst
11531 };
11532
11533 let gl_map = gl_result.as_typed::<half::f16>().unwrap().map().unwrap();
11535 let cpu_map = cpu_result.as_typed::<half::f16>().unwrap().map().unwrap();
11536 let gl_halfs = gl_map.as_slice();
11537 let cpu_halfs = cpu_map.as_slice();
11538
11539 assert_eq!(
11540 gl_halfs.len(),
11541 cpu_halfs.len(),
11542 "GL and CPU output sizes differ"
11543 );
11544
11545 let plane = W * H;
11546 let channel_names = ["R", "G", "B"];
11547 for (idx, (gl_h, cpu_h)) in gl_halfs.iter().zip(cpu_halfs.iter()).enumerate() {
11548 let gl_v = gl_h.to_f32();
11549 let cpu_v = cpu_h.to_f32();
11550 let err = (gl_v - cpu_v).abs();
11551 let ch = channel_names[idx / plane];
11552 let pixel = idx % plane;
11553 assert!(
11554 err <= TOL,
11555 "GL vs CPU mismatch at {ch}[{pixel}]: GL={gl_v}, CPU={cpu_v}, err={err} > tol={TOL}"
11556 );
11557 }
11558 }
11559
11560 #[test]
11568 #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "opengl"))]
11569 fn supported_render_dtypes_linux_smoke() {
11570 let proc = match ImageProcessor::new() {
11571 Ok(p) => p,
11572 Err(e) => {
11573 eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — ImageProcessor::new() failed: {e}");
11574 return;
11575 }
11576 };
11577 if proc.opengl.is_none() {
11578 eprintln!("SKIPPED: supported_render_dtypes_linux_smoke — no GL backend on this host");
11579 return;
11580 }
11581 let support = proc.supported_render_dtypes();
11583 eprintln!(
11584 "supported_render_dtypes_linux_smoke: f16={} f32={}",
11585 support.f16, support.f32
11586 );
11587 }
11589
11590 #[test]
11599 fn convert_f16_pbo_non_4_aligned_width_falls_back() {
11600 const W: usize = 18; const H: usize = 16;
11602
11603 let src = TensorDyn::image(
11605 W,
11606 H,
11607 PixelFormat::Rgba,
11608 DType::U8,
11609 Some(TensorMemory::Mem),
11610 edgefirst_tensor::CpuAccess::ReadWrite,
11611 )
11612 .unwrap();
11613 {
11614 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11615 let data = map.as_mut_slice();
11616 for chunk in data.as_chunks_mut::<4>().0 {
11617 chunk[0] = 128;
11618 chunk[1] = 64;
11619 chunk[2] = 200;
11620 chunk[3] = 255;
11621 }
11622 }
11623
11624 let mut dst = TensorDyn::image(
11627 W,
11628 H,
11629 PixelFormat::PlanarRgb,
11630 DType::F16,
11631 Some(TensorMemory::Mem),
11632 edgefirst_tensor::CpuAccess::ReadWrite,
11633 )
11634 .unwrap();
11635
11636 let mut proc = ImageProcessor::new().unwrap();
11639 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11640 assert!(
11641 result.is_ok(),
11642 "auto-chain PlanarRgb F16 W%4!=0 must not error (CPU fallback): {:?}",
11643 result.err()
11644 );
11645
11646 let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
11648 let halfs = map.as_slice();
11649 assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
11650 for (i, h) in halfs.iter().enumerate() {
11651 let v = h.to_f32();
11652 assert!(
11653 v.is_finite() && (0.0..=1.0).contains(&v),
11654 "output[{i}]={v} is not finite or not in [0,1]"
11655 );
11656 }
11657 }
11658
11659 #[test]
11669 #[allow(clippy::needless_update)]
11672 fn convert_nv12_to_rgb_f32_cpu() {
11673 const W: usize = 16;
11674 const H: usize = 16; let src = TensorDyn::image(
11678 W,
11679 H,
11680 PixelFormat::Nv12,
11681 DType::U8,
11682 Some(TensorMemory::Mem),
11683 edgefirst_tensor::CpuAccess::ReadWrite,
11684 )
11685 .unwrap();
11686 {
11687 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11688 map.as_mut_slice().fill(128); }
11690
11691 let mut dst = TensorDyn::image(
11692 W,
11693 H,
11694 PixelFormat::Rgb,
11695 DType::F32,
11696 Some(TensorMemory::Mem),
11697 edgefirst_tensor::CpuAccess::ReadWrite,
11698 )
11699 .unwrap();
11700
11701 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11702 backend: ComputeBackend::Cpu,
11703 ..Default::default()
11704 })
11705 .unwrap();
11706
11707 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11708 assert!(
11709 result.is_ok(),
11710 "forced-CPU NV12→Rgb F32 must not error: {:?}",
11711 result.err()
11712 );
11713
11714 let map = dst.as_typed::<f32>().unwrap().map().unwrap();
11715 let floats = map.as_slice();
11716 assert_eq!(floats.len(), W * H * 3, "unexpected element count");
11717 for (i, &v) in floats.iter().enumerate() {
11718 assert!(
11719 v.is_finite() && (0.0..=1.0).contains(&v),
11720 "output[{i}]={v} is not finite or not in [0,1]"
11721 );
11722 }
11723 let non_zero = floats.iter().any(|&v| v > 0.01);
11725 assert!(non_zero, "all-zero output from NV12→Rgb F32 CPU path");
11726 }
11727
11728 #[test]
11732 #[allow(clippy::needless_update)]
11735 fn convert_nv12_to_planar_rgb_f16_cpu() {
11736 const W: usize = 16;
11737 const H: usize = 16;
11738
11739 let src = TensorDyn::image(
11740 W,
11741 H,
11742 PixelFormat::Nv12,
11743 DType::U8,
11744 Some(TensorMemory::Mem),
11745 edgefirst_tensor::CpuAccess::ReadWrite,
11746 )
11747 .unwrap();
11748 {
11749 let mut map = src.as_typed::<u8>().unwrap().map().unwrap();
11750 map.as_mut_slice().fill(128);
11751 }
11752
11753 let mut dst = TensorDyn::image(
11754 W,
11755 H,
11756 PixelFormat::PlanarRgb,
11757 DType::F16,
11758 Some(TensorMemory::Mem),
11759 edgefirst_tensor::CpuAccess::ReadWrite,
11760 )
11761 .unwrap();
11762
11763 let mut proc = ImageProcessor::with_config(ImageProcessorConfig {
11764 backend: ComputeBackend::Cpu,
11765 ..Default::default()
11766 })
11767 .unwrap();
11768
11769 let result = proc.convert(&src, &mut dst, Rotation::None, Flip::None, Crop::default());
11770 assert!(
11771 result.is_ok(),
11772 "forced-CPU NV12→PlanarRgb F16 must not error: {:?}",
11773 result.err()
11774 );
11775
11776 let map = dst.as_typed::<half::f16>().unwrap().map().unwrap();
11777 let halfs = map.as_slice();
11778 assert_eq!(halfs.len(), W * H * 3, "unexpected element count");
11779 for (i, h) in halfs.iter().enumerate() {
11780 let v = h.to_f32();
11781 assert!(
11782 v.is_finite() && (0.0..=1.0).contains(&v),
11783 "output[{i}]={v} is not finite or not in [0,1]"
11784 );
11785 }
11786 let non_zero = halfs.iter().any(|h| h.to_f32() > 0.01);
11787 assert!(non_zero, "all-zero output from NV12→PlanarRgb F16 CPU path");
11788 }
11789
11790 #[test]
11799 fn create_image_desc_negotiates_and_counts_fallbacks() {
11800 use edgefirst_tensor::{CpuAccess, ImageDesc};
11801 let proc = ImageProcessor::new().unwrap();
11802
11803 let desc =
11804 ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8).with_access(CpuAccess::ReadWrite);
11805 let plain = proc.create_image_desc(&desc).unwrap();
11806 let classic = proc
11807 .create_image(
11808 64,
11809 64,
11810 PixelFormat::Rgba,
11811 DType::U8,
11812 None,
11813 CpuAccess::ReadWrite,
11814 )
11815 .unwrap();
11816 assert_eq!(plain.memory(), classic.memory(), "same negotiation path");
11817 assert_eq!(plain.compression(), None);
11818
11819 #[cfg(not(target_os = "android"))]
11820 {
11821 use edgefirst_tensor::Compression;
11822 let before = proc.compression_fallback_count();
11823 let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
11824 .with_compression(Compression::Any);
11825 let t = proc.create_image_desc(&desc).unwrap();
11826 assert_eq!(t.compression(), None, "no vendor tile scheme off-Android");
11827 assert!(
11828 proc.compression_fallback_count() > before,
11829 "Any resolving linear must count"
11830 );
11831 }
11832 }
11833
11834 #[test]
11837 #[cfg(target_os = "linux")]
11838 fn create_image_f32_dma_rejected() {
11839 let proc = ImageProcessor::new().unwrap();
11840 let result = proc.create_image(
11841 64,
11842 64,
11843 PixelFormat::Rgb,
11844 DType::F32,
11845 Some(TensorMemory::DmaBuf),
11846 edgefirst_tensor::CpuAccess::ReadWrite,
11847 );
11848 assert!(
11849 result.is_err(),
11850 "create_image(F32, Dma) must fail — no DRM fourcc for f32"
11851 );
11852 }
11853
11854 #[test]
11863 #[cfg(target_os = "linux")]
11864 fn import_image_carries_colorimetry() {
11865 use edgefirst_tensor::{ColorEncoding, ColorRange, Colorimetry, TensorMemory};
11866
11867 let expected = Colorimetry::default()
11868 .with_encoding(ColorEncoding::Bt709)
11869 .with_range(ColorRange::Limited);
11870
11871 if !is_dma_available() {
11872 let mut t = TensorDyn::image(
11875 8,
11876 8,
11877 PixelFormat::Rgba,
11878 DType::U8,
11879 Some(TensorMemory::Mem),
11880 edgefirst_tensor::CpuAccess::ReadWrite,
11881 )
11882 .expect("alloc");
11883 assert_eq!(t.colorimetry(), None, "colorimetry must start as None");
11884 t.set_colorimetry(Some(expected));
11885 assert_eq!(
11886 t.colorimetry(),
11887 Some(expected),
11888 "set_colorimetry must round-trip"
11889 );
11890 eprintln!("SKIPPED import_image_carries_colorimetry (DMA unavailable); storage contract verified via TensorDyn");
11891 return;
11892 }
11893
11894 use edgefirst_tensor::{PlaneDescriptor, Tensor};
11897
11898 let rgba_bytes = 64 * 64 * 4; let dma_tensor = Tensor::<u8>::new(
11900 &[rgba_bytes],
11901 Some(TensorMemory::DmaBuf),
11902 Some("import_test"),
11903 )
11904 .expect("dma alloc");
11905 let pd =
11906 PlaneDescriptor::new(dma_tensor.dmabuf().expect("dma fd")).expect("PlaneDescriptor");
11907
11908 let proc = ImageProcessor::new().expect("ImageProcessor");
11909 let result = proc.import_image(
11910 pd,
11911 None,
11912 64,
11913 64,
11914 PixelFormat::Rgba,
11915 DType::U8,
11916 Some(expected),
11917 );
11918 let tensor = result.expect("import_image must succeed on DMA fd");
11919 assert_eq!(
11920 tensor.colorimetry(),
11921 Some(expected),
11922 "import_image must store the supplied colorimetry on the returned TensorDyn"
11923 );
11924 }
11925}