Skip to main content

opencv_rs_core/
image_ops.rs

1//! Image operations port and its pure-Rust implementation.
2
3use crate::{ImageOpsError, MatView, OwnedMatView, PixelFormat};
4
5/// Supported color-space conversions.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ColorConversion {
8    /// Swap channels 0 and 2 of a 3-channel BGR image to produce RGB.
9    BgrToRgb,
10    /// Swap channels 0 and 2 of a 3-channel RGB image to produce BGR.
11    RgbToBgr,
12    /// Convert a BGR image to single-channel grayscale using OpenCV weights.
13    BgrToGray,
14    /// Broadcast a grayscale image to 3-channel RGB.
15    GrayToRgb,
16}
17
18/// Thresholding algorithm selector.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ThresholdKind {
21    /// Binary threshold: pixels strictly greater than `thresh` become `max_val`, others become 0.
22    Binary,
23}
24
25/// Result of [`ImageOpsPort::min_max_loc`].
26#[derive(Debug, Clone, Copy)]
27pub struct MinMaxResult {
28    /// Minimum pixel value encountered.
29    pub min: f64,
30    /// Maximum pixel value encountered.
31    pub max: f64,
32    /// Location `(x, y)` of the first pixel equal to [`Self::min`].
33    pub min_loc: (u32, u32),
34    /// Location `(x, y)` of the first pixel equal to [`Self::max`].
35    pub max_loc: (u32, u32),
36}
37
38/// Port exposing element-wise and reduction image operations.
39pub trait ImageOpsPort: Send + Sync {
40    /// Convert `src` between color spaces per [`ColorConversion`].
41    fn cvt_color(
42        &self,
43        src: &dyn MatView,
44        conv: ColorConversion,
45    ) -> Result<OwnedMatView, ImageOpsError>;
46    /// Apply a Gaussian blur with the given kernel size and sigmas.
47    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    /// Threshold a grayscale image.
55    fn threshold(
56        &self,
57        src: &dyn MatView,
58        thresh: f64,
59        max_val: f64,
60        kind: ThresholdKind,
61    ) -> Result<OwnedMatView, ImageOpsError>;
62    /// Element-wise absolute difference of two identically shaped images.
63    fn absdiff(&self, lhs: &dyn MatView, rhs: &dyn MatView) -> Result<OwnedMatView, ImageOpsError>;
64    /// Element-wise `saturate_u8(|src * scale + offset|)`.
65    fn convert_scale_abs(
66        &self,
67        src: &dyn MatView,
68        scale: f64,
69        offset: f64,
70    ) -> Result<OwnedMatView, ImageOpsError>;
71    /// Locate the minimum and maximum pixel values and their first positions.
72    fn min_max_loc(&self, src: &dyn MatView) -> Result<MinMaxResult, ImageOpsError>;
73    /// Count non-zero pixels in a grayscale image.
74    fn count_non_zero(&self, src: &dyn MatView) -> Result<u64, ImageOpsError>;
75    /// Resize `src` to the given dimensions.
76    fn resize(
77        &self,
78        src: &dyn MatView,
79        new_width: u32,
80        new_height: u32,
81    ) -> Result<OwnedMatView, ImageOpsError>;
82}
83
84/// Pure-Rust implementation of element-wise and reduction [`ImageOpsPort`] methods.
85///
86/// `gaussian_blur` and `resize` return
87/// `ImageOpsError::Backend("unsupported in PureRustImageOps: requires OpenCV backend")`;
88/// they are outside scope for pure-Rust reimplementation per issue #1.
89#[derive(Debug, Default, Clone, Copy)]
90pub struct PureRustImageOps;
91
92/// Saturating rounding conversion of `v` to `u8`.
93fn 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        // Pixel exactly at threshold must NOT exceed it (original uses `>`).
305        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        // Bgr8 and Rgb8 both have channels=3, so dims() tuples match; only
348        // pixel_format differs. Kills the `||` -> `&&` mutant on the guard.
349        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        // 4x1 and 2x2 Mono8 both have 4 bytes, so the length fallback check
358        // would accept them. Kills the `dims -> const` mutants by forcing the
359        // dims() inequality check to be the decisive gate.
360        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        // Multi-pixel input so the per-chunk offset `i * 3` varies with i;
369        // kills the `*` -> `/` mutant on the offset calculation.
370        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        // Pure red in BGR is (0,0,255); gray = 0.299 * 255 = 76.245 -> 76.
391        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        // All three weights contribute distinctly; kills the arithmetic
402        // mutants on the weighted sum (+ <-> -, + <-> *, * <-> +).
403        // 0.114*100 + 0.587*200 + 0.299*50 = 11.4 + 117.4 + 14.95 = 143.75 -> 144.
404        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        // Asymmetric zero/non-zero split so the `!= 0` filter cannot be
433        // mutated to `== 0` without changing the expected result.
434        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        // 3x2 image, row-major: values 5, 7, 1, 3, 7, 1.
448        // min=1 first at idx 2 -> (x=2, y=0); max=7 first at idx 1 -> (x=1, y=0).
449        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        // Min value 1 appears at idx 4,5,6 -> first at (0,1). Max value 8 only at idx 7 -> (3,1).
460        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        // 10*0.5=5; 20*0.5=10; 240*0.5=120. Abs no-op for non-negative.
485        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        // -1 is impossible for u8, so use negative scale. 10 * -2 = -20, abs=20.
493        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        // 255 * -1 + -100 = -355; abs() -> 355 -> saturates to 255.
501        // Without abs() the result would saturate to 0.
502        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}