//! Rotation detection and correction using projection profiles use super::{PreprocessError, Result}; use image::{GrayImage, Luma}; use imageproc::geometric_transformations::{rotate_about_center, Interpolation}; use std::f32; /// Detect rotation angle using projection profile analysis /// /// Uses horizontal and vertical projection profiles to detect document rotation. /// Returns angle in degrees (typically in range -45 to +45). /// /// # Arguments /// * `image` - Input grayscale image /// /// # Returns /// Rotation angle in degrees (positive = clockwise) /// /// # Example /// ```no_run /// use ruvector_scipix::preprocess::rotation::detect_rotation; /// # use image::GrayImage; /// # let image = GrayImage::new(100, 100); /// let angle = detect_rotation(&image).unwrap(); /// println!("Detected rotation: {:.2}°", angle); /// ``` pub fn detect_rotation(image: &GrayImage) -> Result { let (width, height) = image.dimensions(); if width < 10 || height < 10 { return Err(PreprocessError::InvalidParameters( "Image too small for rotation detection".to_string(), )); } // Calculate projection profiles for different angles let angles = [-45.0, -30.0, -15.0, 0.0, 15.0, 30.0, 45.0]; let mut max_score = 0.0; let mut best_angle = 0.0; for &angle in &angles { let score = calculate_projection_score(image, angle); if score > max_score { max_score = score; best_angle = angle; } } // Refine angle with finer search around best candidate let fine_angles: Vec = (-5..=5).map(|i| best_angle + (i as f32) * 2.0).collect(); max_score = 0.0; for angle in fine_angles { let score = calculate_projection_score(image, angle); if score > max_score { max_score = score; best_angle = angle; } } Ok(best_angle) } /// Calculate projection profile score for a given rotation angle /// /// Higher scores indicate better alignment with text baselines fn calculate_projection_score(image: &GrayImage, angle: f32) -> f32 { let (width, height) = image.dimensions(); // For 0 degrees, use direct projection if angle.abs() < 0.1 { return calculate_horizontal_projection_variance(image); } // For non-zero angles, calculate projection along rotated axis let rad = angle.to_radians(); let cos_a = rad.cos(); let sin_a = rad.sin(); let mut projection = vec![0u32; height as usize]; for y in 0..height { for x in 0..width { let pixel = image.get_pixel(x, y)[0]; if pixel < 128 { // Project pixel onto rotated horizontal axis let proj_y = ((y as f32) * cos_a - (x as f32) * sin_a) as i32; if proj_y >= 0 && proj_y < height as i32 { projection[proj_y as usize] += 1; } } } } // Calculate variance of projection (higher = better alignment) calculate_variance(&projection) } /// Calculate horizontal projection variance fn calculate_horizontal_projection_variance(image: &GrayImage) -> f32 { let (width, height) = image.dimensions(); let mut projection = vec![0u32; height as usize]; for y in 0..height { for x in 0..width { let pixel = image.get_pixel(x, y)[0]; if pixel < 128 { projection[y as usize] += 1; } } } calculate_variance(&projection) } /// Calculate variance of projection profile fn calculate_variance(projection: &[u32]) -> f32 { if projection.is_empty() { return 0.0; } let mean = projection.iter().sum::() as f32 / projection.len() as f32; let variance = projection .iter() .map(|&x| { let diff = x as f32 - mean; diff * diff }) .sum::() / projection.len() as f32; variance } /// Rotate image by specified angle /// /// # Arguments /// * `image` - Input grayscale image /// * `angle` - Rotation angle in degrees (positive = clockwise) /// /// # Returns /// Rotated image with bilinear interpolation /// /// # Example /// ```no_run /// use ruvector_scipix::preprocess::rotation::rotate_image; /// # use image::GrayImage; /// # let image = GrayImage::new(100, 100); /// let rotated = rotate_image(&image, 15.0).unwrap(); /// ``` pub fn rotate_image(image: &GrayImage, angle: f32) -> Result { if angle.abs() < 0.01 { // No rotation needed return Ok(image.clone()); } let radians = -angle.to_radians(); // Negate for correct direction let rotated = rotate_about_center( image, radians, Interpolation::Bilinear, Luma([255]), // White background ); Ok(rotated) } /// Detect rotation with confidence score /// /// Returns tuple of (angle, confidence) where confidence is 0.0-1.0 pub fn detect_rotation_with_confidence(image: &GrayImage) -> Result<(f32, f32)> { let angle = detect_rotation(image)?; // Calculate confidence based on projection profile variance difference let current_score = calculate_projection_score(image, angle); let baseline_score = calculate_projection_score(image, 0.0); // Confidence is relative improvement over baseline let confidence = if baseline_score > 0.0 { (current_score / baseline_score).min(1.0) } else { 0.5 // Default moderate confidence }; Ok((angle, confidence)) } /// Auto-rotate image only if confidence is above threshold /// /// # Arguments /// * `image` - Input grayscale image /// * `confidence_threshold` - Minimum confidence (0.0-1.0) to apply rotation /// /// # Returns /// Tuple of (rotated_image, angle_applied, confidence) pub fn auto_rotate(image: &GrayImage, confidence_threshold: f32) -> Result<(GrayImage, f32, f32)> { let (angle, confidence) = detect_rotation_with_confidence(image)?; if confidence >= confidence_threshold && angle.abs() > 0.5 { let rotated = rotate_image(image, -angle)?; Ok((rotated, angle, confidence)) } else { Ok((image.clone(), 0.0, confidence)) } } #[cfg(test)] mod tests { use super::*; fn create_text_image() -> GrayImage { let mut img = GrayImage::new(200, 100); // Fill with white for pixel in img.pixels_mut() { *pixel = Luma([255]); } // Draw some horizontal lines (simulating text) for y in [20, 25, 50, 55] { for x in 10..190 { img.put_pixel(x, y, Luma([0])); } } img } #[test] fn test_detect_rotation_straight() { let img = create_text_image(); let angle = detect_rotation(&img); assert!(angle.is_ok()); let a = angle.unwrap(); // Should detect near-zero rotation assert!(a.abs() < 10.0); } #[test] fn test_rotate_image() { let img = create_text_image(); // Rotate by 15 degrees let rotated = rotate_image(&img, 15.0); assert!(rotated.is_ok()); let result = rotated.unwrap(); assert_eq!(result.dimensions(), img.dimensions()); } #[test] fn test_rotate_no_change() { let img = create_text_image(); // Rotate by ~0 degrees let rotated = rotate_image(&img, 0.001); assert!(rotated.is_ok()); let result = rotated.unwrap(); assert_eq!(result.dimensions(), img.dimensions()); } #[test] fn test_rotation_confidence() { let img = create_text_image(); let result = detect_rotation_with_confidence(&img); assert!(result.is_ok()); let (angle, confidence) = result.unwrap(); assert!(confidence >= 0.0 && confidence <= 1.0); println!( "Detected angle: {:.2}°, confidence: {:.2}", angle, confidence ); } #[test] fn test_auto_rotate_with_threshold() { let img = create_text_image(); // High threshold - should not rotate if confidence is low let result = auto_rotate(&img, 0.95); assert!(result.is_ok()); let (rotated, angle, confidence) = result.unwrap(); assert_eq!(rotated.dimensions(), img.dimensions()); println!( "Auto-rotate: angle={:.2}°, confidence={:.2}", angle, confidence ); } #[test] fn test_projection_variance() { let projection = vec![10, 50, 100, 50, 10]; let variance = calculate_variance(&projection); assert!(variance > 0.0); } #[test] fn test_rotation_small_image_error() { let small_img = GrayImage::new(5, 5); let result = detect_rotation(&small_img); assert!(result.is_err()); } #[test] fn test_rotation_roundtrip() { let img = create_text_image(); // Rotate and unrotate let rotated = rotate_image(&img, 30.0).unwrap(); let unrotated = rotate_image(&rotated, -30.0).unwrap(); assert_eq!(unrotated.dimensions(), img.dimensions()); } }