Skip to main content

iris/image/
geometric.rs

1use crate::core::types::Point;
2use crate::error::{IrisError, Result};
3use crate::image::Image;
4use burn::tensor::{Tensor, TensorData, backend::Backend};
5
6impl<B: Backend> Image<B> {
7    /// Transposes the image (swaps height and width).
8    pub fn transpose(&self) -> Result<Self> {
9        let transposed = self.tensor.clone().swap_dims(1, 2);
10        Ok(Image::new(transposed))
11    }
12
13    /// Warps the image using a 2x3 affine transformation matrix.
14    pub fn warp_affine(
15        &self,
16        m: [[f64; 3]; 2],
17        new_width: usize,
18        new_height: usize,
19    ) -> Result<Self> {
20        let dims = self.tensor.dims();
21        let c = dims[0];
22        let h = dims[1];
23        let w = dims[2];
24
25        let device = self.tensor.device();
26        let tensor_data = self.tensor.clone().into_data();
27        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
28        let mut out_vals = vec![0.0f32; c * new_height * new_width];
29
30        // Solve M inverse using standard Cramer's rule for the 2x2 part of the matrix
31        let det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
32        if det.abs() < 1e-9 {
33            return Err(IrisError::InvalidParameter(
34                "Transformation matrix is singular".into(),
35            ));
36        }
37        let inv_det = 1.0 / det;
38
39        // M_inv computation
40        let a_inv = [
41            [m[1][1] * inv_det, -m[0][1] * inv_det],
42            [-m[1][0] * inv_det, m[0][0] * inv_det],
43        ];
44        let tx_inv = -(a_inv[0][0] * m[0][2] + a_inv[0][1] * m[1][2]);
45        let ty_inv = -(a_inv[1][0] * m[0][2] + a_inv[1][1] * m[1][2]);
46
47        {
48            use rayon::prelude::*;
49            out_vals
50                .par_chunks_exact_mut(new_width)
51                .enumerate()
52                .for_each(|(idx, row)| {
53                    let ch = idx / new_height;
54                    let dy = idx % new_height;
55                    for dx in 0..new_width {
56                        // Map back to original coordinate space
57                        let sx = a_inv[0][0] * (dx as f64) + a_inv[0][1] * (dy as f64) + tx_inv;
58                        let sy = a_inv[1][0] * (dx as f64) + a_inv[1][1] * (dy as f64) + ty_inv;
59
60                        let sx_round = sx.round() as isize;
61                        let sy_round = sy.round() as isize;
62
63                        if sx_round >= 0
64                            && sx_round < w as isize
65                            && sy_round >= 0
66                            && sy_round < h as isize
67                        {
68                            row[dx] = flat_vals
69                                [ch * h * w + (sy_round as usize) * w + (sx_round as usize)];
70                        }
71                    }
72                });
73        }
74
75        let new_data = TensorData::new(out_vals, [c, new_height, new_width]);
76        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
77        Ok(Image::new(new_tensor))
78    }
79
80    /// Warps the image using a 3x3 homography / perspective transformation matrix.
81    pub fn warp_perspective(
82        &self,
83        m: [[f64; 3]; 3],
84        new_width: usize,
85        new_height: usize,
86    ) -> Result<Self> {
87        let dims = self.tensor.dims();
88        let c = dims[0];
89        let h = dims[1];
90        let w = dims[2];
91
92        let device = self.tensor.device();
93        let tensor_data = self.tensor.clone().into_data();
94        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
95        let mut out_vals = vec![0.0f32; c * new_height * new_width];
96
97        // Invert the 3x3 matrix using standard determinant inverse formula
98        let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
99            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
100            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
101
102        if det.abs() < 1e-9 {
103            return Err(IrisError::InvalidParameter(
104                "Perspective matrix is singular".into(),
105            ));
106        }
107        let inv_det = 1.0 / det;
108
109        let m_inv = [
110            [
111                (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
112                (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
113                (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
114            ],
115            [
116                (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
117                (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
118                (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
119            ],
120            [
121                (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
122                (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
123                (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
124            ],
125        ];
126
127        {
128            use rayon::prelude::*;
129            out_vals
130                .par_chunks_exact_mut(new_width)
131                .enumerate()
132                .for_each(|(idx, row)| {
133                    let ch = idx / new_height;
134                    let dy = idx % new_height;
135                    for dx in 0..new_width {
136                        let x_mapped =
137                            m_inv[0][0] * (dx as f64) + m_inv[0][1] * (dy as f64) + m_inv[0][2];
138                        let y_mapped =
139                            m_inv[1][0] * (dx as f64) + m_inv[1][1] * (dy as f64) + m_inv[1][2];
140                        let z_mapped =
141                            m_inv[2][0] * (dx as f64) + m_inv[2][1] * (dy as f64) + m_inv[2][2];
142
143                        if z_mapped.abs() > 1e-9 {
144                            let sx = x_mapped / z_mapped;
145                            let sy = y_mapped / z_mapped;
146                            let sx_round = sx.round() as isize;
147                            let sy_round = sy.round() as isize;
148
149                            if sx_round >= 0
150                                && sx_round < w as isize
151                                && sy_round >= 0
152                                && sy_round < h as isize
153                            {
154                                row[dx] = flat_vals
155                                    [ch * h * w + (sy_round as usize) * w + (sx_round as usize)];
156                            }
157                        }
158                    }
159                });
160        }
161
162        let new_data = TensorData::new(out_vals, [c, new_height, new_width]);
163        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
164        Ok(Image::new(new_tensor))
165    }
166
167    /// Remaps pixel positions using horizontal and vertical coordinates maps.
168    pub fn remap(&self, map_x: &Tensor<B, 2>, map_y: &Tensor<B, 2>) -> Result<Self> {
169        let dims = self.tensor.dims();
170        let c = dims[0];
171        let h = dims[1];
172        let w = dims[2];
173
174        let map_dims = map_x.dims();
175        let out_h = map_dims[0];
176        let out_w = map_dims[1];
177
178        let device = self.tensor.device();
179        let tensor_data = self.tensor.clone().into_data();
180        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
181
182        let data_map_x = map_x.clone().into_data();
183        let data_map_y = map_y.clone().into_data();
184        let float_map_x: Vec<f32> = data_map_x.iter::<f32>().collect();
185        let float_map_y: Vec<f32> = data_map_y.iter::<f32>().collect();
186
187        let mut out_vals = vec![0.0f32; c * out_h * out_w];
188
189        {
190            use rayon::prelude::*;
191            out_vals
192                .par_chunks_exact_mut(out_w)
193                .enumerate()
194                .for_each(|(idx, row)| {
195                    let ch = idx / out_h;
196                    let dy = idx % out_h;
197                    for dx in 0..out_w {
198                        let map_idx = dy * out_w + dx;
199                        let sx = float_map_x[map_idx].round() as isize;
200                        let sy = float_map_y[map_idx].round() as isize;
201
202                        if sx >= 0 && sx < w as isize && sy >= 0 && sy < h as isize {
203                            row[dx] = flat_vals[ch * h * w + (sy as usize) * w + (sx as usize)];
204                        }
205                    }
206                });
207        }
208
209        let new_data = TensorData::new(out_vals, [c, out_h, out_w]);
210        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
211        Ok(Image::new(new_tensor))
212    }
213
214    /// Undistorts an image using a camera intrinsic matrix and distortion coefficients.
215    ///
216    /// Supports up to 5 radial distortion coefficients (k1..k5) and 2 tangential
217    /// distortion coefficients (p1, p2) following the Brown-Conrady model.
218    ///
219    /// # Arguments
220    /// * `camera_matrix` - 3x3 camera intrinsic matrix as a 2D tensor.
221    ///   Expected layout: `[[fx, 0, cx], [0, fy, cy], [0, 0, 1]]`.
222    /// * `dist_coeffs` - Distortion coefficients `[k1, k2, p1, p2, k3]`. Any
223    ///   trailing values beyond the 5th element are ignored.
224    pub fn undistort(&self, camera_matrix: &Tensor<B, 2>, dist_coeffs: &[f32]) -> Result<Self> {
225        let dims = self.tensor.dims();
226        let c = dims[0];
227        let h = dims[1];
228        let w = dims[2];
229
230        let cm_data = camera_matrix.clone().into_data();
231        let cm_vals: Vec<f32> = cm_data.iter::<f32>().collect();
232        if cm_vals.len() < 9 {
233            return Err(IrisError::InvalidParameter(
234                "Camera matrix must be 3x3".into(),
235            ));
236        }
237        let fx = cm_vals[0] as f64;
238        let fy = cm_vals[4] as f64;
239        let cx = cm_vals[2] as f64;
240        let cy = cm_vals[5] as f64;
241
242        let k1 = dist_coeffs.first().copied().unwrap_or(0.0) as f64;
243        let k2 = dist_coeffs.get(1).copied().unwrap_or(0.0) as f64;
244        let p1 = dist_coeffs.get(2).copied().unwrap_or(0.0) as f64;
245        let p2 = dist_coeffs.get(3).copied().unwrap_or(0.0) as f64;
246        let k3 = dist_coeffs.get(4).copied().unwrap_or(0.0) as f64;
247
248        let tensor_data = self.tensor.clone().into_data();
249        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
250        let mut out_vals = vec![0.0f32; c * h * w];
251
252        {
253            use rayon::prelude::*;
254            out_vals
255                .par_chunks_exact_mut(w)
256                .enumerate()
257                .for_each(|(idx, row)| {
258                    let ch = idx / h;
259                    let dy = idx % h;
260                    for dx in 0..w {
261                        // Map output pixel to normalized camera coordinates
262                        let x_cam = (dx as f64 - cx) / fx;
263                        let y_cam = (dy as f64 - cy) / fy;
264
265                        let r2 = x_cam * x_cam + y_cam * y_cam;
266                        let r4 = r2 * r2;
267                        let r6 = r4 * r2;
268
269                        // Radial factor
270                        let radial = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
271
272                        // Tangential distortion
273                        let x_distorted = x_cam * radial
274                            + 2.0 * p1 * x_cam * y_cam
275                            + p2 * (r2 + 2.0 * x_cam * x_cam);
276                        let y_distorted = y_cam * radial
277                            + p1 * (r2 + 2.0 * y_cam * y_cam)
278                            + 2.0 * p2 * x_cam * y_cam;
279
280                        // Back to pixel coordinates in source image
281                        let sx = (fx * x_distorted + cx).round() as isize;
282                        let sy = (fy * y_distorted + cy).round() as isize;
283
284                        if sx >= 0 && sx < w as isize && sy >= 0 && sy < h as isize {
285                            row[dx] = flat_vals[ch * h * w + (sy as usize) * w + (sx as usize)];
286                        }
287                    }
288                });
289        }
290
291        let new_data = TensorData::new(out_vals, [c, h, w]);
292        let new_tensor = Tensor::<B, 3>::from_data(new_data, &self.tensor.device());
293        Ok(Image::new(new_tensor))
294    }
295
296    /// Downsample one level of the Gaussian pyramid.
297    ///
298    /// The image is first convolved with a 5x5 Gaussian kernel (sigma = 1.0) and
299    /// then every other row and column are discarded, halving both spatial
300    /// dimensions while keeping the channel count unchanged.
301    pub fn pyr_down(&self) -> Result<Self> {
302        let dims = self.tensor.dims();
303        let c = dims[0];
304        let h = dims[1];
305        let w = dims[2];
306
307        if h < 2 || w < 2 {
308            return Err(IrisError::InvalidParameter(
309                "Image too small for pyr_down (need at least 2x2)".into(),
310            ));
311        }
312
313        let new_h = h / 2;
314        let new_w = w / 2;
315
316        let tensor_data = self.tensor.clone().into_data();
317        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
318
319        // 5x5 Gaussian kernel (sigma = 1.0, pre-normalized)
320        let kernel: [f64; 25] = [
321            1.0, 4.0, 6.0, 4.0, 1.0, 4.0, 16.0, 24.0, 16.0, 4.0, 6.0, 24.0, 36.0, 24.0, 6.0, 4.0,
322            16.0, 24.0, 16.0, 4.0, 1.0, 4.0, 6.0, 4.0, 1.0,
323        ];
324        let ksum: f64 = 256.0;
325
326        let mut out_vals = vec![0.0f32; c * new_h * new_w];
327
328        {
329            use rayon::prelude::*;
330            out_vals
331                .par_chunks_exact_mut(new_w)
332                .enumerate()
333                .for_each(|(idx, row)| {
334                    let ch = idx / new_h;
335                    let dy = idx % new_h;
336                    for dx in 0..new_w {
337                        // Source pixel at (dx*2, dy*2) with 5x5 neighbourhood
338                        let sx_base = (dx * 2) as isize - 2;
339                        let sy_base = (dy * 2) as isize - 2;
340
341                        let mut sum = 0.0f64;
342                        for ky in 0..5i32 {
343                            for kx in 0..5i32 {
344                                let px = sx_base + kx as isize;
345                                let py = sy_base + ky as isize;
346                                let px = px.clamp(0, w as isize - 1) as usize;
347                                let py = py.clamp(0, h as isize - 1) as usize;
348                                let pixel = flat_vals[ch * h * w + py * w + px] as f64;
349                                sum += pixel * kernel[(ky * 5 + kx) as usize];
350                            }
351                        }
352                        row[dx] = (sum / ksum) as f32;
353                    }
354                });
355        }
356
357        let new_data = TensorData::new(out_vals, [c, new_h, new_w]);
358        let new_tensor = Tensor::<B, 3>::from_data(new_data, &self.tensor.device());
359        Ok(Image::new(new_tensor))
360    }
361
362    /// Upsample one level of the Gaussian pyramid.
363    ///
364    /// Inserts a zero row/column between every pair of existing rows/columns,
365    /// convolves with the same 5x5 Gaussian kernel, and scales by 4 to
366    /// compensate for the energy lost by the zero-insertion. The output
367    /// dimensions are `2 * (h - 1) + 1` by `2 * (w - 1) + 1`.
368    pub fn pyr_up(&self) -> Result<Self> {
369        let dims = self.tensor.dims();
370        let c = dims[0];
371        let h = dims[1];
372        let w = dims[2];
373
374        let new_h = 2 * (h - 1) + 1;
375        let new_w = 2 * (w - 1) + 1;
376
377        let tensor_data = self.tensor.clone().into_data();
378        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
379
380        // 5x5 Gaussian kernel (sigma = 1.0, pre-normalized)
381        let kernel: [f64; 25] = [
382            1.0, 4.0, 6.0, 4.0, 1.0, 4.0, 16.0, 24.0, 16.0, 4.0, 6.0, 24.0, 36.0, 24.0, 6.0, 4.0,
383            16.0, 24.0, 16.0, 4.0, 1.0, 4.0, 6.0, 4.0, 1.0,
384        ];
385        let ksum: f64 = 256.0;
386
387        // Step 1: Build upsampled (zero-inserted) image of shape [c, new_h, new_w]
388        let mut up_vals = vec![0.0f32; c * new_h * new_w];
389        for ch in 0..c {
390            for sy in 0..h {
391                for sx in 0..w {
392                    up_vals[ch * new_h * new_w + sy * 2 * new_w + sx * 2] =
393                        flat_vals[ch * h * w + sy * w + sx];
394                }
395            }
396        }
397
398        // Step 2: Convolve with 5x5 Gaussian and scale by 4
399        let mut out_vals = vec![0.0f32; c * new_h * new_w];
400
401        {
402            use rayon::prelude::*;
403            out_vals
404                .par_chunks_exact_mut(new_w)
405                .enumerate()
406                .for_each(|(idx, row)| {
407                    let ch = idx / new_h;
408                    let dy = idx % new_h;
409                    for dx in 0..new_w {
410                        let sx_base = dx as isize - 2;
411                        let sy_base = dy as isize - 2;
412
413                        let mut sum = 0.0f64;
414                        for ky in 0..5i32 {
415                            for kx in 0..5i32 {
416                                let px =
417                                    (sx_base + kx as isize).clamp(0, new_w as isize - 1) as usize;
418                                let py =
419                                    (sy_base + ky as isize).clamp(0, new_h as isize - 1) as usize;
420                                let pixel = up_vals[ch * new_h * new_w + py * new_w + px] as f64;
421                                sum += pixel * kernel[(ky * 5 + kx) as usize];
422                            }
423                        }
424                        row[dx] = (sum * 4.0 / ksum) as f32;
425                    }
426                });
427        }
428
429        let new_data = TensorData::new(out_vals, [c, new_h, new_w]);
430        let new_tensor = Tensor::<B, 3>::from_data(new_data, &self.tensor.device());
431        Ok(Image::new(new_tensor))
432    }
433}
434
435/// Helper functions to compute geometric matrices.
436pub struct GeometricTransform;
437
438impl GeometricTransform {
439    /// Computes a 2x3 affine matrix for a rotation around center with given angle (degrees) and scale.
440    #[must_use]
441    pub fn get_rotation_matrix_2d(
442        center: Point<f64>,
443        angle_degrees: f64,
444        scale: f64,
445    ) -> [[f64; 3]; 2] {
446        let angle_rad = angle_degrees.to_radians();
447        let alpha = scale * angle_rad.cos();
448        let beta = scale * angle_rad.sin();
449
450        [
451            [alpha, beta, (1.0 - alpha) * center.x - beta * center.y],
452            [-beta, alpha, beta * center.x + (1.0 - alpha) * center.y],
453        ]
454    }
455
456    /// Computes a 2x3 affine matrix matching 3 point correspondence pairs.
457    #[must_use]
458    pub fn get_affine_transform(src: &[Point<f64>; 3], dst: &[Point<f64>; 3]) -> [[f64; 3]; 2] {
459        // Solves the linear system:
460        // dst[i].x = a*src[i].x + b*src[i].y + c
461        // dst[i].y = d*src[i].x + e*src[i].y + f
462        let solve = |pts_d: [f64; 3]| -> [f64; 3] {
463            // Solve matrix using simple Gaussian elimination/Cramer's rule
464            let a11 = src[0].x;
465            let a12 = src[0].y;
466            let a13 = 1.0;
467            let a21 = src[1].x;
468            let a22 = src[1].y;
469            let a23 = 1.0;
470            let a31 = src[2].x;
471            let a32 = src[2].y;
472            let a33 = 1.0;
473
474            let det = a11 * (a22 * a33 - a23 * a32) - a12 * (a21 * a33 - a23 * a31)
475                + a13 * (a21 * a32 - a22 * a31);
476            if det.abs() < 1e-9 {
477                return [0.0, 0.0, 0.0];
478            }
479            let det_x = pts_d[0] * (a22 * a33 - a23 * a32)
480                - a12 * (pts_d[1] * a33 - a23 * pts_d[2])
481                + a13 * (pts_d[1] * a32 - a22 * pts_d[2]);
482            let det_y = a11 * (pts_d[1] * a33 - a23 * pts_d[2])
483                - pts_d[0] * (a21 * a33 - a23 * a31)
484                + a13 * (a21 * pts_d[2] - pts_d[1] * a31);
485            let det_z = a11 * (a22 * pts_d[2] - pts_d[1] * a32)
486                - a12 * (a21 * pts_d[2] - pts_d[1] * a31)
487                + pts_d[0] * (a21 * a32 - a22 * a31);
488
489            [det_x / det, det_y / det, det_z / det]
490        };
491
492        let row1 = solve([dst[0].x, dst[1].x, dst[2].x]);
493        let row2 = solve([dst[0].y, dst[1].y, dst[2].y]);
494        [row1, row2]
495    }
496
497    /// Computes a 3x3 perspective matrix matching 4 point correspondence pairs.
498    #[must_use]
499    pub fn get_perspective_transform(
500        src: &[Point<f64>; 4],
501        dst: &[Point<f64>; 4],
502    ) -> [[f64; 3]; 3] {
503        // Solves perspective mapping from 4 source coordinates to 4 destination coordinates.
504        // We write the linear equations and compute using direct coefficients matching.
505        let mut m = [[0.0; 3]; 3];
506
507        let x0 = src[0].x;
508        let y0 = src[0].y;
509        let x1 = src[1].x;
510        let y1 = src[1].y;
511        let x2 = src[2].x;
512        let y2 = src[2].y;
513        let x3 = src[3].x;
514        let y3 = src[3].y;
515
516        let _u0 = dst[0].x;
517        let _v0 = dst[0].y;
518        let _u1 = dst[1].x;
519        let _v1 = dst[1].y;
520        let _u2 = dst[2].x;
521        let _v2 = dst[2].y;
522        let _u3 = dst[3].x;
523        let _v3 = dst[3].y;
524
525        let dx1 = x1 - x2;
526        let dx2 = x3 - x2;
527        let dy1 = y1 - y2;
528        let dy2 = y3 - y2;
529        let dx3 = x0 - x1 + x2 - x3;
530        let dy3 = y0 - y1 + y2 - y3;
531
532        let det = dx1 * dy2 - dx2 * dy1;
533        if det.abs() < 1e-9 {
534            m[0][0] = 1.0;
535            m[1][1] = 1.0;
536            m[2][2] = 1.0;
537            return m;
538        }
539
540        let g = (dx3 * dy2 - dx2 * dy3) / det;
541        let h = (dx1 * dy3 - dx3 * dy1) / det;
542
543        let a = x1 - x0 + g * x1;
544        let b = x3 - x0 + h * x3;
545        let c = x0;
546        let d = y1 - y0 + g * y1;
547        let e = y3 - y0 + h * y3;
548        let f = y0;
549
550        // Mapping from src to dst. For warp, we invert this coefficients
551        m[0][0] = a;
552        m[0][1] = b;
553        m[0][2] = c;
554        m[1][0] = d;
555        m[1][1] = e;
556        m[1][2] = f;
557        m[2][0] = g;
558        m[2][1] = h;
559        m[2][2] = 1.0;
560
561        m
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::test_helpers::{TestBackend, test_device};
569    use burn::tensor::TensorData;
570
571    #[test]
572    fn test_geometric_transforms() {
573        let device = test_device();
574        let flat_data = vec![0.5f32; 3 * 10 * 10];
575        let tensor =
576            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 10, 10]), &device);
577        let img = Image::new(tensor);
578
579        let resized = img.resize(20, 20).unwrap();
580        assert_eq!(resized.shape(), [3, 20, 20]);
581
582        let warped_aff = img
583            .warp_affine([[1.0, 0.0, 2.0], [0.0, 1.0, 3.0]], 10, 10)
584            .unwrap();
585        assert_eq!(warped_aff.shape(), [3, 10, 10]);
586
587        let warped_persp = img
588            .warp_perspective([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 10, 10)
589            .unwrap();
590        assert_eq!(warped_persp.shape(), [3, 10, 10]);
591
592        let map_x = Tensor::<TestBackend, 2>::zeros([10, 10], &device);
593        let map_y = Tensor::<TestBackend, 2>::zeros([10, 10], &device);
594        let remapped = img.remap(&map_x, &map_y).unwrap();
595        assert_eq!(remapped.shape(), [3, 10, 10]);
596
597        let rotated = img.rotate(90).unwrap();
598        assert_eq!(rotated.shape(), [3, 10, 10]);
599    }
600
601    #[test]
602    fn test_undistort_identity() {
603        let device = test_device();
604        let flat_data: Vec<f32> = (0..(3 * 8 * 8)).map(|i| i as f32 / 192.0).collect();
605        let tensor =
606            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 8, 8]), &device);
607        let img = Image::new(tensor);
608
609        // Identity camera matrix (pixels == normalised coords)
610        let cam = Tensor::<TestBackend, 2>::from_data(
611            TensorData::new(vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], [3, 3]),
612            &device,
613        );
614        // No distortion
615        let dist: [f32; 0] = [];
616
617        let undistorted = img.undistort(&cam, &dist).unwrap();
618        assert_eq!(undistorted.shape(), [3, 8, 8]);
619
620        // With zero distortion coefficients the output must match the input exactly
621        let dist_zero = [0.0f32; 5];
622        let undistorted2 = img.undistort(&cam, &dist_zero).unwrap();
623        let orig_data: Vec<f32> = img.tensor.clone().into_data().iter::<f32>().collect();
624        let ud_data: Vec<f32> = undistorted2
625            .tensor
626            .clone()
627            .into_data()
628            .iter::<f32>()
629            .collect();
630        for (a, b) in orig_data.iter().zip(ud_data.iter()) {
631            assert!((a - b).abs() < 1e-6, "Mismatch: {a} vs {b}");
632        }
633    }
634
635    #[test]
636    fn test_undistort_with_k1() {
637        let device = test_device();
638        let flat_data: Vec<f32> = (0..(3 * 8 * 8)).map(|i| i as f32 / 192.0).collect();
639        let tensor =
640            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 8, 8]), &device);
641        let img = Image::new(tensor);
642
643        let cam = Tensor::<TestBackend, 2>::from_data(
644            TensorData::new(vec![1.0, 0.0, 3.5, 0.0, 1.0, 3.5, 0.0, 0.0, 1.0], [3, 3]),
645            &device,
646        );
647        let dist_coeffs = [0.1, 0.0, 0.0, 0.0, 0.0];
648
649        let undistorted = img.undistort(&cam, &dist_coeffs).unwrap();
650        assert_eq!(undistorted.shape(), [3, 8, 8]);
651
652        // Result should differ from original (non-zero distortion applied)
653        let orig_data: Vec<f32> = img.tensor.clone().into_data().iter::<f32>().collect();
654        let ud_data: Vec<f32> = undistorted
655            .tensor
656            .clone()
657            .into_data()
658            .iter::<f32>()
659            .collect();
660        let mut differs = false;
661        for (a, b) in orig_data.iter().zip(ud_data.iter()) {
662            if (a - b).abs() > 1e-6 {
663                differs = true;
664                break;
665            }
666        }
667        assert!(differs, "Undistortion with k1 should change pixel values");
668    }
669
670    #[test]
671    fn test_pyr_down_up_roundtrip() {
672        let device = test_device();
673        let flat_data = vec![0.5f32; 3 * 8 * 8];
674        let tensor =
675            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 8, 8]), &device);
676        let img = Image::new(tensor);
677
678        let down = img.pyr_down().unwrap();
679        // pyr_down halves dimensions
680        assert_eq!(down.shape(), [3, 4, 4]);
681
682        let up = down.pyr_up().unwrap();
683        // pyr_up: 2*(4-1)+1 = 7
684        assert_eq!(up.shape(), [3, 7, 7]);
685    }
686
687    #[test]
688    fn test_pyr_down_preserves_energy() {
689        let device = test_device();
690        // Create an image with a bright region so we can check energy is mostly preserved
691        let mut flat_data = vec![0.0f32; 3 * 16 * 16];
692        for c in 0..3 {
693            for y in 4..12 {
694                for x in 4..12 {
695                    flat_data[c * 256 + y * 16 + x] = 1.0;
696                }
697            }
698        }
699        let tensor =
700            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 16, 16]), &device);
701        let img = Image::new(tensor);
702
703        let down = img.pyr_down().unwrap();
704        assert_eq!(down.shape(), [3, 8, 8]);
705
706        // The downsampled image should still have bright pixels
707        let down_data: Vec<f32> = down.tensor.clone().into_data().iter::<f32>().collect();
708        let max_val = down_data.iter().cloned().fold(0.0f32, f32::max);
709        assert!(
710            max_val > 0.5,
711            "pyr_down should preserve bright region, got max={max_val}"
712        );
713    }
714}