Skip to main content

iris/stitching/
mod.rs

1use crate::error::{IrisError, Result};
2use crate::features::{BFMatcher, FeatureDetector, FeatureType};
3use crate::image::Image;
4use burn::tensor::backend::Backend;
5
6/// Image Stitcher for panorama creation.
7///
8/// Uses ORB feature detection, brute-force descriptor matching, and
9/// homography estimation via DLT with RANSAC to warp and blend images.
10pub struct Stitcher;
11
12impl Stitcher {
13    /// Stitches a list of images into a single panorama.
14    ///
15    /// - If only one image is provided, it is returned as-is.
16    /// - For two or more images, computes homography between consecutive pairs
17    ///   and warps them onto a common canvas using the first image as anchor.
18    pub fn stitch<B: Backend>(&self, images: &[Image<B>]) -> Result<Image<B>> {
19        if images.is_empty() {
20            return Err(IrisError::InvalidParameter(
21                "Images list cannot be empty".into(),
22            ));
23        }
24        if images.len() == 1 {
25            return Ok(images[0].clone());
26        }
27
28        // Compute homographies between consecutive pairs
29        let mut homographies: Vec<[[f64; 3]; 3]> = Vec::new();
30        for i in 0..images.len() - 1 {
31            let h = compute_homography(&images[i], &images[i + 1])?;
32            homographies.push(h);
33        }
34
35        // Accumulate homographies: image[i] -> image[0] (the anchor)
36        // accumulated[0] = identity
37        let mut accumulated: Vec<[[f64; 3]; 3]> =
38            vec![[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]];
39        for h in &homographies {
40            let prev = accumulated.last().unwrap();
41            accumulated.push(multiply_3x3(prev, h));
42        }
43
44        // Find the bounding box of the warped canvas
45        let mut min_x = 0.0_f64;
46        let mut min_y = 0.0_f64;
47        let mut max_x = 0.0_f64;
48        let mut max_y = 0.0_f64;
49
50        for (idx, img) in images.iter().enumerate() {
51            let h = img.height() as f64;
52            let w = img.width() as f64;
53            let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)];
54
55            let h_mat = &accumulated[idx];
56            for &(cx, cy) in &corners {
57                let denom = h_mat[2][0] * cx + h_mat[2][1] * cy + h_mat[2][2];
58                if denom.abs() > 1e-10 {
59                    let wx = (h_mat[0][0] * cx + h_mat[0][1] * cy + h_mat[0][2]) / denom;
60                    let wy = (h_mat[1][0] * cx + h_mat[1][1] * cy + h_mat[1][2]) / denom;
61                    min_x = min_x.min(wx);
62                    min_y = min_y.min(wy);
63                    max_x = max_x.max(wx);
64                    max_y = max_y.max(wy);
65                }
66            }
67        }
68
69        // Compute canvas dimensions and translation to shift all into positive coords
70        let canvas_w = (max_x - min_x).ceil() as usize;
71        let canvas_h = (max_y - min_y).ceil() as usize;
72        let tx = -min_x;
73        let ty = -min_y;
74
75        // Translation matrix
76        let t = [[1.0, 0.0, tx], [0.0, 1.0, ty], [0.0, 0.0, 1.0]];
77
78        // Accumulate weight map for blending (for overlap regions)
79        let mut weight_canvas = vec![0.0f32; canvas_h * canvas_w];
80        let mut out_vals = vec![0.0f32; 3 * canvas_h * canvas_w];
81
82        for (idx, img) in images.iter().enumerate() {
83            let d = img.tensor.dims();
84            let img_h = d[1];
85            let img_w = d[2];
86            let data = img.tensor.clone().into_data();
87            let flat: Vec<f32> = data.iter::<f32>().collect();
88
89            // Combine accumulated homography with translation
90            let h_final = multiply_3x3(&t, &accumulated[idx]);
91
92            // Invert for backward mapping
93            let h_inv = invert_3x3(&h_final).ok_or_else(|| {
94                IrisError::InvalidParameter(format!("Singular homography for image pair {}", idx))
95            })?;
96
97            for dy in 0..canvas_h {
98                for dx in 0..canvas_w {
99                    let denom = h_inv[2][0] * dx as f64 + h_inv[2][1] * dy as f64 + h_inv[2][2];
100                    if denom.abs() < 1e-10 {
101                        continue;
102                    }
103                    let sx =
104                        (h_inv[0][0] * dx as f64 + h_inv[0][1] * dy as f64 + h_inv[0][2]) / denom;
105                    let sy =
106                        (h_inv[1][0] * dx as f64 + h_inv[1][1] * dy as f64 + h_inv[1][2]) / denom;
107
108                    let sx_r = sx.round() as isize;
109                    let sy_r = sy.round() as isize;
110
111                    if sx_r >= 0 && sx_r < img_w as isize && sy_r >= 0 && sy_r < img_h as isize {
112                        let src_x = sx_r as usize;
113                        let src_y = sy_r as usize;
114
115                        // Distance-based weight: prefer pixels closer to image center
116                        let cx = src_x as f64 - img_w as f64 / 2.0;
117                        let cy = src_y as f64 - img_h as f64 / 2.0;
118                        let max_r = (img_w as f64 + img_h as f64) / 4.0;
119                        let dist = (cx * cx + cy * cy).sqrt() / max_r;
120                        let w = (1.0 - dist).max(0.0) as f32;
121
122                        let ci = dy * canvas_w + dx;
123                        for ch in 0..3 {
124                            let src_idx = ch * img_h * img_w + src_y * img_w + src_x;
125                            out_vals[ch * canvas_h * canvas_w + ci] += flat[src_idx] * w;
126                        }
127                        weight_canvas[ci] += w;
128                    }
129                }
130            }
131        }
132
133        // Normalize by accumulated weights
134        for ci in 0..canvas_h * canvas_w {
135            if weight_canvas[ci] > 1e-10 {
136                for ch in 0..3 {
137                    let idx = ch * canvas_h * canvas_w + ci;
138                    out_vals[idx] = (out_vals[idx] / weight_canvas[ci]).clamp(0.0, 1.0);
139                }
140            }
141        }
142
143        let device = images[0].tensor.device();
144        let data = burn::tensor::TensorData::new(out_vals, [3, canvas_h, canvas_w]);
145        let tensor = burn::tensor::Tensor::<B, 3>::from_data(data, &device);
146        Ok(Image::new(tensor))
147    }
148}
149
150/// Compute homography from img1 to img2 using ORB features + BFMatcher + DLT + RANSAC.
151fn compute_homography<B: Backend>(img1: &Image<B>, img2: &Image<B>) -> Result<[[f64; 3]; 3]> {
152    let detector = FeatureDetector::new(FeatureType::ORB).with_max_features(500);
153
154    let kps1 = detector.detect(img1)?;
155    let kps2 = detector.detect(img2)?;
156
157    if kps1.len() < 4 || kps2.len() < 4 {
158        return Ok([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
159    }
160
161    let desc1 = detector.compute(img1, &kps1)?;
162    let desc2 = detector.compute(img2, &kps2)?;
163
164    let matcher = BFMatcher;
165    let matches = matcher.match_descriptors(&desc1, &desc2)?;
166
167    // Filter matches with Lowe's ratio test (approximate with distance threshold)
168    let median_dist = {
169        let mut dists: Vec<f32> = matches.iter().map(|m| m.distance).collect();
170        dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
171        dists[dists.len() / 2]
172    };
173    let threshold = median_dist * 1.5;
174
175    let good_matches: Vec<_> = matches.iter().filter(|m| m.distance <= threshold).collect();
176
177    if good_matches.len() < 4 {
178        return Err(IrisError::InvalidParameter(
179            "Not enough good matches for homography estimation".into(),
180        ));
181    }
182
183    // Collect matched point pairs
184    let pts1: Vec<(f64, f64)> = good_matches
185        .iter()
186        .map(|m| {
187            let kp = &kps1[m.query_idx];
188            (kp.pt.x, kp.pt.y)
189        })
190        .collect();
191    let pts2: Vec<(f64, f64)> = good_matches
192        .iter()
193        .map(|m| {
194            let kp = &kps2[m.train_idx];
195            (kp.pt.x, kp.pt.y)
196        })
197        .collect();
198
199    // RANSAC homography estimation
200    let h = ransac_homography(&pts1, &pts2, 1000, 5.0)?;
201    Ok(h)
202}
203
204/// RANSAC-based homography estimation using DLT.
205fn ransac_homography(
206    src: &[(f64, f64)],
207    dst: &[(f64, f64)],
208    max_iterations: usize,
209    inlier_threshold: f64,
210) -> Result<[[f64; 3]; 3]> {
211    let n = src.len();
212    if n < 4 {
213        return Err(IrisError::InvalidParameter(
214            "Need at least 4 point pairs for homography".into(),
215        ));
216    }
217
218    let mut best_h = [[0.0f64; 3]; 3];
219    let mut best_inliers = 0;
220
221    // Deterministic pseudo-random
222    let mut seed: u64 = 0xDEAD_BEEF_CAFE_BABE;
223
224    for _ in 0..max_iterations {
225        // Pick 4 random distinct points
226        let mut indices = [0usize; 4];
227        let mut used = vec![false; n];
228        let mut valid = true;
229        for i in 0..4 {
230            loop {
231                seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
232                let idx = ((seed >> 33) as usize) % n;
233                if !used[idx] {
234                    used[idx] = true;
235                    indices[i] = idx;
236                    break;
237                }
238            }
239            if indices[i] >= n {
240                valid = false;
241                break;
242            }
243        }
244        if !valid {
245            continue;
246        }
247
248        let sample_src: Vec<(f64, f64)> = indices.iter().map(|&i| src[i]).collect();
249        let sample_dst: Vec<(f64, f64)> = indices.iter().map(|&i| dst[i]).collect();
250
251        if let Ok(h_candidate) = compute_homography_dlt(&sample_src, &sample_dst) {
252            // Count inliers
253            let mut inlier_count = 0;
254            for (s, d) in src.iter().zip(dst.iter()) {
255                let projected = apply_homography(&h_candidate, s.0, s.1);
256                let dx = projected.0 - d.0;
257                let dy = projected.1 - d.1;
258                if (dx * dx + dy * dy).sqrt() < inlier_threshold {
259                    inlier_count += 1;
260                }
261            }
262
263            if inlier_count > best_inliers {
264                best_inliers = inlier_count;
265                best_h = h_candidate;
266            }
267        }
268    }
269
270    if best_inliers == 0 {
271        return Err(IrisError::Generic(
272            "RANSAC homography failed to find any inliers".into(),
273        ));
274    }
275
276    // Refine with all inliers
277    let inlier_src: Vec<(f64, f64)> = src
278        .iter()
279        .zip(dst.iter())
280        .filter_map(|(s, d)| {
281            let projected = apply_homography(&best_h, s.0, s.1);
282            let dx = projected.0 - d.0;
283            let dy = projected.1 - d.1;
284            if (dx * dx + dy * dy).sqrt() < inlier_threshold {
285                Some(*s)
286            } else {
287                None
288            }
289        })
290        .collect();
291    let inlier_dst: Vec<(f64, f64)> = src
292        .iter()
293        .zip(dst.iter())
294        .filter_map(|(s, d)| {
295            let projected = apply_homography(&best_h, s.0, s.1);
296            let dx = projected.0 - d.0;
297            let dy = projected.1 - d.1;
298            if (dx * dx + dy * dy).sqrt() < inlier_threshold {
299                Some(*d)
300            } else {
301                None
302            }
303        })
304        .collect();
305
306    if inlier_src.len() >= 4 {
307        compute_homography_dlt(&inlier_src, &inlier_dst)
308    } else {
309        Ok(best_h)
310    }
311}
312
313/// Direct Linear Transform (DLT) homography computation from 4+ point correspondences.
314/// Solves Ah = 0 via Gaussian elimination on the 2n×9 matrix A.
315fn compute_homography_dlt(src: &[(f64, f64)], dst: &[(f64, f64)]) -> Result<[[f64; 3]; 3]> {
316    let n = src.len();
317    if n < 4 {
318        return Err(IrisError::InvalidParameter(
319            "DLT requires at least 4 point pairs".into(),
320        ));
321    }
322
323    // Normalize points (translate centroid to origin, scale to mean distance sqrt(2))
324    let (src_norm, t_src) = normalize_points(src);
325    let (dst_norm, t_dst) = normalize_points(dst);
326
327    // Build 2n x 9 matrix A
328    let mut a = vec![0.0f64; 2 * n * 9];
329    for i in 0..n {
330        let (x, y) = src_norm[i];
331        let (xp, yp) = dst_norm[i];
332        // Row 2i
333        a[2 * i * 9] = -x;
334        a[(2 * i) * 9 + 1] = -y;
335        a[(2 * i) * 9 + 2] = -1.0;
336        a[(2 * i) * 9 + 6] = xp * x;
337        a[(2 * i) * 9 + 7] = xp * y;
338        a[(2 * i) * 9 + 8] = xp;
339        // Row 2i+1
340        a[(2 * i + 1) * 9 + 3] = -x;
341        a[(2 * i + 1) * 9 + 4] = -y;
342        a[(2 * i + 1) * 9 + 5] = -1.0;
343        a[(2 * i + 1) * 9 + 6] = yp * x;
344        a[(2 * i + 1) * 9 + 7] = yp * y;
345        a[(2 * i + 1) * 9 + 8] = yp;
346    }
347
348    // Find null space of A (2n x 9) via Gaussian elimination on A^T A (9 x 9)
349    let ata = multiply_at_a(&a, 2 * n, 9);
350
351    // Use Gaussian elimination with partial pivoting on the 9x9 system
352    // to find the null space vector
353    let h_vec = null_space_via_elimination(&ata, 9)?;
354
355    let mut h_norm = [
356        [h_vec[0], h_vec[1], h_vec[2]],
357        [h_vec[3], h_vec[4], h_vec[5]],
358        [h_vec[6], h_vec[7], h_vec[8]],
359    ];
360
361    // Denormalize: H = T_dst^{-1} * H_norm * T_src
362    let t_dst_inv = invert_3x3(&t_dst).ok_or_else(|| {
363        IrisError::InvalidParameter("Singular destination normalization transform".into())
364    })?;
365    let h_denorm = multiply_3x3(&t_dst_inv, &h_norm);
366    h_norm = multiply_3x3(&h_denorm, &t_src);
367
368    Ok(h_norm)
369}
370
371/// Find the null space vector of a symmetric n×n matrix via Gaussian elimination
372/// with partial pivoting. The matrix is assumed to have a 1D null space.
373fn null_space_via_elimination(m: &[f64], n: usize) -> Result<Vec<f64>> {
374    // Work on a copy
375    let mut mat = vec![0.0f64; n * n];
376    mat.copy_from_slice(m);
377
378    // Forward elimination with partial pivoting to get upper triangular form
379    for col in 0..n {
380        // Find pivot
381        let mut max_val = mat[col * n + col].abs();
382        let mut max_row = col;
383        for row in (col + 1)..n {
384            if mat[row * n + col].abs() > max_val {
385                max_val = mat[row * n + col].abs();
386                max_row = row;
387            }
388        }
389        if max_val < 1e-12 {
390            continue;
391        }
392        // Swap rows
393        for k in 0..n {
394            mat.swap(col * n + k, max_row * n + k);
395        }
396
397        // Eliminate below
398        let pivot = mat[col * n + col];
399        for row in (col + 1)..n {
400            let factor = mat[row * n + col] / pivot;
401            for k in col..n {
402                mat[row * n + k] -= factor * mat[col * n + k];
403            }
404        }
405    }
406
407    // Now mat is upper triangular. Find the column with the smallest pivot
408    // (the near-zero eigenvalue).
409    let mut free_col = 0;
410    let mut min_pivot = f64::MAX;
411    for col in 0..n {
412        let piv = mat[col * n + col].abs();
413        if piv < min_pivot {
414            min_pivot = piv;
415            free_col = col;
416        }
417    }
418
419    // Back-substitute to find the null space vector
420    // Set v[free_col] = 1, solve for the rest
421    let mut v = vec![0.0f64; n];
422    v[free_col] = 1.0;
423
424    // Process rows from bottom to top (excluding the free_col row)
425    for row in (0..n).rev() {
426        if row == free_col {
427            continue;
428        }
429        // mat[row][row] * v[row] + sum_{j>row} mat[row][j] * v[j] = 0
430        let mut sum = 0.0;
431        for j in (row + 1)..n {
432            sum += mat[row * n + j] * v[j];
433        }
434        if mat[row * n + row].abs() > 1e-15 {
435            v[row] = -sum / mat[row * n + row];
436        }
437    }
438
439    // Normalize
440    let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
441    if norm > 1e-15 {
442        for x in &mut v {
443            *x /= norm;
444        }
445    }
446
447    Ok(v)
448}
449
450/// Normalize point set: translate centroid to origin, scale to mean distance sqrt(2).
451fn normalize_points(points: &[(f64, f64)]) -> (Vec<(f64, f64)>, [[f64; 3]; 3]) {
452    let n = points.len() as f64;
453    let cx = points.iter().map(|p| p.0).sum::<f64>() / n;
454    let cy = points.iter().map(|p| p.1).sum::<f64>() / n;
455
456    let mean_dist = points
457        .iter()
458        .map(|p| ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt())
459        .sum::<f64>()
460        / n;
461
462    let scale = if mean_dist > 1e-10 {
463        std::f64::consts::SQRT_2 / mean_dist
464    } else {
465        1.0
466    };
467
468    let normalized: Vec<(f64, f64)> = points
469        .iter()
470        .map(|p| ((p.0 - cx) * scale, (p.1 - cy) * scale))
471        .collect();
472
473    let t = [
474        [scale, 0.0, -cx * scale],
475        [0.0, scale, -cy * scale],
476        [0.0, 0.0, 1.0],
477    ];
478
479    (normalized, t)
480}
481
482/// Multiply A^T * A to get a 9x9 symmetric matrix.
483fn multiply_at_a(a: &[f64], rows: usize, cols: usize) -> Vec<f64> {
484    let mut ata = vec![0.0f64; cols * cols];
485    for i in 0..cols {
486        for j in 0..cols {
487            let mut sum = 0.0f64;
488            for k in 0..rows {
489                sum += a[k * cols + i] * a[k * cols + j];
490            }
491            ata[i * cols + j] = sum;
492        }
493    }
494    ata
495}
496
497/// Find the null space of a symmetric 9x9 matrix using Jacobi eigenvalue
498/// decomposition. Returns the eigenvector corresponding to the smallest
499/// eigenvalue.
500#[allow(dead_code)]
501fn null_space_jacobi(m: &[f64]) -> Vec<f64> {
502    let n = 9;
503    let mut a = m.to_vec();
504    let mut v = vec![0.0f64; n * n];
505    for i in 0..n {
506        v[i * n + i] = 1.0;
507    }
508
509    for _ in 0..200 {
510        // Find largest off-diagonal element
511        let mut max_val = 0.0;
512        let mut p = 0;
513        let mut q = 1;
514        for i in 0..n {
515            for j in (i + 1)..n {
516                if a[i * n + j].abs() > max_val {
517                    max_val = a[i * n + j].abs();
518                    p = i;
519                    q = j;
520                }
521            }
522        }
523
524        if max_val < 1e-12 {
525            break;
526        }
527
528        let diff = a[p * n + p] - a[q * n + q];
529        let theta = if diff.abs() < 1e-15 {
530            std::f64::consts::FRAC_PI_4
531        } else {
532            0.5 * ((2.0 * a[p * n + q]) / diff).atan()
533        };
534
535        let c = theta.cos();
536        let s = theta.sin();
537
538        // Save rows p and q
539        let mut row_p = [0.0f64; 9];
540        let mut row_q = [0.0f64; 9];
541        for j in 0..n {
542            row_p[j] = a[p * n + j];
543            row_q[j] = a[q * n + j];
544        }
545
546        // Update columns p and q for all rows (except p and q)
547        for i in 0..n {
548            if i == p || i == q {
549                continue;
550            }
551            let aip = a[i * n + p];
552            let aiq = a[i * n + q];
553            a[i * n + p] = c * aip + s * aiq;
554            a[i * n + q] = -s * aip + c * aiq;
555        }
556
557        // Update rows p and q
558        for j in 0..n {
559            a[p * n + j] = c * row_p[j] + s * row_q[j];
560            a[q * n + j] = -s * row_p[j] + c * row_q[j];
561        }
562        a[p * n + q] = 0.0;
563        a[q * n + p] = 0.0;
564
565        // Update eigenvectors: V' = V * G
566        for i in 0..n {
567            let vip = v[i * n + p];
568            let viq = v[i * n + q];
569            v[i * n + p] = c * vip + s * viq;
570            v[i * n + q] = -s * vip + c * viq;
571        }
572    }
573
574    // Find eigenvector with smallest eigenvalue
575    let mut min_idx = 0;
576    let mut min_val = a[0];
577    for i in 1..n {
578        if a[i * n + i] < min_val {
579            min_val = a[i * n + i];
580            min_idx = i;
581        }
582    }
583
584    (0..n).map(|i| v[i * n + min_idx]).collect()
585}
586
587/// Solve a 9x9 linear system using Gaussian elimination with partial pivoting.
588#[allow(dead_code)]
589fn solve_9x9(a: &[f64], b: &[f64]) -> Option<Vec<f64>> {
590    let n = 9;
591    let mut aug = vec![vec![0.0f64; n + 1]; n];
592    for i in 0..n {
593        for j in 0..n {
594            aug[i][j] = a[i * n + j];
595        }
596        aug[i][n] = b[i];
597    }
598
599    // Forward elimination with partial pivoting
600    for col in 0..n {
601        // Find pivot
602        let mut max_val = aug[col][col].abs();
603        let mut max_row = col;
604        for row in (col + 1)..n {
605            if aug[row][col].abs() > max_val {
606                max_val = aug[row][col].abs();
607                max_row = row;
608            }
609        }
610        if max_val < 1e-15 {
611            return None;
612        }
613        aug.swap(col, max_row);
614
615        // Eliminate below
616        for row in (col + 1)..n {
617            let factor = aug[row][col] / aug[col][col];
618            for k in col..=n {
619                aug[row][k] -= factor * aug[col][k];
620            }
621        }
622    }
623
624    // Back substitution
625    let mut x = vec![0.0f64; n];
626    for i in (0..n).rev() {
627        if aug[i][i].abs() < 1e-15 {
628            return None;
629        }
630        let mut sum = aug[i][n];
631        for j in (i + 1)..n {
632            sum -= aug[i][j] * x[j];
633        }
634        x[i] = sum / aug[i][i];
635    }
636
637    Some(x)
638}
639
640/// Multiply two 3x3 matrices.
641fn multiply_3x3(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
642    let mut result = [[0.0f64; 3]; 3];
643    for i in 0..3 {
644        for j in 0..3 {
645            for k in 0..3 {
646                result[i][j] += a[i][k] * b[k][j];
647            }
648        }
649    }
650    result
651}
652
653/// Invert a 3x3 matrix using cofactor expansion.
654fn invert_3x3(m: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
655    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
656        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
657        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
658
659    if det.abs() < 1e-12 {
660        return None;
661    }
662
663    let inv_det = 1.0 / det;
664    let inv = [
665        [
666            (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
667            (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
668            (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
669        ],
670        [
671            (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
672            (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
673            (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
674        ],
675        [
676            (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
677            (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
678            (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
679        ],
680    ];
681    Some(inv)
682}
683
684/// Apply a 3x3 homography to a 2D point.
685fn apply_homography(h: &[[f64; 3]; 3], x: f64, y: f64) -> (f64, f64) {
686    let denom = h[2][0] * x + h[2][1] * y + h[2][2];
687    if denom.abs() < 1e-10 {
688        return (x, y);
689    }
690    let wx = (h[0][0] * x + h[0][1] * y + h[0][2]) / denom;
691    let wy = (h[1][0] * x + h[1][1] * y + h[1][2]) / denom;
692    (wx, wy)
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::test_helpers::{TestBackend, test_device};
699    use burn::tensor::{Tensor, TensorData};
700
701    #[test]
702    fn test_stitching_identical_images() {
703        let device = test_device();
704
705        // Two identical images — homography should be near identity
706        let flat_data = vec![0.5f32; 3 * 16 * 16];
707        let img = Image::new(Tensor::<TestBackend, 3>::from_data(
708            TensorData::new(flat_data, [3, 16, 16]),
709            &device,
710        ));
711
712        let stitcher = Stitcher;
713        let stitched = stitcher.stitch(&[img.clone(), img]).unwrap();
714        assert_eq!(stitched.shape()[0], 3);
715        assert!(stitched.shape()[1] > 0);
716        assert!(stitched.shape()[2] > 0);
717    }
718
719    #[test]
720    fn test_stitching_single_image() {
721        let device = test_device();
722        let flat_data = vec![0.3f32; 3 * 8 * 8];
723        let img = Image::new(Tensor::<TestBackend, 3>::from_data(
724            TensorData::new(flat_data, [3, 8, 8]),
725            &device,
726        ));
727
728        let stitcher = Stitcher;
729        let result = stitcher.stitch(std::slice::from_ref(&img)).unwrap();
730        assert_eq!(result.shape(), [3, 8, 8]);
731    }
732
733    #[test]
734    fn test_stitching_empty_input() {
735        let stitcher = Stitcher;
736        let empty: Vec<Image<TestBackend>> = vec![];
737        assert!(stitcher.stitch(&empty).is_err());
738    }
739
740    #[test]
741    fn test_homography_identity() {
742        let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
743        let mut h = compute_homography_dlt(&pts, &pts).unwrap();
744
745        // Normalize by h[2][2] (homographies are scale-invariant)
746        let s = h[2][2];
747        if s.abs() > 1e-10 {
748            for row in &mut h {
749                for val in row.iter_mut() {
750                    *val /= s;
751                }
752            }
753        }
754
755        // Should approximate identity
756        assert!((h[0][0] - 1.0).abs() < 0.01, "h[0][0] = {}", h[0][0]);
757        assert!((h[1][1] - 1.0).abs() < 0.01, "h[1][1] = {}", h[1][1]);
758        assert!((h[2][2] - 1.0).abs() < 0.01, "h[2][2] = {}", h[2][2]);
759        assert!(h[0][1].abs() < 0.01);
760        assert!(h[0][2].abs() < 0.01);
761    }
762
763    #[test]
764    fn test_invert_3x3() {
765        let m = [[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]];
766        let inv = invert_3x3(&m).unwrap();
767        let product = multiply_3x3(&m, &inv);
768
769        // Product should be identity
770        for i in 0..3 {
771            for j in 0..3 {
772                let expected = if i == j { 1.0 } else { 0.0 };
773                assert!(
774                    (product[i][j] - expected).abs() < 1e-10,
775                    "({}, {}) = {} expected {}",
776                    i,
777                    j,
778                    product[i][j],
779                    expected
780                );
781            }
782        }
783    }
784
785    #[test]
786    fn test_solve_9x9() {
787        // Solve a simple system: 3x=6 → x=2
788        let mut a = vec![0.0f64; 81];
789        for i in 0..9 {
790            a[i * 9 + i] = 1.0;
791        }
792        let b = vec![1.0f64; 9];
793        let x = solve_9x9(&a, &b).unwrap();
794        for v in &x {
795            assert!((v - 1.0).abs() < 1e-10);
796        }
797    }
798}