Skip to main content

spatialrust_vision/
pnp.rs

1//! Absolute pose from 3D–2D correspondences (PnP) with deterministic RANSAC.
2
3use spatialrust_math::{
4    solve_linear_system, symmetric_eigen3, LeastSquaresResult, Mat3, Vec2, Vec3,
5};
6
7use crate::{
8    AbsolutePose, CameraMatrix3, GeometricEstimate, ObjectImageCorrespondence,
9    PointCorrespondence2, RobustEstimationOptions, VisionError, VisionResult,
10};
11
12/// Estimates an object-to-camera pose from at least four correspondences.
13///
14/// Uses calibrated DLT, or plane-aware homography initialization for planar and
15/// near-planar object geometry, followed by Gauss–Newton refinement on
16/// the SE(3) tangent space. Four points are accepted for the final refine path
17/// when an initial pose is recoverable; RANSAC minimal samples use six points.
18pub fn solve_pnp(
19    correspondences: &[ObjectImageCorrespondence],
20    camera: CameraMatrix3,
21) -> VisionResult<AbsolutePose> {
22    if correspondences.len() < 4 {
23        return Err(VisionError::InvalidParameter(
24            "PnP requires at least four object-image correspondences".into(),
25        ));
26    }
27    let initial = estimate_pnp_dlt(correspondences, camera)?;
28    refine_pnp(correspondences, camera, initial)
29}
30
31/// Robust PnP using deterministic six-point RANSAC and optional inlier refinement.
32///
33/// Refinement preserves the selected hypothesis on failure or when its consensus
34/// is worse. More inliers win; equal counts require a lower inlier residual sum.
35pub fn solve_pnp_ransac(
36    correspondences: &[ObjectImageCorrespondence],
37    camera: CameraMatrix3,
38    options: RobustEstimationOptions,
39) -> VisionResult<GeometricEstimate<AbsolutePose>> {
40    let options = options.validate()?;
41    const SAMPLE: usize = 6;
42    if correspondences.len() < SAMPLE {
43        return Err(VisionError::InvalidParameter(
44            "robust PnP requires at least six correspondences".into(),
45        ));
46    }
47    let mut rng = XorShift64::new(options.seed);
48    let mut best: Option<PnpConsensus> = None;
49    let mut iteration_limit = options.max_iterations;
50    let mut iteration = 0;
51    while iteration < iteration_limit {
52        let indices = sample_unique(&mut rng, correspondences.len(), SAMPLE);
53        let sample = indices.iter().map(|&index| correspondences[index]).collect::<Vec<_>>();
54        if let Ok(model) =
55            estimate_pnp_dlt(&sample, camera).and_then(|pose| refine_pnp(&sample, camera, pose))
56        {
57            let residuals = correspondences
58                .iter()
59                .copied()
60                .map(|pair| pnp_residual(model, pair, camera))
61                .collect::<Vec<_>>();
62            let inliers =
63                residuals.iter().map(|&value| value <= options.threshold).collect::<Vec<_>>();
64            let count = inliers.iter().filter(|&&value| value).count();
65            let error = residuals
66                .iter()
67                .zip(&inliers)
68                .filter_map(|(value, &is_inlier)| is_inlier.then_some(*value))
69                .sum::<f64>();
70            let improves = best.as_ref().map_or(true, |candidate| {
71                count > candidate.3 || (count == candidate.3 && error < candidate.4)
72            });
73            if improves {
74                if count >= SAMPLE {
75                    let inlier_ratio = count as f64 / correspondences.len() as f64;
76                    let success = inlier_ratio.powi(SAMPLE as i32).clamp(0.0, 1.0);
77                    if success > 0.0 && success < 1.0 {
78                        let required = ((1.0 - options.confidence).ln() / (1.0 - success).ln())
79                            .ceil()
80                            .max(1.0) as usize;
81                        iteration_limit = iteration_limit.min(required.max(iteration + 1));
82                    } else if success == 1.0 {
83                        iteration_limit = iteration + 1;
84                    }
85                }
86                best = Some((model, inliers, residuals, count, error));
87            }
88        }
89        iteration += 1;
90    }
91    let best = best
92        .ok_or_else(|| VisionError::InvalidParameter("robust PnP found no valid model".into()))?;
93    if best.3 < 4 {
94        return Err(VisionError::InvalidParameter("robust PnP found too few inliers".into()));
95    }
96    let inlier_pairs = correspondences
97        .iter()
98        .zip(&best.1)
99        .filter_map(|(&pair, &is_inlier)| is_inlier.then_some(pair))
100        .collect::<Vec<_>>();
101    let refinement = solve_pnp(&inlier_pairs, camera);
102    finish_pnp_consensus(best, refinement, correspondences, camera, options.threshold)
103}
104
105// Pose, original-order decisions/residuals, inlier count and inlier residual sum.
106type PnpConsensus = (AbsolutePose, Vec<bool>, Vec<f64>, usize, f64);
107
108fn finish_pnp_consensus(
109    mut best: PnpConsensus,
110    refinement: VisionResult<AbsolutePose>,
111    correspondences: &[ObjectImageCorrespondence],
112    camera: CameraMatrix3,
113    threshold: f64,
114) -> VisionResult<GeometricEstimate<AbsolutePose>> {
115    if let Ok(refined) = refinement {
116        let (count, error) =
117            rescore_pnp(refined, correspondences, camera, threshold, &mut best.1, &mut best.2);
118        if count > best.3 || (count == best.3 && error < best.4) {
119            best.0 = refined;
120            best.3 = count;
121            best.4 = error;
122        } else {
123            // Reuse the selected hypothesis's existing buffers. A rejected
124            // candidate costs one extra projection pass, not another pair of
125            // full-row output allocations. Restore rows before publishing.
126            rescore_pnp(best.0, correspondences, camera, threshold, &mut best.1, &mut best.2);
127        }
128    }
129    GeometricEstimate::try_new(best.0, correspondences.len(), best.1, best.2)
130}
131
132fn rescore_pnp(
133    pose: AbsolutePose,
134    correspondences: &[ObjectImageCorrespondence],
135    camera: CameraMatrix3,
136    threshold: f64,
137    inliers: &mut [bool],
138    residuals: &mut [f64],
139) -> (usize, f64) {
140    debug_assert_eq!(correspondences.len(), inliers.len());
141    debug_assert_eq!(correspondences.len(), residuals.len());
142    let mut count = 0;
143    let mut error = 0.0;
144    for ((&pair, inlier), residual) in
145        correspondences.iter().zip(inliers.iter_mut()).zip(residuals.iter_mut())
146    {
147        *residual = pnp_residual(pose, pair, camera);
148        *inlier = *residual <= threshold;
149        if *inlier {
150            count += 1;
151            error += *residual;
152        }
153    }
154    (count, error)
155}
156
157/// Projects an object point with an absolute pose into pixel coordinates.
158pub fn project_object_point(
159    pose: AbsolutePose,
160    camera: CameraMatrix3,
161    object: Vec3<f64>,
162) -> VisionResult<Vec2<f64>> {
163    let camera_point = pose.transform_point(object);
164    if camera_point.z <= 1e-12 {
165        return Err(VisionError::InvalidParameter(
166            "projected point lies behind or on the camera plane".into(),
167        ));
168    }
169    let normalized =
170        Vec3::new(camera_point.x / camera_point.z, camera_point.y / camera_point.z, 1.0);
171    let pixel = camera.matrix().mul_vec3(normalized);
172    Ok(Vec2 { x: pixel.x / pixel.z, y: pixel.y / pixel.z })
173}
174
175fn estimate_pnp_dlt(
176    correspondences: &[ObjectImageCorrespondence],
177    camera: CameraMatrix3,
178) -> VisionResult<AbsolutePose> {
179    if correspondences.len() < 4 {
180        return Err(VisionError::InvalidParameter(
181            "PnP DLT requires at least four correspondences".into(),
182        ));
183    }
184    if let Some(pose) = planar_initialization(correspondences, camera)? {
185        return Ok(pose);
186    }
187    let mut normal = vec![vec![0.0; 12]; 12];
188    for pair in correspondences {
189        let object = pair.object();
190        let image = pair.image();
191        let rows = [
192            [
193                object.x,
194                object.y,
195                object.z,
196                1.0,
197                0.0,
198                0.0,
199                0.0,
200                0.0,
201                -image.x * object.x,
202                -image.x * object.y,
203                -image.x * object.z,
204                -image.x,
205            ],
206            [
207                0.0,
208                0.0,
209                0.0,
210                0.0,
211                object.x,
212                object.y,
213                object.z,
214                1.0,
215                -image.y * object.x,
216                -image.y * object.y,
217                -image.y * object.z,
218                -image.y,
219            ],
220        ];
221        for row in rows {
222            for first in 0..12 {
223                for second in 0..12 {
224                    normal[first][second] += row[first] * row[second];
225                }
226            }
227        }
228    }
229    let vector = smallest_symmetric_eigenvector(normal).ok_or_else(|| {
230        VisionError::InvalidParameter("PnP correspondences are degenerate".into())
231    })?;
232    let projection = Mat3::from_rows(
233        [vector[0], vector[1], vector[2]],
234        [vector[4], vector[5], vector[6]],
235        [vector[8], vector[9], vector[10]],
236    );
237    let translation_part = Vec3::new(vector[3], vector[7], vector[11]);
238    let calibrated = camera.inverse().mul_mat3(projection);
239    let calibrated_t = camera.inverse().mul_vec3(translation_part);
240    let (rotation, scale) = orthonormalize_rotation(calibrated)?;
241    let translation =
242        Vec3::new(calibrated_t.x / scale, calibrated_t.y / scale, calibrated_t.z / scale);
243    // Flip if most points have negative depth.
244    let pose = AbsolutePose::try_new(rotation, translation)?;
245    let positive =
246        correspondences.iter().filter(|pair| pose.transform_point(pair.object()).z > 0.0).count();
247    if positive * 2 < correspondences.len() {
248        AbsolutePose::try_new(
249            Mat3::from_rows(
250                [-rotation.m[0][0], -rotation.m[0][1], -rotation.m[0][2]],
251                [-rotation.m[1][0], -rotation.m[1][1], -rotation.m[1][2]],
252                [-rotation.m[2][0], -rotation.m[2][1], -rotation.m[2][2]],
253            ),
254            Vec3::new(-translation.x, -translation.y, -translation.z),
255        )
256    } else {
257        Ok(pose)
258    }
259}
260
261/// Lift normalized plane coordinates through a homography. This avoids the
262/// extra null directions of 3D DLT when object points have negligible thickness.
263fn planar_initialization(
264    correspondences: &[ObjectImageCorrespondence],
265    camera: CameraMatrix3,
266) -> VisionResult<Option<AbsolutePose>> {
267    let origin = correspondences[0].object();
268    let extent = correspondences
269        .iter()
270        .map(|pair| {
271            let p = pair.object() - origin;
272            p.x.abs().max(p.y.abs()).max(p.z.abs())
273        })
274        .fold(0.0_f64, f64::max);
275    if !extent.is_finite() || extent == 0.0 {
276        return Err(VisionError::InvalidParameter("PnP object extent is zero or overflows".into()));
277    }
278    let mut mean = Vec3::new(0.0, 0.0, 0.0);
279    for pair in correspondences {
280        mean = mean + scale_vec(pair.object() - origin, 1.0 / extent);
281    }
282    mean = scale_vec(mean, 1.0 / correspondences.len() as f64);
283    let centroid = origin + scale_vec(mean, extent);
284    let mut covariance = [[0.0; 3]; 3];
285    for pair in correspondences {
286        let p = scale_vec(pair.object() - origin, 1.0 / extent) - mean;
287        let components = [p.x, p.y, p.z];
288        for row in 0..3 {
289            for column in 0..3 {
290                covariance[row][column] += components[row] * components[column];
291            }
292        }
293    }
294    if covariance.iter().flatten().any(|value| !value.is_finite())
295        || ![centroid.x, centroid.y, centroid.z].into_iter().all(f64::is_finite)
296    {
297        return Err(VisionError::InvalidParameter("PnP object covariance overflows".into()));
298    }
299    let eigen = symmetric_eigen3(Mat3::from_rows(covariance[0], covariance[1], covariance[2]));
300    if eigen.eigenvalues[1] <= eigen.eigenvalues[2] * 1e-10 {
301        return Err(VisionError::InvalidParameter("PnP object points are collinear".into()));
302    }
303    if eigen.eigenvalues[0] > eigen.eigenvalues[2] * 1e-6 {
304        return Ok(None);
305    }
306    let vectors = eigen.eigenvectors;
307    let u = Vec3::new(vectors.m[0][2], vectors.m[1][2], vectors.m[2][2]);
308    let v = Vec3::new(vectors.m[0][1], vectors.m[1][1], vectors.m[2][1]);
309    let n = u.cross(v);
310    let basis = Mat3::from_rows([u.x, v.x, n.x], [u.y, v.y, n.y], [u.z, v.z, n.z]);
311    let pairs = correspondences
312        .iter()
313        .map(|pair| {
314            let p = scale_vec(pair.object() - origin, 1.0 / extent) - mean;
315            let pixel = camera.normalize_pixel(pair.image());
316            PointCorrespondence2::try_new(
317                Vec2 { x: p.dot(u), y: p.dot(v) },
318                Vec2 { x: pixel.x, y: pixel.y },
319            )
320        })
321        .collect::<VisionResult<Vec<_>>>()?;
322    let homography = crate::multiview::estimate_homography(&pairs)?.matrix();
323    let a = Vec3::new(homography.m[0][0], homography.m[1][0], homography.m[2][0]);
324    let b = Vec3::new(homography.m[0][1], homography.m[1][1], homography.m[2][1]);
325    let c = Vec3::new(homography.m[0][2], homography.m[1][2], homography.m[2][2]);
326    let scale = (a.length() + b.length()) * 0.5;
327    if !scale.is_finite() || scale <= f64::EPSILON {
328        return Err(VisionError::InvalidParameter(
329            "PnP planar homography has invalid scale".into(),
330        ));
331    }
332    let mut best: Option<(AbsolutePose, usize, f64)> = None;
333    for sign in [1.0, -1.0] {
334        let first = scale_vec(a, sign / scale);
335        let second = scale_vec(b, sign / scale);
336        let third = first.cross(second);
337        let approximate = Mat3::from_rows(
338            [first.x, second.x, third.x],
339            [first.y, second.y, third.y],
340            [first.z, second.z, third.z],
341        );
342        let (camera_basis, _) = orthonormalize_rotation(approximate)?;
343        let rotation = camera_basis.mul_mat3(basis.transpose());
344        let translation = scale_vec(c, sign * extent / scale) - rotation.mul_vec3(centroid);
345        let pose = AbsolutePose::try_new(rotation, translation)?;
346        let positive = correspondences
347            .iter()
348            .filter(|pair| pose.transform_point(pair.object()).z > 0.0)
349            .count();
350        let error =
351            correspondences.iter().map(|pair| pnp_residual(pose, *pair, camera)).sum::<f64>();
352        if best.as_ref().map_or(true, |previous| {
353            positive > previous.1 || (positive == previous.1 && error < previous.2)
354        }) {
355            best = Some((pose, positive, error));
356        }
357    }
358    let (pose, positive, _) = best.expect("two planar signs evaluated");
359    if positive == 0 {
360        return Err(VisionError::InvalidParameter("PnP planar pose has no positive depth".into()));
361    }
362    Ok(Some(pose))
363}
364
365fn refine_pnp(
366    correspondences: &[ObjectImageCorrespondence],
367    camera: CameraMatrix3,
368    mut pose: AbsolutePose,
369) -> VisionResult<AbsolutePose> {
370    for _ in 0..20 {
371        let mut normal = vec![vec![0.0; 6]; 6];
372        let mut rhs = vec![0.0; 6];
373        let mut residual_sum = 0.0;
374        for pair in correspondences {
375            let camera_point = pose.transform_point(pair.object());
376            if camera_point.z <= 1e-12 {
377                continue;
378            }
379            let predicted = project_object_point(pose, camera, pair.object())?;
380            let error = [predicted.x - pair.image().x, predicted.y - pair.image().y];
381            residual_sum += error[0] * error[0] + error[1] * error[1];
382            let jacobian = projection_jacobian(pose, camera, pair.object(), camera_point)?;
383            for row in 0..2 {
384                for col in 0..6 {
385                    rhs[col] -= jacobian[row][col] * error[row];
386                    for other in 0..6 {
387                        normal[col][other] += jacobian[row][col] * jacobian[row][other];
388                    }
389                }
390            }
391        }
392        let LeastSquaresResult::Solved(delta) = solve_linear_system(normal, rhs) else {
393            break;
394        };
395        let update = Vec3::new(delta[0], delta[1], delta[2]);
396        if update.length() + Vec3::new(delta[3], delta[4], delta[5]).length() < 1e-10 {
397            break;
398        }
399        let rotated = exp_so3(update).mul_mat3(pose.rotation());
400        let (rotation, _) = orthonormalize_rotation(rotated)?;
401        let translation = pose.translation() + Vec3::new(delta[3], delta[4], delta[5]);
402        pose = AbsolutePose::try_new(rotation, translation)?;
403        if residual_sum < 1e-18 {
404            break;
405        }
406    }
407    Ok(pose)
408}
409
410fn projection_jacobian(
411    pose: AbsolutePose,
412    camera: CameraMatrix3,
413    object: Vec3<f64>,
414    camera_point: Vec3<f64>,
415) -> VisionResult<[[f64; 6]; 2]> {
416    let z = camera_point.z;
417    let z2 = z * z;
418    let fx = camera.matrix().m[0][0];
419    let fy = camera.matrix().m[1][1];
420    // d(u,v)/dX_c
421    let du_dx = fx / z;
422    let du_dy = 0.0;
423    let du_dz = -fx * camera_point.x / z2;
424    let dv_dx = 0.0;
425    let dv_dy = fy / z;
426    let dv_dz = -fy * camera_point.y / z2;
427    // dX_c / d(omega,t): omega acts as [omega]_x R X, t is additive.
428    let rotated = pose.rotation().mul_vec3(object);
429    let dx_domega = [
430        Vec3::new(0.0, -rotated.z, rotated.y),
431        Vec3::new(rotated.z, 0.0, -rotated.x),
432        Vec3::new(-rotated.y, rotated.x, 0.0),
433    ];
434    let mut jacobian = [[0.0; 6]; 2];
435    for axis in 0..3 {
436        let d = dx_domega[axis];
437        jacobian[0][axis] = du_dx * d.x + du_dy * d.y + du_dz * d.z;
438        jacobian[1][axis] = dv_dx * d.x + dv_dy * d.y + dv_dz * d.z;
439    }
440    jacobian[0][3] = du_dx;
441    jacobian[0][4] = du_dy;
442    jacobian[0][5] = du_dz;
443    jacobian[1][3] = dv_dx;
444    jacobian[1][4] = dv_dy;
445    jacobian[1][5] = dv_dz;
446    Ok(jacobian)
447}
448
449fn pnp_residual(pose: AbsolutePose, pair: ObjectImageCorrespondence, camera: CameraMatrix3) -> f64 {
450    match project_object_point(pose, camera, pair.object()) {
451        Ok(pixel) => (pixel.x - pair.image().x).hypot(pixel.y - pair.image().y),
452        Err(_) => f64::MAX,
453    }
454}
455
456fn orthonormalize_rotation(matrix: Mat3<f64>) -> VisionResult<(Mat3<f64>, f64)> {
457    let eigen = symmetric_eigen3(matrix.transpose().mul_mat3(matrix));
458    let scale = ((eigen.eigenvalues[0].max(0.0).sqrt()
459        + eigen.eigenvalues[1].max(0.0).sqrt()
460        + eigen.eigenvalues[2].max(0.0).sqrt())
461        / 3.0)
462        .max(1e-12);
463    let right = eigen.eigenvectors;
464    let mut left_cols = [Vec3::new(0.0, 0.0, 0.0); 3];
465    for (column, left_col) in left_cols.iter_mut().enumerate() {
466        let right_col = Vec3::new(right.m[0][column], right.m[1][column], right.m[2][column]);
467        let sigma = eigen.eigenvalues[column].max(0.0).sqrt().max(1e-12);
468        *left_col = scale_vec(matrix.mul_vec3(right_col), 1.0 / (sigma * scale));
469    }
470    left_cols[0] = left_cols[0].normalize();
471    left_cols[1] =
472        (left_cols[1] - scale_vec(left_cols[0], left_cols[0].dot(left_cols[1]))).normalize();
473    left_cols[2] = left_cols[0].cross(left_cols[1]).normalize();
474    let right0 = Vec3::new(right.m[0][0], right.m[1][0], right.m[2][0]).normalize();
475    let mut right1 = Vec3::new(right.m[0][1], right.m[1][1], right.m[2][1]);
476    right1 = (right1 - scale_vec(right0, right0.dot(right1))).normalize();
477    let right2 = right0.cross(right1).normalize();
478    let mut rotation = Mat3::from_rows(
479        [
480            left_cols[0].x * right0.x + left_cols[1].x * right1.x + left_cols[2].x * right2.x,
481            left_cols[0].x * right0.y + left_cols[1].x * right1.y + left_cols[2].x * right2.y,
482            left_cols[0].x * right0.z + left_cols[1].x * right1.z + left_cols[2].x * right2.z,
483        ],
484        [
485            left_cols[0].y * right0.x + left_cols[1].y * right1.x + left_cols[2].y * right2.x,
486            left_cols[0].y * right0.y + left_cols[1].y * right1.y + left_cols[2].y * right2.y,
487            left_cols[0].y * right0.z + left_cols[1].y * right1.z + left_cols[2].y * right2.z,
488        ],
489        [
490            left_cols[0].z * right0.x + left_cols[1].z * right1.x + left_cols[2].z * right2.x,
491            left_cols[0].z * right0.y + left_cols[1].z * right1.y + left_cols[2].z * right2.y,
492            left_cols[0].z * right0.z + left_cols[1].z * right1.z + left_cols[2].z * right2.z,
493        ],
494    );
495    if determinant(rotation) < 0.0 {
496        for row in &mut rotation.m {
497            for value in row {
498                *value = -*value;
499            }
500        }
501    }
502    Ok((rotation, if determinant(matrix) < 0.0 { -scale } else { scale }))
503}
504
505fn exp_so3(omega: Vec3<f64>) -> Mat3<f64> {
506    let theta = omega.length();
507    if theta < 1e-12 {
508        return Mat3::from_rows(
509            [1.0, -omega.z, omega.y],
510            [omega.z, 1.0, -omega.x],
511            [-omega.y, omega.x, 1.0],
512        );
513    }
514    let axis = omega.normalize();
515    let skew =
516        Mat3::from_rows([0.0, -axis.z, axis.y], [axis.z, 0.0, -axis.x], [-axis.y, axis.x, 0.0]);
517    let skew2 = skew.mul_mat3(skew);
518    let mut result = Mat3::<f64>::identity();
519    let s = theta.sin();
520    let c = 1.0 - theta.cos();
521    for row in 0..3 {
522        for column in 0..3 {
523            result.m[row][column] += s * skew.m[row][column] + c * skew2.m[row][column];
524        }
525    }
526    result
527}
528
529#[allow(clippy::needless_range_loop)]
530fn smallest_symmetric_eigenvector(mut matrix: Vec<Vec<f64>>) -> Option<[f64; 12]> {
531    let size = matrix.len();
532    if size != 12 || matrix.iter().any(|row| row.len() != size) {
533        return None;
534    }
535    let mut vectors = vec![vec![0.0; size]; size];
536    for (index, row) in vectors.iter_mut().enumerate() {
537        row[index] = 1.0;
538    }
539    for _ in 0..size * size * 32 {
540        let mut pivot = (0, 1);
541        let mut maximum = 0.0_f64;
542        for (row, values) in matrix.iter().enumerate() {
543            for (column, &value) in values.iter().enumerate().skip(row + 1) {
544                if value.abs() > maximum {
545                    maximum = value.abs();
546                    pivot = (row, column);
547                }
548            }
549        }
550        if maximum < 1e-12 {
551            break;
552        }
553        let (p, q) = pivot;
554        let angle = 0.5 * (2.0 * matrix[p][q]).atan2(matrix[q][q] - matrix[p][p]);
555        let (sine, cosine) = angle.sin_cos();
556        for row in 0..size {
557            if row != p && row != q {
558                let rp = matrix[row][p];
559                let rq = matrix[row][q];
560                matrix[row][p] = cosine * rp - sine * rq;
561                matrix[p][row] = matrix[row][p];
562                matrix[row][q] = sine * rp + cosine * rq;
563                matrix[q][row] = matrix[row][q];
564            }
565        }
566        let pp = matrix[p][p];
567        let qq = matrix[q][q];
568        let pq = matrix[p][q];
569        matrix[p][p] = cosine * cosine * pp - 2.0 * sine * cosine * pq + sine * sine * qq;
570        matrix[q][q] = sine * sine * pp + 2.0 * sine * cosine * pq + cosine * cosine * qq;
571        matrix[p][q] = 0.0;
572        matrix[q][p] = 0.0;
573        for row in &mut vectors {
574            let rp = row[p];
575            let rq = row[q];
576            row[p] = cosine * rp - sine * rq;
577            row[q] = sine * rp + cosine * rq;
578        }
579    }
580    let index =
581        (0..size).min_by(|&left, &right| matrix[left][left].total_cmp(&matrix[right][right]))?;
582    let mut vector = vectors.iter().map(|row| row[index]).collect::<Vec<_>>();
583    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
584    if !norm.is_finite() || norm <= f64::EPSILON {
585        return None;
586    }
587    for value in &mut vector {
588        *value /= norm;
589    }
590    let mut out = [0.0; 12];
591    out.copy_from_slice(&vector);
592    Some(out)
593}
594
595fn sample_unique(rng: &mut XorShift64, upper: usize, count: usize) -> Vec<usize> {
596    let mut selected = Vec::with_capacity(count);
597    while selected.len() < count {
598        let value = rng.next_usize(upper);
599        if !selected.contains(&value) {
600            selected.push(value);
601        }
602    }
603    selected
604}
605
606fn scale_vec(vector: Vec3<f64>, scale: f64) -> Vec3<f64> {
607    Vec3::new(vector.x * scale, vector.y * scale, vector.z * scale)
608}
609
610fn determinant(matrix: Mat3<f64>) -> f64 {
611    matrix.m[0][0] * (matrix.m[1][1] * matrix.m[2][2] - matrix.m[1][2] * matrix.m[2][1])
612        - matrix.m[0][1] * (matrix.m[1][0] * matrix.m[2][2] - matrix.m[1][2] * matrix.m[2][0])
613        + matrix.m[0][2] * (matrix.m[1][0] * matrix.m[2][1] - matrix.m[1][1] * matrix.m[2][0])
614}
615
616struct XorShift64 {
617    state: u64,
618}
619
620impl XorShift64 {
621    fn new(seed: u64) -> Self {
622        Self { state: seed | 1 }
623    }
624
625    fn next_u64(&mut self) -> u64 {
626        self.state ^= self.state << 13;
627        self.state ^= self.state >> 7;
628        self.state ^= self.state << 17;
629        self.state
630    }
631
632    fn next_usize(&mut self, upper: usize) -> usize {
633        (self.next_u64() as usize) % upper.max(1)
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::{project_object_point, solve_pnp, solve_pnp_ransac};
640    use crate::{AbsolutePose, CameraMatrix3, ObjectImageCorrespondence, RobustEstimationOptions};
641    use spatialrust_camera::CameraIntrinsics;
642    use spatialrust_math::{Mat3, Vec2, Vec3};
643
644    fn camera() -> CameraMatrix3 {
645        let intrinsics = CameraIntrinsics::try_new(500.0, 500.0, 320.0, 240.0, 640, 480).unwrap();
646        CameraMatrix3::from_intrinsics(intrinsics)
647    }
648
649    fn sample_pose() -> AbsolutePose {
650        AbsolutePose::try_new(
651            Mat3::from_rows(
652                [0.936_293_4, -0.275_095_9, 0.218_350_8],
653                [0.289_629_5, 0.956_425_1, -0.036_957_0],
654                [-0.198_669_3, 0.097_843_4, 0.975_170_3],
655            ),
656            Vec3::new(0.15, -0.05, 2.5),
657        )
658        .unwrap()
659    }
660
661    #[test]
662    fn solve_pnp_recovers_known_pose() {
663        let camera = camera();
664        let pose = sample_pose();
665        let objects = [
666            Vec3::new(0.0, 0.0, 0.0),
667            Vec3::new(0.4, 0.0, 0.0),
668            Vec3::new(0.0, 0.3, 0.0),
669            Vec3::new(0.0, 0.0, 0.2),
670            Vec3::new(0.25, 0.2, 0.1),
671            Vec3::new(-0.1, 0.15, -0.05),
672            Vec3::new(0.1, -0.2, 0.05),
673            Vec3::new(-0.2, -0.1, 0.15),
674        ];
675        let pairs = objects
676            .into_iter()
677            .map(|object| {
678                let image = project_object_point(pose, camera, object).unwrap();
679                ObjectImageCorrespondence::try_new(object, image).unwrap()
680            })
681            .collect::<Vec<_>>();
682        let estimated = solve_pnp(&pairs, camera).unwrap();
683        for (expected, actual) in
684            pose.rotation().m.iter().flatten().zip(estimated.rotation().m.iter().flatten())
685        {
686            assert!((expected - actual).abs() < 2e-3);
687        }
688        assert!((pose.translation().x - estimated.translation().x).abs() < 2e-3);
689        assert!((pose.translation().y - estimated.translation().y).abs() < 2e-3);
690        assert!((pose.translation().z - estimated.translation().z).abs() < 2e-3);
691    }
692
693    #[test]
694    fn planar_initialization_recovers_arbitrary_plane_at_multiple_scales() {
695        let camera = camera();
696        let base = sample_pose();
697        let u = Vec3::new(0.8, 0.6, 0.0);
698        let v = Vec3::new(0.0, 0.0, 1.0);
699        let n = u.cross(v);
700        for scale in [1e-4, 1.0, 1e4] {
701            for thickness in [0.0, 1e-4] {
702                let pose = AbsolutePose::try_new(
703                    base.rotation(),
704                    super::scale_vec(base.translation(), scale),
705                )
706                .unwrap();
707                let pairs = (0..25)
708                    .map(|index| {
709                        let object = super::scale_vec(
710                            super::scale_vec(u, (index % 5) as f64 * 0.2 - 0.4)
711                                + super::scale_vec(v, (index / 5) as f64 * 0.2 - 0.4)
712                                + super::scale_vec(n, thickness * ((index % 3) as f64 - 1.0))
713                                + Vec3::new(0.3, -0.2, 0.1),
714                            scale,
715                        );
716                        ObjectImageCorrespondence::try_new(
717                            object,
718                            project_object_point(pose, camera, object).unwrap(),
719                        )
720                        .unwrap()
721                    })
722                    .collect::<Vec<_>>();
723                let estimated = solve_pnp(&pairs, camera).unwrap();
724                for (a, b) in
725                    estimated.rotation().m.iter().flatten().zip(pose.rotation().m.iter().flatten())
726                {
727                    assert!((a - b).abs() < 1e-5, "scale={scale}, thickness={thickness}");
728                }
729                let error = estimated.translation() - pose.translation();
730                assert!(error.length() / scale < 1e-5);
731            }
732        }
733    }
734
735    #[test]
736    fn planar_four_points_work_and_collinear_points_are_rejected() {
737        let camera = camera();
738        let pose = sample_pose();
739        let objects = [
740            Vec3::new(-0.4, -0.3, 0.0),
741            Vec3::new(0.4, -0.3, 0.0),
742            Vec3::new(-0.4, 0.3, 0.0),
743            Vec3::new(0.4, 0.3, 0.0),
744        ];
745        let pairs = objects
746            .iter()
747            .map(|&object| {
748                ObjectImageCorrespondence::try_new(
749                    object,
750                    project_object_point(pose, camera, object).unwrap(),
751                )
752                .unwrap()
753            })
754            .collect::<Vec<_>>();
755        let estimated = solve_pnp(&pairs, camera).unwrap();
756        assert!((estimated.translation() - pose.translation()).length() < 1e-5);
757        let line = (0..8)
758            .map(|index| {
759                let object = Vec3::new(index as f64 * 0.1, 0.0, 0.0);
760                ObjectImageCorrespondence::try_new(
761                    object,
762                    project_object_point(pose, camera, object).unwrap(),
763                )
764                .unwrap()
765            })
766            .collect::<Vec<_>>();
767        assert!(solve_pnp(&line, camera).is_err());
768    }
769
770    #[test]
771    fn planar_ransac_recovers_pose_with_incorrect_image_pairs() {
772        let camera = camera();
773        let pose = sample_pose();
774        let pairs = (0..30)
775            .map(|index| {
776                let object =
777                    Vec3::new((index % 6) as f64 * 0.2 - 0.5, (index / 6) as f64 * 0.2 - 0.4, 0.0);
778                let mut image = project_object_point(pose, camera, object).unwrap();
779                if index < 6 {
780                    image.x += 80.0;
781                    image.y -= 60.0;
782                }
783                ObjectImageCorrespondence::try_new(object, image).unwrap()
784            })
785            .collect::<Vec<_>>();
786        let estimate = solve_pnp_ransac(
787            &pairs,
788            camera,
789            RobustEstimationOptions {
790                threshold: 2.0,
791                max_iterations: 300,
792                seed: 7,
793                ..RobustEstimationOptions::default()
794            },
795        )
796        .unwrap();
797        assert_eq!(estimate.inlier_count(), 24);
798        assert!((estimate.model().translation() - pose.translation()).length() < 1e-5);
799    }
800
801    #[test]
802    fn solve_pnp_ransac_rejects_outliers() {
803        let camera = camera();
804        let pose = sample_pose();
805        let mut pairs = (0..20)
806            .map(|index| {
807                let object = Vec3::new(
808                    (index % 5) as f64 * 0.1 - 0.2,
809                    (index / 5) as f64 * 0.1 - 0.15,
810                    (index % 3) as f64 * 0.05,
811                );
812                let image = project_object_point(pose, camera, object).unwrap();
813                ObjectImageCorrespondence::try_new(object, image).unwrap()
814            })
815            .collect::<Vec<_>>();
816        for pair in pairs.iter_mut().take(4) {
817            *pair = ObjectImageCorrespondence::try_new(
818                pair.object(),
819                Vec2 { x: pair.image().x + 80.0, y: pair.image().y - 60.0 },
820            )
821            .unwrap();
822        }
823        let estimate = solve_pnp_ransac(
824            &pairs,
825            camera,
826            RobustEstimationOptions {
827                threshold: 2.0,
828                confidence: 0.99,
829                max_iterations: 500,
830                seed: 7,
831            },
832        )
833        .unwrap();
834        assert!(estimate.inlier_count() >= 14);
835        let recovered = *estimate.model();
836        assert!((pose.translation().z - recovered.translation().z).abs() < 5e-2);
837    }
838
839    #[test]
840    fn robust_pose_survives_failed_inlier_reinitialization() {
841        // Procedural near-planar geometry, not a captured sensor fixture.
842        // The selected six-point hypothesis is valid, while cold DLT over its
843        // consensus can produce an invalid rotation on the parent implementation.
844        let camera = CameraMatrix3::from_intrinsics(
845            CameraIntrinsics::try_new(525.0, 525.0, 319.5, 239.5, 640, 480).unwrap(),
846        );
847        let truth = AbsolutePose::try_new(
848            Mat3::from_rows(
849                [0.9983758533596424, -0.03588494159791196, -0.04424846206277005],
850                [0.034957430317503015, 0.999156210831159, -0.021560250998475538],
851                [0.044984814037660234, 0.019978421460035138, 0.9987878799735018],
852            ),
853            Vec3::new(0.01, -0.004, 0.007),
854        )
855        .unwrap();
856        let mut rng = super::XorShift64::new(5);
857        let uniform = |rng: &mut super::XorShift64| {
858            (rng.next_u64() >> 11) as f64 * (1.0 / ((1_u64 << 53) as f64))
859        };
860        let pairs = (0..90)
861            .map(|index| {
862                let x = -0.95 + 1.9 * uniform(&mut rng);
863                let y = -0.7 + 1.4 * uniform(&mut rng);
864                let z = 2.0 + 0.2 * x + 0.1 * y + 0.002 * (uniform(&mut rng) - 0.5);
865                let object = Vec3::new(x, y, z);
866                let mut image = project_object_point(truth, camera, object).unwrap();
867                image.x += 0.5 * (uniform(&mut rng) - 0.5);
868                image.y += 0.5 * (uniform(&mut rng) - 0.5);
869                if index % 3 == 0 {
870                    image.x = 40.0 + 560.0 * uniform(&mut rng);
871                    image.y = 40.0 + 400.0 * uniform(&mut rng);
872                }
873                ObjectImageCorrespondence::try_new(object, image).unwrap()
874            })
875            .collect::<Vec<_>>();
876        let estimate = solve_pnp_ransac(
877            &pairs,
878            camera,
879            RobustEstimationOptions { threshold: 2.0, ..Default::default() },
880        )
881        .expect("optional refitting must not discard a valid robust hypothesis");
882        assert!(estimate.inlier_count() >= 58);
883        assert_eq!(estimate.inliers().len(), pairs.len());
884        assert_eq!(estimate.residuals().len(), pairs.len());
885        for ((pair, &inlier), &residual) in
886            pairs.iter().zip(estimate.inliers()).zip(estimate.residuals())
887        {
888            let expected = super::pnp_residual(*estimate.model(), *pair, camera);
889            assert_eq!(residual, expected);
890            assert_eq!(inlier, expected <= 2.0);
891        }
892    }
893
894    fn consensus_fixture() -> (CameraMatrix3, Vec<ObjectImageCorrespondence>) {
895        let camera = camera();
896        let pairs = (0..32)
897            .map(|index| {
898                let object = Vec3::new(
899                    (index % 8) as f64 * 0.04 - 0.14,
900                    (index / 8) as f64 * 0.06 - 0.09,
901                    (index % 3) as f64 * 0.1,
902                );
903                let image = Vec2 {
904                    x: 500.0 * (object.x / (object.z + 3.0)) + 320.0,
905                    y: 500.0 * (object.y / (object.z + 3.0)) + 240.0,
906                };
907                ObjectImageCorrespondence::try_new(object, image).unwrap()
908            })
909            .collect();
910        (camera, pairs)
911    }
912
913    fn translated_pose(x: f64, z: f64) -> AbsolutePose {
914        AbsolutePose::try_new(Mat3::<f64>::identity(), Vec3::new(x, 0.0, z)).unwrap()
915    }
916
917    fn consensus_for(
918        pose: AbsolutePose,
919        pairs: &[ObjectImageCorrespondence],
920        camera: CameraMatrix3,
921    ) -> super::PnpConsensus {
922        let mut inliers = vec![false; pairs.len()];
923        let mut residuals = vec![0.0; pairs.len()];
924        let (count, error) =
925            super::rescore_pnp(pose, pairs, camera, 2.0, &mut inliers, &mut residuals);
926        (pose, inliers, residuals, count, error)
927    }
928
929    #[test]
930    fn optional_refit_error_or_worse_pose_preserves_owned_rows() {
931        let (camera, pairs) = consensus_fixture();
932        let original_pairs = pairs.clone();
933        let selected = translated_pose(0.003, 3.0);
934        for candidate in [
935            Err(crate::VisionError::InvalidParameter("optional refit failed".into())),
936            Ok(translated_pose(0.1, 3.0)),   // Fewer inliers.
937            Ok(translated_pose(0.006, 3.0)), // Same count, larger error.
938            Ok(translated_pose(0.0, -3.0)),  // All projections behind camera.
939        ] {
940            let best = consensus_for(selected, &pairs, camera);
941            let decisions = best.1.as_ptr();
942            let residuals = best.2.as_ptr();
943            let estimate =
944                super::finish_pnp_consensus(best, candidate, &pairs, camera, 2.0).unwrap();
945            assert_eq!(*estimate.model(), selected);
946            assert_eq!(estimate.inlier_count(), pairs.len());
947            assert_eq!(estimate.inliers().as_ptr(), decisions);
948            assert_eq!(estimate.residuals().as_ptr(), residuals);
949            for (&residual, pair) in estimate.residuals().iter().zip(&pairs) {
950                let expected = 500.0 * 0.003 / (pair.object().z + 3.0);
951                assert!((residual - expected).abs() < 1e-12);
952            }
953        }
954        assert_eq!(pairs, original_pairs);
955    }
956
957    #[test]
958    fn equal_count_lower_error_refit_is_retained() {
959        let (camera, pairs) = consensus_fixture();
960        let best = consensus_for(translated_pose(0.003, 3.0), &pairs, camera);
961        let refined = translated_pose(0.0, 3.0);
962        let estimate = super::finish_pnp_consensus(best, Ok(refined), &pairs, camera, 2.0).unwrap();
963        assert_eq!(*estimate.model(), refined);
964        assert_eq!(estimate.inlier_count(), pairs.len());
965        assert!(estimate.residuals().iter().all(|&value| value < 1e-12));
966    }
967
968    #[test]
969    fn more_inliers_win_even_with_a_larger_inlier_residual_sum() {
970        let (camera, mut pairs) = consensus_fixture();
971        for pair in pairs.iter_mut().skip(30) {
972            let mut image = pair.image();
973            image.x += 2.2;
974            *pair = ObjectImageCorrespondence::try_new(pair.object(), image).unwrap();
975        }
976        let best = consensus_for(translated_pose(0.0, 3.0), &pairs, camera);
977        assert_eq!(best.3, 30);
978        assert!(best.4 < 1e-12);
979        let refined = translated_pose(0.0048, 3.0);
980        let estimate = super::finish_pnp_consensus(best, Ok(refined), &pairs, camera, 2.0).unwrap();
981        assert_eq!(*estimate.model(), refined);
982        assert_eq!(estimate.inlier_count(), 32);
983        assert!(estimate.residuals().iter().sum::<f64>() > 20.0);
984    }
985
986    #[test]
987    fn exact_consensus_ties_keep_the_selected_pose() {
988        let camera = camera();
989        let pair = ObjectImageCorrespondence::try_new(
990            Vec3::new(0.0, 0.0, 1.0),
991            Vec2 { x: 320.0, y: 240.0 },
992        )
993        .unwrap();
994        let pairs = vec![pair; 8];
995        let selected = translated_pose(0.0, 3.0);
996        let tied = translated_pose(0.0, 4.0);
997        let best = consensus_for(selected, &pairs, camera);
998        let estimate = super::finish_pnp_consensus(best, Ok(tied), &pairs, camera, 2.0).unwrap();
999        assert_eq!(*estimate.model(), selected);
1000        assert!(estimate.residuals().iter().all(|&value| value == 0.0));
1001    }
1002
1003    #[test]
1004    fn missing_robust_hypothesis_still_returns_an_error() {
1005        let camera = camera();
1006        let pair = ObjectImageCorrespondence::try_new(
1007            Vec3::new(0.0, 0.0, 1.0),
1008            Vec2 { x: 320.0, y: 240.0 },
1009        )
1010        .unwrap();
1011        assert!(solve_pnp_ransac(
1012            &[pair; 8],
1013            camera,
1014            RobustEstimationOptions { max_iterations: 8, ..Default::default() },
1015        )
1016        .is_err());
1017    }
1018}