Skip to main content

locus_core/
pose.rs

1//! 3D Pose Estimation (PnP) for fiducial markers.
2//!
3//! This module recovers the 6-DOF transformation between the camera and the tag.
4//! It supports:
5//! - **Fast Mode (IPPE)**: Infinitesimal Plane-Based Pose Estimation for low latency.
6//! - **Accurate Mode (Weighted LM)**: Iterative refinement weighted by sub-pixel uncertainty.
7
8#![allow(clippy::many_single_char_names, clippy::similar_names)]
9use crate::batch::{DetectionBatch, Pose6D};
10use crate::config::PoseEstimationMode;
11use crate::image::ImageView;
12use nalgebra::{Matrix2, Matrix3, Matrix6, Rotation3, UnitQuaternion, Vector3, Vector6};
13
14// ---------------------------------------------------------------------------
15// Distortion model storage (embedded in CameraIntrinsics)
16// ---------------------------------------------------------------------------
17
18/// Lens distortion coefficients stored alongside the intrinsic parameters.
19///
20/// Variants correspond to the two supported distortion models plus the ideal
21/// pinhole (no distortion) case.
22#[derive(Debug, Clone, Copy, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum DistortionCoeffs {
25    /// No distortion — ideal pinhole / pre-rectified image.
26    None,
27    /// Brown-Conrady polynomial radial + tangential distortion (OpenCV convention).
28    ///
29    /// Coefficient order: `k1, k2, p1, p2, k3`.
30    #[cfg(feature = "non_rectified")]
31    BrownConrady {
32        /// Radial coefficient k1.
33        k1: f64,
34        /// Radial coefficient k2.
35        k2: f64,
36        /// Tangential coefficient p1.
37        p1: f64,
38        /// Tangential coefficient p2.
39        p2: f64,
40        /// Radial coefficient k3.
41        k3: f64,
42    },
43    /// Kannala-Brandt equidistant fisheye model.
44    ///
45    /// Coefficient order: `k1, k2, k3, k4`.
46    #[cfg(feature = "non_rectified")]
47    KannalaBrandt {
48        /// Fisheye coefficient k1.
49        k1: f64,
50        /// Fisheye coefficient k2.
51        k2: f64,
52        /// Fisheye coefficient k3.
53        k3: f64,
54        /// Fisheye coefficient k4.
55        k4: f64,
56    },
57}
58
59impl DistortionCoeffs {
60    /// Returns `true` for any non-pinhole distortion model.
61    #[must_use]
62    #[inline]
63    pub const fn is_distorted(&self) -> bool {
64        !matches!(self, Self::None)
65    }
66}
67
68// ---------------------------------------------------------------------------
69// CameraIntrinsics
70// ---------------------------------------------------------------------------
71
72/// Camera intrinsics parameters with optional lens distortion.
73#[derive(Debug, Clone, Copy, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct CameraIntrinsics {
76    /// Focal length in x (pixels).
77    pub fx: f64,
78    /// Focal length in y (pixels).
79    pub fy: f64,
80    /// Principal point x (pixels).
81    pub cx: f64,
82    /// Principal point y (pixels).
83    pub cy: f64,
84    /// Lens distortion model and coefficients. Defaults to [`DistortionCoeffs::None`].
85    pub distortion: DistortionCoeffs,
86}
87
88impl CameraIntrinsics {
89    /// Create new intrinsics with no distortion (ideal pinhole).
90    ///
91    /// Existing call sites remain unchanged.
92    #[must_use]
93    pub fn new(fx: f64, fy: f64, cx: f64, cy: f64) -> Self {
94        Self {
95            fx,
96            fy,
97            cx,
98            cy,
99            distortion: DistortionCoeffs::None,
100        }
101    }
102
103    /// Create new intrinsics with Brown-Conrady distortion.
104    #[cfg(feature = "non_rectified")]
105    #[must_use]
106    #[allow(clippy::too_many_arguments)]
107    pub fn with_brown_conrady(
108        fx: f64,
109        fy: f64,
110        cx: f64,
111        cy: f64,
112        k1: f64,
113        k2: f64,
114        p1: f64,
115        p2: f64,
116        k3: f64,
117    ) -> Self {
118        Self {
119            fx,
120            fy,
121            cx,
122            cy,
123            distortion: DistortionCoeffs::BrownConrady { k1, k2, p1, p2, k3 },
124        }
125    }
126
127    /// Create new intrinsics with Kannala-Brandt fisheye distortion.
128    #[cfg(feature = "non_rectified")]
129    #[must_use]
130    #[allow(clippy::too_many_arguments)]
131    pub fn with_kannala_brandt(
132        fx: f64,
133        fy: f64,
134        cx: f64,
135        cy: f64,
136        k1: f64,
137        k2: f64,
138        k3: f64,
139        k4: f64,
140    ) -> Self {
141        Self {
142            fx,
143            fy,
144            cx,
145            cy,
146            distortion: DistortionCoeffs::KannalaBrandt { k1, k2, k3, k4 },
147        }
148    }
149
150    /// Convert to a 3x3 matrix.
151    #[must_use]
152    pub fn as_matrix(&self) -> Matrix3<f64> {
153        Matrix3::new(self.fx, 0.0, self.cx, 0.0, self.fy, self.cy, 0.0, 0.0, 1.0)
154    }
155
156    /// Get inverse matrix.
157    #[must_use]
158    pub fn inv_matrix(&self) -> Matrix3<f64> {
159        Matrix3::new(
160            1.0 / self.fx,
161            0.0,
162            -self.cx / self.fx,
163            0.0,
164            1.0 / self.fy,
165            -self.cy / self.fy,
166            0.0,
167            0.0,
168            1.0,
169        )
170    }
171
172    /// Map a pixel coordinate `(px, py)` in the distorted image to an ideal
173    /// (undistorted) pixel coordinate.
174    ///
175    /// For [`DistortionCoeffs::None`] this is an identity operation.
176    #[must_use]
177    pub fn undistort_pixel(&self, px: f64, py: f64) -> [f64; 2] {
178        match self.distortion {
179            DistortionCoeffs::None => [px, py],
180            #[cfg(feature = "non_rectified")]
181            DistortionCoeffs::BrownConrady { k1, k2, p1, p2, k3 } => {
182                let m = crate::camera::BrownConradyModel { k1, k2, p1, p2, k3 };
183                let xn = (px - self.cx) / self.fx;
184                let yn = (py - self.cy) / self.fy;
185                let [xu, yu] = crate::camera::CameraModel::undistort(&m, xn, yn);
186                [xu * self.fx + self.cx, yu * self.fy + self.cy]
187            },
188            #[cfg(feature = "non_rectified")]
189            DistortionCoeffs::KannalaBrandt { k1, k2, k3, k4 } => {
190                let m = crate::camera::KannalaBrandtModel { k1, k2, k3, k4 };
191                let xn = (px - self.cx) / self.fx;
192                let yn = (py - self.cy) / self.fy;
193                let [xu, yu] = crate::camera::CameraModel::undistort(&m, xn, yn);
194                [xu * self.fx + self.cx, yu * self.fy + self.cy]
195            },
196        }
197    }
198
199    /// Apply distortion to a normalized ideal point `(xn, yn)` and return
200    /// the distorted pixel coordinates `(px, py)`.
201    ///
202    /// For [`DistortionCoeffs::None`] this projects without distortion.
203    #[must_use]
204    pub fn distort_normalized(&self, xn: f64, yn: f64) -> [f64; 2] {
205        let [xd, yd] = match self.distortion {
206            DistortionCoeffs::None => [xn, yn],
207            #[cfg(feature = "non_rectified")]
208            DistortionCoeffs::BrownConrady { k1, k2, p1, p2, k3 } => {
209                let m = crate::camera::BrownConradyModel { k1, k2, p1, p2, k3 };
210                crate::camera::CameraModel::distort(&m, xn, yn)
211            },
212            #[cfg(feature = "non_rectified")]
213            DistortionCoeffs::KannalaBrandt { k1, k2, k3, k4 } => {
214                let m = crate::camera::KannalaBrandtModel { k1, k2, k3, k4 };
215                crate::camera::CameraModel::distort(&m, xn, yn)
216            },
217        };
218        [xd * self.fx + self.cx, yd * self.fy + self.cy]
219    }
220
221    /// Compute the 2×2 Jacobian of the distortion map at normalized point `(xn, yn)`.
222    ///
223    /// Returns `[[∂xd/∂xn, ∂xd/∂yn], [∂yd/∂xn, ∂yd/∂yn]]`.
224    #[must_use]
225    #[cfg_attr(not(feature = "non_rectified"), allow(unused_variables))]
226    pub(crate) fn distortion_jacobian(&self, xn: f64, yn: f64) -> [[f64; 2]; 2] {
227        match self.distortion {
228            DistortionCoeffs::None => [[1.0, 0.0], [0.0, 1.0]],
229            #[cfg(feature = "non_rectified")]
230            DistortionCoeffs::BrownConrady { k1, k2, p1, p2, k3 } => {
231                let m = crate::camera::BrownConradyModel { k1, k2, p1, p2, k3 };
232                crate::camera::CameraModel::distort_jacobian(&m, xn, yn)
233            },
234            #[cfg(feature = "non_rectified")]
235            DistortionCoeffs::KannalaBrandt { k1, k2, k3, k4 } => {
236                let m = crate::camera::KannalaBrandtModel { k1, k2, k3, k4 };
237                crate::camera::CameraModel::distort_jacobian(&m, xn, yn)
238            },
239        }
240    }
241}
242
243// 3D Pose Estimation using PnP (Perspective-n-Point).
244/// A 3D pose representing rotation and translation.
245#[derive(Debug, Clone, Copy)]
246#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
247pub struct Pose {
248    /// 3x3 Rotation matrix.
249    pub rotation: Matrix3<f64>,
250    /// 3x1 Translation vector.
251    pub translation: Vector3<f64>,
252}
253
254impl Pose {
255    /// Create a new pose.
256    #[must_use]
257    pub fn new(rotation: Matrix3<f64>, translation: Vector3<f64>) -> Self {
258        Self {
259            rotation,
260            translation,
261        }
262    }
263
264    /// Project a 3D point into the image using this pose and intrinsics.
265    #[must_use]
266    pub fn project(&self, point: &Vector3<f64>, intrinsics: &CameraIntrinsics) -> [f64; 2] {
267        let p_cam = self.rotation * point + self.translation;
268        let z = p_cam.z.max(1e-4);
269        let x = (p_cam.x / z) * intrinsics.fx + intrinsics.cx;
270        let y = (p_cam.y / z) * intrinsics.fy + intrinsics.cy;
271        [x, y]
272    }
273}
274
275/// Fills the lower triangle of a 6×6 normal-equations matrix from its upper triangle.
276#[inline]
277pub(crate) fn symmetrize_jtj6(jtj: &mut Matrix6<f64>) {
278    for r in 1..6 {
279        for c in 0..r {
280            jtj[(r, c)] = jtj[(c, r)];
281        }
282    }
283}
284
285/// Computes the 10 non-zero scalar entries of the 2×6 left-perturbation SE(3) Jacobian
286/// for a calibrated projection.
287///
288/// The full 2×6 Jacobian has zeros at column (0,1) and (1,0); those are omitted.
289///
290/// # Returns
291/// `(ju0, ju2, ju3, ju4, ju5, jv1, jv2, jv3, jv4, jv5)` where
292/// `du/dξ = [ju0, 0, ju2, ju3, ju4, ju5]` and `dv/dξ = [0, jv1, jv2, jv3, jv4, jv5]`.
293#[inline]
294pub(crate) fn projection_jacobian(
295    x_z: f64,
296    y_z: f64,
297    z_inv: f64,
298    intrinsics: &CameraIntrinsics,
299) -> (f64, f64, f64, f64, f64, f64, f64, f64, f64, f64) {
300    let fx = intrinsics.fx;
301    let fy = intrinsics.fy;
302    (
303        fx * z_inv,
304        -fx * x_z * z_inv,
305        -fx * x_z * y_z,
306        fx * (x_z * x_z + 1.0),
307        -fx * y_z,
308        fy * z_inv,
309        -fy * y_z * z_inv,
310        -fy * (y_z * y_z + 1.0),
311        fy * y_z * x_z,
312        fy * x_z,
313    )
314}
315
316/// Estimate pose from tag detection using homography decomposition and refinement.
317///
318/// # Arguments
319/// * `intrinsics` - Camera intrinsics.
320/// * `corners` - Detected corners in image coordinates [[x, y]; 4].
321/// * `tag_size` - Physical size of the tag in world units (e.g., meters).
322/// * `img` - Optional image view (required for Accurate mode).
323/// * `mode` - Pose estimation mode (Fast vs Accurate).
324///
325/// # Returns
326/// A tuple containing:
327/// * `Option<Pose>`: The estimated pose (if successful).
328/// * `Option<[[f64; 6]; 6]>`: The estimated covariance matrix (if Accurate mode enabled).
329///
330/// # Panics
331/// Panics if SVD decomposition fails during orthogonalization (extremely rare).
332#[must_use]
333#[allow(clippy::missing_panics_doc)]
334#[tracing::instrument(skip_all, name = "pipeline::estimate_tag_pose")]
335pub fn estimate_tag_pose(
336    intrinsics: &CameraIntrinsics,
337    corners: &[[f64; 2]; 4],
338    tag_size: f64,
339    img: Option<&ImageView>,
340    mode: PoseEstimationMode,
341) -> (Option<Pose>, Option<[[f64; 6]; 6]>) {
342    estimate_tag_pose_with_config(
343        intrinsics,
344        corners,
345        tag_size,
346        img,
347        mode,
348        &crate::config::DetectorConfig::default(),
349        None,
350    )
351}
352
353/// Estimate pose with explicit configuration for tuning parameters.
354#[must_use]
355#[allow(clippy::missing_panics_doc)]
356#[tracing::instrument(skip_all, name = "pipeline::estimate_tag_pose")]
357pub fn estimate_tag_pose_with_config(
358    intrinsics: &CameraIntrinsics,
359    corners: &[[f64; 2]; 4],
360    tag_size: f64,
361    img: Option<&ImageView>,
362    mode: PoseEstimationMode,
363    config: &crate::config::DetectorConfig,
364    external_covariances: Option<&[Matrix2<f64>; 4]>,
365) -> (Option<Pose>, Option<[[f64; 6]; 6]>) {
366    // For distorted cameras, undistort the detected corners before feeding them
367    // into the homography / IPPE solver, which operates in ideal projective space.
368    // The original distorted `corners` are preserved for the LM residual step, where
369    // the distorted projection is compared against the raw observations.
370    let ideal_corners: [[f64; 2]; 4] = if intrinsics.distortion == DistortionCoeffs::None {
371        *corners
372    } else {
373        core::array::from_fn(|i| intrinsics.undistort_pixel(corners[i][0], corners[i][1]))
374    };
375
376    // 1. Canonical Homography: Map canonical square [-1,1]x[-1,1] to ideal image pixels.
377    let Some(h_poly) = crate::decoder::Homography::square_to_quad(&ideal_corners) else {
378        return (None, None);
379    };
380    let h_pixel = h_poly.h;
381
382    // 2. Normalize Homography: H_norm = K_inv * H_pixel
383    let k_inv = intrinsics.inv_matrix();
384    let h_norm = k_inv * h_pixel;
385
386    // 3. Scale to Physical Model (Center Origin Convention)
387    // Object points are centered: x_obj in [-s/2, s/2], y_obj in [-s/2, s/2]
388    // Canonical mapping: xc = (2/s)*x_obj, yc = (2/s)*y_obj  (no offset)
389    // P_cam = H_norm * [ (2/s)*x_obj, (2/s)*y_obj, 1 ]^T
390    // P_cam = [ (2/s)*h1, (2/s)*h2, h3 ] * [x_obj, y_obj, 1 ]^T
391    let scaler = 2.0 / tag_size;
392    let mut h_metric = h_norm;
393    h_metric.column_mut(0).scale_mut(scaler);
394    h_metric.column_mut(1).scale_mut(scaler);
395
396    // 4. IPPE Core: Decompose Jacobian (first 2 image cols) into 2 potential poses.
397    let Some(candidates) = solve_ippe_square(&h_metric) else {
398        return (None, None);
399    };
400
401    // 5. Disambiguation: Choose the pose with lower reprojection error.
402    // Use ideal_corners because the initial poses were derived from ideal homography.
403    let best_pose = find_best_pose(intrinsics, &ideal_corners, tag_size, &candidates);
404
405    // 6. Refinement: Levenberg-Marquardt (LM)
406    // Pass original distorted `corners` — the LM solver applies distortion internally
407    // so its projections match the raw observations.
408    match (mode, img, external_covariances) {
409        (PoseEstimationMode::Accurate, _, Some(ext_covs)) => {
410            // Use provided covariances (e.g. from GWLF)
411            let (refined_pose, covariance) = crate::pose_weighted::refine_pose_lm_weighted(
412                intrinsics, corners, tag_size, best_pose, ext_covs,
413            );
414            (Some(refined_pose), Some(covariance))
415        },
416        (PoseEstimationMode::Accurate, Some(image), None) => {
417            // Compute corner uncertainty from structure tensor (use ideal corners for h_poly)
418            let uncertainty = crate::pose_weighted::compute_framework_uncertainty(
419                image,
420                &ideal_corners,
421                &h_poly,
422                config.tikhonov_alpha_max,
423                config.sigma_n_sq,
424                config.structure_tensor_radius,
425            );
426            let (refined_pose, covariance) = crate::pose_weighted::refine_pose_lm_weighted(
427                intrinsics,
428                corners,
429                tag_size,
430                best_pose,
431                &uncertainty,
432            );
433            (Some(refined_pose), Some(covariance))
434        },
435        _ => {
436            // Fast mode (Identity weights)
437            (
438                Some(refine_pose_lm(
439                    intrinsics,
440                    corners,
441                    tag_size,
442                    best_pose,
443                    config.huber_delta_px,
444                )),
445                None,
446            )
447        },
448    }
449}
450
451/// Solves the IPPE-Square problem.
452/// Returns two possible poses ($R_a, t$) and ($R_b, t$) corresponding to the two minima of the PnP error function.
453///
454/// This uses an analytical approach derived from the homography Jacobian's SVD.
455/// The second solution handles the "Necker reversal" ambiguity inherent in planar pose estimation.
456fn solve_ippe_square(h: &Matrix3<f64>) -> Option<[Pose; 2]> {
457    // IpPE-Square Analytical Solution (Zero Alloc)
458    // Jacobian J = [h1, h2]
459    let h1 = h.column(0);
460    let h2 = h.column(1);
461
462    // 1. Compute B = J^T J (2x2 symmetric matrix)
463    //    [ a  c ]
464    //    [ c  b ]
465    let a = h1.dot(&h1);
466    let b = h2.dot(&h2);
467    let c = h1.dot(&h2);
468
469    // 2. Eigen Analysis of 2x2 Matrix B
470    //    Characteristic eq: lambda^2 - Tr(B)lambda + Det(B) = 0
471    let trace = a + b;
472    let det = a * b - c * c;
473    let delta = (trace * trace - 4.0 * det).max(0.0).sqrt();
474
475    // lambda1 >= lambda2
476    let s1_sq = (trace + delta) * 0.5;
477    let s2_sq = (trace - delta) * 0.5;
478
479    // Singular values sigma = sqrt(lambda)
480    let s1 = s1_sq.sqrt();
481    let s2 = s2_sq.sqrt();
482
483    // Check for Frontal View (Degeneracy: s1 ~= s2)
484    // We use a safe threshold relative to the max singular value.
485    if (s1 - s2).abs() < 1e-4 * s1 {
486        // Degenerate Case: Frontal View (J columns orthogonal & equal length)
487        // R = Gram-Schmidt orthonormalization of [h1, h2, h1xh2]
488
489        let mut r1 = h1.clone_owned();
490        let scale = 1.0 / r1.norm();
491        r1 *= scale;
492
493        // Orthogonalize r2 w.r.t r1
494        let mut r2 = h2 - r1 * (h2.dot(&r1));
495        r2 = r2.normalize();
496
497        let r3 = r1.cross(&r2);
498        let rot = Matrix3::from_columns(&[r1, r2, r3]);
499
500        // Translation: t = h3 * scale.
501        // Gamma (homography scale) is recovered from J.
502        // gamma * R = J => gamma = ||h1|| (roughly).
503        // We use average of singular values for robustness.
504        let gamma = (s1 + s2) * 0.5;
505        if gamma < 1e-8 {
506            return None;
507        } // Avoid div/0
508        let tz = 1.0 / gamma;
509        let t = h.column(2) * tz;
510
511        let pose = Pose::new(rot, t);
512        return Some([pose, pose]);
513    }
514
515    // 3. Recover Rotation A (Primary)
516    // We basically want R such that J ~ R * S_prj.
517    // Standard approach: R = U * V^T where J = U S V^T.
518    //
519    // Analytical 3x2 SVD reconstruction:
520    // U = [u1, u2], V = [v1, v2]
521    // U_i = J * v_i / s_i
522
523    // Eigenvectors of B (columns of V)
524    // For 2x2 matrix [a c; c b]:
525    // If c != 0:
526    //   v1 = [s1^2 - b, c], normalized
527    // else:
528    //   v1 = [1, 0] if a > b else [0, 1]
529
530    let v1 = if c.abs() > 1e-8 {
531        let v = nalgebra::Vector2::new(s1_sq - b, c);
532        v.normalize()
533    } else if a >= b {
534        nalgebra::Vector2::new(1.0, 0.0)
535    } else {
536        nalgebra::Vector2::new(0.0, 1.0)
537    };
538
539    // v2 is orthogonal to v1. For 2D, [-v1.y, v1.x]
540    let v2 = nalgebra::Vector2::new(-v1.y, v1.x);
541
542    // Compute Left Singular Vectors u1, u2 inside the 3D space
543    // u1 = J * v1 / s1
544    // u2 = J * v2 / s2
545    let j_v1 = h1 * v1.x + h2 * v1.y;
546    let j_v2 = h1 * v2.x + h2 * v2.y;
547
548    // Safe division check
549    if s1 < 1e-8 {
550        return None;
551    }
552    let u1 = j_v1 / s1;
553    let u2 = j_v2 / s2.max(1e-8); // Avoid div/0 for s2
554
555    // Reconstruct Rotation A
556    // R = U * V^T (extended to 3x3)
557    // The columns of R are r1, r2, r3.
558    // In SVD terms:
559    // [r1 r2] = [u1 u2] * [v1 v2]^T
560    // r1 = u1 * v1.x + u2 * v2.x
561    // r2 = u1 * v1.y + u2 * v2.y
562    let r1_a = u1 * v1.x + u2 * v2.x;
563    let r2_a = u1 * v1.y + u2 * v2.y;
564    let r3_a = r1_a.cross(&r2_a);
565    let rot_a = Matrix3::from_columns(&[r1_a, r2_a, r3_a]);
566
567    // Translation A
568    let gamma = (s1 + s2) * 0.5;
569    let tz = 1.0 / gamma;
570    let t_a = h.column(2) * tz;
571    let pose_a = Pose::new(rot_a, t_a);
572
573    // 4. Recover Rotation B (Second Solution)
574    // Necker Reversal: Reflect normal across line of sight.
575    // n_b = [-nx, -ny, nz] (approx).
576    // Better analytical dual from IPPE:
577    // The second solution corresponds to rotating U's second column?
578    //
579    // Let's stick to the robust normal reflection method which works well.
580    let n_a = rot_a.column(2);
581    // Safe normalize for n_b
582    let n_b_raw = Vector3::new(-n_a.x, -n_a.y, n_a.z);
583    let n_b = if n_b_raw.norm_squared() > 1e-8 {
584        n_b_raw.normalize()
585    } else {
586        // Fallback (should be impossible for unit vector n_a)
587        Vector3::z_axis().into_inner()
588    };
589
590    // Construct R_b using Gram-Schmidt from (h1, n_b)
591    // x_axis projection is h1 (roughly).
592    // x_b = (h1 - (h1.n)n).normalize
593    let h1_norm = h1.normalize();
594    let x_b_raw = h1_norm - n_b * h1_norm.dot(&n_b);
595    let x_b = if x_b_raw.norm_squared() > 1e-8 {
596        x_b_raw.normalize()
597    } else {
598        // Fallback: if h1 is parallel to n_b (degenerate), pick any orthogonal
599        let tangent = if n_b.x.abs() > 0.9 {
600            Vector3::y_axis().into_inner()
601        } else {
602            Vector3::x_axis().into_inner()
603        };
604        tangent.cross(&n_b).normalize()
605    };
606
607    let y_b = n_b.cross(&x_b);
608    let rot_b = Matrix3::from_columns(&[x_b, y_b, n_b]);
609    let pose_b = Pose::new(rot_b, t_a);
610
611    Some([pose_a, pose_b])
612}
613
614/// Returns the 4 tag corners in object space with origin at the geometric center of the tag.
615/// Corner order: TL, TR, BR, BL (clockwise). All Z=0 (tag lies on the object plane).
616pub(crate) fn centered_tag_corners(tag_size: f64) -> [Vector3<f64>; 4] {
617    let h = tag_size * 0.5;
618    [
619        Vector3::new(-h, -h, 0.0),
620        Vector3::new(h, -h, 0.0),
621        Vector3::new(h, h, 0.0),
622        Vector3::new(-h, h, 0.0),
623    ]
624}
625
626/// Project a 3D camera-space point through the intrinsics (with distortion if present).
627///
628/// Returns `[u, v]` in pixel coordinates.
629#[inline]
630fn project_with_distortion(p_cam: &Vector3<f64>, intrinsics: &CameraIntrinsics) -> [f64; 2] {
631    let z = p_cam.z.max(1e-4);
632    let xn = p_cam.x / z;
633    let yn = p_cam.y / z;
634    intrinsics.distort_normalized(xn, yn)
635}
636
637/// Use a Manifold-Aware Trust-Region Levenberg-Marquardt solver to refine the pose.
638///
639/// This upgrades the classic LM recipe to a SOTA production solver with three key improvements:
640///
641/// 1. **Huber M-Estimator (IRLS):** Wraps the reprojection residual in a Huber loss to
642///    dynamically down-weight corners with large errors (e.g. from motion blur or occlusion),
643///    preventing a single bad corner from corrupting the entire solution.
644///
645/// 2. **Marquardt Diagonal Scaling:** Damps each parameter proportionally to its own curvature
646///    via `D = diag(J^T W J)`, instead of a uniform identity matrix. This correctly handles the
647///    scale mismatch between rotational (radians) and translational (meters) parameters.
648///
649/// 3. **Nielsen's Gain Ratio Control:** Evaluates step quality using the ratio of actual vs.
650///    predicted cost reduction. Good steps shrink `lambda` aggressively (Gauss-Newton speed),
651///    while bad steps grow it with doubling-`nu` backoff (gradient descent safety). This is
652///    strictly superior to the heuristic `lambda *= 10 / 0.1` approach.
653///
654/// 4. **Distortion-Aware Jacobian:** When `intrinsics` carries a non-trivial distortion model,
655///    the projection `P_cam → pixel` routes through the distortion map, and the analytic
656///    chain-rule Jacobian `∂[u,v]/∂P_cam = diag(fx,fy) · J_dist · J_normalize` is used,
657///    ensuring the solver converges to the correct distortion-compensated pose.
658#[allow(clippy::too_many_lines)]
659fn refine_pose_lm(
660    intrinsics: &CameraIntrinsics,
661    corners: &[[f64; 2]; 4],
662    tag_size: f64,
663    initial_pose: Pose,
664    huber_delta_px: f64,
665) -> Pose {
666    let huber_delta = huber_delta_px;
667
668    let mut pose = initial_pose;
669    let obj_pts = centered_tag_corners(tag_size);
670
671    // Nielsen's trust-region state. Start with small damping to encourage Gauss-Newton steps.
672    let mut lambda = 1e-3_f64;
673    let mut nu = 2.0_f64;
674    let mut current_cost = huber_cost(intrinsics, corners, &obj_pts, &pose, huber_delta);
675
676    // Allow up to 20 iterations; typically exits in 3-6 via the convergence gates below.
677    for _ in 0..20 {
678        // Build J^T W J (6x6) and J^T W r (6x1) with Huber IRLS weights.
679        // Layout: delta = [tx, ty, tz, rx, ry, rz] (translation then rotation in se(3)).
680        let mut jtj = Matrix6::<f64>::zeros();
681        let mut jtr = Vector6::<f64>::zeros();
682
683        for i in 0..4 {
684            let p_world = obj_pts[i];
685            let p_cam = pose.rotation * p_world + pose.translation;
686            let z = p_cam.z.max(1e-4);
687            let z_inv = 1.0 / z;
688            let xn = p_cam.x / z;
689            let yn = p_cam.y / z;
690
691            // Apply distortion and compute pixel residual.
692            // For DistortionCoeffs::None, distort_normalized is an identity pass-through.
693            let [u_est, v_est] = intrinsics.distort_normalized(xn, yn);
694
695            let res_u = corners[i][0] - u_est;
696            let res_v = corners[i][1] - v_est;
697
698            // Huber IRLS weight: w=1 inside the trust region, w=delta/r outside.
699            // Applied per 2D point (geometric pixel distance).
700            let r_norm = (res_u * res_u + res_v * res_v).sqrt();
701            let w = if r_norm <= huber_delta {
702                1.0
703            } else {
704                huber_delta / r_norm
705            };
706
707            // Distortion Jacobian J_dist = [[∂xd/∂xn, ∂xd/∂yn], [∂yd/∂xn, ∂yd/∂yn]].
708            // For DistortionCoeffs::None this is the 2×2 identity, which recovers the
709            // original pinhole Jacobian exactly.
710            let jd = intrinsics.distortion_jacobian(xn, yn);
711
712            // Full Jacobian of pixel coordinates wrt camera-space point P_cam:
713            //
714            //   ∂u/∂P_cam = fx · [J_dist[0][0]/z, J_dist[0][1]/z, -(J_dist[0][0]·xn + J_dist[0][1]·yn)/z]
715            //   ∂v/∂P_cam = fy · [J_dist[1][0]/z, J_dist[1][1]/z, -(J_dist[1][0]·xn + J_dist[1][1]·yn)/z]
716            //
717            // Derivation: u = fx·xd + cx, xd = distort_x(xn, yn), xn = Px/Pz, yn = Py/Pz
718            //   ∂u/∂Px = fx·(∂xd/∂xn·1/z) = fx·jd[0][0]/z
719            //   ∂u/∂Py = fx·(∂xd/∂yn·1/z) = fx·jd[0][1]/z
720            //   ∂u/∂Pz = fx·(∂xd/∂xn·(-xn/z) + ∂xd/∂yn·(-yn/z))
721            let du_dp = Vector3::new(
722                intrinsics.fx * jd[0][0] * z_inv,
723                intrinsics.fx * jd[0][1] * z_inv,
724                -intrinsics.fx * (jd[0][0] * xn + jd[0][1] * yn) * z_inv,
725            );
726            let dv_dp = Vector3::new(
727                intrinsics.fy * jd[1][0] * z_inv,
728                intrinsics.fy * jd[1][1] * z_inv,
729                -intrinsics.fy * (jd[1][0] * xn + jd[1][1] * yn) * z_inv,
730            );
731
732            // Jacobian of Camera Point wrt Pose Update (Lie Algebra) (3x6):
733            // d(exp(w)*P)/d(xi) at xi=0 = [ I | -[P]_x ]
734            let mut row_u = Vector6::zeros();
735            row_u[0] = du_dp[0];
736            row_u[1] = du_dp[1];
737            row_u[2] = du_dp[2];
738            row_u[3] = du_dp[1] * p_cam.z - du_dp[2] * p_cam.y;
739            row_u[4] = du_dp[2] * p_cam.x - du_dp[0] * p_cam.z;
740            row_u[5] = du_dp[0] * p_cam.y - du_dp[1] * p_cam.x;
741
742            let mut row_v = Vector6::zeros();
743            row_v[0] = dv_dp[0];
744            row_v[1] = dv_dp[1];
745            row_v[2] = dv_dp[2];
746            row_v[3] = dv_dp[1] * p_cam.z - dv_dp[2] * p_cam.y;
747            row_v[4] = dv_dp[2] * p_cam.x - dv_dp[0] * p_cam.z;
748            row_v[5] = dv_dp[0] * p_cam.y - dv_dp[1] * p_cam.x;
749
750            // Accumulate J^T W J and J^T W r with Huber weight.
751            jtj += w * (row_u * row_u.transpose() + row_v * row_v.transpose());
752            jtr += w * (row_u * res_u + row_v * res_v);
753        }
754
755        // Gate 1: Gradient convergence — solver is at a stationary point.
756        if jtr.amax() < 1e-8 {
757            break;
758        }
759
760        // Marquardt diagonal scaling: D = diag(J^T W J).
761        // Damps each DOF proportionally to its own curvature, correcting for the
762        // scale mismatch between rotational and translational gradient magnitudes.
763        let d_diag = Vector6::new(
764            jtj[(0, 0)].max(1e-8),
765            jtj[(1, 1)].max(1e-8),
766            jtj[(2, 2)].max(1e-8),
767            jtj[(3, 3)].max(1e-8),
768            jtj[(4, 4)].max(1e-8),
769            jtj[(5, 5)].max(1e-8),
770        );
771
772        // Solve (J^T W J + lambda * D) delta = J^T W r
773        let mut jtj_damped = jtj;
774        for k in 0..6 {
775            jtj_damped[(k, k)] += lambda * d_diag[k];
776        }
777
778        let delta = if let Some(chol) = jtj_damped.cholesky() {
779            chol.solve(&jtr)
780        } else {
781            // System is ill-conditioned; increase damping and retry.
782            lambda *= 10.0;
783            nu = 2.0;
784            continue;
785        };
786
787        // Nielsen's gain ratio: rho = actual_reduction / predicted_reduction.
788        // Predicted reduction from the quadratic model (Madsen et al. eq 3.9):
789        // L(0) - L(delta) = 0.5 * delta^T (lambda * D * delta + J^T W r)
790        let predicted_reduction = 0.5 * delta.dot(&(lambda * d_diag.component_mul(&delta) + jtr));
791
792        // Evaluate new pose via SE(3) exponential map (manifold-safe update).
793        let twist = Vector3::new(delta[3], delta[4], delta[5]);
794        let trans_update = Vector3::new(delta[0], delta[1], delta[2]);
795        let rot_update = nalgebra::Rotation3::new(twist).matrix().into_owned();
796        let new_pose = Pose::new(
797            rot_update * pose.rotation,
798            rot_update * pose.translation + trans_update,
799        );
800
801        let new_cost = huber_cost(intrinsics, corners, &obj_pts, &new_pose, huber_delta);
802        let actual_reduction = current_cost - new_cost;
803
804        let rho = if predicted_reduction > 1e-12 {
805            actual_reduction / predicted_reduction
806        } else {
807            0.0
808        };
809
810        if rho > 0.0 {
811            // Accept step: update state and shrink lambda toward Gauss-Newton regime.
812            pose = new_pose;
813            current_cost = new_cost;
814            // Nielsen's update rule: lambda scales by max(1/3, 1 - (2*rho - 1)^3).
815            lambda *= (1.0 - (2.0 * rho - 1.0).powi(3)).max(1.0 / 3.0);
816            nu = 2.0;
817
818            // Gate 2: Step size convergence — pose has stopped moving.
819            if delta.norm() < 1e-7 {
820                break;
821            }
822        } else {
823            // Reject step: grow lambda with doubling backoff to stay within trust region.
824            lambda *= nu;
825            nu *= 2.0;
826        }
827    }
828
829    pose
830}
831
832/// Computes the total Huber robust cost over all four corner reprojection residuals.
833///
834/// The Huber function is quadratic for `|r| <= delta` (L2 regime) and linear beyond
835/// (L1 regime), providing continuous differentiability at the transition point.
836/// Uses distortion-aware projection when `intrinsics` carries a distortion model.
837fn huber_cost(
838    intrinsics: &CameraIntrinsics,
839    corners: &[[f64; 2]; 4],
840    obj_pts: &[Vector3<f64>; 4],
841    pose: &Pose,
842    delta: f64,
843) -> f64 {
844    let mut cost = 0.0;
845    for i in 0..4 {
846        let p_cam = pose.rotation * obj_pts[i] + pose.translation;
847        let p = project_with_distortion(&p_cam, intrinsics);
848        let r_u = corners[i][0] - p[0];
849        let r_v = corners[i][1] - p[1];
850        // Huber on the 2D geometric distance, consistent with the IRLS weight computation.
851        let r_norm = (r_u * r_u + r_v * r_v).sqrt();
852        if r_norm <= delta {
853            cost += 0.5 * r_norm * r_norm;
854        } else {
855            cost += delta * (r_norm - 0.5 * delta);
856        }
857    }
858    cost
859}
860
861fn reprojection_error(
862    intrinsics: &CameraIntrinsics,
863    corners: &[[f64; 2]; 4],
864    obj_pts: &[Vector3<f64>; 4],
865    pose: &Pose,
866) -> f64 {
867    let mut err_sq = 0.0;
868    for i in 0..4 {
869        let p_cam = pose.rotation * obj_pts[i] + pose.translation;
870        let p = project_with_distortion(&p_cam, intrinsics);
871        err_sq += (p[0] - corners[i][0]).powi(2) + (p[1] - corners[i][1]).powi(2);
872    }
873    err_sq
874}
875
876fn find_best_pose(
877    intrinsics: &CameraIntrinsics,
878    corners: &[[f64; 2]; 4],
879    tag_size: f64,
880    candidates: &[Pose; 2],
881) -> Pose {
882    let obj_pts = centered_tag_corners(tag_size);
883
884    let err0 = reprojection_error(intrinsics, corners, &obj_pts, &candidates[0]);
885    let err1 = reprojection_error(intrinsics, corners, &obj_pts, &candidates[1]);
886
887    // Choose the candidate with lower reprojection error.
888    if err1 < err0 {
889        candidates[1]
890    } else {
891        candidates[0]
892    }
893}
894
895/// Refine poses for all valid candidates in the batch using the Structure of Arrays (SoA) layout.
896///
897/// This function operates only on the first `v` candidates in the batch, which must have been
898/// partitioned such that all valid candidates are in the range `[0..v]`.
899#[tracing::instrument(skip_all, name = "pipeline::pose_refinement")]
900pub fn refine_poses_soa(
901    batch: &mut DetectionBatch,
902    v: usize,
903    intrinsics: &CameraIntrinsics,
904    tag_size: f64,
905    img: Option<&ImageView>,
906    mode: PoseEstimationMode,
907) {
908    refine_poses_soa_with_config(
909        batch,
910        v,
911        intrinsics,
912        tag_size,
913        img,
914        mode,
915        &crate::config::DetectorConfig::default(),
916    );
917}
918
919/// Refine poses for all valid candidates with explicit config for tuning parameters.
920#[tracing::instrument(skip_all, name = "pipeline::pose_refinement")]
921pub fn refine_poses_soa_with_config(
922    batch: &mut DetectionBatch,
923    v: usize,
924    intrinsics: &CameraIntrinsics,
925    tag_size: f64,
926    img: Option<&ImageView>,
927    mode: PoseEstimationMode,
928    config: &crate::config::DetectorConfig,
929) {
930    use rayon::prelude::*;
931
932    // Process valid candidates in parallel.
933    // We collect into a temporary Vec to avoid unsafe parallel writes to the batch.
934    let poses: Vec<_> = (0..v)
935        .into_par_iter()
936        .map(|i| {
937            let corners = [
938                [
939                    f64::from(batch.corners[i][0].x),
940                    f64::from(batch.corners[i][0].y),
941                ],
942                [
943                    f64::from(batch.corners[i][1].x),
944                    f64::from(batch.corners[i][1].y),
945                ],
946                [
947                    f64::from(batch.corners[i][2].x),
948                    f64::from(batch.corners[i][2].y),
949                ],
950                [
951                    f64::from(batch.corners[i][3].x),
952                    f64::from(batch.corners[i][3].y),
953                ],
954            ];
955
956            // If GWLF is enabled, we should have corner covariances in the batch.
957            // Check if any covariance is non-zero.
958            let mut ext_covs = [Matrix2::zeros(); 4];
959            let mut has_ext_covs = false;
960            #[allow(clippy::needless_range_loop)]
961            for j in 0..4 {
962                ext_covs[j] = Matrix2::new(
963                    f64::from(batch.corner_covariances[i][j * 4]),
964                    f64::from(batch.corner_covariances[i][j * 4 + 1]),
965                    f64::from(batch.corner_covariances[i][j * 4 + 2]),
966                    f64::from(batch.corner_covariances[i][j * 4 + 3]),
967                );
968                if ext_covs[j].norm_squared() > 1e-12 {
969                    has_ext_covs = true;
970                }
971            }
972
973            let (pose_opt, _) = estimate_tag_pose_with_config(
974                intrinsics,
975                &corners,
976                tag_size,
977                img,
978                mode,
979                config,
980                if has_ext_covs { Some(&ext_covs) } else { None },
981            );
982
983            if let Some(pose) = pose_opt {
984                // `UnitQuaternion::from_matrix` delegates to `Rotation3::from_matrix_eps` with
985                // `max_iter = 0`, which nalgebra treats as usize::MAX ("loop until convergence").
986                // For degenerate IPPE outputs (near-singular homographies at extreme tag angles),
987                // the Müller iterative algorithm never converges — infinite loop.
988                //
989                // Use the closed-form Shepperd method instead: wrap the matrix as a `Rotation3`
990                // (no orthogonalization) then extract the quaternion analytically. The LM solver
991                // maintains pose on SO(3) via the exponential map, so `pose.rotation` is always
992                // sufficiently close to orthogonal for this to be accurate.
993                let rot = Rotation3::from_matrix_unchecked(pose.rotation);
994                let q = UnitQuaternion::from_rotation_matrix(&rot);
995                let t = pose.translation;
996
997                // Data layout: [tx, ty, tz, qx, qy, qz, qw]
998                let mut data = [0.0f32; 7];
999                data[0] = t.x as f32;
1000                data[1] = t.y as f32;
1001                data[2] = t.z as f32;
1002                data[3] = q.coords.x as f32;
1003                data[4] = q.coords.y as f32;
1004                data[5] = q.coords.z as f32;
1005                data[6] = q.coords.w as f32;
1006                Some(data)
1007            } else {
1008                None
1009            }
1010        })
1011        .collect();
1012
1013    for (i, pose_data) in poses.into_iter().enumerate() {
1014        if let Some(data) = pose_data {
1015            batch.poses[i] = Pose6D { data, padding: 0.0 };
1016        } else {
1017            batch.poses[i] = Pose6D {
1018                data: [0.0; 7],
1019                padding: 0.0,
1020            };
1021        }
1022    }
1023}
1024
1025#[cfg(test)]
1026#[allow(clippy::expect_used, clippy::unwrap_used)]
1027mod tests {
1028    use super::*;
1029    use proptest::prelude::*;
1030
1031    #[test]
1032    fn test_pose_projection() {
1033        let intrinsics = CameraIntrinsics::new(500.0, 500.0, 320.0, 240.0);
1034        let rotation = Matrix3::identity();
1035        let translation = Vector3::new(0.0, 0.0, 2.0); // 2 meters away
1036        let pose = Pose::new(rotation, translation);
1037
1038        let p_world = Vector3::new(0.1, 0.1, 0.0);
1039        let p_img = pose.project(&p_world, &intrinsics);
1040
1041        // x = (0.1 / 2.0) * 500 + 320 = 0.05 * 500 + 320 = 25 + 320 = 345
1042        // y = (0.1 / 2.0) * 500 + 240 = 265
1043        assert!((p_img[0] - 345.0).abs() < 1e-6);
1044        assert!((p_img[1] - 265.0).abs() < 1e-6);
1045    }
1046
1047    #[test]
1048    fn test_perfect_pose_estimation() {
1049        let intrinsics = CameraIntrinsics::new(800.0, 800.0, 400.0, 300.0);
1050        let gt_rot = Matrix3::identity(); // Facing straight
1051        let gt_t = Vector3::new(0.1, -0.2, 1.5);
1052        let gt_pose = Pose::new(gt_rot, gt_t);
1053
1054        let tag_size = 0.16; // 16cm
1055        let obj_pts = centered_tag_corners(tag_size);
1056
1057        let mut img_pts = [[0.0, 0.0]; 4];
1058        for i in 0..4 {
1059            img_pts[i] = gt_pose.project(&obj_pts[i], &intrinsics);
1060        }
1061
1062        let (est_pose, _) = estimate_tag_pose(
1063            &intrinsics,
1064            &img_pts,
1065            tag_size,
1066            None,
1067            PoseEstimationMode::Fast,
1068        );
1069        let est_pose = est_pose.expect("Pose estimation failed");
1070
1071        // Check translation
1072        assert!((est_pose.translation.x - gt_t.x).abs() < 1e-3);
1073        assert!((est_pose.translation.y - gt_t.y).abs() < 1e-3);
1074        assert!((est_pose.translation.z - gt_t.z).abs() < 1e-3);
1075
1076        // Check rotation (identity)
1077        let diff_rot = est_pose.rotation - gt_rot;
1078        assert!(diff_rot.norm() < 1e-3);
1079    }
1080
1081    proptest! {
1082        #[test]
1083        fn prop_intrinsics_inversion(
1084            fx in 100.0..2000.0f64,
1085            fy in 100.0..2000.0f64,
1086            cx in 0.0..1000.0f64,
1087            cy in 0.0..1000.0f64
1088        ) {
1089            let intrinsics = CameraIntrinsics::new(fx, fy, cx, cy);
1090            let k = intrinsics.as_matrix();
1091            let k_inv = intrinsics.inv_matrix();
1092            let identity = k * k_inv;
1093
1094            let expected = Matrix3::<f64>::identity();
1095            for i in 0..3 {
1096                for j in 0..3 {
1097                    prop_assert!((identity[(i, j)] - expected[(i, j)]).abs() < 1e-9);
1098                }
1099            }
1100        }
1101
1102        #[test]
1103        fn prop_pose_recovery_stability(
1104            tx in -0.5..0.5f64,
1105            ty in -0.5..0.5f64,
1106            tz in 0.5..5.0f64, // Tag must be in front of camera
1107            roll in -0.5..0.5f64,
1108            pitch in -0.5..0.5f64,
1109            yaw in -0.5..0.5f64,
1110            noise in 0.0..0.1f64 // pixels of noise
1111        ) {
1112            let intrinsics = CameraIntrinsics::new(800.0, 800.0, 400.0, 300.0);
1113            let translation = Vector3::new(tx, ty, tz);
1114
1115            // Create rotation from Euler angles using Rotation3
1116            let r_obj = nalgebra::Rotation3::from_euler_angles(roll, pitch, yaw);
1117            let rotation = r_obj.matrix().into_owned();
1118            let gt_pose = Pose::new(rotation, translation);
1119
1120            let tag_size = 0.16;
1121            let obj_pts = centered_tag_corners(tag_size);
1122
1123            let mut img_pts = [[0.0, 0.0]; 4];
1124            for i in 0..4 {
1125                let p = gt_pose.project(&obj_pts[i], &intrinsics);
1126                // Add tiny bit of noise
1127                img_pts[i] = [p[0] + noise, p[1] + noise];
1128            }
1129
1130            if let (Some(est_pose), _) = estimate_tag_pose(&intrinsics, &img_pts, tag_size, None, PoseEstimationMode::Fast) {
1131                // Check if recovered pose is reasonably close
1132                // Note: noise decreases accuracy, so we use a loose threshold
1133                let t_err = (est_pose.translation - translation).norm();
1134                prop_assert!(t_err < 0.1 + noise * 0.1, "Translation error {} too high for noise {}", t_err, noise);
1135
1136                let r_err = (est_pose.rotation - rotation).norm();
1137                prop_assert!(r_err < 0.1 + noise * 0.1, "Rotation error {} too high for noise {}", r_err, noise);
1138            }
1139        }
1140    }
1141}