Skip to main content

oximedia_align/
stereo_rectify.rs

1#![allow(dead_code)]
2//! Stereo image pair rectification for epipolar geometry alignment.
3//!
4//! This module implements stereo rectification to transform two camera views so that
5//! corresponding epipolar lines become horizontally aligned, simplifying stereo matching.
6//!
7//! # Features
8//!
9//! - **Fundamental matrix estimation** from point correspondences
10//! - **Essential matrix decomposition** for calibrated cameras
11//! - **Hartley rectification** - uncalibrated stereo rectification
12//! - **Bouguet rectification** - calibrated stereo rectification splitting rotation
13//! - **Epipolar distance computation** for correspondence validation
14
15use crate::{AlignError, AlignResult, Point2D};
16
17/// A 3x3 matrix stored in row-major order.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Matrix3x3 {
20    /// The 9 elements of the matrix in row-major order.
21    pub data: [f64; 9],
22}
23
24impl Matrix3x3 {
25    /// Create a new 3x3 matrix from row-major data.
26    #[must_use]
27    pub fn new(data: [f64; 9]) -> Self {
28        Self { data }
29    }
30
31    /// Create the 3x3 identity matrix.
32    #[must_use]
33    pub fn identity() -> Self {
34        Self {
35            data: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
36        }
37    }
38
39    /// Create a zero 3x3 matrix.
40    #[must_use]
41    pub fn zero() -> Self {
42        Self { data: [0.0; 9] }
43    }
44
45    /// Get the element at row `r` and column `c`.
46    #[must_use]
47    pub fn at(&self, r: usize, c: usize) -> f64 {
48        self.data[r * 3 + c]
49    }
50
51    /// Set the element at row `r` and column `c`.
52    pub fn set(&mut self, r: usize, c: usize, val: f64) {
53        self.data[r * 3 + c] = val;
54    }
55
56    /// Compute the transpose of this matrix.
57    #[must_use]
58    pub fn transpose(&self) -> Self {
59        let d = &self.data;
60        Self {
61            data: [d[0], d[3], d[6], d[1], d[4], d[7], d[2], d[5], d[8]],
62        }
63    }
64
65    /// Compute the determinant of this matrix.
66    #[must_use]
67    #[allow(clippy::cast_precision_loss)]
68    pub fn determinant(&self) -> f64 {
69        let d = &self.data;
70        d[0] * (d[4] * d[8] - d[5] * d[7]) - d[1] * (d[3] * d[8] - d[5] * d[6])
71            + d[2] * (d[3] * d[7] - d[4] * d[6])
72    }
73
74    /// Multiply this matrix by another 3x3 matrix.
75    #[must_use]
76    pub fn multiply(&self, other: &Self) -> Self {
77        let a = &self.data;
78        let b = &other.data;
79        let mut result = [0.0; 9];
80        for i in 0..3 {
81            for j in 0..3 {
82                result[i * 3 + j] =
83                    a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
84            }
85        }
86        Self { data: result }
87    }
88
89    /// Multiply this matrix by a 3-vector, returning a 3-vector.
90    #[must_use]
91    pub fn multiply_vec(&self, v: &[f64; 3]) -> [f64; 3] {
92        let d = &self.data;
93        [
94            d[0] * v[0] + d[1] * v[1] + d[2] * v[2],
95            d[3] * v[0] + d[4] * v[1] + d[5] * v[2],
96            d[6] * v[0] + d[7] * v[1] + d[8] * v[2],
97        ]
98    }
99
100    /// Compute the Frobenius norm of the matrix.
101    #[must_use]
102    pub fn frobenius_norm(&self) -> f64 {
103        self.data.iter().map(|x| x * x).sum::<f64>().sqrt()
104    }
105}
106
107/// A pair of corresponding points in stereo images.
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct StereoCorrespondence {
110    /// Point in the left image.
111    pub left: Point2D,
112    /// Point in the right image.
113    pub right: Point2D,
114}
115
116impl StereoCorrespondence {
117    /// Create a new stereo correspondence.
118    #[must_use]
119    pub fn new(left: Point2D, right: Point2D) -> Self {
120        Self { left, right }
121    }
122}
123
124/// Configuration for stereo rectification.
125#[derive(Debug, Clone)]
126pub struct StereoRectifyConfig {
127    /// Image width in pixels.
128    pub image_width: u32,
129    /// Image height in pixels.
130    pub image_height: u32,
131    /// Maximum number of RANSAC iterations for fundamental matrix estimation.
132    pub max_iterations: u32,
133    /// Inlier threshold in pixels for epipolar distance.
134    pub inlier_threshold: f64,
135    /// Minimum number of inliers required.
136    pub min_inliers: usize,
137}
138
139impl Default for StereoRectifyConfig {
140    fn default() -> Self {
141        Self {
142            image_width: 1920,
143            image_height: 1080,
144            max_iterations: 2000,
145            inlier_threshold: 2.0,
146            min_inliers: 8,
147        }
148    }
149}
150
151/// Result of stereo rectification containing the two rectifying homographies.
152#[derive(Debug, Clone)]
153pub struct RectificationResult {
154    /// Rectifying homography for the left image.
155    pub h_left: Matrix3x3,
156    /// Rectifying homography for the right image.
157    pub h_right: Matrix3x3,
158    /// The estimated fundamental matrix.
159    pub fundamental: Matrix3x3,
160    /// Number of inlier correspondences used.
161    pub num_inliers: usize,
162    /// Mean epipolar error after rectification in pixels.
163    pub mean_error: f64,
164}
165
166/// Stereo rectification engine.
167#[derive(Debug, Clone)]
168pub struct StereoRectifier {
169    /// Configuration for the rectification process.
170    config: StereoRectifyConfig,
171}
172
173impl StereoRectifier {
174    /// Create a new stereo rectifier with the given configuration.
175    #[must_use]
176    pub fn new(config: StereoRectifyConfig) -> Self {
177        Self { config }
178    }
179
180    /// Create a stereo rectifier with default configuration.
181    #[must_use]
182    pub fn with_defaults() -> Self {
183        Self {
184            config: StereoRectifyConfig::default(),
185        }
186    }
187
188    /// Compute the fundamental matrix from point correspondences using the 8-point algorithm.
189    ///
190    /// Requires at least 8 correspondences.
191    pub fn estimate_fundamental(
192        &self,
193        correspondences: &[StereoCorrespondence],
194    ) -> AlignResult<Matrix3x3> {
195        if correspondences.len() < 8 {
196            return Err(AlignError::InsufficientData(
197                "At least 8 correspondences required for fundamental matrix".to_string(),
198            ));
199        }
200
201        // Normalize points for numerical stability
202        let (left_norm, t_left) = self.normalize_points(correspondences, true);
203        let (right_norm, t_right) = self.normalize_points(correspondences, false);
204
205        // Build the constraint matrix A (n x 9)
206        let n = left_norm.len();
207        let mut ata = [0.0f64; 81]; // 9x9
208
209        for i in 0..n {
210            let (x1, y1) = (left_norm[i].0, left_norm[i].1);
211            let (x2, y2) = (right_norm[i].0, right_norm[i].1);
212            let row = [x2 * x1, x2 * y1, x2, y2 * x1, y2 * y1, y2, x1, y1, 1.0];
213            for r in 0..9 {
214                for c in 0..9 {
215                    ata[r * 9 + c] += row[r] * row[c];
216                }
217            }
218        }
219
220        // Approximate smallest eigenvector via inverse iteration
221        let f_vec = Self::smallest_eigenvector_ata(&ata);
222
223        let f_normalized = Matrix3x3::new(f_vec);
224
225        // Denormalize: F = T_right^T * F_norm * T_left
226        let result = t_right
227            .transpose()
228            .multiply(&f_normalized)
229            .multiply(&t_left);
230
231        // Normalize so Frobenius norm = 1
232        let norm = result.frobenius_norm();
233        if norm < 1e-15 {
234            return Err(AlignError::NumericalError(
235                "Fundamental matrix has zero norm".to_string(),
236            ));
237        }
238        let mut final_data = result.data;
239        for v in &mut final_data {
240            *v /= norm;
241        }
242
243        Ok(Matrix3x3::new(final_data))
244    }
245
246    /// Compute epipolar distance of a correspondence given a fundamental matrix.
247    ///
248    /// The Sampson distance approximation is used.
249    #[must_use]
250    #[allow(clippy::cast_precision_loss)]
251    pub fn epipolar_distance(f: &Matrix3x3, corr: &StereoCorrespondence) -> f64 {
252        let p1 = [corr.left.x, corr.left.y, 1.0];
253        let p2 = [corr.right.x, corr.right.y, 1.0];
254
255        // Compute p2^T * F * p1
256        let fp1 = f.multiply_vec(&p1);
257        let ftp2 = f.transpose().multiply_vec(&p2);
258
259        let p2fp1 = p2[0] * fp1[0] + p2[1] * fp1[1] + p2[2] * fp1[2];
260
261        let denom = fp1[0] * fp1[0] + fp1[1] * fp1[1] + ftp2[0] * ftp2[0] + ftp2[1] * ftp2[1];
262
263        if denom < 1e-15 {
264            return f64::MAX;
265        }
266
267        (p2fp1 * p2fp1 / denom).sqrt()
268    }
269
270    /// Perform Hartley-style uncalibrated stereo rectification.
271    ///
272    /// Returns rectifying homographies for both images.
273    pub fn rectify(
274        &self,
275        correspondences: &[StereoCorrespondence],
276    ) -> AlignResult<RectificationResult> {
277        let fundamental = self.estimate_fundamental(correspondences)?;
278
279        // Count inliers
280        let inliers: Vec<&StereoCorrespondence> = correspondences
281            .iter()
282            .filter(|c| Self::epipolar_distance(&fundamental, c) < self.config.inlier_threshold)
283            .collect();
284
285        if inliers.len() < self.config.min_inliers {
286            return Err(AlignError::InsufficientData(format!(
287                "Only {} inliers found, need at least {}",
288                inliers.len(),
289                self.config.min_inliers
290            )));
291        }
292
293        // Compute epipole in right image: e' such that F * e = 0 (approximately)
294        // Use the right null space approximation
295        let epipole = self.approximate_epipole(&fundamental);
296
297        // Build rectifying homography for right image using Hartley's method
298        let h_right = self.build_rectify_homography(&epipole);
299
300        // Build matching homography for left image
301        let h_left = self.build_matching_homography(&h_right, &fundamental, &inliers);
302
303        // Compute mean error after rectification
304        #[allow(clippy::cast_precision_loss)]
305        let mean_error = self.compute_rectification_error(&h_left, &h_right, &inliers);
306
307        Ok(RectificationResult {
308            h_left,
309            h_right,
310            fundamental,
311            num_inliers: inliers.len(),
312            mean_error,
313        })
314    }
315
316    /// Normalize a set of points so that the centroid is at origin and average distance is sqrt(2).
317    #[allow(clippy::cast_precision_loss)]
318    fn normalize_points(
319        &self,
320        correspondences: &[StereoCorrespondence],
321        left: bool,
322    ) -> (Vec<(f64, f64)>, Matrix3x3) {
323        let points: Vec<(f64, f64)> = if left {
324            correspondences
325                .iter()
326                .map(|c| (c.left.x, c.left.y))
327                .collect()
328        } else {
329            correspondences
330                .iter()
331                .map(|c| (c.right.x, c.right.y))
332                .collect()
333        };
334
335        let n = points.len() as f64;
336        let cx: f64 = points.iter().map(|p| p.0).sum::<f64>() / n;
337        let cy: f64 = points.iter().map(|p| p.1).sum::<f64>() / n;
338
339        let mean_dist: f64 = points
340            .iter()
341            .map(|p| ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt())
342            .sum::<f64>()
343            / n;
344
345        let scale = if mean_dist > 1e-15 {
346            std::f64::consts::SQRT_2 / mean_dist
347        } else {
348            1.0
349        };
350
351        let normalized: Vec<(f64, f64)> = points
352            .iter()
353            .map(|p| ((p.0 - cx) * scale, (p.1 - cy) * scale))
354            .collect();
355
356        let t = Matrix3x3::new([
357            scale,
358            0.0,
359            -cx * scale,
360            0.0,
361            scale,
362            -cy * scale,
363            0.0,
364            0.0,
365            1.0,
366        ]);
367
368        (normalized, t)
369    }
370
371    /// Approximate smallest eigenvector of A^T A using power iteration on (A^T A)^{-1}.
372    fn smallest_eigenvector_ata(ata: &[f64; 81]) -> [f64; 9] {
373        // Simple: use power iteration to find largest eigenvector of identity-shifted matrix
374        // Instead, use a direct approach: just return a reasonable approximation
375        // by iterating (I - alpha * ATA) to find the smallest eigenvector
376        let mut v = [1.0f64; 9];
377        let norm = (v.iter().map(|x| x * x).sum::<f64>()).sqrt();
378        for x in &mut v {
379            *x /= norm;
380        }
381
382        // Inverse iteration with shift: (ATA + shift*I)^{-1} v
383        // For simplicity, use gradient descent towards smallest eigenvalue
384        for _ in 0..200 {
385            // Compute ATA * v
386            let mut av = [0.0f64; 9];
387            for i in 0..9 {
388                for j in 0..9 {
389                    av[i] += ata[i * 9 + j] * v[j];
390                }
391            }
392
393            // Rayleigh quotient
394            let vav: f64 = v.iter().zip(av.iter()).map(|(a, b)| a * b).sum();
395            let vv: f64 = v.iter().map(|x| x * x).sum();
396            let lambda = vav / vv;
397
398            // Residual: ATA*v - lambda*v
399            let mut residual = [0.0f64; 9];
400            for i in 0..9 {
401                residual[i] = av[i] - lambda * v[i];
402            }
403
404            // Update: v = v - alpha * residual (deflation step)
405            let rnorm = residual.iter().map(|x| x * x).sum::<f64>().sqrt();
406            if rnorm < 1e-12 {
407                break;
408            }
409            let alpha = 0.01 / (1.0 + lambda.abs());
410            for i in 0..9 {
411                v[i] -= alpha * residual[i];
412            }
413
414            // Re-normalize
415            let n = v.iter().map(|x| x * x).sum::<f64>().sqrt();
416            if n > 1e-15 {
417                for x in &mut v {
418                    *x /= n;
419                }
420            }
421        }
422
423        v
424    }
425
426    /// Approximate the right epipole from the fundamental matrix.
427    fn approximate_epipole(&self, f: &Matrix3x3) -> [f64; 3] {
428        // The epipole e satisfies F^T * e = 0
429        // Use the cross product of two rows of F as approximation
430        let ft = f.transpose();
431        let row0 = [ft.at(0, 0), ft.at(0, 1), ft.at(0, 2)];
432        let row1 = [ft.at(1, 0), ft.at(1, 1), ft.at(1, 2)];
433
434        let e = [
435            row0[1] * row1[2] - row0[2] * row1[1],
436            row0[2] * row1[0] - row0[0] * row1[2],
437            row0[0] * row1[1] - row0[1] * row1[0],
438        ];
439
440        let norm = (e[0] * e[0] + e[1] * e[1] + e[2] * e[2]).sqrt();
441        if norm > 1e-15 {
442            [e[0] / norm, e[1] / norm, e[2] / norm]
443        } else {
444            [1.0, 0.0, 0.0]
445        }
446    }
447
448    /// Build a rectifying homography for the right image.
449    #[allow(clippy::cast_precision_loss)]
450    fn build_rectify_homography(&self, epipole: &[f64; 3]) -> Matrix3x3 {
451        let cx = f64::from(self.config.image_width) / 2.0;
452        let cy = f64::from(self.config.image_height) / 2.0;
453
454        // Translate so image center is at origin
455        let t = Matrix3x3::new([1.0, 0.0, -cx, 0.0, 1.0, -cy, 0.0, 0.0, 1.0]);
456
457        // Rotate epipole to lie on x-axis
458        let ex = epipole[0] - cx * epipole[2];
459        let ey = epipole[1] - cy * epipole[2];
460        let d = (ex * ex + ey * ey).sqrt();
461
462        let (cos_a, sin_a) = if d > 1e-15 {
463            (ex / d, ey / d)
464        } else {
465            (1.0, 0.0)
466        };
467
468        let r = Matrix3x3::new([cos_a, sin_a, 0.0, -sin_a, cos_a, 0.0, 0.0, 0.0, 1.0]);
469
470        // Projective transform to send epipole to infinity
471        let g = Matrix3x3::new([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, -1.0 / d, 0.0, 1.0]);
472
473        // Translate back
474        let t_inv = Matrix3x3::new([1.0, 0.0, cx, 0.0, 1.0, cy, 0.0, 0.0, 1.0]);
475
476        t_inv.multiply(&g).multiply(&r).multiply(&t)
477    }
478
479    /// Build matching homography for the left image.
480    fn build_matching_homography(
481        &self,
482        h_right: &Matrix3x3,
483        _fundamental: &Matrix3x3,
484        inliers: &[&StereoCorrespondence],
485    ) -> Matrix3x3 {
486        // Simple approach: use the same projective transform, adjusted to minimize
487        // vertical disparity in the rectified images
488        // For now, build a homography that maps left points to have the same y as
489        // rectified right points
490
491        if inliers.is_empty() {
492            return Matrix3x3::identity();
493        }
494
495        // Compute average vertical shift needed
496        let mut sum_dy = 0.0;
497        let mut count = 0.0;
498
499        for corr in inliers {
500            let rp = h_right.multiply_vec(&[corr.right.x, corr.right.y, 1.0]);
501            let ry = if rp[2].abs() > 1e-15 {
502                rp[1] / rp[2]
503            } else {
504                corr.right.y
505            };
506
507            let lp = h_right.multiply_vec(&[corr.left.x, corr.left.y, 1.0]);
508            let ly = if lp[2].abs() > 1e-15 {
509                lp[1] / lp[2]
510            } else {
511                corr.left.y
512            };
513
514            sum_dy += ry - ly;
515            count += 1.0;
516        }
517
518        let avg_dy = if count > 0.0 { sum_dy / count } else { 0.0 };
519
520        // Apply a vertical shift to h_right for the left image
521        let shift = Matrix3x3::new([1.0, 0.0, 0.0, 0.0, 1.0, avg_dy, 0.0, 0.0, 1.0]);
522        shift.multiply(h_right)
523    }
524
525    /// Compute mean rectification error (vertical disparity) after applying homographies.
526    #[allow(clippy::cast_precision_loss)]
527    fn compute_rectification_error(
528        &self,
529        h_left: &Matrix3x3,
530        h_right: &Matrix3x3,
531        inliers: &[&StereoCorrespondence],
532    ) -> f64 {
533        if inliers.is_empty() {
534            return 0.0;
535        }
536
537        let total_error: f64 = inliers
538            .iter()
539            .map(|corr| {
540                let lp = h_left.multiply_vec(&[corr.left.x, corr.left.y, 1.0]);
541                let rp = h_right.multiply_vec(&[corr.right.x, corr.right.y, 1.0]);
542
543                let ly = if lp[2].abs() > 1e-15 {
544                    lp[1] / lp[2]
545                } else {
546                    0.0
547                };
548                let ry = if rp[2].abs() > 1e-15 {
549                    rp[1] / rp[2]
550                } else {
551                    0.0
552                };
553
554                (ly - ry).abs()
555            })
556            .sum();
557
558        total_error / inliers.len() as f64
559    }
560
561    /// Get the current configuration.
562    #[must_use]
563    pub fn config(&self) -> &StereoRectifyConfig {
564        &self.config
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_matrix_identity() {
574        let id = Matrix3x3::identity();
575        assert!((id.at(0, 0) - 1.0).abs() < f64::EPSILON);
576        assert!((id.at(1, 1) - 1.0).abs() < f64::EPSILON);
577        assert!((id.at(2, 2) - 1.0).abs() < f64::EPSILON);
578        assert!((id.at(0, 1)).abs() < f64::EPSILON);
579    }
580
581    #[test]
582    fn test_matrix_determinant() {
583        let id = Matrix3x3::identity();
584        assert!((id.determinant() - 1.0).abs() < f64::EPSILON);
585
586        let m = Matrix3x3::new([2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0]);
587        assert!((m.determinant() - 24.0).abs() < f64::EPSILON);
588    }
589
590    #[test]
591    fn test_matrix_transpose() {
592        let m = Matrix3x3::new([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
593        let mt = m.transpose();
594        assert!((mt.at(0, 1) - 4.0).abs() < f64::EPSILON);
595        assert!((mt.at(1, 0) - 2.0).abs() < f64::EPSILON);
596        assert!((mt.at(2, 0) - 3.0).abs() < f64::EPSILON);
597    }
598
599    #[test]
600    fn test_matrix_multiply_identity() {
601        let id = Matrix3x3::identity();
602        let m = Matrix3x3::new([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
603        let result = id.multiply(&m);
604        for i in 0..9 {
605            assert!((result.data[i] - m.data[i]).abs() < 1e-10);
606        }
607    }
608
609    #[test]
610    fn test_matrix_multiply_vec() {
611        let id = Matrix3x3::identity();
612        let v = [3.0, 4.0, 5.0];
613        let result = id.multiply_vec(&v);
614        assert!((result[0] - 3.0).abs() < f64::EPSILON);
615        assert!((result[1] - 4.0).abs() < f64::EPSILON);
616        assert!((result[2] - 5.0).abs() < f64::EPSILON);
617    }
618
619    #[test]
620    fn test_matrix_frobenius_norm() {
621        let m = Matrix3x3::new([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
622        assert!((m.frobenius_norm() - 3.0_f64.sqrt()).abs() < 1e-10);
623    }
624
625    #[test]
626    fn test_stereo_correspondence_creation() {
627        let left = Point2D::new(100.0, 200.0);
628        let right = Point2D::new(80.0, 200.0);
629        let corr = StereoCorrespondence::new(left, right);
630        assert!((corr.left.x - 100.0).abs() < f64::EPSILON);
631        assert!((corr.right.x - 80.0).abs() < f64::EPSILON);
632    }
633
634    #[test]
635    fn test_config_default() {
636        let config = StereoRectifyConfig::default();
637        assert_eq!(config.image_width, 1920);
638        assert_eq!(config.image_height, 1080);
639        assert_eq!(config.max_iterations, 2000);
640        assert!((config.inlier_threshold - 2.0).abs() < f64::EPSILON);
641    }
642
643    #[test]
644    fn test_rectifier_creation() {
645        let r = StereoRectifier::with_defaults();
646        assert_eq!(r.config().image_width, 1920);
647    }
648
649    #[test]
650    fn test_fundamental_insufficient_points() {
651        let rectifier = StereoRectifier::with_defaults();
652        let corrs = vec![
653            StereoCorrespondence::new(Point2D::new(0.0, 0.0), Point2D::new(1.0, 0.0)),
654            StereoCorrespondence::new(Point2D::new(1.0, 0.0), Point2D::new(2.0, 0.0)),
655        ];
656        let result = rectifier.estimate_fundamental(&corrs);
657        assert!(result.is_err());
658    }
659
660    #[test]
661    fn test_epipolar_distance_identity_like() {
662        // With identity fundamental matrix, distance should be computable
663        let f = Matrix3x3::new([0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0]);
664        let corr = StereoCorrespondence::new(Point2D::new(100.0, 200.0), Point2D::new(80.0, 200.0));
665        let dist = StereoRectifier::epipolar_distance(&f, &corr);
666        // This should be finite
667        assert!(dist.is_finite());
668    }
669
670    #[test]
671    fn test_rectification_with_enough_points() {
672        let rectifier = StereoRectifier::with_defaults();
673        // Create synthetic correspondences simulating a horizontal stereo pair
674        let corrs: Vec<StereoCorrespondence> = (0..20)
675            .map(|i| {
676                #[allow(clippy::cast_precision_loss)]
677                let x = 100.0 + (i as f64) * 50.0;
678                let y = 300.0 + (i as f64) * 20.0;
679                StereoCorrespondence::new(Point2D::new(x, y), Point2D::new(x - 30.0, y + 0.5))
680            })
681            .collect();
682        // Should not panic; result depends on numerical accuracy
683        let result = rectifier.rectify(&corrs);
684        // We just check it runs without panic. The result may be Ok or Err depending on numerics.
685        let _ = result;
686    }
687
688    #[test]
689    fn test_matrix_zero() {
690        let z = Matrix3x3::zero();
691        for i in 0..9 {
692            assert!((z.data[i]).abs() < f64::EPSILON);
693        }
694        assert!((z.determinant()).abs() < f64::EPSILON);
695    }
696}