Skip to main content

spatialrust_vision/
multiview.rs

1//! Linear two-view model estimation and deterministic robust sampling.
2
3use spatialrust_math::{
4    solve_linear_system, symmetric_eigen3, LeastSquaresResult, Mat3, Vec2, Vec3,
5};
6
7use crate::{
8    CameraMatrix3, Essential3, Fundamental3, GeometricEstimate, Homography3, PointCorrespondence2,
9    RelativePose, RelativePoseEstimate, RobustEstimationOptions, TriangulatedPoint, VisionError,
10    VisionResult,
11};
12
13/// Estimates a homography from at least four correspondences using normalized DLT.
14pub fn estimate_homography(correspondences: &[PointCorrespondence2]) -> VisionResult<Homography3> {
15    if correspondences.len() < 4 {
16        return Err(VisionError::InvalidParameter(
17            "homography estimation requires at least four correspondences".into(),
18        ));
19    }
20    let source = correspondences.iter().map(|pair| pair.source()).collect::<Vec<_>>();
21    let target = correspondences.iter().map(|pair| pair.target()).collect::<Vec<_>>();
22    let source_normalization = Normalization2::from_points(&source)?;
23    let target_normalization = Normalization2::from_points(&target)?;
24    let mut normal = vec![vec![0.0; 8]; 8];
25    let mut rhs = vec![0.0; 8];
26    for pair in correspondences {
27        let source = source_normalization.apply(pair.source());
28        let target = target_normalization.apply(pair.target());
29        accumulate_least_squares(
30            &mut normal,
31            &mut rhs,
32            &[source.x, source.y, 1.0, 0.0, 0.0, 0.0, -target.x * source.x, -target.x * source.y],
33            target.x,
34        );
35        accumulate_least_squares(
36            &mut normal,
37            &mut rhs,
38            &[0.0, 0.0, 0.0, source.x, source.y, 1.0, -target.y * source.x, -target.y * source.y],
39            target.y,
40        );
41    }
42    let LeastSquaresResult::Solved(solution) = solve_linear_system(normal, rhs) else {
43        return Err(VisionError::InvalidParameter(
44            "homography correspondences are degenerate".into(),
45        ));
46    };
47    let normalized = Mat3::from_rows(
48        [solution[0], solution[1], solution[2]],
49        [solution[3], solution[4], solution[5]],
50        [solution[6], solution[7], 1.0],
51    );
52    Homography3::try_new(
53        target_normalization.inverse.mul_mat3(normalized).mul_mat3(source_normalization.matrix),
54    )
55}
56
57/// Estimates a fundamental matrix from at least eight correspondences.
58///
59/// Points are Hartley-normalized before the linear solve and the result is
60/// projected to rank two before denormalization.
61pub fn estimate_fundamental(
62    correspondences: &[PointCorrespondence2],
63) -> VisionResult<Fundamental3> {
64    if correspondences.len() < 8 {
65        return Err(VisionError::InvalidParameter(
66            "fundamental estimation requires at least eight correspondences".into(),
67        ));
68    }
69    let source = correspondences.iter().map(|pair| pair.source()).collect::<Vec<_>>();
70    let target = correspondences.iter().map(|pair| pair.target()).collect::<Vec<_>>();
71    let source_normalization = Normalization2::from_points(&source)?;
72    let target_normalization = Normalization2::from_points(&target)?;
73    let mut normal = vec![vec![0.0; 9]; 9];
74    for pair in correspondences {
75        let source = source_normalization.apply(pair.source());
76        let target = target_normalization.apply(pair.target());
77        let row = [
78            target.x * source.x,
79            target.x * source.y,
80            target.x,
81            target.y * source.x,
82            target.y * source.y,
83            target.y,
84            source.x,
85            source.y,
86            1.0,
87        ];
88        for row_index in 0..9 {
89            for column in 0..9 {
90                normal[row_index][column] += row[row_index] * row[column];
91            }
92        }
93    }
94    let vector = smallest_symmetric_eigenvector(normal).ok_or_else(|| {
95        VisionError::InvalidParameter("fundamental correspondences are degenerate".into())
96    })?;
97    let normalized = enforce_rank_two(Mat3::from_rows(
98        [vector[0], vector[1], vector[2]],
99        [vector[3], vector[4], vector[5]],
100        [vector[6], vector[7], vector[8]],
101    ));
102    Fundamental3::try_new(
103        target_normalization
104            .matrix
105            .transpose()
106            .mul_mat3(normalized)
107            .mul_mat3(source_normalization.matrix),
108    )
109}
110
111/// Robustly estimates a homography using deterministic four-point RANSAC.
112pub fn estimate_homography_ransac(
113    correspondences: &[PointCorrespondence2],
114    options: RobustEstimationOptions,
115) -> VisionResult<GeometricEstimate<Homography3>> {
116    robust_estimate(correspondences, options, 4, estimate_homography, homography_residual)
117}
118
119/// Robustly estimates a fundamental matrix using deterministic eight-point RANSAC.
120pub fn estimate_fundamental_ransac(
121    correspondences: &[PointCorrespondence2],
122    options: RobustEstimationOptions,
123) -> VisionResult<GeometricEstimate<Fundamental3>> {
124    robust_estimate(correspondences, options, 8, estimate_fundamental, fundamental_residual)
125}
126
127/// Estimates an essential matrix after normalizing pixels with both cameras.
128pub fn estimate_essential(
129    correspondences: &[PointCorrespondence2],
130    source_camera: CameraMatrix3,
131    target_camera: CameraMatrix3,
132) -> VisionResult<Essential3> {
133    let normalized = normalize_correspondences(correspondences, source_camera, target_camera)?;
134    estimate_essential_normalized(&normalized)
135}
136
137/// Robustly estimates an essential matrix in normalized-camera coordinates.
138///
139/// The RANSAC threshold is therefore expressed on the normalized image plane,
140/// not in pixels.
141pub fn estimate_essential_ransac(
142    correspondences: &[PointCorrespondence2],
143    source_camera: CameraMatrix3,
144    target_camera: CameraMatrix3,
145    options: RobustEstimationOptions,
146) -> VisionResult<GeometricEstimate<Essential3>> {
147    let normalized = normalize_correspondences(correspondences, source_camera, target_camera)?;
148    robust_estimate(&normalized, options, 8, estimate_essential_normalized, essential_residual)
149}
150
151/// Triangulates one calibrated correspondence for a known relative pose.
152pub fn triangulate_correspondence(
153    correspondence: PointCorrespondence2,
154    source_camera: CameraMatrix3,
155    target_camera: CameraMatrix3,
156    pose: RelativePose,
157) -> VisionResult<TriangulatedPoint> {
158    let source = source_camera.normalize_pixel(correspondence.source());
159    let target = target_camera.normalize_pixel(correspondence.target());
160    triangulate_normalized(
161        Vec2 { x: source.x, y: source.y },
162        Vec2 { x: target.x, y: target.y },
163        pose,
164    )
165    .ok_or_else(|| VisionError::InvalidParameter("triangulation is degenerate".into()))
166}
167
168/// Recovers the essential-matrix pose with the most positive-depth points.
169pub fn recover_relative_pose(
170    essential: Essential3,
171    correspondences: &[PointCorrespondence2],
172    source_camera: CameraMatrix3,
173    target_camera: CameraMatrix3,
174) -> VisionResult<RelativePoseEstimate> {
175    if correspondences.is_empty() {
176        return Err(VisionError::InvalidParameter(
177            "pose recovery requires at least one correspondence".into(),
178        ));
179    }
180    let normalized = normalize_correspondences(correspondences, source_camera, target_camera)?;
181    let (mut left, mut right) = essential_singular_vectors(essential.matrix())?;
182    if determinant(left) < 0.0 {
183        negate_column(&mut left, 2);
184    }
185    if determinant(right) < 0.0 {
186        negate_column(&mut right, 2);
187    }
188    let w = Mat3::from_rows([0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
189    let rotations = [
190        proper_rotation(left.mul_mat3(w).mul_mat3(right.transpose())),
191        proper_rotation(left.mul_mat3(w.transpose()).mul_mat3(right.transpose())),
192    ];
193    let translation = column(left, 2).normalize();
194    let mut best: Option<(RelativePose, Vec<Option<TriangulatedPoint>>, usize, f64)> = None;
195    for rotation in rotations {
196        for translation in [translation, Vec3::new(-translation.x, -translation.y, -translation.z)]
197        {
198            let pose = RelativePose::try_new(rotation, translation)?;
199            let points = normalized
200                .iter()
201                .map(|pair| triangulate_normalized(pair.source(), pair.target(), pose))
202                .collect::<Vec<_>>();
203            let count = points.iter().flatten().filter(|point| point.has_positive_depth()).count();
204            let error = points
205                .iter()
206                .flatten()
207                .filter(|point| point.has_positive_depth())
208                .map(|point| point.reprojection_error())
209                .sum::<f64>();
210            if best.as_ref().map_or(true, |candidate| {
211                count > candidate.2 || (count == candidate.2 && error < candidate.3)
212            }) {
213                best = Some((pose, points, count, error));
214            }
215        }
216    }
217    let (pose, points, _, _) = best.expect("four essential-pose candidates");
218    Ok(RelativePoseEstimate::new(pose, points))
219}
220
221fn normalize_correspondences(
222    correspondences: &[PointCorrespondence2],
223    source_camera: CameraMatrix3,
224    target_camera: CameraMatrix3,
225) -> VisionResult<Vec<PointCorrespondence2>> {
226    correspondences
227        .iter()
228        .map(|pair| {
229            let source = source_camera.normalize_pixel(pair.source());
230            let target = target_camera.normalize_pixel(pair.target());
231            PointCorrespondence2::try_new(
232                Vec2 { x: source.x, y: source.y },
233                Vec2 { x: target.x, y: target.y },
234            )
235        })
236        .collect()
237}
238
239fn estimate_essential_normalized(
240    correspondences: &[PointCorrespondence2],
241) -> VisionResult<Essential3> {
242    let fundamental = estimate_fundamental(correspondences)?;
243    Essential3::try_new(project_essential(fundamental.matrix())?)
244}
245
246fn robust_estimate<Model: Copy>(
247    correspondences: &[PointCorrespondence2],
248    options: RobustEstimationOptions,
249    sample_size: usize,
250    estimate: fn(&[PointCorrespondence2]) -> VisionResult<Model>,
251    residual: fn(Model, PointCorrespondence2) -> f64,
252) -> VisionResult<GeometricEstimate<Model>> {
253    let options = options.validate()?;
254    if correspondences.len() < sample_size {
255        return Err(VisionError::InvalidParameter(format!(
256            "robust estimation requires at least {sample_size} correspondences"
257        )));
258    }
259    let mut rng = XorShift64::new(options.seed);
260    let mut best: Option<(Model, Vec<bool>, Vec<f64>, usize, f64)> = None;
261    let mut iteration_limit = options.max_iterations;
262    let mut iteration = 0;
263    while iteration < iteration_limit {
264        let indices = sample_unique(&mut rng, correspondences.len(), sample_size);
265        let sample = indices.iter().map(|&index| correspondences[index]).collect::<Vec<_>>();
266        if let Ok(model) = estimate(&sample) {
267            let residuals = correspondences
268                .iter()
269                .copied()
270                .map(|pair| residual(model, pair))
271                .collect::<Vec<_>>();
272            let inliers =
273                residuals.iter().map(|&value| value <= options.threshold).collect::<Vec<_>>();
274            let count = inliers.iter().filter(|&&value| value).count();
275            let error = residuals
276                .iter()
277                .zip(&inliers)
278                .filter_map(|(value, &is_inlier)| is_inlier.then_some(*value))
279                .sum::<f64>();
280            let improves = best.as_ref().map_or(true, |candidate| {
281                count > candidate.3 || (count == candidate.3 && error < candidate.4)
282            });
283            if improves {
284                if count >= sample_size {
285                    let inlier_ratio = count as f64 / correspondences.len() as f64;
286                    let success = inlier_ratio.powi(sample_size as i32).clamp(0.0, 1.0);
287                    if success > 0.0 && success < 1.0 {
288                        let required = ((1.0 - options.confidence).ln() / (1.0 - success).ln())
289                            .ceil()
290                            .max(1.0) as usize;
291                        iteration_limit = iteration_limit.min(required.max(iteration + 1));
292                    } else if success == 1.0 {
293                        iteration_limit = iteration + 1;
294                    }
295                }
296                best = Some((model, inliers, residuals, count, error));
297            }
298        }
299        iteration += 1;
300    }
301    let (_, best_inliers, _, count, _) = best.ok_or_else(|| {
302        VisionError::InvalidParameter("robust geometry estimation found no valid model".into())
303    })?;
304    if count < sample_size {
305        return Err(VisionError::InvalidParameter(
306            "robust geometry estimation found too few inliers".into(),
307        ));
308    }
309    let inlier_pairs = correspondences
310        .iter()
311        .zip(&best_inliers)
312        .filter_map(|(&pair, &is_inlier)| is_inlier.then_some(pair))
313        .collect::<Vec<_>>();
314    let refined = estimate(&inlier_pairs)?;
315    let residuals =
316        correspondences.iter().copied().map(|pair| residual(refined, pair)).collect::<Vec<_>>();
317    let inliers = residuals.iter().map(|&value| value <= options.threshold).collect();
318    GeometricEstimate::try_new(refined, correspondences.len(), inliers, residuals)
319}
320
321fn homography_residual(model: Homography3, pair: PointCorrespondence2) -> f64 {
322    let projected = model.matrix().mul_vec3(Vec3::new(pair.source().x, pair.source().y, 1.0));
323    if projected.z.abs() <= f64::EPSILON {
324        return f64::MAX;
325    }
326    let dx = projected.x / projected.z - pair.target().x;
327    let dy = projected.y / projected.z - pair.target().y;
328    dx.hypot(dy)
329}
330
331fn fundamental_residual(model: Fundamental3, pair: PointCorrespondence2) -> f64 {
332    epipolar_residual(model.matrix(), pair)
333}
334
335fn essential_residual(model: Essential3, pair: PointCorrespondence2) -> f64 {
336    epipolar_residual(model.matrix(), pair)
337}
338
339fn epipolar_residual(matrix: Mat3<f64>, pair: PointCorrespondence2) -> f64 {
340    let source = Vec3::new(pair.source().x, pair.source().y, 1.0);
341    let target = Vec3::new(pair.target().x, pair.target().y, 1.0);
342    let line_target = matrix.mul_vec3(source);
343    let line_source = matrix.transpose().mul_vec3(target);
344    let numerator = target.dot(line_target).abs();
345    let denominator = line_target.x * line_target.x
346        + line_target.y * line_target.y
347        + line_source.x * line_source.x
348        + line_source.y * line_source.y;
349    if denominator <= f64::EPSILON {
350        f64::MAX
351    } else {
352        numerator / denominator.sqrt()
353    }
354}
355
356fn project_essential(matrix: Mat3<f64>) -> VisionResult<Mat3<f64>> {
357    let (left, right) = essential_singular_vectors(matrix)?;
358    let covariance = matrix.transpose().mul_mat3(matrix);
359    let eigen = symmetric_eigen3(covariance);
360    let singular = [eigen.eigenvalues[2].max(0.0).sqrt(), eigen.eigenvalues[1].max(0.0).sqrt()];
361    let scale = 0.5 * (singular[0] + singular[1]);
362    let mut result = Mat3::from_rows([0.0; 3], [0.0; 3], [0.0; 3]);
363    for component in 0..2 {
364        let left_column = column(left, component);
365        let right_column = column(right, component);
366        let left_values = [left_column.x, left_column.y, left_column.z];
367        let right_values = [right_column.x, right_column.y, right_column.z];
368        for (row, &left_value) in left_values.iter().enumerate() {
369            for (column, &right_value) in right_values.iter().enumerate() {
370                result.m[row][column] += scale * left_value * right_value;
371            }
372        }
373    }
374    Ok(result)
375}
376
377fn essential_singular_vectors(matrix: Mat3<f64>) -> VisionResult<(Mat3<f64>, Mat3<f64>)> {
378    let eigen = symmetric_eigen3(matrix.transpose().mul_mat3(matrix));
379    let right0 = column(eigen.eigenvectors, 2).normalize();
380    let right1 = column(eigen.eigenvectors, 1).normalize();
381    let right2 = right0.cross(right1).normalize();
382    let sigma0 = eigen.eigenvalues[2].max(0.0).sqrt();
383    let sigma1 = eigen.eigenvalues[1].max(0.0).sqrt();
384    if sigma0 <= 1e-12 || sigma1 <= 1e-12 {
385        return Err(VisionError::InvalidParameter(
386            "essential matrix has fewer than two non-zero singular values".into(),
387        ));
388    }
389    let left0 = scale_vec(matrix.mul_vec3(right0), 1.0 / sigma0).normalize();
390    let mut left1 = scale_vec(matrix.mul_vec3(right1), 1.0 / sigma1);
391    left1 = (left1 - scale_vec(left0, left0.dot(left1))).normalize();
392    let left2 = left0.cross(left1).normalize();
393    Ok((matrix_from_columns(left0, left1, left2), matrix_from_columns(right0, right1, right2)))
394}
395
396fn triangulate_normalized(
397    source: Vec2<f64>,
398    target: Vec2<f64>,
399    pose: RelativePose,
400) -> Option<TriangulatedPoint> {
401    let rotation = pose.rotation();
402    let translation = pose.translation();
403    let rows = [
404        vec![-1.0, 0.0, source.x, 0.0],
405        vec![0.0, -1.0, source.y, 0.0],
406        vec![
407            target.x * rotation.m[2][0] - rotation.m[0][0],
408            target.x * rotation.m[2][1] - rotation.m[0][1],
409            target.x * rotation.m[2][2] - rotation.m[0][2],
410            target.x * translation.z - translation.x,
411        ],
412        vec![
413            target.y * rotation.m[2][0] - rotation.m[1][0],
414            target.y * rotation.m[2][1] - rotation.m[1][1],
415            target.y * rotation.m[2][2] - rotation.m[1][2],
416            target.y * translation.z - translation.y,
417        ],
418    ];
419    let mut normal = vec![vec![0.0; 4]; 4];
420    for row in rows {
421        for first in 0..4 {
422            for second in 0..4 {
423                normal[first][second] += row[first] * row[second];
424            }
425        }
426    }
427    let homogeneous = smallest_symmetric_eigenvector(normal)?;
428    if homogeneous[3].abs() <= 1e-12 {
429        return None;
430    }
431    let position = Vec3::new(
432        homogeneous[0] / homogeneous[3],
433        homogeneous[1] / homogeneous[3],
434        homogeneous[2] / homogeneous[3],
435    );
436    let target_position = rotation.mul_vec3(position) + translation;
437    if position.z.abs() <= 1e-12 || target_position.z.abs() <= 1e-12 {
438        return None;
439    }
440    let source_error =
441        (position.x / position.z - source.x).hypot(position.y / position.z - source.y);
442    let target_error = (target_position.x / target_position.z - target.x)
443        .hypot(target_position.y / target_position.z - target.y);
444    TriangulatedPoint::try_new(
445        position,
446        position.z,
447        target_position.z,
448        0.5 * (source_error + target_error),
449    )
450    .ok()
451}
452
453fn matrix_from_columns(first: Vec3<f64>, second: Vec3<f64>, third: Vec3<f64>) -> Mat3<f64> {
454    Mat3::from_rows(
455        [first.x, second.x, third.x],
456        [first.y, second.y, third.y],
457        [first.z, second.z, third.z],
458    )
459}
460
461fn column(matrix: Mat3<f64>, index: usize) -> Vec3<f64> {
462    Vec3::new(matrix.m[0][index], matrix.m[1][index], matrix.m[2][index])
463}
464
465fn scale_vec(vector: Vec3<f64>, scale: f64) -> Vec3<f64> {
466    Vec3::new(vector.x * scale, vector.y * scale, vector.z * scale)
467}
468
469fn determinant(matrix: Mat3<f64>) -> f64 {
470    matrix.m[0][0] * (matrix.m[1][1] * matrix.m[2][2] - matrix.m[1][2] * matrix.m[2][1])
471        - matrix.m[0][1] * (matrix.m[1][0] * matrix.m[2][2] - matrix.m[1][2] * matrix.m[2][0])
472        + matrix.m[0][2] * (matrix.m[1][0] * matrix.m[2][1] - matrix.m[1][1] * matrix.m[2][0])
473}
474
475fn negate_column(matrix: &mut Mat3<f64>, column: usize) {
476    for row in 0..3 {
477        matrix.m[row][column] = -matrix.m[row][column];
478    }
479}
480
481fn proper_rotation(mut matrix: Mat3<f64>) -> Mat3<f64> {
482    if determinant(matrix) < 0.0 {
483        for row in &mut matrix.m {
484            for value in row {
485                *value = -*value;
486            }
487        }
488    }
489    matrix
490}
491
492fn enforce_rank_two(matrix: Mat3<f64>) -> Mat3<f64> {
493    let covariance = matrix.transpose().mul_mat3(matrix);
494    let eigen = symmetric_eigen3(covariance);
495    let vector = Vec3::new(
496        eigen.eigenvectors.m[0][0],
497        eigen.eigenvectors.m[1][0],
498        eigen.eigenvectors.m[2][0],
499    );
500    let image = matrix.mul_vec3(vector);
501    let mut result = matrix;
502    for row in 0..3 {
503        for column in 0..3 {
504            result.m[row][column] -=
505                [image.x, image.y, image.z][row] * [vector.x, vector.y, vector.z][column];
506        }
507    }
508    result
509}
510
511fn accumulate_least_squares(normal: &mut [Vec<f64>], rhs: &mut [f64], row: &[f64], value: f64) {
512    for row_index in 0..row.len() {
513        rhs[row_index] += row[row_index] * value;
514        for column in 0..row.len() {
515            normal[row_index][column] += row[row_index] * row[column];
516        }
517    }
518}
519
520struct Normalization2 {
521    matrix: Mat3<f64>,
522    inverse: Mat3<f64>,
523}
524
525impl Normalization2 {
526    fn from_points(points: &[Vec2<f64>]) -> VisionResult<Self> {
527        let count = points.len() as f64;
528        let center = Vec2 {
529            x: points.iter().map(|point| point.x).sum::<f64>() / count,
530            y: points.iter().map(|point| point.y).sum::<f64>() / count,
531        };
532        let rms = (points
533            .iter()
534            .map(|point| {
535                let dx = point.x - center.x;
536                let dy = point.y - center.y;
537                dx * dx + dy * dy
538            })
539            .sum::<f64>()
540            / count)
541            .sqrt();
542        if !rms.is_finite() || rms <= f64::EPSILON {
543            return Err(VisionError::InvalidParameter(
544                "geometry points have zero spatial extent".into(),
545            ));
546        }
547        let scale = 2.0_f64.sqrt() / rms;
548        Ok(Self {
549            matrix: Mat3::from_rows(
550                [scale, 0.0, -scale * center.x],
551                [0.0, scale, -scale * center.y],
552                [0.0, 0.0, 1.0],
553            ),
554            inverse: Mat3::from_rows(
555                [1.0 / scale, 0.0, center.x],
556                [0.0, 1.0 / scale, center.y],
557                [0.0, 0.0, 1.0],
558            ),
559        })
560    }
561
562    fn apply(&self, point: Vec2<f64>) -> Vec2<f64> {
563        let normalized = self.matrix.mul_vec3(Vec3::new(point.x, point.y, 1.0));
564        Vec2 { x: normalized.x, y: normalized.y }
565    }
566}
567
568#[allow(clippy::needless_range_loop)]
569fn smallest_symmetric_eigenvector(mut matrix: Vec<Vec<f64>>) -> Option<Vec<f64>> {
570    let size = matrix.len();
571    if size == 0 || matrix.iter().any(|row| row.len() != size) {
572        return None;
573    }
574    let mut vectors = vec![vec![0.0; size]; size];
575    for (index, row) in vectors.iter_mut().enumerate() {
576        row[index] = 1.0;
577    }
578    for _ in 0..size * size * 32 {
579        let mut pivot = (0, 1);
580        let mut maximum = 0.0_f64;
581        for (row, values) in matrix.iter().enumerate() {
582            for (column, &value) in values.iter().enumerate().skip(row + 1) {
583                if value.abs() > maximum {
584                    maximum = value.abs();
585                    pivot = (row, column);
586                }
587            }
588        }
589        if maximum < 1e-12 {
590            break;
591        }
592        let (p, q) = pivot;
593        let angle = 0.5 * (2.0 * matrix[p][q]).atan2(matrix[q][q] - matrix[p][p]);
594        let (sine, cosine) = angle.sin_cos();
595        for row in 0..size {
596            if row != p && row != q {
597                let rp = matrix[row][p];
598                let rq = matrix[row][q];
599                matrix[row][p] = cosine * rp - sine * rq;
600                matrix[p][row] = matrix[row][p];
601                matrix[row][q] = sine * rp + cosine * rq;
602                matrix[q][row] = matrix[row][q];
603            }
604        }
605        let pp = matrix[p][p];
606        let qq = matrix[q][q];
607        let pq = matrix[p][q];
608        matrix[p][p] = cosine * cosine * pp - 2.0 * sine * cosine * pq + sine * sine * qq;
609        matrix[q][q] = sine * sine * pp + 2.0 * sine * cosine * pq + cosine * cosine * qq;
610        matrix[p][q] = 0.0;
611        matrix[q][p] = 0.0;
612        for row in &mut vectors {
613            let rp = row[p];
614            let rq = row[q];
615            row[p] = cosine * rp - sine * rq;
616            row[q] = sine * rp + cosine * rq;
617        }
618    }
619    let index =
620        (0..size).min_by(|&left, &right| matrix[left][left].total_cmp(&matrix[right][right]))?;
621    let mut vector = vectors.iter().map(|row| row[index]).collect::<Vec<_>>();
622    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
623    if !norm.is_finite() || norm <= f64::EPSILON {
624        return None;
625    }
626    for value in &mut vector {
627        *value /= norm;
628    }
629    Some(vector)
630}
631
632struct XorShift64(u64);
633
634impl XorShift64 {
635    fn new(seed: u64) -> Self {
636        Self(if seed == 0 { 0x9e37_79b9_7f4a_7c15 } else { seed })
637    }
638
639    fn next(&mut self) -> u64 {
640        self.0 ^= self.0 << 13;
641        self.0 ^= self.0 >> 7;
642        self.0 ^= self.0 << 17;
643        self.0
644    }
645}
646
647fn sample_unique(rng: &mut XorShift64, population: usize, count: usize) -> Vec<usize> {
648    let mut sample = Vec::with_capacity(count);
649    while sample.len() < count {
650        let candidate = (rng.next() % population as u64) as usize;
651        if !sample.contains(&candidate) {
652            sample.push(candidate);
653        }
654    }
655    sample
656}
657
658#[cfg(test)]
659mod tests {
660    use super::{
661        estimate_essential, estimate_fundamental, estimate_homography, estimate_homography_ransac,
662        fundamental_residual, homography_residual, recover_relative_pose,
663        triangulate_correspondence,
664    };
665    use crate::{
666        CameraMatrix3, Essential3, PointCorrespondence2, RelativePose, RobustEstimationOptions,
667    };
668    use spatialrust_camera::CameraIntrinsics;
669    use spatialrust_math::{Mat3, Vec2, Vec3};
670
671    fn correspondence(source: (f64, f64), target: (f64, f64)) -> PointCorrespondence2 {
672        PointCorrespondence2::try_new(
673            Vec2 { x: source.0, y: source.1 },
674            Vec2 { x: target.0, y: target.1 },
675        )
676        .unwrap()
677    }
678
679    #[test]
680    fn homography_recovers_known_projective_mapping() {
681        let pairs = [
682            ((0.0, 0.0), (3.0, -2.0)),
683            ((10.0, 0.0), (23.0, -2.0)),
684            ((0.0, 5.0), (3.0, 13.0)),
685            ((10.0, 5.0), (23.0, 13.0)),
686            ((4.0, 2.0), (11.0, 4.0)),
687        ]
688        .map(|(source, target)| correspondence(source, target));
689        let model = estimate_homography(&pairs).unwrap();
690        assert!(pairs.iter().all(|&pair| homography_residual(model, pair) < 1e-9));
691    }
692
693    #[test]
694    fn homography_ransac_rejects_large_outliers_deterministically() {
695        let mut pairs = (0..20)
696            .map(|index| {
697                let x = f64::from(index % 5) * 8.0;
698                let y = f64::from(index / 5) * 7.0;
699                correspondence((x, y), (1.5 * x + 4.0, 0.75 * y - 3.0))
700            })
701            .collect::<Vec<_>>();
702        pairs.push(correspondence((5.0, 5.0), (500.0, -300.0)));
703        pairs.push(correspondence((9.0, 11.0), (-200.0, 400.0)));
704        let options = RobustEstimationOptions { threshold: 0.1, seed: 7, ..Default::default() };
705        let first = estimate_homography_ransac(&pairs, options).unwrap();
706        let second = estimate_homography_ransac(&pairs, options).unwrap();
707        assert_eq!(first, second);
708        assert_eq!(first.inlier_count(), 20);
709    }
710
711    #[test]
712    fn eight_point_model_satisfies_synthetic_epipolar_constraints() {
713        let pairs = (0..16)
714            .map(|index| {
715                let x = f64::from(index % 4) * 0.3 - 0.4;
716                let y = f64::from(index / 4) * 0.2 - 0.3;
717                let depth = 2.0 + f64::from(index) * 0.05;
718                let source = (x / depth, y / depth);
719                let target = ((x + 0.2) / depth, y / depth);
720                correspondence(source, target)
721            })
722            .collect::<Vec<_>>();
723        let model = estimate_fundamental(&pairs).unwrap();
724        assert!(pairs.iter().all(|&pair| fundamental_residual(model, pair) < 1e-7));
725    }
726
727    #[test]
728    fn calibrated_triangulation_and_pose_recovery_choose_positive_depth() {
729        let intrinsics = CameraIntrinsics::try_new(500.0, 500.0, 320.0, 240.0, 640, 480).unwrap();
730        let camera = CameraMatrix3::from_intrinsics(intrinsics);
731        let known_pose =
732            RelativePose::try_new(Mat3::<f64>::identity(), Vec3::new(0.2, 0.0, 0.0)).unwrap();
733        let points = (0..16)
734            .map(|index| {
735                Vec3::new(
736                    f64::from(index % 4) * 0.25 - 0.4,
737                    f64::from(index / 4) * 0.2 - 0.3,
738                    2.0 + f64::from((index * index + 3 * index) % 17) * 0.07,
739                )
740            })
741            .collect::<Vec<_>>();
742        let pairs = points
743            .iter()
744            .map(|point| {
745                let target = *point + known_pose.translation();
746                correspondence(
747                    (500.0 * point.x / point.z + 320.0, 500.0 * point.y / point.z + 240.0),
748                    (500.0 * target.x / target.z + 320.0, 500.0 * target.y / target.z + 240.0),
749                )
750            })
751            .collect::<Vec<_>>();
752        let triangulated =
753            triangulate_correspondence(pairs[3], camera, camera, known_pose).unwrap();
754        assert!((triangulated.position().x - points[3].x).abs() < 1e-8);
755        assert!((triangulated.position().z - points[3].z).abs() < 1e-8);
756        assert!(triangulated.has_positive_depth());
757
758        let estimated = estimate_essential(&pairs, camera, camera).unwrap();
759        let maximum_residual = pairs
760            .iter()
761            .map(|pair| {
762                let source = camera.normalize_pixel(pair.source());
763                let target = camera.normalize_pixel(pair.target());
764                let normalized = correspondence((source.x, source.y), (target.x, target.y));
765                super::essential_residual(estimated, normalized)
766            })
767            .fold(0.0_f64, f64::max);
768        assert!(maximum_residual < 1e-7, "maximum residual {maximum_residual}");
769        let essential = Essential3::try_new(Mat3::from_rows(
770            [0.0, 0.0, 0.0],
771            [0.0, 0.0, -0.2],
772            [0.0, 0.2, 0.0],
773        ))
774        .unwrap();
775        let recovered = recover_relative_pose(essential, &pairs, camera, camera).unwrap();
776        assert_eq!(recovered.positive_depth_count(), pairs.len());
777        assert!(recovered.pose().translation().x.abs() > 0.99);
778    }
779}