oar_ocr/pipeline/oarocr/
image_processing.rs

1//! Image processing utilities for the OAROCR pipeline.
2
3use crate::core::OCRError;
4use crate::processors::BoundingBox;
5use crate::utils::transform::{Point2f, get_rotate_crop_image};
6use image::{RgbImage, imageops};
7
8/// Image processing utilities for the OAROCR pipeline.
9pub struct ImageProcessor;
10
11impl ImageProcessor {
12    /// Crops an image based on a bounding box.
13    ///
14    /// This function calculates the bounding rectangle of a polygonal bounding box
15    /// and crops the image to that region. It handles edge cases like empty bounding
16    /// boxes and ensures the crop region is within the image boundaries.
17    ///
18    /// # Arguments
19    ///
20    /// * `image` - The source image
21    /// * `bbox` - The bounding box defining the crop region
22    ///
23    /// # Returns
24    ///
25    /// A Result containing the cropped image or an OCRError
26    pub fn crop_bounding_box(image: &RgbImage, bbox: &BoundingBox) -> Result<RgbImage, OCRError> {
27        // Check if the bounding box is empty
28        if bbox.points.is_empty() {
29            return Err(OCRError::image_processing_error("Empty bounding box"));
30        }
31
32        // Calculate the bounding rectangle of the polygon
33        let min_x = bbox
34            .points
35            .iter()
36            .map(|p| p.x)
37            .fold(f32::INFINITY, f32::min)
38            .max(0.0);
39        let max_x = bbox
40            .points
41            .iter()
42            .map(|p| p.x)
43            .fold(f32::NEG_INFINITY, f32::max);
44        let min_y = bbox
45            .points
46            .iter()
47            .map(|p| p.y)
48            .fold(f32::INFINITY, f32::min)
49            .max(0.0);
50        let max_y = bbox
51            .points
52            .iter()
53            .map(|p| p.y)
54            .fold(f32::NEG_INFINITY, f32::max);
55
56        // Convert to integer coordinates, ensuring they're within image bounds
57        let x1 = (min_x as u32).min(image.width().saturating_sub(1));
58        let y1 = (min_y as u32).min(image.height().saturating_sub(1));
59        let x2 = (max_x as u32).min(image.width());
60        let y2 = (max_y as u32).min(image.height());
61
62        // Validate the crop region
63        if x2 <= x1 || y2 <= y1 {
64            return Err(OCRError::image_processing_error(format!(
65                "Invalid crop region: ({x1}, {y1}) to ({x2}, {y2})"
66            )));
67        }
68
69        let coords = (x1, y1, x2, y2);
70        Ok(Self::slice_rgb_image(image, coords))
71    }
72
73    /// Slices an RGB image based on coordinates.
74    ///
75    /// This function creates a new image by copying pixels from a rectangular
76    /// region of the source image. It performs bounds checking to ensure
77    /// that only valid pixels are copied.
78    ///
79    /// # Arguments
80    ///
81    /// * `img` - The source image
82    /// * `coords` - The coordinates as (x1, y1, x2, y2)
83    ///
84    /// # Returns
85    ///
86    /// The sliced image
87    fn slice_rgb_image(img: &RgbImage, coords: (u32, u32, u32, u32)) -> RgbImage {
88        let (x1, y1, x2, y2) = coords;
89        let width = x2 - x1;
90        let height = y2 - y1;
91        // Use library-provided immutable crop (zero-copy view) and then materialize
92        imageops::crop_imm(img, x1, y1, width, height).to_image()
93    }
94
95    /// Efficiently crops multiple bounding boxes from the same source image.
96    ///
97    /// This function is optimized for batch cropping operations, such as extracting
98    /// multiple text regions from a document image. It processes all bounding boxes
99    /// in a single pass and uses efficient cropping operations.
100    ///
101    /// # Arguments
102    ///
103    /// * `image` - The source image
104    /// * `bboxes` - A slice of bounding boxes to crop
105    ///
106    /// # Returns
107    ///
108    /// A vector of Results, each containing either a cropped image or an OCRError.
109    /// The order corresponds to the input bounding boxes.
110    pub fn batch_crop_bounding_boxes(
111        image: &RgbImage,
112        bboxes: &[BoundingBox],
113    ) -> Vec<Result<RgbImage, OCRError>> {
114        bboxes
115            .iter()
116            .map(|bbox| Self::crop_bounding_box(image, bbox))
117            .collect()
118    }
119
120    /// Efficiently crops multiple rotated bounding boxes from the same source image.
121    ///
122    /// This function is optimized for batch cropping operations with perspective correction.
123    ///
124    /// # Arguments
125    ///
126    /// * `image` - The source image
127    /// * `bboxes` - A slice of bounding boxes to crop with rotation
128    ///
129    /// # Returns
130    ///
131    /// A vector of Results, each containing either a cropped image or an OCRError.
132    /// The order corresponds to the input bounding boxes.
133    #[allow(dead_code)]
134    pub fn batch_crop_rotated_bounding_boxes(
135        image: &RgbImage,
136        bboxes: &[BoundingBox],
137    ) -> Vec<Result<RgbImage, OCRError>> {
138        bboxes
139            .iter()
140            .map(|bbox| Self::crop_rotated_bounding_box(image, bbox))
141            .collect()
142    }
143
144    /// Crops and rectifies an image region using rotated crop with perspective transformation.
145    ///
146    /// This function implements the same functionality as OpenCV's GetRotateCropImage.
147    /// It takes a bounding box (quadrilateral) and applies perspective transformation
148    /// to rectify it into a rectangular image. This is particularly useful for text
149    /// regions that may be rotated or have perspective distortion.
150    ///
151    /// # Arguments
152    ///
153    /// * `image` - The source image
154    /// * `bbox` - The bounding box defining the quadrilateral region
155    ///
156    /// # Returns
157    ///
158    /// A Result containing the rotated and cropped image or an OCRError
159    pub fn crop_rotated_bounding_box(
160        image: &RgbImage,
161        bbox: &BoundingBox,
162    ) -> Result<RgbImage, OCRError> {
163        // Check if the bounding box has exactly 4 points
164        if bbox.points.len() != 4 {
165            return Err(OCRError::image_processing_error(format!(
166                "Bounding box must have exactly 4 points, got {}",
167                bbox.points.len()
168            )));
169        }
170
171        // Convert BoundingBox points to Point2f
172        let box_points: Vec<Point2f> = bbox.points.iter().map(|p| Point2f::new(p.x, p.y)).collect();
173
174        // Fast path: if the quadrilateral is axis-aligned rectangle, use simple crop
175        if let [p0, p1, p2, p3] = &box_points[..] {
176            let is_axis_aligned = (p0.y == p1.y && p2.y == p3.y && p0.x == p3.x && p1.x == p2.x)
177                || (p0.x == p1.x && p2.x == p3.x && p0.y == p3.y && p1.y == p2.y);
178            if is_axis_aligned {
179                let min_x = p0.x.min(p1.x).min(p2.x).min(p3.x).max(0.0) as u32;
180                let min_y = p0.y.min(p1.y).min(p2.y).min(p3.y).max(0.0) as u32;
181                let max_x = p0.x.max(p1.x).max(p2.x).max(p3.x).min(image.width() as f32) as u32;
182                let max_y =
183                    p0.y.max(p1.y)
184                        .max(p2.y)
185                        .max(p3.y)
186                        .min(image.height() as f32) as u32;
187                if max_x > min_x && max_y > min_y {
188                    use image::imageops;
189                    let w = max_x - min_x;
190                    let h = max_y - min_y;
191                    return Ok(imageops::crop_imm(image, min_x, min_y, w, h).to_image());
192                }
193            }
194        }
195
196        // Apply rotated crop transformation
197        get_rotate_crop_image(image, &box_points)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::processors::Point;
205    use image::{ImageBuffer, Rgb};
206
207    fn create_test_image(width: u32, height: u32) -> RgbImage {
208        let mut img = ImageBuffer::new(width, height);
209        for y in 0..height {
210            for x in 0..width {
211                // Create a pattern for testing
212                let r = (x * 255 / width.max(1)) as u8;
213                let g = (y * 255 / height.max(1)) as u8;
214                let b = 128;
215                img.put_pixel(x, y, Rgb([r, g, b]));
216            }
217        }
218        img
219    }
220
221    #[test]
222    fn test_crop_bounding_box_valid_rectangle() {
223        let img = create_test_image(100, 100);
224        let bbox = BoundingBox {
225            points: vec![
226                Point { x: 10.0, y: 10.0 },
227                Point { x: 50.0, y: 10.0 },
228                Point { x: 50.0, y: 40.0 },
229                Point { x: 10.0, y: 40.0 },
230            ],
231        };
232
233        let result = ImageProcessor::crop_bounding_box(&img, &bbox);
234        assert!(result.is_ok());
235
236        let cropped = result.unwrap();
237        assert_eq!(cropped.width(), 40); // 50 - 10
238        assert_eq!(cropped.height(), 30); // 40 - 10
239    }
240
241    #[test]
242    fn test_crop_bounding_box_empty_points() {
243        let img = create_test_image(100, 100);
244        let bbox = BoundingBox { points: vec![] };
245
246        let result = ImageProcessor::crop_bounding_box(&img, &bbox);
247        assert!(result.is_err());
248
249        let error_msg = result.unwrap_err().to_string();
250        assert!(error_msg.contains("Empty bounding box"));
251    }
252
253    #[test]
254    fn test_crop_bounding_box_single_point() {
255        let img = create_test_image(100, 100);
256        let bbox = BoundingBox {
257            points: vec![Point { x: 50.0, y: 50.0 }],
258        };
259
260        let result = ImageProcessor::crop_bounding_box(&img, &bbox);
261        assert!(result.is_err());
262
263        let error_msg = result.unwrap_err().to_string();
264        assert!(error_msg.contains("Invalid crop region"));
265    }
266
267    #[test]
268    fn test_crop_bounding_box_negative_coordinates() {
269        let img = create_test_image(100, 100);
270        let bbox = BoundingBox {
271            points: vec![
272                Point { x: -10.0, y: -5.0 },
273                Point { x: 30.0, y: -5.0 },
274                Point { x: 30.0, y: 25.0 },
275                Point { x: -10.0, y: 25.0 },
276            ],
277        };
278
279        let result = ImageProcessor::crop_bounding_box(&img, &bbox);
280        assert!(result.is_ok());
281
282        let cropped = result.unwrap();
283        // Should clamp negative coordinates to 0
284        assert_eq!(cropped.width(), 30); // 30 - 0 (clamped from -10)
285        assert_eq!(cropped.height(), 25); // 25 - 0 (clamped from -5)
286    }
287
288    #[test]
289    fn test_crop_bounding_box_out_of_bounds() {
290        let img = create_test_image(100, 100);
291        let bbox = BoundingBox {
292            points: vec![
293                Point { x: 80.0, y: 80.0 },
294                Point { x: 150.0, y: 80.0 },  // Beyond image width
295                Point { x: 150.0, y: 120.0 }, // Beyond image height
296                Point { x: 80.0, y: 120.0 },
297            ],
298        };
299
300        let result = ImageProcessor::crop_bounding_box(&img, &bbox);
301        assert!(result.is_ok());
302
303        let cropped = result.unwrap();
304        // Should clamp to image boundaries
305        assert_eq!(cropped.width(), 20); // 100 - 80
306        assert_eq!(cropped.height(), 20); // 100 - 80
307    }
308
309    #[test]
310    fn test_crop_bounding_box_irregular_polygon() {
311        let img = create_test_image(100, 100);
312        let bbox = BoundingBox {
313            points: vec![
314                Point { x: 20.0, y: 30.0 },
315                Point { x: 60.0, y: 10.0 },
316                Point { x: 80.0, y: 50.0 },
317                Point { x: 40.0, y: 70.0 },
318                Point { x: 10.0, y: 40.0 },
319            ],
320        };
321
322        let result = ImageProcessor::crop_bounding_box(&img, &bbox);
323        assert!(result.is_ok());
324
325        let cropped = result.unwrap();
326        // Should use bounding rectangle of the polygon
327        assert_eq!(cropped.width(), 70); // 80 - 10
328        assert_eq!(cropped.height(), 60); // 70 - 10
329    }
330
331    #[test]
332    fn test_crop_rotated_bounding_box_valid() {
333        let img = create_test_image(100, 100);
334        let bbox = BoundingBox {
335            points: vec![
336                Point { x: 20.0, y: 20.0 },
337                Point { x: 60.0, y: 20.0 },
338                Point { x: 60.0, y: 40.0 },
339                Point { x: 20.0, y: 40.0 },
340            ],
341        };
342
343        let result = ImageProcessor::crop_rotated_bounding_box(&img, &bbox);
344        assert!(result.is_ok());
345
346        let cropped = result.unwrap();
347        assert!(cropped.width() > 0);
348        assert!(cropped.height() > 0);
349    }
350
351    #[test]
352    fn test_crop_rotated_bounding_box_wrong_point_count() {
353        let img = create_test_image(100, 100);
354        let bbox = BoundingBox {
355            points: vec![
356                Point { x: 20.0, y: 20.0 },
357                Point { x: 60.0, y: 20.0 },
358                Point { x: 60.0, y: 40.0 },
359            ], // Only 3 points instead of 4
360        };
361
362        let result = ImageProcessor::crop_rotated_bounding_box(&img, &bbox);
363        assert!(result.is_err());
364
365        let error_msg = result.unwrap_err().to_string();
366        assert!(error_msg.contains("must have exactly 4 points"));
367    }
368
369    #[test]
370    fn test_crop_rotated_bounding_box_axis_aligned_fast_path() {
371        let img = create_test_image(100, 100);
372        // Define an axis-aligned rectangle with 4 points
373        let bbox = BoundingBox {
374            points: vec![
375                Point { x: 10.0, y: 20.0 },
376                Point { x: 60.0, y: 20.0 },
377                Point { x: 60.0, y: 50.0 },
378                Point { x: 10.0, y: 50.0 },
379            ],
380        };
381        let cropped_fast = ImageProcessor::crop_rotated_bounding_box(&img, &bbox).unwrap();
382        // Expected via simple crop
383        let expected = imageops::crop_imm(&img, 10, 20, 50, 30).to_image();
384        assert_eq!(cropped_fast.dimensions(), expected.dimensions());
385        // Sample a couple of pixels to ensure identical content
386        assert_eq!(cropped_fast.get_pixel(0, 0), expected.get_pixel(0, 0));
387        assert_eq!(cropped_fast.get_pixel(49, 29), expected.get_pixel(49, 29));
388    }
389
390    #[test]
391    fn test_slice_rgb_image() {
392        let img = create_test_image(100, 100);
393        let coords = (10, 20, 50, 60);
394
395        let sliced = ImageProcessor::slice_rgb_image(&img, coords);
396        assert_eq!(sliced.width(), 40); // 50 - 10
397        assert_eq!(sliced.height(), 40); // 60 - 20
398
399        // Check that the pixel values are correctly copied
400        let original_pixel = img.get_pixel(10, 20);
401        let sliced_pixel = sliced.get_pixel(0, 0);
402        assert_eq!(original_pixel, sliced_pixel);
403    }
404}