ruvector_scipix/preprocess/
transforms.rs

1//! Image transformation functions for preprocessing
2
3use super::{PreprocessError, Result};
4use image::{DynamicImage, GrayImage, Luma};
5use imageproc::filter::gaussian_blur_f32;
6use std::f32;
7
8/// Convert image to grayscale
9///
10/// # Arguments
11/// * `image` - Input color or grayscale image
12///
13/// # Returns
14/// Grayscale image
15pub fn to_grayscale(image: &DynamicImage) -> GrayImage {
16    image.to_luma8()
17}
18
19/// Apply Gaussian blur for noise reduction
20///
21/// # Arguments
22/// * `image` - Input grayscale image
23/// * `sigma` - Standard deviation of Gaussian kernel
24///
25/// # Returns
26/// Blurred image
27///
28/// # Example
29/// ```no_run
30/// use ruvector_scipix::preprocess::transforms::gaussian_blur;
31/// # use image::GrayImage;
32/// # let image = GrayImage::new(100, 100);
33/// let blurred = gaussian_blur(&image, 1.5).unwrap();
34/// ```
35pub fn gaussian_blur(image: &GrayImage, sigma: f32) -> Result<GrayImage> {
36    if sigma <= 0.0 {
37        return Err(PreprocessError::InvalidParameters(
38            "Sigma must be positive".to_string(),
39        ));
40    }
41
42    Ok(gaussian_blur_f32(image, sigma))
43}
44
45/// Sharpen image using unsharp mask
46///
47/// # Arguments
48/// * `image` - Input grayscale image
49/// * `sigma` - Gaussian blur sigma
50/// * `amount` - Sharpening strength (typically 0.5-2.0)
51///
52/// # Returns
53/// Sharpened image
54pub fn sharpen(image: &GrayImage, sigma: f32, amount: f32) -> Result<GrayImage> {
55    if sigma <= 0.0 || amount < 0.0 {
56        return Err(PreprocessError::InvalidParameters(
57            "Invalid sharpening parameters".to_string(),
58        ));
59    }
60
61    let blurred = gaussian_blur_f32(image, sigma);
62    let (width, height) = image.dimensions();
63    let mut result = GrayImage::new(width, height);
64
65    for y in 0..height {
66        for x in 0..width {
67            let original = image.get_pixel(x, y)[0] as f32;
68            let blur = blurred.get_pixel(x, y)[0] as f32;
69
70            // Unsharp mask: original + amount * (original - blurred)
71            let sharpened = original + amount * (original - blur);
72            let clamped = sharpened.clamp(0.0, 255.0) as u8;
73
74            result.put_pixel(x, y, Luma([clamped]));
75        }
76    }
77
78    Ok(result)
79}
80
81/// Calculate optimal threshold using Otsu's method
82///
83/// Implements full Otsu's algorithm for automatic threshold selection
84/// based on maximizing inter-class variance.
85///
86/// # Arguments
87/// * `image` - Input grayscale image
88///
89/// # Returns
90/// Optimal threshold value (0-255)
91///
92/// # Example
93/// ```no_run
94/// use ruvector_scipix::preprocess::transforms::otsu_threshold;
95/// # use image::GrayImage;
96/// # let image = GrayImage::new(100, 100);
97/// let threshold = otsu_threshold(&image).unwrap();
98/// println!("Optimal threshold: {}", threshold);
99/// ```
100pub fn otsu_threshold(image: &GrayImage) -> Result<u8> {
101    // Calculate histogram
102    let mut histogram = [0u32; 256];
103    for pixel in image.pixels() {
104        histogram[pixel[0] as usize] += 1;
105    }
106
107    let total_pixels = (image.width() * image.height()) as f64;
108
109    // Calculate cumulative sums
110    let mut sum_total = 0.0;
111    for (i, &count) in histogram.iter().enumerate() {
112        sum_total += (i as f64) * (count as f64);
113    }
114
115    let mut sum_background = 0.0;
116    let mut weight_background = 0.0;
117    let mut max_variance = 0.0;
118    let mut threshold = 0u8;
119
120    // Find threshold that maximizes inter-class variance
121    for (t, &count) in histogram.iter().enumerate() {
122        weight_background += count as f64;
123        if weight_background == 0.0 {
124            continue;
125        }
126
127        let weight_foreground = total_pixels - weight_background;
128        if weight_foreground == 0.0 {
129            break;
130        }
131
132        sum_background += (t as f64) * (count as f64);
133
134        let mean_background = sum_background / weight_background;
135        let mean_foreground = (sum_total - sum_background) / weight_foreground;
136
137        // Inter-class variance
138        let variance = weight_background * weight_foreground *
139                      (mean_background - mean_foreground).powi(2);
140
141        if variance > max_variance {
142            max_variance = variance;
143            threshold = t as u8;
144        }
145    }
146
147    Ok(threshold)
148}
149
150/// Apply binary thresholding
151///
152/// # Arguments
153/// * `image` - Input grayscale image
154/// * `threshold` - Threshold value (0-255)
155///
156/// # Returns
157/// Binary image (0 or 255)
158pub fn threshold(image: &GrayImage, threshold_val: u8) -> GrayImage {
159    let (width, height) = image.dimensions();
160    let mut result = GrayImage::new(width, height);
161
162    for y in 0..height {
163        for x in 0..width {
164            let pixel = image.get_pixel(x, y)[0];
165            let value = if pixel >= threshold_val { 255 } else { 0 };
166            result.put_pixel(x, y, Luma([value]));
167        }
168    }
169
170    result
171}
172
173/// Apply adaptive thresholding using local window statistics
174///
175/// Uses a sliding window to calculate local mean and applies threshold
176/// relative to local statistics. Better for images with varying illumination.
177///
178/// # Arguments
179/// * `image` - Input grayscale image
180/// * `window_size` - Size of local window (must be odd)
181///
182/// # Returns
183/// Binary image with adaptive thresholding applied
184///
185/// # Example
186/// ```no_run
187/// use ruvector_scipix::preprocess::transforms::adaptive_threshold;
188/// # use image::GrayImage;
189/// # let image = GrayImage::new(100, 100);
190/// let binary = adaptive_threshold(&image, 15).unwrap();
191/// ```
192pub fn adaptive_threshold(image: &GrayImage, window_size: u32) -> Result<GrayImage> {
193    if window_size % 2 == 0 {
194        return Err(PreprocessError::InvalidParameters(
195            "Window size must be odd".to_string(),
196        ));
197    }
198
199    let (width, height) = image.dimensions();
200    let mut result = GrayImage::new(width, height);
201    let half_window = (window_size / 2) as i32;
202
203    // Use integral image for fast window sum calculation
204    let integral = compute_integral_image(image);
205
206    for y in 0..height as i32 {
207        for x in 0..width as i32 {
208            // Define window bounds
209            let x1 = (x - half_window).max(0);
210            let y1 = (y - half_window).max(0);
211            let x2 = (x + half_window + 1).min(width as i32);
212            let y2 = (y + half_window + 1).min(height as i32);
213
214            // Calculate mean using integral image
215            let area = ((x2 - x1) * (y2 - y1)) as f64;
216            let sum = get_integral_sum(&integral, x1, y1, x2, y2);
217            let mean = (sum as f64 / area) as u8;
218
219            // Apply threshold with small bias
220            let pixel = image.get_pixel(x as u32, y as u32)[0];
221            let bias = 5; // Small bias to reduce noise
222            let value = if pixel >= mean.saturating_sub(bias) { 255 } else { 0 };
223
224            result.put_pixel(x as u32, y as u32, Luma([value]));
225        }
226    }
227
228    Ok(result)
229}
230
231/// Compute integral image for fast rectangle sum queries
232fn compute_integral_image(image: &GrayImage) -> Vec<Vec<u64>> {
233    let (width, height) = image.dimensions();
234    let mut integral = vec![vec![0u64; width as usize + 1]; height as usize + 1];
235
236    for y in 1..=height as usize {
237        for x in 1..=width as usize {
238            let pixel = image.get_pixel(x as u32 - 1, y as u32 - 1)[0] as u64;
239            integral[y][x] = pixel
240                + integral[y - 1][x]
241                + integral[y][x - 1]
242                - integral[y - 1][x - 1];
243        }
244    }
245
246    integral
247}
248
249/// Get sum of rectangle in integral image
250fn get_integral_sum(integral: &[Vec<u64>], x1: i32, y1: i32, x2: i32, y2: i32) -> u64 {
251    let x1 = x1 as usize;
252    let y1 = y1 as usize;
253    let x2 = x2 as usize;
254    let y2 = y2 as usize;
255
256    integral[y2][x2] + integral[y1][x1] - integral[y1][x2] - integral[y2][x1]
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use approx::assert_relative_eq;
263
264    fn create_gradient_image(width: u32, height: u32) -> GrayImage {
265        let mut img = GrayImage::new(width, height);
266        for y in 0..height {
267            for x in 0..width {
268                let val = ((x + y) * 255 / (width + height)) as u8;
269                img.put_pixel(x, y, Luma([val]));
270            }
271        }
272        img
273    }
274
275    #[test]
276    fn test_to_grayscale() {
277        let img = DynamicImage::new_rgb8(100, 100);
278        let gray = to_grayscale(&img);
279        assert_eq!(gray.dimensions(), (100, 100));
280    }
281
282    #[test]
283    fn test_gaussian_blur() {
284        let img = create_gradient_image(50, 50);
285        let blurred = gaussian_blur(&img, 1.0);
286        assert!(blurred.is_ok());
287
288        let result = blurred.unwrap();
289        assert_eq!(result.dimensions(), img.dimensions());
290    }
291
292    #[test]
293    fn test_gaussian_blur_invalid_sigma() {
294        let img = create_gradient_image(50, 50);
295        let result = gaussian_blur(&img, -1.0);
296        assert!(result.is_err());
297    }
298
299    #[test]
300    fn test_sharpen() {
301        let img = create_gradient_image(50, 50);
302        let sharpened = sharpen(&img, 1.0, 1.5);
303        assert!(sharpened.is_ok());
304
305        let result = sharpened.unwrap();
306        assert_eq!(result.dimensions(), img.dimensions());
307    }
308
309    #[test]
310    fn test_otsu_threshold() {
311        // Create bimodal image (good for Otsu)
312        let mut img = GrayImage::new(100, 100);
313        for y in 0..100 {
314            for x in 0..100 {
315                let val = if x < 50 { 50 } else { 200 };
316                img.put_pixel(x, y, Luma([val]));
317            }
318        }
319
320        let threshold = otsu_threshold(&img);
321        assert!(threshold.is_ok());
322
323        let t = threshold.unwrap();
324        // Should be somewhere between the two values (not necessarily strictly between)
325        // Otsu finds optimal threshold which could be at boundary
326        assert!(t >= 50 && t <= 200, "threshold {} should be between 50 and 200", t);
327    }
328
329    #[test]
330    fn test_threshold() {
331        let img = create_gradient_image(100, 100);
332        let binary = threshold(&img, 128);
333
334        assert_eq!(binary.dimensions(), img.dimensions());
335
336        // Check that output is binary
337        for pixel in binary.pixels() {
338            let val = pixel[0];
339            assert!(val == 0 || val == 255);
340        }
341    }
342
343    #[test]
344    fn test_adaptive_threshold() {
345        let img = create_gradient_image(100, 100);
346        let binary = adaptive_threshold(&img, 15);
347        assert!(binary.is_ok());
348
349        let result = binary.unwrap();
350        assert_eq!(result.dimensions(), img.dimensions());
351
352        // Check binary output
353        for pixel in result.pixels() {
354            let val = pixel[0];
355            assert!(val == 0 || val == 255);
356        }
357    }
358
359    #[test]
360    fn test_adaptive_threshold_invalid_window() {
361        let img = create_gradient_image(50, 50);
362        let result = adaptive_threshold(&img, 16); // Even number
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn test_integral_image() {
368        let mut img = GrayImage::new(3, 3);
369        for y in 0..3 {
370            for x in 0..3 {
371                img.put_pixel(x, y, Luma([1]));
372            }
373        }
374
375        let integral = compute_integral_image(&img);
376
377        // Check 3x3 sum
378        let sum = get_integral_sum(&integral, 0, 0, 3, 3);
379        assert_eq!(sum, 9); // 3x3 image with all 1s
380    }
381
382    #[test]
383    fn test_threshold_extremes() {
384        let img = create_gradient_image(100, 100);
385
386        // Threshold at 0 should make everything white
387        let binary = threshold(&img, 0);
388        assert!(binary.pixels().all(|p| p[0] == 255));
389
390        // Threshold at 255 should make everything black
391        let binary = threshold(&img, 255);
392        assert!(binary.pixels().all(|p| p[0] == 0));
393    }
394}