Skip to main content

iris/photo/
mod.rs

1pub mod nlm;
2
3use crate::error::{IrisError, Result};
4use crate::image::Image;
5use burn::tensor::backend::Backend;
6
7/// Photo processing and enhancement algorithms.
8pub struct Photo;
9
10impl Photo {
11    /// Non-Local Means Denoising filter with patch-based similarity.
12    ///
13    /// For each pixel, searches a larger neighborhood for patches similar to the
14    /// current pixel's patch. Pixels in similar patches are weighted by their
15    /// Gaussian-weighted distance to produce the denoised value.
16    ///
17    /// # Arguments
18    /// * `image` - Input image with values in [0, 1]
19    /// * `h` - Filter strength (higher removes more noise but may blur details)
20    /// * `patch_radius` - Half-size of the comparison patch (default: 3 → 7×7 patch)
21    /// * `search_radius` - Half-size of the search window (default: 5 → 11×11 window)
22    pub fn fast_nl_means_denoising<B: Backend>(
23        image: &Image<B>,
24        h: f32,
25        patch_radius: usize,
26        search_radius: usize,
27    ) -> Result<Image<B>> {
28        let dims = image.tensor.dims();
29        let c = dims[0];
30        let img_h = dims[1];
31        let img_w = dims[2];
32
33        let tensor_data = image.tensor.clone().into_data();
34        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
35        let mut out_vals = vec![0.0f32; c * img_h * img_w];
36
37        let h_sq_inv = 1.0 / (h * h * (2.0 * patch_radius as f32 + 1.0).powi(2));
38
39        for ch in 0..c {
40            let ch_offset = ch * img_h * img_w;
41            for cy in 0..img_h {
42                for cx in 0..img_w {
43                    let center_idx = ch_offset + cy * img_w + cx;
44                    let center_val = flat_vals[center_idx];
45
46                    let mut weight_sum = 0.0f32;
47                    let mut value_sum = 0.0f32;
48
49                    // Search window around (cx, cy)
50                    let sy_start = cy.saturating_sub(search_radius);
51                    let sy_end = (cy + search_radius + 1).min(img_h);
52                    let sx_start = cx.saturating_sub(search_radius);
53                    let sx_end = (cx + search_radius + 1).min(img_w);
54
55                    for sy in sy_start..sy_end {
56                        for sx in sx_start..sx_end {
57                            // Compute patch distance (L2 between patches centered at center and at (sx, sy))
58                            let mut patch_dist = 0.0f32;
59                            let mut valid = true;
60
61                            for py in 0..=(2 * patch_radius) {
62                                for px in 0..=(2 * patch_radius) {
63                                    let offy = py as isize - patch_radius as isize;
64                                    let offx = px as isize - patch_radius as isize;
65
66                                    let ny_c = cy as isize + offy;
67                                    let nx_c = cx as isize + offx;
68                                    let ny_s = sy as isize + offy;
69                                    let nx_s = sx as isize + offx;
70
71                                    if ny_c < 0
72                                        || ny_c >= img_h as isize
73                                        || nx_c < 0
74                                        || nx_c >= img_w as isize
75                                        || ny_s < 0
76                                        || ny_s >= img_h as isize
77                                        || nx_s < 0
78                                        || nx_s >= img_w as isize
79                                    {
80                                        valid = false;
81                                        break;
82                                    }
83
84                                    let val_c = flat_vals
85                                        [ch_offset + ny_c as usize * img_w + nx_c as usize];
86                                    let val_s = flat_vals
87                                        [ch_offset + ny_s as usize * img_w + nx_s as usize];
88                                    let diff = val_c - val_s;
89                                    patch_dist += diff * diff;
90                                }
91                                if !valid {
92                                    break;
93                                }
94                            }
95
96                            if !valid {
97                                continue;
98                            }
99
100                            // Gaussian spatial weight (center of search is preferred)
101                            let dx = (sx as f64 - cx as f64) as f32;
102                            let dy = (sy as f64 - cy as f64) as f32;
103                            let spatial_dist =
104                                (dx * dx + dy * dy) / (search_radius as f32 * search_radius as f32);
105                            let spatial_weight = (-spatial_dist * 2.0).exp();
106
107                            let weight = (-patch_dist * h_sq_inv).exp() * spatial_weight;
108                            value_sum += flat_vals[ch_offset + sy * img_w + sx] * weight;
109                            weight_sum += weight;
110                        }
111                    }
112
113                    out_vals[center_idx] = if weight_sum > 1e-10 {
114                        (value_sum / weight_sum).clamp(0.0, 1.0)
115                    } else {
116                        center_val
117                    };
118                }
119            }
120        }
121
122        let device = image.tensor.device();
123        let data = burn::tensor::TensorData::new(out_vals, [c, img_h, img_w]);
124        let tensor = burn::tensor::Tensor::<B, 3>::from_data(data, &device);
125        Ok(Image::new(tensor))
126    }
127}
128
129/// HDR Merging using Mertens exposure fusion algorithm.
130///
131/// Fuses multiple differently-exposed images into a single image with
132/// expanded dynamic range using perceptually-motivated weight maps.
133///
134/// The algorithm computes three weight maps per image:
135/// - **Contrast**: absolute value of the Laplacian (edge strength)
136/// - **Saturation**: standard deviation of the color channels per pixel
137/// - **Exposure**: how close the luminance is to 0.5 (optimal exposure)
138///
139/// These are multiplied together and normalized across all images to produce
140/// the final fused result.
141pub struct MergeMertens {
142    pub contrast_weight: f32,
143    pub saturation_weight: f32,
144    pub exposure_weight: f32,
145}
146
147impl MergeMertens {
148    #[must_use]
149    pub fn new() -> Self {
150        Self {
151            contrast_weight: 1.0,
152            saturation_weight: 1.0,
153            exposure_weight: 1.0,
154        }
155    }
156
157    /// Sets the contrast weight (default 1.0).
158    #[must_use]
159    pub fn with_contrast_weight(mut self, w: f32) -> Self {
160        self.contrast_weight = w;
161        self
162    }
163
164    /// Sets the saturation weight (default 1.0).
165    #[must_use]
166    pub fn with_saturation_weight(mut self, w: f32) -> Self {
167        self.saturation_weight = w;
168        self
169    }
170
171    /// Sets the exposure weight (default 1.0).
172    #[must_use]
173    pub fn with_exposure_weight(mut self, w: f32) -> Self {
174        self.exposure_weight = w;
175        self
176    }
177
178    /// Merges multiple exposure images using Mertens exposure fusion.
179    ///
180    /// All images must have the same dimensions and 3 channels.
181    pub fn process<B: Backend>(&self, images: &[Image<B>]) -> Result<Image<B>> {
182        if images.is_empty() {
183            return Err(IrisError::InvalidParameter(
184                "Images list cannot be empty".into(),
185            ));
186        }
187
188        let dims = images[0].tensor.dims();
189        if dims[0] != 3 {
190            return Err(IrisError::InvalidParameter(
191                "Input images must be 3-channel RGB".into(),
192            ));
193        }
194        let h = dims[1];
195        let w = dims[2];
196        let n = images.len();
197
198        // Validate all images have the same dimensions
199        for img in images.iter().skip(1) {
200            let d = img.tensor.dims();
201            if d[0] != 3 || d[1] != h || d[2] != w {
202                return Err(IrisError::DimensionMismatch {
203                    expected: vec![3, h, w],
204                    actual: vec![d[0], d[1], d[2]],
205                });
206            }
207        }
208
209        // Compute weight maps for each image
210        let mut all_weights: Vec<Vec<f32>> = Vec::with_capacity(n);
211        let mut all_pixel_data: Vec<Vec<f32>> = Vec::with_capacity(n);
212
213        for img in images {
214            let data = img.tensor.clone().into_data();
215            let flat: Vec<f32> = data.iter::<f32>().collect();
216
217            let mut weights = vec![1.0f32; h * w];
218
219            // 1. Contrast weight: absolute Laplacian on grayscale
220            let gray: Vec<f32> = (0..h * w)
221                .map(|i| 0.299 * flat[i] + 0.587 * flat[h * w + i] + 0.114 * flat[2 * h * w + i])
222                .collect();
223
224            for y in 0..h {
225                for x in 0..w {
226                    let c = gray[y * w + x];
227                    let mut laplacian = -4.0 * c;
228                    if y > 0 {
229                        laplacian += gray[(y - 1) * w + x];
230                    }
231                    if y + 1 < h {
232                        laplacian += gray[(y + 1) * w + x];
233                    }
234                    if x > 0 {
235                        laplacian += gray[y * w + x - 1];
236                    }
237                    if x + 1 < w {
238                        laplacian += gray[y * w + x + 1];
239                    }
240                    let contrast = laplacian.abs();
241                    let ci = y * w + x;
242                    weights[ci] *= contrast.powf(self.contrast_weight);
243                }
244            }
245
246            // 2. Saturation weight: std deviation of color channels per pixel
247            for y in 0..h {
248                for x in 0..w {
249                    let ci = y * w + x;
250                    let r = flat[ci];
251                    let g = flat[h * w + ci];
252                    let b = flat[2 * h * w + ci];
253                    let mean = (r + g + b) / 3.0;
254                    let variance =
255                        ((r - mean).powi(2) + (g - mean).powi(2) + (b - mean).powi(2)) / 3.0;
256                    let saturation = variance.sqrt();
257                    weights[ci] *= saturation.powf(self.saturation_weight);
258                }
259            }
260
261            // 3. Exposure weight: Gaussian distance to optimal exposure (0.5)
262            for y in 0..h {
263                for x in 0..w {
264                    let ci = y * w + x;
265                    let lum = gray[ci];
266                    let sigma = 0.2;
267                    let diff = lum - 0.5f32;
268                    let exposure_w = (-(diff * diff) / (2.0 * sigma * sigma)).exp();
269                    weights[ci] *= exposure_w.powf(self.exposure_weight);
270                }
271            }
272
273            all_pixel_data.push(flat);
274            all_weights.push(weights);
275        }
276
277        // Normalize weights across all images so they sum to 1 at each pixel
278        let mut out_vals = vec![0.0f32; 3 * h * w];
279        for y in 0..h {
280            for x in 0..w {
281                let ci = y * w + x;
282                let mut total_weight = 0.0f32;
283
284                for w_map in &all_weights {
285                    total_weight += w_map[ci];
286                }
287
288                if total_weight < 1e-10 {
289                    // Fallback: simple average
290                    for ch in 0..3 {
291                        let mut sum = 0.0f32;
292                        for pixel_data in &all_pixel_data {
293                            sum += pixel_data[ch * h * w + ci];
294                        }
295                        out_vals[ch * h * w + ci] = sum / n as f32;
296                    }
297                } else {
298                    for ch in 0..3 {
299                        let mut blended = 0.0f32;
300                        for (idx, pixel_data) in all_pixel_data.iter().enumerate() {
301                            blended +=
302                                pixel_data[ch * h * w + ci] * all_weights[idx][ci] / total_weight;
303                        }
304                        out_vals[ch * h * w + ci] = blended.clamp(0.0, 1.0);
305                    }
306                }
307            }
308        }
309
310        let device = images[0].tensor.device();
311        let data = burn::tensor::TensorData::new(out_vals, [3, h, w]);
312        let tensor = burn::tensor::Tensor::<B, 3>::from_data(data, &device);
313        Ok(Image::new(tensor))
314    }
315}
316
317impl Default for MergeMertens {
318    fn default() -> Self {
319        Self::new()
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::test_helpers::{TestBackend, test_device};
327    use burn::tensor::{Tensor, TensorData};
328
329    #[test]
330    fn test_nlmeans_denoising() {
331        let device = test_device();
332        let flat_data = vec![0.5f32; 3 * 16 * 16];
333        let tensor =
334            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 16, 16]), &device);
335        let img = Image::new(tensor);
336
337        let denoised = Photo::fast_nl_means_denoising(&img, 12.0, 3, 5).unwrap();
338        assert_eq!(denoised.shape(), [3, 16, 16]);
339
340        // Uniform image should remain uniform after denoising
341        let out_data = denoised.tensor.into_data();
342        let out_vals: Vec<f32> = out_data.iter::<f32>().collect();
343        for v in &out_vals {
344            assert!(
345                (*v - 0.5).abs() < 1e-5,
346                "Uniform image should stay uniform, got {}",
347                v
348            );
349        }
350    }
351
352    #[test]
353    fn test_mertens_exposure_fusion() {
354        let device = test_device();
355
356        // Create two different "exposures" of a scene
357        // Image 1: darker
358        let mut data1 = vec![0.3f32; 3 * 16 * 16];
359        // Image 2: brighter
360        let mut data2 = vec![0.7f32; 3 * 16 * 16];
361
362        // Add some variation so weights differ
363        for y in 0..16 {
364            for x in 0..16 {
365                let ci = y * 16 + x;
366                // Add gradient to image 1
367                data1[ci] = 0.2 + 0.4 * (x as f32 / 16.0);
368                data1[16 * 16 + ci] = 0.2 + 0.3 * (y as f32 / 16.0);
369                data1[2 * 16 * 16 + ci] = 0.3;
370                // Add gradient to image 2
371                data2[ci] = 0.5 + 0.3 * (x as f32 / 16.0);
372                data2[16 * 16 + ci] = 0.4 + 0.4 * (y as f32 / 16.0);
373                data2[2 * 16 * 16 + ci] = 0.6;
374            }
375        }
376
377        let img1 = Image::new(Tensor::<TestBackend, 3>::from_data(
378            TensorData::new(data1, [3, 16, 16]),
379            &device,
380        ));
381        let img2 = Image::new(Tensor::<TestBackend, 3>::from_data(
382            TensorData::new(data2, [3, 16, 16]),
383            &device,
384        ));
385
386        let mertens = MergeMertens::new();
387        let merged = mertens.process(&[img1, img2]).unwrap();
388        assert_eq!(merged.shape(), [3, 16, 16]);
389
390        // Verify output is within [0, 1]
391        let out_data = merged.tensor.into_data();
392        let out_vals: Vec<f32> = out_data.iter::<f32>().collect();
393        for v in &out_vals {
394            assert!(*v >= 0.0 && *v <= 1.0, "Output out of range: {}", v);
395        }
396    }
397
398    #[test]
399    fn test_mertens_empty_input() {
400        let mertens = MergeMertens::new();
401        let empty: Vec<Image<TestBackend>> = vec![];
402        assert!(mertens.process(&empty).is_err());
403    }
404}