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.
- {trainingsample-0.2.18 → trainingsample-0.3.0}/Cargo.lock +1 -1
- {trainingsample-0.2.18 → trainingsample-0.3.0}/Cargo.toml +1 -1
- {trainingsample-0.2.18 → trainingsample-0.3.0}/PKG-INFO +1 -1
- {trainingsample-0.2.18 → trainingsample-0.3.0}/pyproject.toml +1 -1
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cropping.rs +34 -15
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/opencv_ops.rs +110 -66
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/python_bindings.rs +15 -18
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/tests.rs +11 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/comprehensive_tests.rs +40 -26
- {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/test_performance_benchmarks.py +30 -54
- {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/test_python_bindings.py +38 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.cargo/config.toml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.envrc +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.github/workflows/ci.yml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.github/workflows/publish.yml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.gitignore +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.pre-commit-config.yaml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/.rustfmt.toml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/BENCHMARKS.md +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/Dockerfile +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/LICENSE +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/README.md +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/clippy.toml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/docs/API_COMPAT_CV2.md +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/docs/BUILDING_STATIC_OPENCV.md +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/rust-toolchain.toml +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/scripts/build-opencv-static.sh +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/scripts/repair-wheel.sh +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/scripts/run_competitive_benchmarks.sh +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/setup-env.sh +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/core.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cv_batch_ops.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cv_batch_ops_optimized.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/cv_compat.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/format_conversion_simd.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/lib.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/loading.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/luminance.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/luminance_simd.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/src/true_batch_ops.rs +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/__init__.py +0 -0
- {trainingsample-0.2.18 → trainingsample-0.3.0}/tests/test_comprehensive.py +0 -0
|
@@ -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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, &
|
|
74
|
-
let (frames,
|
|
75
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
99
|
-
);
|
|
100
|
-
let
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
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
|
-
|
|
141
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
///
|
|
603
|
-
buffers:
|
|
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
|
-
|
|
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
|
-
//
|
|
624
|
-
|
|
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, //
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
|
610
|
+
// Warm both Rayon paths before measuring.
|
|
606
611
|
for _ in 0..3 {
|
|
607
|
-
|
|
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
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
batch_crop_image_arrays(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
let
|
|
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
|
-
|
|
638
|
-
"Batch
|
|
639
|
-
|
|
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
|
|
262
|
-
"""
|
|
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:
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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"
|
|
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:
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
423
|
-
print("
|
|
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(" -
|
|
639
|
-
print(" -
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|