trainingsample 0.2.18__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {trainingsample-0.2.18 → trainingsample-0.3.0}/Cargo.lock +1 -1
  2. {trainingsample-0.2.18 → trainingsample-0.3.0}/Cargo.toml +1 -1
  3. {trainingsample-0.2.18 → trainingsample-0.3.0}/PKG-INFO +1 -1
  4. {trainingsample-0.2.18 → trainingsample-0.3.0}/pyproject.toml +1 -1
  5. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cropping.rs +34 -15
  6. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/opencv_ops.rs +110 -66
  7. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/python_bindings.rs +15 -18
  8. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/tests.rs +11 -0
  9. {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/comprehensive_tests.rs +40 -26
  10. {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/test_performance_benchmarks.py +30 -54
  11. {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/test_python_bindings.py +38 -0
  12. {trainingsample-0.2.18 → trainingsample-0.3.0}/.cargo/config.toml +0 -0
  13. {trainingsample-0.2.18 → trainingsample-0.3.0}/.envrc +0 -0
  14. {trainingsample-0.2.18 → trainingsample-0.3.0}/.github/workflows/ci.yml +0 -0
  15. {trainingsample-0.2.18 → trainingsample-0.3.0}/.github/workflows/publish.yml +0 -0
  16. {trainingsample-0.2.18 → trainingsample-0.3.0}/.gitignore +0 -0
  17. {trainingsample-0.2.18 → trainingsample-0.3.0}/.pre-commit-config.yaml +0 -0
  18. {trainingsample-0.2.18 → trainingsample-0.3.0}/.rustfmt.toml +0 -0
  19. {trainingsample-0.2.18 → trainingsample-0.3.0}/BENCHMARKS.md +0 -0
  20. {trainingsample-0.2.18 → trainingsample-0.3.0}/Dockerfile +0 -0
  21. {trainingsample-0.2.18 → trainingsample-0.3.0}/LICENSE +0 -0
  22. {trainingsample-0.2.18 → trainingsample-0.3.0}/README.md +0 -0
  23. {trainingsample-0.2.18 → trainingsample-0.3.0}/clippy.toml +0 -0
  24. {trainingsample-0.2.18 → trainingsample-0.3.0}/docs/API_COMPAT_CV2.md +0 -0
  25. {trainingsample-0.2.18 → trainingsample-0.3.0}/docs/BUILDING_STATIC_OPENCV.md +0 -0
  26. {trainingsample-0.2.18 → trainingsample-0.3.0}/rust-toolchain.toml +0 -0
  27. {trainingsample-0.2.18 → trainingsample-0.3.0}/scripts/build-opencv-static.sh +0 -0
  28. {trainingsample-0.2.18 → trainingsample-0.3.0}/scripts/repair-wheel.sh +0 -0
  29. {trainingsample-0.2.18 → trainingsample-0.3.0}/scripts/run_competitive_benchmarks.sh +0 -0
  30. {trainingsample-0.2.18 → trainingsample-0.3.0}/setup-env.sh +0 -0
  31. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/core.rs +0 -0
  32. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cv_batch_ops.rs +0 -0
  33. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cv_batch_ops_optimized.rs +0 -0
  34. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cv_compat.rs +0 -0
  35. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/format_conversion_simd.rs +0 -0
  36. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/lib.rs +0 -0
  37. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/loading.rs +0 -0
  38. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/luminance.rs +0 -0
  39. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/luminance_simd.rs +0 -0
  40. {trainingsample-0.2.18 → trainingsample-0.3.0}/src/true_batch_ops.rs +0 -0
  41. {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/__init__.py +0 -0
  42. {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/test_comprehensive.py +0 -0
@@ -1835,7 +1835,7 @@ dependencies = [
1835
1835
 
1836
1836
  [[package]]
1837
1837
  name = "trainingsample"
1838
- version = "0.2.18"
1838
+ version = "0.3.0"
1839
1839
  dependencies = [
1840
1840
  "anyhow",
1841
1841
  "criterion",
@@ -39,7 +39,7 @@ description = "High-performance Rust reimplementation of GIL-blocking video/imag
39
39
  edition = "2021"
40
40
  license = "MIT"
41
41
  name = "trainingsample"
42
- version = "0.2.18"
42
+ version = "0.3.0"
43
43
  readme = "README.md"
44
44
 
45
45
  [profile.release]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trainingsample
3
- Version: 0.2.18
3
+ Version: 0.3.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -17,7 +17,7 @@ description = "High-performance Rust reimplementation of GIL-blocking video/imag
17
17
  license = {text = "MIT"}
18
18
  name = "trainingsample"
19
19
  requires-python = ">=3.11"
20
- version = "0.2.18"
20
+ version = "0.3.0"
21
21
 
22
22
  [project.optional-dependencies]
23
23
  dev = ["pytest>=6.0", "pytest-benchmark", "maturin>=1.0,<2.0", "pre-commit"]
@@ -8,19 +8,47 @@ pub fn crop_image_array(
8
8
  width: usize,
9
9
  height: usize,
10
10
  ) -> Result<Array3<u8>> {
11
- let (img_height, img_width, _channels) = image.dim();
11
+ let (img_height, img_width, channels) = image.dim();
12
12
 
13
13
  if width == 0 || height == 0 {
14
14
  return Err(anyhow::anyhow!("Crop dimensions must be greater than zero"));
15
15
  }
16
16
 
17
- if x + width > img_width || y + height > img_height {
17
+ let end_x = x
18
+ .checked_add(width)
19
+ .ok_or_else(|| anyhow::anyhow!("Crop bounds exceed image dimensions"))?;
20
+ let end_y = y
21
+ .checked_add(height)
22
+ .ok_or_else(|| anyhow::anyhow!("Crop bounds exceed image dimensions"))?;
23
+
24
+ if end_x > img_width || end_y > img_height {
18
25
  return Err(anyhow::anyhow!("Crop bounds exceed image dimensions"));
19
26
  }
20
27
 
21
- // Use assign to avoid allocation where possible, but still return owned array
22
- let slice = image.slice(s![y..y + height, x..x + width, ..]);
23
- // Only allocate when we need to return an owned array
28
+ if let Some(src) = image.as_slice() {
29
+ let output_len = height
30
+ .checked_mul(width)
31
+ .and_then(|pixels| pixels.checked_mul(channels))
32
+ .ok_or_else(|| anyhow::anyhow!("Crop dimensions are too large"))?;
33
+ let mut output = Vec::with_capacity(output_len);
34
+
35
+ // Every byte in the reserved output is initialized by crop_raw_buffer
36
+ // before the Vec length is exposed to safe Rust.
37
+ unsafe {
38
+ crop_raw_buffer(
39
+ src.as_ptr(),
40
+ (img_height, img_width, channels),
41
+ output.as_mut_ptr(),
42
+ (y, x, height, width),
43
+ );
44
+ output.set_len(output_len);
45
+ }
46
+
47
+ return Array3::from_shape_vec((height, width, channels), output).map_err(Into::into);
48
+ }
49
+
50
+ // Preserve support for non-contiguous ndarray views with the generic path.
51
+ let slice = image.slice(s![y..end_y, x..end_x, ..]);
24
52
  Ok(slice.to_owned())
25
53
  }
26
54
 
@@ -111,16 +139,7 @@ pub fn batch_center_crop_arrays(
111
139
  let actual_width = target_width.min(img_width);
112
140
  let actual_height = target_height.min(img_height);
113
141
 
114
- // Validate bounds
115
- if x + actual_width > img_width || y + actual_height > img_height {
116
- return Err(anyhow::anyhow!("Crop bounds exceed image dimensions"));
117
- }
118
-
119
- // Direct slice and copy - optimized for speed
120
- let cropped = image
121
- .slice(s![y..y + actual_height, x..x + actual_width, ..])
122
- .to_owned();
123
- results.push(cropped);
142
+ results.push(crop_image_array(image, x, y, actual_width, actual_height)?);
124
143
  }
125
144
 
126
145
  Ok(results)
@@ -65,47 +65,81 @@ impl OpenCVBatchProcessor {
65
65
  anyhow::bail!("Number of videos and target sizes must match");
66
66
  }
67
67
 
68
- // ULTRA-OPTIMIZED: Flatten all frames from all videos into one massive batch
69
- let mut all_frames = Vec::new();
70
- let mut all_target_sizes = Vec::new();
71
- let mut video_frame_counts = Vec::new();
68
+ let mut results = Vec::with_capacity(videos.len());
72
69
 
73
- for (video, &target_size) in videos.iter().zip(target_sizes.iter()) {
74
- let (frames, _, _, _) = video.dim();
75
- video_frame_counts.push(frames);
76
-
77
- // Collect all frames from this video
78
- for frame_idx in 0..frames {
79
- let frame = video.index_axis(ndarray::Axis(0), frame_idx);
80
- all_frames.push(frame);
81
- all_target_sizes.push(target_size);
70
+ for (video, &(target_width, target_height)) in videos.iter().zip(target_sizes.iter()) {
71
+ let (frames, src_height, src_width, channels) = video.dim();
72
+ if channels != 3 {
73
+ anyhow::bail!("Only 3-channel RGB videos are supported");
82
74
  }
83
- }
84
75
 
85
- // Process ALL frames in one giant batch operation - maximum efficiency!
86
- let resized_frames = self.batch_resize_images(&all_frames, &all_target_sizes)?;
87
-
88
- // Reconstruct video structure from flattened results
89
- let mut results = Vec::new();
90
- let mut frame_idx = 0;
91
-
92
- for (video_idx, frame_count) in video_frame_counts.iter().enumerate() {
93
- let (target_width, target_height) = target_sizes[video_idx];
94
- let result_shape = (
95
- *frame_count,
76
+ let src_height = i32::try_from(src_height)?;
77
+ let src_width = i32::try_from(src_width)?;
78
+ let dst_height = i32::try_from(target_height)?;
79
+ let dst_width = i32::try_from(target_width)?;
80
+ let mut video_result = ndarray::Array4::<u8>::zeros((
81
+ frames,
96
82
  target_height as usize,
97
83
  target_width as usize,
98
- 3,
99
- );
100
- let mut video_result = ndarray::Array4::<u8>::zeros(result_shape);
101
-
102
- // Copy frames for this video
103
- for local_frame_idx in 0..*frame_count {
104
- let global_frame = &resized_frames[frame_idx];
105
- video_result
106
- .index_axis_mut(ndarray::Axis(0), local_frame_idx)
107
- .assign(global_frame);
108
- frame_idx += 1;
84
+ channels,
85
+ ));
86
+ let output = video_result
87
+ .as_slice_mut()
88
+ .ok_or_else(|| anyhow::anyhow!("Output video is not contiguous"))?;
89
+ let output_frame_len = (target_height as usize)
90
+ .checked_mul(target_width as usize)
91
+ .and_then(|pixels| pixels.checked_mul(channels))
92
+ .ok_or_else(|| anyhow::anyhow!("Target video dimensions are too large"))?;
93
+
94
+ for frame_idx in 0..frames {
95
+ let frame = video.index_axis(ndarray::Axis(0), frame_idx);
96
+ let input = frame
97
+ .as_slice()
98
+ .ok_or_else(|| anyhow::anyhow!("Video frame data is not contiguous"))?;
99
+ let output_offset = frame_idx
100
+ .checked_mul(output_frame_len)
101
+ .ok_or_else(|| anyhow::anyhow!("Target video dimensions are too large"))?;
102
+ let output_end = output_offset
103
+ .checked_add(output_frame_len)
104
+ .ok_or_else(|| anyhow::anyhow!("Target video dimensions are too large"))?;
105
+ let output_len = output.len();
106
+ let output_frame = output.get_mut(output_offset..output_end).ok_or_else(|| {
107
+ anyhow::anyhow!(
108
+ "Output frame {} range {}..{} exceeds the video buffer length {}",
109
+ frame_idx,
110
+ output_offset,
111
+ output_end,
112
+ output_len
113
+ )
114
+ })?;
115
+
116
+ let src_mat = unsafe {
117
+ Mat::new_rows_cols_with_data_unsafe(
118
+ src_height,
119
+ src_width,
120
+ opencv::core::CV_8UC3,
121
+ input.as_ptr() as *mut std::ffi::c_void,
122
+ opencv::core::Mat_AUTO_STEP,
123
+ )?
124
+ };
125
+ let mut dst_mat = unsafe {
126
+ Mat::new_rows_cols_with_data_unsafe(
127
+ dst_height,
128
+ dst_width,
129
+ opencv::core::CV_8UC3,
130
+ output_frame.as_mut_ptr() as *mut std::ffi::c_void,
131
+ opencv::core::Mat_AUTO_STEP,
132
+ )?
133
+ };
134
+
135
+ resize(
136
+ &src_mat,
137
+ &mut dst_mat,
138
+ opencv::core::Size::new(dst_width, dst_height),
139
+ 0.0,
140
+ 0.0,
141
+ INTER_LINEAR,
142
+ )?;
109
143
  }
110
144
 
111
145
  results.push(video_result);
@@ -131,28 +165,60 @@ impl OpenCVBatchProcessor {
131
165
  target_height: u32,
132
166
  interpolation: i32,
133
167
  ) -> Result<Array3<u8>> {
134
- let (_height, _width, channels) = image.dim();
168
+ let (height, width, channels) = image.dim();
135
169
 
136
170
  if channels != 3 {
137
171
  anyhow::bail!("Only 3-channel RGB images are supported");
138
172
  }
139
173
 
140
- // Convert ndarray to OpenCV Mat
141
- let src_mat = self.ndarray_to_mat(image)?;
174
+ let data_slice = image
175
+ .as_slice()
176
+ .ok_or_else(|| anyhow::anyhow!("Image data is not contiguous"))?;
177
+ let src_height = i32::try_from(height)
178
+ .map_err(|_| anyhow::anyhow!("Source height {} exceeds OpenCV's limit", height))?;
179
+ let src_width = i32::try_from(width)
180
+ .map_err(|_| anyhow::anyhow!("Source width {} exceeds OpenCV's limit", width))?;
181
+ let dst_height = i32::try_from(target_height).map_err(|_| {
182
+ anyhow::anyhow!("Target height {} exceeds OpenCV's limit", target_height)
183
+ })?;
184
+ let dst_width = i32::try_from(target_width)
185
+ .map_err(|_| anyhow::anyhow!("Target width {} exceeds OpenCV's limit", target_width))?;
186
+
187
+ // OpenCV only needs lightweight Mat headers here. Point them at the
188
+ // ndarray storage so resize can read the input and write the final
189
+ // output without copying either full image through an intermediate Mat.
190
+ let src_mat = unsafe {
191
+ Mat::new_rows_cols_with_data_unsafe(
192
+ src_height,
193
+ src_width,
194
+ opencv::core::CV_8UC3,
195
+ data_slice.as_ptr() as *mut std::ffi::c_void,
196
+ opencv::core::Mat_AUTO_STEP,
197
+ )?
198
+ };
199
+
200
+ let mut result =
201
+ Array3::<u8>::zeros((target_height as usize, target_width as usize, channels));
202
+ let mut dst_mat = unsafe {
203
+ Mat::new_rows_cols_with_data_unsafe(
204
+ dst_height,
205
+ dst_width,
206
+ opencv::core::CV_8UC3,
207
+ result.as_mut_ptr() as *mut std::ffi::c_void,
208
+ opencv::core::Mat_AUTO_STEP,
209
+ )?
210
+ };
142
211
 
143
- // Perform OpenCV resize
144
- let mut dst_mat = Mat::default();
145
212
  resize(
146
213
  &src_mat,
147
214
  &mut dst_mat,
148
- opencv::core::Size::new(target_width as i32, target_height as i32),
215
+ opencv::core::Size::new(dst_width, dst_height),
149
216
  0.0,
150
217
  0.0,
151
218
  interpolation,
152
219
  )?;
153
220
 
154
- // Convert back to ndarray
155
- self.mat_to_ndarray(&dst_mat)
221
+ Ok(result)
156
222
  }
157
223
 
158
224
  // Single video resize function removed - now using batch_resize_videos for all operations
@@ -218,28 +284,6 @@ impl OpenCVBatchProcessor {
218
284
 
219
285
  Ok(mat)
220
286
  }
221
-
222
- /// Convert OpenCV Mat back to ndarray (optimized for performance)
223
- fn mat_to_ndarray(&self, mat: &Mat) -> Result<Array3<u8>> {
224
- let height = mat.rows() as usize;
225
- let width = mat.cols() as usize;
226
- let channels = mat.channels() as usize;
227
-
228
- if channels != 3 {
229
- anyhow::bail!("Expected 3-channel image");
230
- }
231
-
232
- let mut result = Array3::<u8>::zeros((height, width, channels));
233
-
234
- // Optimized bulk memory copy from OpenCV Mat to ndarray
235
- unsafe {
236
- let src_ptr = mat.ptr(0)?;
237
- let dst_ptr = result.as_mut_ptr();
238
- std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, height * width * channels);
239
- }
240
-
241
- Ok(result)
242
- }
243
287
  }
244
288
 
245
289
  // HIGH-PERFORMANCE RESIZE WRAPPERS (REPLACE METAL GPU FUNCTIONS)
@@ -599,10 +599,8 @@ fn is_tuple_or_list_of_two(obj: &Bound<PyAny>) -> bool {
599
599
  #[cfg(feature = "opencv")]
600
600
  #[pyclass]
601
601
  pub struct ResizeIterator {
602
- /// Raw buffer pointers and their dimensions
603
- buffers: Vec<(Vec<u8>, (usize, usize, usize))>, // (buffer, (height, width, channels))
604
- /// Current iteration index
605
- index: usize,
602
+ /// Completed buffers waiting to be transferred to NumPy.
603
+ buffers: std::collections::VecDeque<(Vec<u8>, (usize, usize, usize))>,
606
604
  }
607
605
 
608
606
  #[cfg(feature = "opencv")]
@@ -613,17 +611,13 @@ impl ResizeIterator {
613
611
  }
614
612
 
615
613
  fn __next__<'py>(&mut self, py: Python<'py>) -> Option<Bound<'py, PyArray3<u8>>> {
616
- if self.index >= self.buffers.len() {
617
- return None;
618
- }
619
-
620
- let (buffer, (height, width, channels)) = &self.buffers[self.index];
621
- self.index += 1;
614
+ let (buffer, (height, width, channels)) = self.buffers.pop_front()?;
622
615
 
623
- // Convert raw buffer directly to PyArray3 - ZERO intermediate steps!
624
- match ndarray::Array3::from_shape_vec((*height, *width, *channels), buffer.clone()) {
616
+ // Transfer the completed allocation directly into the ndarray instead
617
+ // of cloning the full resized image during iteration.
618
+ match ndarray::Array3::from_shape_vec((height, width, channels), buffer) {
625
619
  Ok(array) => Some(PyArray3::from_owned_array_bound(py, array)),
626
- Err(_) => None, // Skip malformed arrays
620
+ Err(_) => None, // Malformed buffer; end iteration (StopIteration)
627
621
  }
628
622
  }
629
623
 
@@ -677,8 +671,7 @@ pub fn batch_resize_images_iterator<'py>(
677
671
  return Bound::new(
678
672
  py,
679
673
  ResizeIterator {
680
- buffers: Vec::new(),
681
- index: 0,
674
+ buffers: std::collections::VecDeque::new(),
682
675
  },
683
676
  );
684
677
  }
@@ -856,7 +849,12 @@ pub fn batch_resize_images_iterator<'py>(
856
849
  .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Resize failed: {}", e)))?;
857
850
 
858
851
  // Return iterator with raw buffers - conversion happens on-demand!
859
- Bound::new(py, ResizeIterator { buffers, index: 0 })
852
+ Bound::new(
853
+ py,
854
+ ResizeIterator {
855
+ buffers: buffers.into(),
856
+ },
857
+ )
860
858
  }
861
859
 
862
860
  // TSR CROPPING OPERATIONS (BENCHMARK WINNERS)
@@ -1204,7 +1202,7 @@ pub fn resize_lanczos4_opencv<'py>(
1204
1202
  #[pyfunction]
1205
1203
  pub fn imdecode_py<'py>(
1206
1204
  py: Python<'py>,
1207
- buf: &[u8],
1205
+ buf: pyo3::pybacked::PyBackedBytes,
1208
1206
  flags: i32,
1209
1207
  ) -> PyResult<Bound<'py, PyArray3<u8>>> {
1210
1208
  use crate::cv_compat::{imdecode, ImreadFlags};
@@ -1220,7 +1218,6 @@ pub fn imdecode_py<'py>(
1220
1218
  }
1221
1219
  };
1222
1220
 
1223
- let buf = buf.to_vec();
1224
1221
  let decoded = py.allow_threads(move || imdecode(&buf, imread_flags));
1225
1222
 
1226
1223
  match decoded {
@@ -78,6 +78,17 @@ mod cropping_tests {
78
78
  assert!(result.is_err());
79
79
  }
80
80
 
81
+ #[test]
82
+ fn test_crop_non_contiguous_view() {
83
+ let img = create_test_image();
84
+ let strided = img.slice(ndarray::s![.., ..;2, ..]);
85
+ let result = crop_image_array(&strided, 5, 10, 20, 30).unwrap();
86
+
87
+ assert_eq!(result.dim(), (30, 20, 3));
88
+ assert_eq!(result[[0, 0, 0]], img[[10, 10, 0]]);
89
+ assert_eq!(result[[29, 19, 2]], img[[39, 48, 2]]);
90
+ }
91
+
81
92
  #[test]
82
93
  fn test_center_crop_image_array() {
83
94
  let img = create_test_image();
@@ -598,45 +598,59 @@ mod performance_validation_tests {
598
598
 
599
599
  #[test]
600
600
  fn test_batch_operations_scale_appropriately() {
601
+ use std::hint::black_box;
601
602
  use std::time::Instant;
602
603
 
603
604
  let base_img = create_gradient_image();
605
+ let crop = (10, 10, 30, 20);
606
+ let batch_size = 50;
607
+ let batch_imgs = vec![base_img.clone(); batch_size];
608
+ let batch_crops = vec![crop; batch_size];
604
609
 
605
- // Warm up - run a few operations to ensure consistent timing
610
+ // Warm both Rayon paths before measuring.
606
611
  for _ in 0..3 {
607
- let _ = batch_crop_image_arrays(std::slice::from_ref(&base_img), &[(10, 10, 30, 20)]);
612
+ black_box(batch_crop_image_arrays(
613
+ std::slice::from_ref(&base_img),
614
+ &[crop],
615
+ ));
616
+ black_box(batch_crop_image_arrays(&batch_imgs, &batch_crops));
608
617
  }
609
618
 
610
- // Single operation - run multiple times and take average
611
- let mut single_total = std::time::Duration::new(0, 0);
612
- for _ in 0..5 {
619
+ // Each sample performs enough work to keep microsecond-scale timer
620
+ // noise from dominating the comparison.
621
+ let mut single_samples = Vec::with_capacity(11);
622
+ let mut batch_samples = Vec::with_capacity(11);
623
+ for _ in 0..11 {
613
624
  let start = Instant::now();
614
- let _single =
615
- batch_crop_image_arrays(std::slice::from_ref(&base_img), &[(10, 10, 30, 20)]);
616
- single_total += start.elapsed();
617
- }
618
- let single_duration = single_total / 5;
619
-
620
- // Batch of 50 (larger batch for better parallelism benefits)
621
- let batch_imgs = vec![base_img.clone(); 50];
622
- let batch_crops = vec![(10, 10, 30, 20); 50];
625
+ for _ in 0..100 {
626
+ black_box(batch_crop_image_arrays(
627
+ std::slice::from_ref(&base_img),
628
+ &[crop],
629
+ ));
630
+ }
631
+ single_samples.push(start.elapsed().as_nanos() / 100);
623
632
 
624
- // Run batch operation multiple times and take average
625
- let mut batch_total = std::time::Duration::new(0, 0);
626
- for _ in 0..3 {
627
633
  let start = Instant::now();
628
- let _batch = batch_crop_image_arrays(&batch_imgs, &batch_crops);
629
- batch_total += start.elapsed();
634
+ for _ in 0..10 {
635
+ black_box(batch_crop_image_arrays(&batch_imgs, &batch_crops));
636
+ }
637
+ batch_samples.push(start.elapsed().as_nanos() / (10 * batch_size as u128));
630
638
  }
631
- let batch_duration = batch_total / 3;
632
639
 
633
- // Batch should not be more than 75x slower (more reasonable for 50x operations)
634
- // Allow for system variance and parallelism overhead
635
- let slowdown_ratio = batch_duration.as_nanos() as f64 / single_duration.as_nanos() as f64;
640
+ single_samples.sort_unstable();
641
+ batch_samples.sort_unstable();
642
+ let single_per_image = single_samples[single_samples.len() / 2];
643
+ let batch_per_image = batch_samples[batch_samples.len() / 2];
644
+ let per_image_ratio = batch_per_image as f64 / single_per_image as f64;
645
+
646
+ // Small crops may not amortize Rayon scheduling, so this is a broad
647
+ // regression guard rather than an assertion that batching must win.
636
648
  assert!(
637
- slowdown_ratio < 75.0,
638
- "Batch processing should benefit from parallelism: ratio {:.2}, single: {:?}, batch: {:?}",
639
- slowdown_ratio, single_duration, batch_duration
649
+ per_image_ratio < 20.0,
650
+ "Batch crop per-image cost regressed: ratio {:.2}, single: {}ns, batch: {}ns",
651
+ per_image_ratio,
652
+ single_per_image,
653
+ batch_per_image
640
654
  );
641
655
  }
642
656
  }
@@ -258,8 +258,8 @@ class TestBasicPerformance:
258
258
  )
259
259
 
260
260
  @pytest.mark.skipif(not HAS_OPENCV, reason="OpenCV not available")
261
- def test_resize_batch_vs_individual_opencv(self, performance_test_images):
262
- """Show TSR batch advantage: batch processing vs individual OpenCV calls."""
261
+ def test_resize_batch_vs_direct_opencv(self, performance_test_images):
262
+ """Compare TSR batching with equivalent direct OpenCV resize calls."""
263
263
  images = performance_test_images["mixed_sizes"][:8] # Mix of different sizes
264
264
  target_size = (224, 224)
265
265
  target_sizes = [target_size] * len(images)
@@ -269,35 +269,29 @@ class TestBasicPerformance:
269
269
  tsr_results = tsr.batch_resize_images(images, target_sizes)
270
270
  tsr_time = time.perf_counter() - start
271
271
 
272
- # OpenCV: Individual calls (the old way)
272
+ # OpenCV: equivalent individual resize calls. Resize is channel-order
273
+ # agnostic, so RGB/BGR conversions would add unrelated work.
273
274
  start = time.perf_counter()
274
- opencv_results = []
275
- for img in images:
276
- # Convert to BGR for OpenCV, then back to RGB
277
- bgr_img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
278
- resized_bgr = cv2.resize(
279
- bgr_img, target_size, interpolation=cv2.INTER_LINEAR
280
- )
281
- resized_rgb = cv2.cvtColor(resized_bgr, cv2.COLOR_BGR2RGB)
282
- opencv_results.append(resized_rgb)
275
+ opencv_results = [
276
+ cv2.resize(img, target_size, interpolation=cv2.INTER_LINEAR)
277
+ for img in images
278
+ ]
283
279
  opencv_time = time.perf_counter() - start
284
280
 
285
- speedup = opencv_time / tsr_time if tsr_time > 0 else float("inf")
281
+ relative_time = opencv_time / tsr_time if tsr_time > 0 else float("inf")
286
282
 
287
283
  # Validate results
288
284
  assert len(tsr_results) == len(opencv_results) == len(images)
289
285
  for i, (tsr_img, cv_img) in enumerate(zip(tsr_results, opencv_results)):
290
286
  expected_shape = (target_size[1], target_size[0], 3)
291
287
  assert tsr_img.shape == cv_img.shape == expected_shape
292
- # Results should be reasonably close (different algorithms may differ)
293
- # TSR uses OpenCV internally but may have different interpolation settings
288
+ # Separate OpenCV builds can differ slightly in interpolation rounding.
294
289
  tsr_float = tsr_img.astype(float)
295
290
  cv_float = cv_img.astype(float)
296
291
  diff = np.mean(np.abs(tsr_float - cv_float))
297
- # More lenient comparison since different resize implementations can vary
298
- assert diff < 25.0, f"Image {i}: Results too different (mean diff: {diff})"
292
+ assert diff < 1.0, f"Image {i}: Results too different (mean diff: {diff})"
299
293
 
300
- print(f"🚀 Resize Performance Comparison (batch size {len(images)}):")
294
+ print(f"Resize performance comparison (batch size {len(images)}):")
301
295
  print(f" Input shapes: {[img.shape[:2] for img in images]}")
302
296
  tsr_imgs_per_sec = len(images) / tsr_time
303
297
  print(f" TSR (batch): {tsr_time:.4f}s ({tsr_imgs_per_sec:.1f} imgs/sec)")
@@ -306,14 +300,11 @@ class TestBasicPerformance:
306
300
  f" OpenCV (individual): {opencv_time:.4f}s "
307
301
  f"({cv_imgs_per_sec:.1f} imgs/sec)"
308
302
  )
309
- print(f" 🎯 TSR Advantage: {speedup:.2f}x faster")
310
- print(" 💡 Why TSR exists: Batch processing beats " "individual OpenCV calls")
303
+ print(f" Relative time (OpenCV / TSR): {relative_time:.2f}x")
311
304
 
312
305
  @pytest.mark.skipif(not HAS_OPENCV, reason="OpenCV not available")
313
306
  def test_luminance_batch_vs_individual_opencv(self, performance_test_images):
314
- """
315
- Show TSR batch advantage: luminance calculation vs individual OpenCV calls.
316
- """
307
+ """Compare TSR luminance with equivalent individual OpenCV calls."""
317
308
  images = performance_test_images["mixed_sizes"][:12] # Mix of different sizes
318
309
 
319
310
  # TSR: Single batch call handles mixed shapes
@@ -321,32 +312,29 @@ class TestBasicPerformance:
321
312
  tsr_results = tsr.batch_calculate_luminance(images)
322
313
  tsr_time = time.perf_counter() - start
323
314
 
324
- # OpenCV: Individual calls (the old way)
315
+ # OpenCV accepts RGB input directly for grayscale conversion.
325
316
  start = time.perf_counter()
326
317
  opencv_results = []
327
318
  for img in images:
328
- # Convert RGB to BGR for OpenCV
329
- bgr_img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
330
- # Convert to grayscale using OpenCV
331
- gray = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)
319
+ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
332
320
  # Calculate mean luminance
333
321
  luminance = np.mean(gray)
334
322
  opencv_results.append(luminance)
335
323
  opencv_time = time.perf_counter() - start
336
324
 
337
- speedup = opencv_time / tsr_time if tsr_time > 0 else float("inf")
325
+ relative_time = opencv_time / tsr_time if tsr_time > 0 else float("inf")
338
326
 
339
327
  # Validate results
340
328
  assert len(tsr_results) == len(opencv_results) == len(images)
341
329
  for i, (tsr_lum, cv_lum) in enumerate(zip(tsr_results, opencv_results)):
342
- # Results should be close (small differences due to RGB vs BGR conversion)
330
+ # Results should be close despite implementation rounding differences.
343
331
  diff = abs(tsr_lum - cv_lum)
344
332
  assert diff < 2.0, (
345
333
  f"Image {i}: Results too different: "
346
334
  f"TSR {tsr_lum:.2f} vs OpenCV {cv_lum:.2f}"
347
335
  )
348
336
 
349
- print(f"🚀 Luminance Performance Comparison (batch size {len(images)}):")
337
+ print(f"Luminance performance comparison (batch size {len(images)}):")
350
338
  print(f" Input shapes: {[img.shape[:2] for img in images]}")
351
339
  tsr_imgs_per_sec = len(images) / tsr_time
352
340
  print(
@@ -357,14 +345,11 @@ class TestBasicPerformance:
357
345
  f" OpenCV (individual): {opencv_time:.4f}s "
358
346
  f"({cv_imgs_per_sec:.1f} imgs/sec)"
359
347
  )
360
- print(f" 🎯 TSR Advantage: {speedup:.2f}x faster")
361
- print(" 💡 Why TSR exists: Batch SIMD processing beats individual calls")
348
+ print(f" Relative time (OpenCV / TSR): {relative_time:.2f}x")
362
349
 
363
350
  @pytest.mark.skipif(not HAS_OPENCV, reason="OpenCV not available")
364
351
  def test_complete_pipeline_batch_vs_individual(self, performance_test_images):
365
- """
366
- Show TSR's ultimate advantage: complete pipeline in batch vs individual ops.
367
- """
352
+ """Compare equivalent resize-and-luminance pipelines."""
368
353
  images = performance_test_images["mixed_sizes"][:8] # Mix of different sizes
369
354
  target_size = (224, 224)
370
355
  target_sizes = [target_size] * len(images)
@@ -377,22 +362,17 @@ class TestBasicPerformance:
377
362
  luminances = tsr.batch_calculate_luminance(resized)
378
363
  tsr_time = time.perf_counter() - start
379
364
 
380
- # OpenCV: Individual operations (the painful old way)
365
+ # OpenCV: equivalent individual operations on RGB data.
381
366
  start = time.perf_counter()
382
367
  opencv_luminances = []
383
368
  for img in images:
384
- # Individual resize with color space conversions
385
- bgr_img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
386
- resized_bgr = cv2.resize(
387
- bgr_img, target_size, interpolation=cv2.INTER_LINEAR
388
- )
389
- # Individual luminance calculation
390
- gray = cv2.cvtColor(resized_bgr, cv2.COLOR_BGR2GRAY)
369
+ resized_rgb = cv2.resize(img, target_size, interpolation=cv2.INTER_LINEAR)
370
+ gray = cv2.cvtColor(resized_rgb, cv2.COLOR_RGB2GRAY)
391
371
  luminance = np.mean(gray)
392
372
  opencv_luminances.append(luminance)
393
373
  opencv_time = time.perf_counter() - start
394
374
 
395
- speedup = opencv_time / tsr_time if tsr_time > 0 else float("inf")
375
+ relative_time = opencv_time / tsr_time if tsr_time > 0 else float("inf")
396
376
 
397
377
  # Validate results
398
378
  assert len(luminances) == len(opencv_luminances) == len(images)
@@ -404,7 +384,7 @@ class TestBasicPerformance:
404
384
  )
405
385
 
406
386
  print(
407
- f"🚀 Complete Pipeline Performance (resize + luminance, "
387
+ f"Complete pipeline performance (resize + luminance, "
408
388
  f"batch size {len(images)}):"
409
389
  )
410
390
  input_shapes = [img.shape[:2] for img in images]
@@ -419,12 +399,8 @@ class TestBasicPerformance:
419
399
  f" OpenCV (individual): {opencv_time:.4f}s "
420
400
  f"({cv_imgs_per_sec:.1f} imgs/sec)"
421
401
  )
422
- print(f" 🎯 TSR Advantage: {speedup:.2f}x faster")
423
- print(" 💡 Key Benefits:")
424
- print(f" • 2 batch calls vs {len(images)} individual operations")
425
- print(" • Mixed-shape handling in single API calls")
426
- print(" • No color space conversion overhead")
427
- print(" • SIMD parallelization across entire batches")
402
+ print(f" Relative time (OpenCV / TSR): {relative_time:.2f}x")
403
+ print(" TSR uses two extension calls; OpenCV uses an explicit Python loop")
428
404
 
429
405
  @pytest.mark.skipif(not HAS_OPENCV, reason="OpenCV not available")
430
406
  def test_mixed_shapes_batch_functionality(self, performance_test_images):
@@ -635,8 +611,8 @@ class TestBasicPerformance:
635
611
  print(" 🎯 Key advantages:")
636
612
  print(f" - Single API call handles {unique_input_shapes} input shapes")
637
613
  print(f" - Produces {unique_output_shapes} different output shapes")
638
- print(" - No loops needed: tsr.batch_crop_images(mixed_images, mixed_crops)")
639
- print(" - vs NumPy: Must write loop for each different shape combination")
614
+ print(" - One Python call: tsr.batch_crop_images(mixed_images, mixed_crops)")
615
+ print(" - NumPy requires an explicit Python loop for mixed crop shapes")
640
616
 
641
617
 
642
618
  class TestMemoryEfficiency:
@@ -1,9 +1,11 @@
1
1
  """Tests for Python bindings of training_sample_rust."""
2
2
 
3
3
  import importlib.util
4
+ import io
4
5
 
5
6
  import numpy as np
6
7
  import pytest
8
+ from PIL import Image
7
9
 
8
10
  try:
9
11
  import trainingsample as tsr
@@ -34,6 +36,17 @@ def sample_video():
34
36
  class TestImageOperations:
35
37
  """Test image processing operations."""
36
38
 
39
+ @pytest.mark.parametrize("buffer_type", [bytes, bytearray])
40
+ def test_imdecode_owned_buffer(self, buffer_type):
41
+ """Decode buffers that remain valid while the GIL is released."""
42
+ image = np.arange(4 * 5 * 3, dtype=np.uint8).reshape((4, 5, 3))
43
+ encoded = io.BytesIO()
44
+ Image.fromarray(image).save(encoded, format="PNG")
45
+
46
+ decoded = tsr.imdecode_py(buffer_type(encoded.getvalue()), 1)
47
+
48
+ np.testing.assert_array_equal(decoded, image)
49
+
37
50
  def test_batch_crop_images(self, sample_images):
38
51
  """Test batch cropping with custom coordinates."""
39
52
  crop_boxes = [(10, 10, 50, 50), (20, 20, 40, 40), (5, 5, 60, 60)]
@@ -156,6 +169,31 @@ class TestZeroCopyBindings:
156
169
  with pytest.raises(ValueError, match="C-contiguous"):
157
170
  tsr.batch_resize_images_iterator([strided], [(16, 16)])
158
171
 
172
+ def test_zero_copy_resize_iterator_transfers_each_result(self):
173
+ """Iterator should yield owned results once and report remaining length."""
174
+ if not hasattr(tsr, "batch_resize_images_iterator"):
175
+ pytest.skip("OpenCV resize iterator bindings not available")
176
+
177
+ images = [
178
+ np.full((32, 48, 3), 17, dtype=np.uint8),
179
+ np.full((24, 40, 3), 231, dtype=np.uint8),
180
+ ]
181
+ iterator = tsr.batch_resize_images_iterator(images, [(12, 8), (10, 6)])
182
+
183
+ assert len(iterator) == 2
184
+ first = next(iterator)
185
+ assert first.shape == (8, 12, 3)
186
+ assert np.all(first == 17)
187
+ assert len(iterator) == 1
188
+
189
+ second = next(iterator)
190
+ assert second.shape == (6, 10, 3)
191
+ assert np.all(second == 231)
192
+ assert len(iterator) == 0
193
+
194
+ with pytest.raises(StopIteration):
195
+ next(iterator)
196
+
159
197
 
160
198
  class TestErrorHandling:
161
199
  """Test error handling and edge cases."""
File without changes
File without changes