Skip to main content

iris/image/
ops.rs

1use crate::error::{IrisError, Result};
2use crate::image::Image;
3use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
4
5impl<B: Backend> Image<B> {
6    /// Resizes the image to the specified width and height using nearest-neighbor interpolation.
7    /// This runs fully in parallel on the GPU/CPU backend using Burn's tensor indexing.
8    pub fn resize(&self, new_width: usize, new_height: usize) -> Result<Self> {
9        let dims = self.tensor.dims();
10        let _c = dims[0];
11        let h = dims[1];
12        let w = dims[2];
13
14        if new_width == 0 || new_height == 0 {
15            return Err(IrisError::InvalidParameter(
16                "Dimensions must be greater than zero".into(),
17            ));
18        }
19
20        let device = &self.tensor.device();
21
22        // Calculate Y mapping indices
23        let y_indices_vec: Vec<i32> = (0..new_height)
24            .map(|y| ((y * h) / new_height) as i32)
25            .collect();
26        let y_indices =
27            Tensor::<B, 1, Int>::from_data(TensorData::new(y_indices_vec, [new_height]), device);
28
29        // Calculate X mapping indices
30        let x_indices_vec: Vec<i32> = (0..new_width)
31            .map(|x| ((x * w) / new_width) as i32)
32            .collect();
33        let x_indices =
34            Tensor::<B, 1, Int>::from_data(TensorData::new(x_indices_vec, [new_width]), device);
35
36        // Perform fast index selections on the tensor
37        let resized = self
38            .tensor
39            .clone()
40            .select(1, y_indices)
41            .select(2, x_indices);
42
43        Ok(Image::new(resized))
44    }
45
46    /// Crops a rectangular region from the image.
47    pub fn crop(&self, x: usize, y: usize, width: usize, height: usize) -> Result<Self> {
48        let dims = self.tensor.dims();
49        let c = dims[0];
50        let h = dims[1];
51        let w = dims[2];
52
53        if x + width > w || y + height > h {
54            return Err(IrisError::DimensionMismatch {
55                expected: vec![c, height, width],
56                actual: vec![c, h, w],
57            });
58        }
59
60        // Slice the tensor using ranges
61        let cropped = self
62            .tensor
63            .clone()
64            .slice([0..c, y..(y + height), x..(x + width)]);
65        Ok(Image::new(cropped))
66    }
67
68    /// Flips the image.
69    /// - horizontal: Flip along the width dimension.
70    /// - vertical: Flip along the height dimension.
71    pub fn flip(&self, horizontal: bool, vertical: bool) -> Result<Self> {
72        let mut flipped = self.tensor.clone();
73        if vertical {
74            // Dimension 1 is height
75            flipped = flipped.flip([1]);
76        }
77        if horizontal {
78            // Dimension 2 is width
79            flipped = flipped.flip([2]);
80        }
81        Ok(Image::new(flipped))
82    }
83
84    /// Rotates the image by 90, 180, or 270 degrees clockwise.
85    pub fn rotate(&self, angle_degrees: u32) -> Result<Self> {
86        match angle_degrees {
87            0 | 360 => Ok(self.clone()),
88            90 => {
89                // Swap height & width, then flip horizontally
90                let transposed = self.tensor.clone().swap_dims(1, 2);
91                let rotated = transposed.flip([2]);
92                Ok(Image::new(rotated))
93            }
94            180 => {
95                // Flip both vertically and horizontally
96                let rotated = self.tensor.clone().flip([1, 2]);
97                Ok(Image::new(rotated))
98            }
99            270 => {
100                // Swap height & width, then flip vertically
101                let transposed = self.tensor.clone().swap_dims(1, 2);
102                let rotated = transposed.flip([1]);
103                Ok(Image::new(rotated))
104            }
105            _ => Err(IrisError::InvalidParameter(
106                "Only 90, 180, 270 degrees rotations are supported".into(),
107            )),
108        }
109    }
110
111    /// Converts the image to grayscale using standard ITU-R BT.601 luma weights:
112    /// Y = 0.299*R + 0.587*G + 0.114*B
113    pub fn grayscale(&self) -> Result<Self> {
114        let dims = self.tensor.dims();
115        let c = dims[0];
116        let h = dims[1];
117        let w = dims[2];
118
119        if c == 1 {
120            return Ok(self.clone());
121        }
122
123        if c < 3 {
124            return Err(IrisError::Tensor(
125                "Cannot convert image with less than 3 channels to grayscale".into(),
126            ));
127        }
128
129        // Slice R, G, B channels
130        let r = self.tensor.clone().slice([0..1, 0..h, 0..w]);
131        let g = self.tensor.clone().slice([1..2, 0..h, 0..w]);
132        let b = self.tensor.clone().slice([2..3, 0..h, 0..w]);
133
134        let gray = r
135            .mul_scalar(0.299)
136            .add(g.mul_scalar(0.587))
137            .add(b.mul_scalar(0.114));
138
139        Ok(Image::new(gray))
140    }
141
142    /// Converts a single-channel grayscale image to a 3-channel RGB image.
143    pub fn to_rgb(&self) -> Result<Self> {
144        let dims = self.tensor.dims();
145        let c = dims[0];
146        if c == 3 {
147            return Ok(self.clone());
148        }
149        if c != 1 {
150            return Err(IrisError::Tensor(
151                "Input image must be single-channel to convert to RGB".into(),
152            ));
153        }
154
155        // Concatenate along the channel axis (dimension 0)
156        let rgb = Tensor::cat(
157            vec![
158                self.tensor.clone(),
159                self.tensor.clone(),
160                self.tensor.clone(),
161            ],
162            0,
163        );
164        Ok(Image::new(rgb))
165    }
166
167    /// Builds a Gaussian pyramid with the specified number of levels.
168    /// Each level is half the size of the previous one, smoothed with Gaussian blur.
169    pub fn gaussian_pyramid(&self, levels: usize) -> Result<Vec<Self>> {
170        let mut pyramid = Vec::with_capacity(levels);
171        pyramid.push(self.clone());
172
173        let mut current = self.clone();
174        for _ in 1..levels {
175            let dims = current.tensor.dims();
176            let h = dims[1];
177            let w = dims[2];
178            let new_h = h / 2;
179            let new_w = w / 2;
180            if new_h == 0 || new_w == 0 {
181                break;
182            }
183            // Gaussian blur before downsampling to prevent aliasing
184            let blurred = current.gaussian_blur(3, 1.0)?;
185            let downsampled = blurred.resize(new_w, new_h)?;
186            pyramid.push(downsampled);
187            current = pyramid.last().cloned().unwrap();
188        }
189
190        Ok(pyramid)
191    }
192
193    /// Computes the integral image (summed area table) of the grayscale channel.
194    /// Returns a tensor of shape [1, H+1, W+1] where integral[y][x] is the sum
195    /// of all pixels in the rectangle (0,0) to (x-1, y-1).
196    pub fn integral_image(&self) -> Result<Image<B>> {
197        let gray = self.grayscale()?;
198        let dims = gray.tensor.dims();
199        let h = dims[1];
200        let w = dims[2];
201
202        let tensor_data = gray.tensor.clone().into_data();
203        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
204
205        let mut integral = vec![0.0f32; (h + 1) * (w + 1)];
206
207        for y in 0..h {
208            let mut row_sum = 0.0f32;
209            for x in 0..w {
210                row_sum += flat_vals[y * w + x];
211                integral[(y + 1) * (w + 1) + (x + 1)] = integral[y * (w + 1) + (x + 1)] + row_sum;
212            }
213        }
214
215        let device = gray.tensor.device();
216        let data = TensorData::new(integral, [1, h + 1, w + 1]);
217        let tensor = Tensor::<B, 3>::from_data(data, &device);
218        Ok(Image::new(tensor))
219    }
220
221    /// Performs a flood fill operation starting from seed point (x, y).
222    /// Fills connected pixels with `fill_value` if they are within `lo_diff` and `hi_diff`
223    /// of the seed pixel value.
224    pub fn flood_fill(
225        &self,
226        seed_x: usize,
227        seed_y: usize,
228        fill_value: f32,
229        lo_diff: f32,
230        hi_diff: f32,
231    ) -> Result<Self> {
232        let gray = self.grayscale()?;
233        let dims = gray.tensor.dims();
234        let h = dims[1];
235        let w = dims[2];
236
237        if seed_x >= w || seed_y >= h {
238            return Err(IrisError::InvalidParameter(
239                "Seed point is outside image bounds".into(),
240            ));
241        }
242
243        let tensor_data = gray.tensor.clone().into_data();
244        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
245        let mut out_vals = flat_vals.clone();
246
247        let seed_val = flat_vals[seed_y * w + seed_x];
248        let lo = seed_val - lo_diff;
249        let hi = seed_val + hi_diff;
250
251        // BFS-based flood fill
252        let mut visited = vec![false; h * w];
253        let mut queue = std::collections::VecDeque::new();
254        queue.push_back((seed_x, seed_y));
255        visited[seed_y * w + seed_x] = true;
256
257        let dx = [1, 0, -1, 0];
258        let dy = [0, 1, 0, -1];
259
260        while let Some((cx, cy)) = queue.pop_front() {
261            out_vals[cy * w + cx] = fill_value;
262
263            for d in 0..4 {
264                let nx = cx as isize + dx[d];
265                let ny = cy as isize + dy[d];
266                if nx >= 0 && nx < w as isize && ny >= 0 && ny < h as isize {
267                    let ux = nx as usize;
268                    let uy = ny as usize;
269                    let idx = uy * w + ux;
270                    if !visited[idx] {
271                        let pixel = flat_vals[idx];
272                        if pixel >= lo && pixel <= hi {
273                            visited[idx] = true;
274                            queue.push_back((ux, uy));
275                        }
276                    }
277                }
278            }
279        }
280
281        let device = gray.tensor.device();
282        let data = TensorData::new(out_vals, [1, h, w]);
283        let tensor = Tensor::<B, 3>::from_data(data, &device);
284        Ok(Image::new(tensor))
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::test_helpers::{TestBackend, test_device};
292    use burn::tensor::TensorData;
293
294    #[test]
295    fn test_image_conversions() {
296        let device = test_device();
297        let flat_data = vec![
298            0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2,
299        ];
300        let tensor =
301            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 2, 2]), &device);
302        let img = Image::new(tensor);
303
304        let gray = img.grayscale().unwrap();
305        assert_eq!(gray.shape(), [1, 2, 2]);
306
307        let rgb = gray.to_rgb().unwrap();
308        assert_eq!(rgb.shape(), [3, 2, 2]);
309    }
310}