Skip to main content

rawshift_image/transforms/
denoise.rs

1//! Noise reduction algorithms for RGB image data.
2//!
3//! This module provides spatial noise reduction filters that operate on fully
4//! demosaiced RGB images. The primary algorithm is the bilateral filter, which
5//! preserves edges while smoothing noise by weighting contributions from both
6//! spatial proximity and intensity similarity.
7
8use crate::core::image::RgbImage;
9
10/// Apply bilateral noise reduction filter to an RGB image.
11///
12/// The bilateral filter replaces each pixel with a weighted average of its
13/// neighbors. The weight for each neighbor is the product of a spatial Gaussian
14/// (based on pixel distance) and a range Gaussian (based on intensity difference).
15/// This allows the filter to smooth flat regions aggressively while leaving
16/// sharp edges largely intact.
17///
18/// # Arguments
19/// * `image`          - RGB image to filter in-place.
20/// * `spatial_sigma`  - Spatial Gaussian sigma in pixels (typically 2–5).
21/// * `range_sigma`    - Range/intensity sigma in u16 units (typically 1000–5000).
22/// * `radius` - Filter half-width in pixels; the kernel covers `(2*radius+1)²` pixels.
23///
24/// # Performance
25/// Complexity is O(width × height × (2·radius+1)²). For small radii (2–3) this
26/// is fast; for larger radii consider a separable approximation.
27pub fn apply_bilateral_filter(
28    image: &mut RgbImage,
29    spatial_sigma: f32,
30    range_sigma: f32,
31    radius: u32,
32) {
33    let width = image.width() as usize;
34    let height = image.height() as usize;
35
36    if width == 0 || height == 0 {
37        return;
38    }
39
40    let r = radius as usize;
41
42    // Pre-compute spatial Gaussian weights for all (dx, dy) offsets in the kernel.
43    let ksize = 2 * r + 1;
44    let two_ss_sq = 2.0_f32 * spatial_sigma * spatial_sigma;
45    let two_rs_sq = 2.0_f32 * range_sigma * range_sigma;
46
47    let mut spatial_lut = vec![0.0_f32; ksize * ksize];
48    for dy in 0..ksize {
49        for dx in 0..ksize {
50            let fx = dx as f32 - r as f32;
51            let fy = dy as f32 - r as f32;
52            let d2 = fx * fx + fy * fy;
53            spatial_lut[dy * ksize + dx] = (-d2 / two_ss_sq).exp();
54        }
55    }
56
57    let input = image.data.clone();
58
59    for y in 0..height {
60        for x in 0..width {
61            let center_idx = (y * width + x) * 3;
62
63            for c in 0..3usize {
64                let center_val = input[center_idx + c] as f32;
65                let mut numerator = 0.0_f32;
66                let mut denominator = 0.0_f32;
67
68                let y_min = y.saturating_sub(r);
69                let y_max = (y + r).min(height - 1);
70                let x_min = x.saturating_sub(r);
71                let x_max = (x + r).min(width - 1);
72
73                for ny in y_min..=y_max {
74                    for nx in x_min..=x_max {
75                        let lut_dy = (ny as isize - y as isize + r as isize) as usize;
76                        let lut_dx = (nx as isize - x as isize + r as isize) as usize;
77                        let spatial_w = spatial_lut[lut_dy * ksize + lut_dx];
78
79                        let neighbor_val = input[(ny * width + nx) * 3 + c] as f32;
80                        let diff = center_val - neighbor_val;
81                        let range_w = (-diff * diff / two_rs_sq).exp();
82
83                        let w = spatial_w * range_w;
84                        numerator += neighbor_val * w;
85                        denominator += w;
86                    }
87                }
88
89                let result = if denominator > 0.0 {
90                    (numerator / denominator).round() as u16
91                } else {
92                    input[center_idx + c]
93                };
94
95                image.data[center_idx + c] = result;
96            }
97        }
98    }
99}
100
101/// Apply a simple Gaussian blur for fast noise reduction (lower quality).
102///
103/// Unlike the bilateral filter this does not preserve edges, but is faster
104/// and simpler. Useful as a preview step or for very high-ISO data where
105/// edge sharpness matters less.
106///
107/// # Arguments
108/// * `image`  - RGB image to filter in-place.
109/// * `sigma`  - Gaussian sigma in pixels.
110/// * `radius` - Filter half-width in pixels.
111pub fn apply_gaussian_blur(image: &mut RgbImage, sigma: f32, radius: u32) {
112    let width = image.width() as usize;
113    let height = image.height() as usize;
114
115    if width == 0 || height == 0 {
116        return;
117    }
118
119    let r = radius as usize;
120    let ksize = 2 * r + 1;
121    let two_sq = 2.0_f32 * sigma * sigma;
122
123    // Build 1-D Gaussian kernel (normalized).
124    let mut kernel = vec![0.0_f32; ksize];
125    for (i, v) in kernel.iter_mut().enumerate() {
126        let x = i as f32 - r as f32;
127        *v = (-x * x / two_sq).exp();
128    }
129    let k_sum: f32 = kernel.iter().sum();
130    for v in kernel.iter_mut() {
131        *v /= k_sum;
132    }
133
134    // Horizontal pass.
135    let mut tmp = image.data.clone();
136    for y in 0..height {
137        for x in 0..width {
138            for c in 0..3usize {
139                let mut acc = 0.0_f32;
140                let mut wsum = 0.0_f32;
141                let x_min = x.saturating_sub(r);
142                let x_max = (x + r).min(width - 1);
143                for nx in x_min..=x_max {
144                    let ki = (nx as isize - x as isize + r as isize) as usize;
145                    let w = kernel[ki];
146                    acc += image.data[(y * width + nx) * 3 + c] as f32 * w;
147                    wsum += w;
148                }
149                tmp[(y * width + x) * 3 + c] = if wsum > 0.0 {
150                    (acc / wsum).round() as u16
151                } else {
152                    image.data[(y * width + x) * 3 + c]
153                };
154            }
155        }
156    }
157
158    // Vertical pass.
159    for y in 0..height {
160        for x in 0..width {
161            for c in 0..3usize {
162                let mut acc = 0.0_f32;
163                let mut wsum = 0.0_f32;
164                let y_min = y.saturating_sub(r);
165                let y_max = (y + r).min(height - 1);
166                for ny in y_min..=y_max {
167                    let ki = (ny as isize - y as isize + r as isize) as usize;
168                    let w = kernel[ki];
169                    acc += tmp[(ny * width + x) * 3 + c] as f32 * w;
170                    wsum += w;
171                }
172                image.data[(y * width + x) * 3 + c] = if wsum > 0.0 {
173                    (acc / wsum).round() as u16
174                } else {
175                    tmp[(y * width + x) * 3 + c]
176                };
177            }
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    /// Build a uniform RGB image.
187    fn make_uniform(width: u32, height: u32, value: u16) -> RgbImage {
188        let n = (width as usize) * (height as usize) * 3;
189        RgbImage::new(width, height, vec![value; n])
190    }
191
192    /// Compute the variance of the values in the given channel (0, 1, or 2).
193    fn channel_variance(image: &RgbImage, channel: usize) -> f64 {
194        let vals: Vec<f64> = image
195            .data
196            .chunks_exact(3)
197            .map(|px| px[channel] as f64)
198            .collect();
199        let mean = vals.iter().sum::<f64>() / vals.len() as f64;
200        vals.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / vals.len() as f64
201    }
202
203    #[test]
204    fn test_bilateral_uniform_image_unchanged() {
205        // A perfectly uniform image should remain unchanged.
206        let mut img = make_uniform(8, 8, 1000);
207        apply_bilateral_filter(&mut img, 2.0, 2000.0, 2);
208        assert!(img.data.iter().all(|&v| v == 1000));
209    }
210
211    #[test]
212    fn test_bilateral_reduces_noise() {
213        // Add alternating noise: even pixels 1000, odd pixels 2000.
214        let w = 16u32;
215        let h = 16u32;
216        let n = (w as usize) * (h as usize);
217        let mut data = Vec::with_capacity(n * 3);
218        for i in 0..n {
219            let v = if i % 2 == 0 { 1000u16 } else { 2000u16 };
220            data.extend_from_slice(&[v, v, v]);
221        }
222        let mut img = RgbImage::new(w, h, data.clone());
223        let var_before = channel_variance(&img, 0);
224        apply_bilateral_filter(&mut img, 3.0, 5000.0, 3);
225        let var_after = channel_variance(&img, 0);
226        assert!(
227            var_after < var_before,
228            "variance should decrease: before={var_before}, after={var_after}"
229        );
230    }
231
232    #[test]
233    fn test_bilateral_preserves_edges() {
234        // Left half = 0, right half = 65000. After bilateral the edge pixels
235        // should still differ substantially.
236        let w = 16u32;
237        let h = 8u32;
238        let n = (w as usize) * (h as usize);
239        let mut data = Vec::with_capacity(n * 3);
240        for _y in 0..h {
241            for x in 0..w {
242                let v = if x < w / 2 { 0u16 } else { 60000u16 };
243                data.extend_from_slice(&[v, v, v]);
244            }
245        }
246        let mut img = RgbImage::new(w, h, data);
247        apply_bilateral_filter(&mut img, 2.0, 1000.0, 2);
248
249        // Pixel in the left half should stay dark.
250        let left_px = img.data[((4 * w as usize) + 2) * 3];
251        // Pixel in the right half should stay bright.
252        let right_px = img.data[((4 * w as usize) + 13) * 3];
253        assert!(left_px < 10000, "left edge should stay dark, got {left_px}");
254        assert!(
255            right_px > 50000,
256            "right edge should stay bright, got {right_px}"
257        );
258    }
259
260    #[test]
261    fn test_gaussian_blur_uniform_unchanged() {
262        let mut img = make_uniform(8, 8, 5000);
263        apply_gaussian_blur(&mut img, 1.5, 2);
264        // A uniform image blurred with any kernel is still uniform.
265        assert!(img.data.iter().all(|&v| v == 5000));
266    }
267
268    #[test]
269    fn test_filter_small_image() {
270        // 1×1 and 2×2 images must not crash.
271        let mut img1 = make_uniform(1, 1, 100);
272        apply_bilateral_filter(&mut img1, 2.0, 1000.0, 2);
273
274        let mut img2 = make_uniform(2, 2, 200);
275        apply_gaussian_blur(&mut img2, 1.0, 2);
276    }
277
278    #[test]
279    fn test_gaussian_blur_reduces_noise() {
280        let w = 16u32;
281        let h = 16u32;
282        let n = (w as usize) * (h as usize);
283        let mut data = Vec::with_capacity(n * 3);
284        for i in 0..n {
285            let v: u16 = if i % 2 == 0 { 1000 } else { 3000 };
286            data.extend_from_slice(&[v, v, v]);
287        }
288        let mut img = RgbImage::new(w, h, data);
289        let var_before = channel_variance(&img, 0);
290        apply_gaussian_blur(&mut img, 2.0, 3);
291        let var_after = channel_variance(&img, 0);
292        assert!(
293            var_after < var_before,
294            "Gaussian blur should reduce variance: before={var_before}, after={var_after}"
295        );
296    }
297}