1use crate::core::OCRError;
9use crate::core::errors::ImageProcessError;
10use image::{DynamicImage, GrayImage, ImageBuffer, ImageError, ImageReader, RgbImage};
11use std::fs::File;
12use std::io::BufReader;
13use std::path::Path;
14
15pub fn dynamic_to_rgb(img: DynamicImage) -> RgbImage {
28 img.to_rgb8()
29}
30
31pub fn dynamic_to_gray(img: DynamicImage) -> GrayImage {
44 img.to_luma8()
45}
46
47pub fn load_image_from_memory(bytes: &[u8]) -> Result<RgbImage, OCRError> {
66 let img = image::load_from_memory(bytes).map_err(OCRError::ImageLoad)?;
67 Ok(dynamic_to_rgb(img))
68}
69
70pub fn load_image<P: AsRef<Path>>(path: P) -> Result<RgbImage, OCRError> {
89 let img = open_image_any_format(path.as_ref()).map_err(OCRError::ImageLoad)?;
90 Ok(dynamic_to_rgb(img))
91}
92
93fn open_image_any_format(path: &Path) -> Result<DynamicImage, ImageError> {
94 match image::open(path) {
95 Ok(img) => Ok(img),
96 Err(err) if should_retry(&err) => {
97 tracing::warn!(
98 "Standard decode failed for {} ({err}). Retrying with format sniffing.",
99 path.display()
100 );
101 decode_with_guessed_format(path)
102 }
103 Err(err) => Err(err),
104 }
105}
106
107fn should_retry(err: &ImageError) -> bool {
108 matches!(err, ImageError::Decoding(_) | ImageError::Unsupported(_))
109}
110
111fn decode_with_guessed_format(path: &Path) -> Result<DynamicImage, ImageError> {
112 let file = File::open(path)?;
113 let reader = BufReader::new(file);
114 let reader = ImageReader::new(reader).with_guessed_format()?;
115 reader.decode()
116}
117
118pub fn create_rgb_image(width: u32, height: u32, data: Vec<u8>) -> Option<RgbImage> {
135 if data.len() != (width * height * 3) as usize {
136 return None;
137 }
138
139 ImageBuffer::from_raw(width, height, data)
140}
141
142pub fn check_image_size(size: &[u32; 2]) -> Result<(), ImageProcessError> {
144 if size[0] == 0 || size[1] == 0 {
145 return Err(ImageProcessError::InvalidCropSize);
146 }
147 Ok(())
148}
149
150pub fn slice_image(
152 img: &RgbImage,
153 coords: (u32, u32, u32, u32),
154) -> Result<RgbImage, ImageProcessError> {
155 let (x1, y1, x2, y2) = coords;
156 let (img_width, img_height) = img.dimensions();
157
158 if x1 >= x2 || y1 >= y2 {
159 return Err(ImageProcessError::InvalidCropCoordinates);
160 }
161
162 if x2 > img_width || y2 > img_height {
163 return Err(ImageProcessError::CropOutOfBounds);
164 }
165
166 let crop_width = x2 - x1;
167 let crop_height = y2 - y1;
168
169 Ok(image::imageops::crop_imm(img, x1, y1, crop_width, crop_height).to_image())
170}
171
172pub fn slice_gray_image(
174 img: &GrayImage,
175 coords: (u32, u32, u32, u32),
176) -> Result<GrayImage, ImageProcessError> {
177 let (x1, y1, x2, y2) = coords;
178 let (img_width, img_height) = img.dimensions();
179
180 if x1 >= x2 || y1 >= y2 {
181 return Err(ImageProcessError::InvalidCropCoordinates);
182 }
183
184 if x2 > img_width || y2 > img_height {
185 return Err(ImageProcessError::CropOutOfBounds);
186 }
187
188 let crop_width = x2 - x1;
189 let crop_height = y2 - y1;
190
191 Ok(image::imageops::crop_imm(img, x1, y1, crop_width, crop_height).to_image())
192}
193
194pub fn calculate_center_crop_coords(
196 img_width: u32,
197 img_height: u32,
198 crop_width: u32,
199 crop_height: u32,
200) -> Result<(u32, u32), ImageProcessError> {
201 if crop_width > img_width || crop_height > img_height {
202 return Err(ImageProcessError::CropSizeTooLarge);
203 }
204
205 let x = (img_width - crop_width) / 2;
206 let y = (img_height - crop_height) / 2;
207
208 Ok((x, y))
209}
210
211pub fn validate_crop_bounds(
213 img_width: u32,
214 img_height: u32,
215 x: u32,
216 y: u32,
217 crop_width: u32,
218 crop_height: u32,
219) -> Result<(), ImageProcessError> {
220 if x + crop_width > img_width || y + crop_height > img_height {
221 return Err(ImageProcessError::CropOutOfBounds);
222 }
223 Ok(())
224}
225
226pub fn resize_image(
232 img: &RgbImage,
233 width: u32,
234 height: u32,
235) -> Result<RgbImage, ImageProcessError> {
236 if width == 0 || height == 0 {
237 return Err(ImageProcessError::InvalidCropSize);
238 }
239 Ok(image::imageops::resize(
240 img,
241 width,
242 height,
243 image::imageops::FilterType::Lanczos3,
244 ))
245}
246
247pub fn resize_gray_image(
253 img: &GrayImage,
254 width: u32,
255 height: u32,
256) -> Result<GrayImage, ImageProcessError> {
257 if width == 0 || height == 0 {
258 return Err(ImageProcessError::InvalidCropSize);
259 }
260 Ok(image::imageops::resize(
261 img,
262 width,
263 height,
264 image::imageops::FilterType::Lanczos3,
265 ))
266}
267
268pub fn rgb_to_grayscale(img: &RgbImage) -> GrayImage {
270 image::imageops::grayscale(img)
271}
272
273pub fn pad_image(
275 img: &RgbImage,
276 target_width: u32,
277 target_height: u32,
278 fill_color: [u8; 3],
279) -> Result<RgbImage, ImageProcessError> {
280 let (src_width, src_height) = img.dimensions();
281
282 if target_width < src_width || target_height < src_height {
283 return Err(ImageProcessError::InvalidCropSize);
284 }
285
286 if target_width == src_width && target_height == src_height {
287 return Ok(img.clone());
288 }
289
290 let mut padded = RgbImage::from_pixel(target_width, target_height, image::Rgb(fill_color));
291 let x_offset = (target_width - src_width) / 2;
292 let y_offset = (target_height - src_height) / 2;
293 image::imageops::overlay(&mut padded, img, x_offset as i64, y_offset as i64);
294
295 Ok(padded)
296}
297
298pub fn load_images<P: AsRef<std::path::Path> + Send + Sync>(
318 paths: &[P],
319) -> Result<Vec<RgbImage>, OCRError> {
320 load_images_batch_with_threshold(paths, None)
321}
322
323pub fn load_images_batch_with_threshold<P: AsRef<std::path::Path> + Send + Sync>(
346 paths: &[P],
347 parallel_threshold: Option<usize>,
348) -> Result<Vec<RgbImage>, OCRError> {
349 use crate::core::constants::DEFAULT_PARALLEL_THRESHOLD;
350
351 let threshold = parallel_threshold.unwrap_or(DEFAULT_PARALLEL_THRESHOLD);
352
353 if paths.len() > threshold {
354 use rayon::prelude::*;
355 paths.par_iter().map(|p| load_image(p.as_ref())).collect()
356 } else {
357 paths.iter().map(|p| load_image(p.as_ref())).collect()
358 }
359}
360
361pub fn load_images_batch_with_policy<P: AsRef<std::path::Path> + Send + Sync>(
382 paths: &[P],
383 policy: &crate::core::config::ParallelPolicy,
384) -> Result<Vec<RgbImage>, OCRError> {
385 if paths.len() > policy.utility_threshold {
386 use rayon::prelude::*;
387 paths.par_iter().map(|p| load_image(p.as_ref())).collect()
388 } else {
389 paths.iter().map(|p| load_image(p.as_ref())).collect()
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Default)]
395pub enum PaddingStrategy {
396 SolidColor([u8; 3]),
398 #[default]
400 Black,
401 LeftAlign([u8; 3]),
403}
404
405#[derive(Debug, Clone)]
407pub struct ResizePadConfig {
408 pub target_dims: (u32, u32),
410 pub padding_strategy: PaddingStrategy,
412 pub filter_type: image::imageops::FilterType,
414}
415
416impl ResizePadConfig {
417 pub fn new(target_dims: (u32, u32)) -> Self {
419 Self {
420 target_dims,
421 padding_strategy: PaddingStrategy::default(),
422 filter_type: image::imageops::FilterType::Triangle,
423 }
424 }
425
426 pub fn with_padding_strategy(mut self, strategy: PaddingStrategy) -> Self {
428 self.padding_strategy = strategy;
429 self
430 }
431
432 pub fn with_filter_type(mut self, filter_type: image::imageops::FilterType) -> Self {
434 self.filter_type = filter_type;
435 self
436 }
437}
438
439pub fn resize_and_pad(
458 image: &RgbImage,
459 config: &ResizePadConfig,
460) -> Result<RgbImage, ImageProcessError> {
461 let (target_width, target_height) = config.target_dims;
462
463 if target_width == 0 || target_height == 0 {
464 return Err(ImageProcessError::InvalidCropSize);
465 }
466
467 let (orig_width, orig_height) = image.dimensions();
468
469 let scale_w = target_width as f32 / orig_width as f32;
471 let scale_h = target_height as f32 / orig_height as f32;
472 let scale = scale_w.min(scale_h);
473
474 let new_width = (orig_width as f32 * scale) as u32;
476 let new_height = (orig_height as f32 * scale) as u32;
477
478 let resized = image::imageops::resize(image, new_width, new_height, config.filter_type);
480
481 let padding_color = match config.padding_strategy {
483 PaddingStrategy::SolidColor(color) => color,
484 PaddingStrategy::Black => [0, 0, 0],
485 PaddingStrategy::LeftAlign(color) => color,
486 };
487 let padding_rgb = image::Rgb(padding_color);
488 let mut padded = ImageBuffer::from_pixel(target_width, target_height, padding_rgb);
489
490 let (pad_x, pad_y) = match config.padding_strategy {
492 PaddingStrategy::LeftAlign(_) => (0, 0),
493 _ => {
494 let pad_x = (target_width - new_width) / 2;
496 let pad_y = (target_height - new_height) / 2;
497 (pad_x, pad_y)
498 }
499 };
500
501 image::imageops::overlay(&mut padded, &resized, pad_x as i64, pad_y as i64);
503
504 Ok(padded)
505}
506
507#[derive(Debug, Clone)]
509pub struct OCRResizePadConfig {
510 pub target_height: u32,
512 pub max_width: u32,
514 pub padding_strategy: PaddingStrategy,
516 pub filter_type: image::imageops::FilterType,
518}
519
520impl OCRResizePadConfig {
521 pub fn new(target_height: u32, max_width: u32) -> Self {
525 Self {
526 target_height,
527 max_width,
528 padding_strategy: PaddingStrategy::default(),
529 filter_type: image::imageops::FilterType::Triangle,
531 }
532 }
533
534 pub fn with_padding_strategy(mut self, strategy: PaddingStrategy) -> Self {
536 self.padding_strategy = strategy;
537 self
538 }
539
540 pub fn with_filter_type(mut self, filter_type: image::imageops::FilterType) -> Self {
542 self.filter_type = filter_type;
543 self
544 }
545}
546
547pub fn ocr_resize_and_pad(
570 image: &RgbImage,
571 config: &OCRResizePadConfig,
572 target_width_ratio: Option<f32>,
573) -> Result<(RgbImage, u32), ImageProcessError> {
574 if config.target_height == 0 {
575 return Err(ImageProcessError::InvalidCropSize);
576 }
577
578 let (original_w, original_h) = image.dimensions();
579 let original_ratio = original_w as f32 / original_h as f32;
580
581 let mut target_w = if let Some(ratio) = target_width_ratio {
583 (config.target_height as f32 * ratio) as u32
584 } else {
585 (config.target_height as f32 * original_ratio).ceil() as u32
586 };
587
588 let resized_w = if target_w > config.max_width {
590 target_w = config.max_width;
591 config.max_width
592 } else {
593 let ratio = original_w as f32 / original_h as f32;
595 if (config.target_height as f32 * ratio).ceil() as u32 > target_w {
596 target_w
597 } else {
598 (config.target_height as f32 * ratio).ceil() as u32
599 }
600 };
601
602 let resized_image =
604 image::imageops::resize(image, resized_w, config.target_height, config.filter_type);
605
606 let padding_color = match config.padding_strategy {
608 PaddingStrategy::SolidColor(color) => color,
609 PaddingStrategy::Black => [0, 0, 0],
610 PaddingStrategy::LeftAlign(color) => color,
611 };
612 let padding_rgb = image::Rgb(padding_color);
613 let mut padded_image = ImageBuffer::from_pixel(target_w, config.target_height, padding_rgb);
614
615 image::imageops::overlay(&mut padded_image, &resized_image, 0, 0);
617
618 Ok((padded_image, target_w))
619}
620
621pub fn resize_images_batch(
650 images: &[RgbImage],
651 target_width: u32,
652 target_height: u32,
653 filter_type: Option<image::imageops::FilterType>,
654) -> Vec<RgbImage> {
655 let filter = filter_type.unwrap_or(image::imageops::FilterType::Lanczos3);
656
657 images
658 .iter()
659 .map(|img| image::imageops::resize(img, target_width, target_height, filter))
660 .collect()
661}
662
663pub fn resize_images_batch_to_dynamic(
679 images: &[RgbImage],
680 target_width: u32,
681 target_height: u32,
682 filter_type: Option<image::imageops::FilterType>,
683) -> Vec<DynamicImage> {
684 let filter = filter_type.unwrap_or(image::imageops::FilterType::Lanczos3);
685
686 images
687 .iter()
688 .map(|img| {
689 let resized = image::imageops::resize(img, target_width, target_height, filter);
690 DynamicImage::ImageRgb8(resized)
691 })
692 .collect()
693}
694
695pub fn mask_region(
728 image: &mut RgbImage,
729 x1: u32,
730 y1: u32,
731 x2: u32,
732 y2: u32,
733 fill_color: [u8; 3],
734) -> Result<(), ImageProcessError> {
735 let (img_width, img_height) = image.dimensions();
736
737 let x1 = x1.min(img_width);
739 let y1 = y1.min(img_height);
740 let x2 = x2.min(img_width);
741 let y2 = y2.min(img_height);
742
743 if x1 >= x2 || y1 >= y2 {
744 return Err(ImageProcessError::InvalidCropCoordinates);
745 }
746
747 let rgb = image::Rgb(fill_color);
748 for y in y1..y2 {
749 for x in x1..x2 {
750 image.put_pixel(x, y, rgb);
751 }
752 }
753
754 Ok(())
755}
756
757pub fn mask_regions(
785 image: &mut RgbImage,
786 bboxes: &[crate::processors::BoundingBox],
787 fill_color: [u8; 3],
788) {
789 for bbox in bboxes {
790 let x1 = bbox.x_min() as u32;
791 let y1 = bbox.y_min() as u32;
792 let x2 = bbox.x_max() as u32;
793 let y2 = bbox.y_max() as u32;
794
795 let _ = mask_region(image, x1, y1, x2, y2, fill_color);
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use ::image::{GenericImageView, GrayImage, ImageBuffer, Rgb, RgbImage};
804
805 fn create_test_image(width: u32, height: u32, color: [u8; 3]) -> RgbImage {
806 ImageBuffer::from_pixel(width, height, Rgb(color))
807 }
808
809 #[test]
810 fn basic_size_checks() {
811 assert!(check_image_size(&[100, 100]).is_ok());
812 assert!(check_image_size(&[0, 50]).is_err());
813 }
814
815 #[test]
816 fn slice_rgb_image_region() -> Result<(), ImageProcessError> {
817 let img = RgbImage::from_pixel(10, 10, Rgb([255, 0, 0]));
818 let cropped = slice_image(&img, (2, 2, 6, 6))?;
819 assert_eq!(cropped.dimensions(), (4, 4));
820 assert!(slice_image(&img, (6, 6, 2, 2)).is_err());
821 Ok(())
822 }
823
824 #[test]
825 fn slice_gray_image_region() -> Result<(), ImageProcessError> {
826 let img = GrayImage::from_pixel(10, 10, image::Luma([128]));
827 let cropped = slice_gray_image(&img, (1, 1, 5, 5))?;
828 assert_eq!(cropped.dimensions(), (4, 4));
829 Ok(())
830 }
831
832 #[test]
833 fn center_crop_coordinates() -> Result<(), ImageProcessError> {
834 let coords = calculate_center_crop_coords(100, 60, 40, 20)?;
835 assert_eq!(coords, (30, 20));
836 assert!(calculate_center_crop_coords(20, 20, 40, 10).is_err());
837 Ok(())
838 }
839
840 #[test]
841 fn crop_bounds_validation() {
842 assert!(validate_crop_bounds(100, 80, 10, 10, 40, 40).is_ok());
843 assert!(validate_crop_bounds(100, 80, 70, 10, 40, 40).is_err());
844 }
845
846 #[test]
847 fn pad_image_to_target() -> Result<(), ImageProcessError> {
848 let img = RgbImage::from_pixel(20, 20, Rgb([10, 20, 30]));
849 let padded = pad_image(&img, 40, 40, [0, 0, 0])?;
850 assert_eq!(padded.dimensions(), (40, 40));
851 assert!(pad_image(&img, 10, 10, [0, 0, 0]).is_err());
852 Ok(())
853 }
854
855 #[test]
856 fn test_resize_and_pad_with_custom_padding() -> Result<(), ImageProcessError> {
857 let image = create_test_image(50, 100, [255, 0, 0]); let config = ResizePadConfig::new((80, 80))
859 .with_padding_strategy(PaddingStrategy::SolidColor([0, 255, 0])); let result = resize_and_pad(&image, &config)?;
862
863 assert_eq!(result.dimensions(), (80, 80));
864
865 let center_pixel = result.get_pixel(40, 40); assert_eq!(*center_pixel, Rgb([255, 0, 0])); let left_padding = result.get_pixel(10, 40); assert_eq!(*left_padding, Rgb([0, 255, 0])); Ok(())
873 }
874
875 #[test]
876 fn test_resize_and_pad_left_align() -> Result<(), ImageProcessError> {
877 let image = create_test_image(50, 100, [0, 0, 255]); let config = ResizePadConfig::new((80, 80))
879 .with_padding_strategy(PaddingStrategy::LeftAlign([255, 255, 0])); let result = resize_and_pad(&image, &config)?;
882
883 assert_eq!(result.dimensions(), (80, 80));
884
885 let left_edge_pixel = result.get_pixel(20, 40); assert_eq!(*left_edge_pixel, Rgb([0, 0, 255])); let right_padding = result.get_pixel(60, 40); assert_eq!(*right_padding, Rgb([255, 255, 0])); Ok(())
892 }
893
894 #[test]
895 fn test_resize_images_batch() {
896 let img1 = create_test_image(100, 50, [255, 0, 0]); let img2 = create_test_image(200, 100, [0, 255, 0]); let images = vec![img1, img2];
900
901 let resized = resize_images_batch(&images, 64, 64, None);
903
904 assert_eq!(resized.len(), 2);
905 assert_eq!(resized[0].dimensions(), (64, 64));
906 assert_eq!(resized[1].dimensions(), (64, 64));
907
908 let pixel1 = resized[0].get_pixel(32, 32);
910 let pixel2 = resized[1].get_pixel(32, 32);
911
912 assert!(pixel1[0] > pixel1[1] && pixel1[0] > pixel1[2]);
914 assert!(pixel2[1] > pixel2[0] && pixel2[1] > pixel2[2]);
916 }
917
918 #[test]
919 fn test_resize_images_batch_to_dynamic() {
920 let img1 = create_test_image(100, 50, [255, 0, 0]);
922 let img2 = create_test_image(200, 100, [0, 255, 0]);
923 let images = vec![img1, img2];
924
925 let resized = resize_images_batch_to_dynamic(&images, 32, 32, None);
927
928 assert_eq!(resized.len(), 2);
929
930 for dynamic_img in &resized {
932 assert_eq!(dynamic_img.dimensions(), (32, 32));
933 assert!(
934 matches!(dynamic_img, DynamicImage::ImageRgb8(_)),
935 "Expected ImageRgb8 variant"
936 );
937 }
938 }
939
940 #[test]
941 fn test_resize_images_batch_empty() {
942 let images: Vec<RgbImage> = vec![];
943 let resized = resize_images_batch(&images, 64, 64, None);
944 assert!(resized.is_empty());
945 }
946
947 #[test]
948 fn test_resize_images_batch_custom_filter() {
949 let img = create_test_image(100, 100, [128, 128, 128]);
950 let images = vec![img];
951
952 let resized_lanczos =
954 resize_images_batch(&images, 50, 50, Some(image::imageops::FilterType::Lanczos3));
955 let resized_nearest =
956 resize_images_batch(&images, 50, 50, Some(image::imageops::FilterType::Nearest));
957
958 assert_eq!(resized_lanczos.len(), 1);
959 assert_eq!(resized_nearest.len(), 1);
960 assert_eq!(resized_lanczos[0].dimensions(), (50, 50));
961 assert_eq!(resized_nearest[0].dimensions(), (50, 50));
962 }
963
964 #[test]
965 fn test_ocr_resize_and_pad_with_max_width_constraint() -> Result<(), ImageProcessError> {
966 let image = create_test_image(400, 100, [200, 100, 50]); let config = OCRResizePadConfig::new(32, 100); let (result, actual_width) = ocr_resize_and_pad(&image, &config, None)?;
970
971 assert_eq!(result.height(), 32);
972 assert_eq!(actual_width, 100); assert_eq!(result.width(), 100);
974
975 let left_pixel = result.get_pixel(0, 16); assert_eq!(*left_pixel, Rgb([200, 100, 50])); Ok(())
979 }
980
981 #[test]
982 fn test_ocr_resize_and_pad_with_target_ratio() -> Result<(), ImageProcessError> {
983 let image = create_test_image(100, 50, [255, 128, 64]); let config = OCRResizePadConfig::new(32, 200); let target_ratio = 3.0; let (result, actual_width) = ocr_resize_and_pad(&image, &config, Some(target_ratio))?;
988
989 assert_eq!(result.height(), 32);
990 assert_eq!(actual_width, 96); assert_eq!(result.width(), 96);
992 Ok(())
993 }
994
995 #[test]
996 fn test_resize_pad_config_builder() {
997 let config = ResizePadConfig::new((100, 50))
998 .with_padding_strategy(PaddingStrategy::SolidColor([255, 0, 0]))
999 .with_filter_type(image::imageops::FilterType::Lanczos3);
1000
1001 assert_eq!(config.target_dims, (100, 50));
1002 assert_eq!(
1003 config.padding_strategy,
1004 PaddingStrategy::SolidColor([255, 0, 0])
1005 );
1006 assert_eq!(config.filter_type, image::imageops::FilterType::Lanczos3);
1007 }
1008
1009 #[test]
1010 fn test_ocr_resize_pad_config_builder() {
1011 let config = OCRResizePadConfig::new(64, 320)
1012 .with_padding_strategy(PaddingStrategy::SolidColor([100, 100, 100]))
1013 .with_filter_type(image::imageops::FilterType::Nearest);
1014
1015 assert_eq!(config.target_height, 64);
1016 assert_eq!(config.max_width, 320);
1017 assert_eq!(
1018 config.padding_strategy,
1019 PaddingStrategy::SolidColor([100, 100, 100])
1020 );
1021 assert_eq!(config.filter_type, image::imageops::FilterType::Nearest);
1022 }
1023}