1use crate::{ImageOpsError, MatView, OwnedMatView, PixelFormat};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ColorConversion {
8 BgrToRgb,
10 RgbToBgr,
12 BgrToGray,
14 GrayToRgb,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ThresholdKind {
21 Binary,
23}
24
25#[derive(Debug, Clone, Copy)]
27pub struct MinMaxResult {
28 pub min: f64,
30 pub max: f64,
32 pub min_loc: (u32, u32),
34 pub max_loc: (u32, u32),
36}
37
38pub trait ImageOpsPort: Send + Sync {
40 fn cvt_color(
42 &self,
43 src: &dyn MatView,
44 conv: ColorConversion,
45 ) -> Result<OwnedMatView, ImageOpsError>;
46 fn gaussian_blur(
48 &self,
49 src: &dyn MatView,
50 ksize: (u32, u32),
51 sigma_x: f64,
52 sigma_y: f64,
53 ) -> Result<OwnedMatView, ImageOpsError>;
54 fn threshold(
56 &self,
57 src: &dyn MatView,
58 thresh: f64,
59 max_val: f64,
60 kind: ThresholdKind,
61 ) -> Result<OwnedMatView, ImageOpsError>;
62 fn absdiff(&self, lhs: &dyn MatView, rhs: &dyn MatView) -> Result<OwnedMatView, ImageOpsError>;
64 fn convert_scale_abs(
66 &self,
67 src: &dyn MatView,
68 scale: f64,
69 offset: f64,
70 ) -> Result<OwnedMatView, ImageOpsError>;
71 fn min_max_loc(&self, src: &dyn MatView) -> Result<MinMaxResult, ImageOpsError>;
73 fn count_non_zero(&self, src: &dyn MatView) -> Result<u64, ImageOpsError>;
75 fn resize(
77 &self,
78 src: &dyn MatView,
79 new_width: u32,
80 new_height: u32,
81 ) -> Result<OwnedMatView, ImageOpsError>;
82}
83
84#[derive(Debug, Default, Clone, Copy)]
90pub struct PureRustImageOps;
91
92fn sat_u8(v: f64) -> u8 {
94 v.round().clamp(0.0, 255.0) as u8
95}
96
97fn dims(view: &dyn MatView) -> (u32, u32, u32) {
98 (view.width(), view.height(), view.channels())
99}
100
101impl ImageOpsPort for PureRustImageOps {
102 fn cvt_color(
103 &self,
104 src: &dyn MatView,
105 conv: ColorConversion,
106 ) -> Result<OwnedMatView, ImageOpsError> {
107 let w = src.width();
108 let h = src.height();
109 let pf = src.pixel_format();
110 let data = src.data();
111 match conv {
112 ColorConversion::BgrToRgb | ColorConversion::RgbToBgr => {
113 let (expected_src, out_pf) = match conv {
114 ColorConversion::BgrToRgb => (PixelFormat::Bgr8, PixelFormat::Rgb8),
115 ColorConversion::RgbToBgr => (PixelFormat::Rgb8, PixelFormat::Bgr8),
116 ColorConversion::BgrToGray | ColorConversion::GrayToRgb => unreachable!(),
117 };
118 if pf != expected_src {
119 return Err(ImageOpsError::UnsupportedConversion {
120 src: pf,
121 dst: out_pf,
122 });
123 }
124 let mut out = vec![0u8; data.len()];
125 for (i, chunk) in out.chunks_exact_mut(3).enumerate() {
126 let src = i * 3;
127 chunk[0] = data[src + 2];
128 chunk[1] = data[src + 1];
129 chunk[2] = data[src];
130 }
131 OwnedMatView::new(w, h, out_pf, out)
132 }
133 ColorConversion::BgrToGray => {
134 if pf != PixelFormat::Bgr8 {
135 return Err(ImageOpsError::UnsupportedConversion {
136 src: pf,
137 dst: PixelFormat::Mono8,
138 });
139 }
140 let out: Vec<u8> = data
141 .chunks_exact(3)
142 .map(|px| {
143 let b = px[0] as f64;
144 let g = px[1] as f64;
145 let r = px[2] as f64;
146 sat_u8(0.114 * b + 0.587 * g + 0.299 * r)
147 })
148 .collect();
149 OwnedMatView::new(w, h, PixelFormat::Mono8, out)
150 }
151 ColorConversion::GrayToRgb => {
152 if pf != PixelFormat::Mono8 {
153 return Err(ImageOpsError::UnsupportedConversion {
154 src: pf,
155 dst: PixelFormat::Rgb8,
156 });
157 }
158 let out: Vec<u8> = data.iter().flat_map(|&v| [v, v, v]).collect();
159 OwnedMatView::new(w, h, PixelFormat::Rgb8, out)
160 }
161 }
162 }
163
164 fn gaussian_blur(
165 &self,
166 _src: &dyn MatView,
167 _ksize: (u32, u32),
168 _sigma_x: f64,
169 _sigma_y: f64,
170 ) -> Result<OwnedMatView, ImageOpsError> {
171 Err(ImageOpsError::Backend(
172 "unsupported in PureRustImageOps: requires OpenCV backend".to_string(),
173 ))
174 }
175
176 fn threshold(
177 &self,
178 src: &dyn MatView,
179 thresh: f64,
180 max_val: f64,
181 kind: ThresholdKind,
182 ) -> Result<OwnedMatView, ImageOpsError> {
183 if src.pixel_format() != PixelFormat::Mono8 {
184 return Err(ImageOpsError::UnsupportedPixelFormat(src.pixel_format()));
185 }
186 let data = src.data();
187 let max_u8 = sat_u8(max_val);
188 let out = match kind {
189 ThresholdKind::Binary => data
190 .iter()
191 .map(|&b| if (b as f64) > thresh { max_u8 } else { 0 })
192 .collect::<Vec<u8>>(),
193 };
194 OwnedMatView::new(src.width(), src.height(), PixelFormat::Mono8, out)
195 }
196
197 fn absdiff(&self, lhs: &dyn MatView, rhs: &dyn MatView) -> Result<OwnedMatView, ImageOpsError> {
198 let ld = dims(lhs);
199 let rd = dims(rhs);
200 if ld != rd || lhs.pixel_format() != rhs.pixel_format() {
201 return Err(ImageOpsError::DimensionMismatch { lhs: ld, rhs: rd });
202 }
203 let a = lhs.data();
204 let b = rhs.data();
205 if a.len() != b.len() {
206 return Err(ImageOpsError::DimensionMismatch { lhs: ld, rhs: rd });
207 }
208 let out: Vec<u8> = a
209 .iter()
210 .zip(b.iter())
211 .map(|(&x, &y)| x.abs_diff(y))
212 .collect();
213 OwnedMatView::new(lhs.width(), lhs.height(), lhs.pixel_format(), out)
214 }
215
216 fn convert_scale_abs(
217 &self,
218 src: &dyn MatView,
219 scale: f64,
220 offset: f64,
221 ) -> Result<OwnedMatView, ImageOpsError> {
222 let out: Vec<u8> = src
223 .data()
224 .iter()
225 .map(|&b| sat_u8((b as f64 * scale + offset).abs()))
226 .collect();
227 OwnedMatView::new(src.width(), src.height(), src.pixel_format(), out)
228 }
229
230 fn min_max_loc(&self, src: &dyn MatView) -> Result<MinMaxResult, ImageOpsError> {
231 if src.pixel_format() != PixelFormat::Mono8 {
232 return Err(ImageOpsError::UnsupportedPixelFormat(src.pixel_format()));
233 }
234 let data = src.data();
235 if data.is_empty() {
236 return Err(ImageOpsError::EmptyInput);
237 }
238 let w = src.width();
239 let mut min_v: u8 = data[0];
240 let mut max_v: u8 = data[0];
241 let mut min_loc = (0u32, 0u32);
242 let mut max_loc = (0u32, 0u32);
243 for (idx, &b) in data.iter().enumerate() {
244 let x = (idx as u32) % w;
245 let y = (idx as u32) / w;
246 if b < min_v {
247 min_v = b;
248 min_loc = (x, y);
249 }
250 if b > max_v {
251 max_v = b;
252 max_loc = (x, y);
253 }
254 }
255 Ok(MinMaxResult {
256 min: min_v as f64,
257 max: max_v as f64,
258 min_loc,
259 max_loc,
260 })
261 }
262
263 fn count_non_zero(&self, src: &dyn MatView) -> Result<u64, ImageOpsError> {
264 if src.pixel_format() != PixelFormat::Mono8 {
265 return Err(ImageOpsError::UnsupportedPixelFormat(src.pixel_format()));
266 }
267 Ok(src.data().iter().filter(|&&b| b != 0).count() as u64)
268 }
269
270 fn resize(
271 &self,
272 _src: &dyn MatView,
273 _new_width: u32,
274 _new_height: u32,
275 ) -> Result<OwnedMatView, ImageOpsError> {
276 Err(ImageOpsError::Backend(
277 "unsupported in PureRustImageOps: requires OpenCV backend".to_string(),
278 ))
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn mono(w: u32, h: u32, data: Vec<u8>) -> OwnedMatView {
287 OwnedMatView::new(w, h, PixelFormat::Mono8, data).unwrap()
288 }
289 fn bgr(w: u32, h: u32, data: Vec<u8>) -> OwnedMatView {
290 OwnedMatView::new(w, h, PixelFormat::Bgr8, data).unwrap()
291 }
292
293 #[test]
294 fn threshold_binary_basic() {
295 let src = mono(3, 1, vec![10, 50, 200]);
296 let out = PureRustImageOps
297 .threshold(&src, 30.0, 255.0, ThresholdKind::Binary)
298 .unwrap();
299 assert_eq!(out.data(), &[0, 255, 255]);
300 }
301
302 #[test]
303 fn threshold_binary_equal_to_threshold_is_zero() {
304 let src = mono(3, 1, vec![29, 30, 31]);
306 let out = PureRustImageOps
307 .threshold(&src, 30.0, 255.0, ThresholdKind::Binary)
308 .unwrap();
309 assert_eq!(out.data(), &[0, 0, 255]);
310 }
311
312 #[test]
313 fn threshold_rejects_non_mono() {
314 let src = bgr(1, 1, vec![1, 2, 3]);
315 let err = PureRustImageOps
316 .threshold(&src, 0.0, 255.0, ThresholdKind::Binary)
317 .unwrap_err();
318 assert!(matches!(err, ImageOpsError::UnsupportedPixelFormat(_)));
319 }
320
321 #[test]
322 fn absdiff_mono() {
323 let a = mono(2, 2, vec![10, 20, 30, 40]);
324 let b = mono(2, 2, vec![5, 25, 30, 100]);
325 let out = PureRustImageOps.absdiff(&a, &b).unwrap();
326 assert_eq!(out.data(), &[5, 5, 0, 60]);
327 }
328
329 #[test]
330 fn absdiff_rejects_mismatched_dims() {
331 let a = mono(2, 2, vec![0; 4]);
332 let b = mono(2, 3, vec![0; 6]);
333 let err = PureRustImageOps.absdiff(&a, &b).unwrap_err();
334 assert!(matches!(err, ImageOpsError::DimensionMismatch { .. }));
335 }
336
337 #[test]
338 fn absdiff_rejects_mismatched_format() {
339 let a = mono(1, 1, vec![0]);
340 let b = bgr(1, 1, vec![0, 0, 0]);
341 let err = PureRustImageOps.absdiff(&a, &b).unwrap_err();
342 assert!(matches!(err, ImageOpsError::DimensionMismatch { .. }));
343 }
344
345 #[test]
346 fn absdiff_rejects_same_dims_different_format() {
347 let a = bgr(1, 1, vec![1, 2, 3]);
350 let b = OwnedMatView::new(1, 1, PixelFormat::Rgb8, vec![1, 2, 3]).unwrap();
351 let err = PureRustImageOps.absdiff(&a, &b).unwrap_err();
352 assert!(matches!(err, ImageOpsError::DimensionMismatch { .. }));
353 }
354
355 #[test]
356 fn absdiff_rejects_when_dims_differ_but_byte_count_matches() {
357 let a = mono(4, 1, vec![1, 2, 3, 4]);
361 let b = mono(2, 2, vec![1, 2, 3, 4]);
362 let err = PureRustImageOps.absdiff(&a, &b).unwrap_err();
363 assert!(matches!(err, ImageOpsError::DimensionMismatch { .. }));
364 }
365
366 #[test]
367 fn cvt_color_bgr_to_rgb_swaps_channels() {
368 let src = bgr(2, 1, vec![1, 2, 3, 10, 20, 30]);
371 let out = PureRustImageOps
372 .cvt_color(&src, ColorConversion::BgrToRgb)
373 .unwrap();
374 assert_eq!(out.pixel_format(), PixelFormat::Rgb8);
375 assert_eq!(out.data(), &[3, 2, 1, 30, 20, 10]);
376 }
377
378 #[test]
379 fn cvt_color_rgb_to_bgr_swaps_channels() {
380 let src = OwnedMatView::new(1, 1, PixelFormat::Rgb8, vec![1, 2, 3]).unwrap();
381 let out = PureRustImageOps
382 .cvt_color(&src, ColorConversion::RgbToBgr)
383 .unwrap();
384 assert_eq!(out.pixel_format(), PixelFormat::Bgr8);
385 assert_eq!(out.data(), &[3, 2, 1]);
386 }
387
388 #[test]
389 fn cvt_color_bgr_to_gray_uses_opencv_weights() {
390 let src = bgr(1, 1, vec![0, 0, 255]);
392 let out = PureRustImageOps
393 .cvt_color(&src, ColorConversion::BgrToGray)
394 .unwrap();
395 assert_eq!(out.pixel_format(), PixelFormat::Mono8);
396 assert_eq!(out.data(), &[76]);
397 }
398
399 #[test]
400 fn cvt_color_bgr_to_gray_all_channels_nonzero() {
401 let src = bgr(1, 1, vec![100, 200, 50]);
405 let out = PureRustImageOps
406 .cvt_color(&src, ColorConversion::BgrToGray)
407 .unwrap();
408 assert_eq!(out.data(), &[144]);
409 }
410
411 #[test]
412 fn cvt_color_gray_to_rgb_broadcasts() {
413 let src = mono(2, 1, vec![10, 200]);
414 let out = PureRustImageOps
415 .cvt_color(&src, ColorConversion::GrayToRgb)
416 .unwrap();
417 assert_eq!(out.pixel_format(), PixelFormat::Rgb8);
418 assert_eq!(out.data(), &[10, 10, 10, 200, 200, 200]);
419 }
420
421 #[test]
422 fn cvt_color_rejects_wrong_input_format() {
423 let src = mono(1, 1, vec![5]);
424 let err = PureRustImageOps
425 .cvt_color(&src, ColorConversion::BgrToRgb)
426 .unwrap_err();
427 assert!(matches!(err, ImageOpsError::UnsupportedConversion { .. }));
428 }
429
430 #[test]
431 fn count_non_zero_counts_bytes() {
432 let src = mono(3, 1, vec![0, 1, 2]);
435 assert_eq!(PureRustImageOps.count_non_zero(&src).unwrap(), 2);
436 }
437
438 #[test]
439 fn count_non_zero_rejects_non_mono() {
440 let src = bgr(1, 1, vec![0, 0, 0]);
441 let err = PureRustImageOps.count_non_zero(&src).unwrap_err();
442 assert!(matches!(err, ImageOpsError::UnsupportedPixelFormat(_)));
443 }
444
445 #[test]
446 fn min_max_loc_returns_first_occurrence() {
447 let src = mono(3, 2, vec![5, 7, 1, 3, 7, 1]);
450 let r = PureRustImageOps.min_max_loc(&src).unwrap();
451 assert_eq!(r.min, 1.0);
452 assert_eq!(r.max, 7.0);
453 assert_eq!(r.min_loc, (2, 0));
454 assert_eq!(r.max_loc, (1, 0));
455 }
456
457 #[test]
458 fn min_max_loc_first_occurrence_with_duplicates_and_multi_row() {
459 let src = mono(4, 2, vec![5, 5, 7, 7, 1, 1, 1, 8]);
461 let r = PureRustImageOps.min_max_loc(&src).unwrap();
462 assert_eq!(r.min, 1.0);
463 assert_eq!(r.min_loc, (0, 1));
464 assert_eq!(r.max, 8.0);
465 assert_eq!(r.max_loc, (3, 1));
466 }
467
468 #[test]
469 fn min_max_loc_empty_input_errors() {
470 let src = mono(0, 0, vec![]);
471 let err = PureRustImageOps.min_max_loc(&src).unwrap_err();
472 assert!(matches!(err, ImageOpsError::EmptyInput));
473 }
474
475 #[test]
476 fn min_max_loc_rejects_non_mono() {
477 let src = bgr(1, 1, vec![1, 2, 3]);
478 let err = PureRustImageOps.min_max_loc(&src).unwrap_err();
479 assert!(matches!(err, ImageOpsError::UnsupportedPixelFormat(_)));
480 }
481
482 #[test]
483 fn convert_scale_abs_scales_and_saturates() {
484 let src = mono(3, 1, vec![10, 20, 240]);
486 let out = PureRustImageOps.convert_scale_abs(&src, 0.5, 0.0).unwrap();
487 assert_eq!(out.data(), &[5, 10, 120]);
488 }
489
490 #[test]
491 fn convert_scale_abs_takes_absolute_value() {
492 let src = mono(1, 1, vec![10]);
494 let out = PureRustImageOps.convert_scale_abs(&src, -2.0, 0.0).unwrap();
495 assert_eq!(out.data(), &[20]);
496 }
497
498 #[test]
499 fn convert_scale_abs_requires_abs_for_negative_intermediate() {
500 let src = mono(1, 1, vec![255]);
503 let out = PureRustImageOps
504 .convert_scale_abs(&src, -1.0, -100.0)
505 .unwrap();
506 assert_eq!(out.data(), &[255]);
507 }
508
509 #[test]
510 fn convert_scale_abs_saturates_high() {
511 let src = mono(1, 1, vec![200]);
512 let out = PureRustImageOps.convert_scale_abs(&src, 2.0, 0.0).unwrap();
513 assert_eq!(out.data(), &[255]);
514 }
515
516 #[test]
517 fn gaussian_blur_returns_backend_error() {
518 let src = mono(1, 1, vec![0]);
519 let err = PureRustImageOps
520 .gaussian_blur(&src, (3, 3), 0.0, 0.0)
521 .unwrap_err();
522 assert!(matches!(err, ImageOpsError::Backend(_)));
523 }
524
525 #[test]
526 fn resize_returns_backend_error() {
527 let src = mono(1, 1, vec![0]);
528 let err = PureRustImageOps.resize(&src, 2, 2).unwrap_err();
529 assert!(matches!(err, ImageOpsError::Backend(_)));
530 }
531}