Skip to main content

apex_solver/factors/
projection_factor.rs

1//! Generic projection factor for bundle adjustment and SfM.
2
3use faer::prelude::ReborrowMut;
4use nalgebra::{Matrix2xX, Matrix3xX};
5use std::convert::TryFrom;
6use std::marker::PhantomData;
7use tracing::warn;
8
9use crate::factors::{Factor, OptimizeParams};
10use apex_camera_models::CameraModel;
11use apex_manifolds::LieGroup;
12use apex_manifolds::se3::SE3;
13
14/// Trait for optimization configuration.
15///
16/// This trait allows accessing the compile-time boolean flags for
17/// parameter optimization (pose, landmarks, intrinsics).
18pub trait OptimizationConfig: Send + Sync + 'static {
19    const POSE: bool;
20    const LANDMARK: bool;
21    const INTRINSIC: bool;
22}
23
24impl<const P: bool, const L: bool, const I: bool> OptimizationConfig for OptimizeParams<P, L, I> {
25    const POSE: bool = P;
26    const LANDMARK: bool = L;
27    const INTRINSIC: bool = I;
28}
29
30/// Generic projection factor for bundle adjustment and structure from motion.
31///
32/// This factor computes reprojection errors between observed 2D image points
33/// and projected 3D landmarks. It supports flexible optimization configurations
34/// via generic types implementing `OptimizationConfig`.
35///
36/// # Type Parameters
37///
38/// - `CAM`: Camera model implementing [`CameraModel`] trait
39/// - `OP`: Optimization configuration (e.g., [`BundleAdjustment`](crate::factors::BundleAdjustment))
40///
41/// # Examples
42///
43/// ```
44/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
45/// use apex_solver::factors::projection_factor::ProjectionFactor;
46/// use apex_solver::factors::BundleAdjustment;
47/// use apex_camera_models::PinholeCamera;
48/// use nalgebra::{Matrix2xX, Vector2};
49///
50/// let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
51/// let observations = Matrix2xX::from_columns(&[
52///     Vector2::new(100.0, 150.0),
53///     Vector2::new(200.0, 250.0),
54/// ]);
55///
56/// // Bundle adjustment: optimize pose + landmarks (intrinsics fixed)
57/// let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> =
58///     ProjectionFactor::new(observations, camera);
59/// # Ok(())
60/// # }
61/// ```
62#[derive(Clone)]
63pub struct ProjectionFactor<CAM, OP>
64where
65    CAM: CameraModel,
66    OP: OptimizationConfig,
67{
68    /// 2D observations in image coordinates (2×N for N observations)
69    pub observations: Matrix2xX<f64>,
70
71    /// Camera model with intrinsic parameters
72    pub camera: CAM,
73
74    /// Fixed pose (required when POSE = false)
75    pub fixed_pose: Option<SE3>,
76
77    /// Fixed landmarks (required when LANDMARK = false), 3×N matrix
78    pub fixed_landmarks: Option<Matrix3xX<f64>>,
79
80    /// Log warnings for cheirality exceptions (points behind camera)
81    pub verbose_cheirality: bool,
82
83    /// Phantom data for optimization type
84    _phantom: PhantomData<OP>,
85}
86
87impl<CAM, OP> ProjectionFactor<CAM, OP>
88where
89    CAM: CameraModel,
90    OP: OptimizationConfig,
91{
92    /// Create a new projection factor.
93    ///
94    /// # Arguments
95    ///
96    /// * `observations` - 2D image measurements (2×N matrix)
97    /// * `camera` - Camera model with intrinsics
98    ///
99    /// # Example
100    ///
101    /// ```
102    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
103    /// # use apex_solver::factors::projection_factor::ProjectionFactor;
104    /// # use apex_solver::factors::BundleAdjustment;
105    /// # use apex_camera_models::PinholeCamera;
106    /// # use nalgebra::{Matrix2xX, Vector2};
107    /// # let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
108    /// # let observations = Matrix2xX::from_columns(&[Vector2::new(100.0, 150.0)]);
109    /// let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> =
110    ///     ProjectionFactor::new(observations, camera);
111    /// # Ok(())
112    /// # }
113    /// ```
114    pub fn new(observations: Matrix2xX<f64>, camera: CAM) -> Self {
115        Self {
116            observations,
117            camera,
118            fixed_pose: None,
119            fixed_landmarks: None,
120            verbose_cheirality: false,
121            _phantom: PhantomData,
122        }
123    }
124
125    /// Set fixed pose (required when POSE = false).
126    ///
127    /// # Example
128    ///
129    /// ```
130    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
131    /// # use apex_solver::factors::projection_factor::ProjectionFactor;
132    /// # use apex_solver::factors::BundleAdjustment;
133    /// # use apex_camera_models::PinholeCamera;
134    /// # use apex_solver::manifold::se3::SE3;
135    /// # use nalgebra::{Matrix2xX, Vector2};
136    /// # let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
137    /// # let observations = Matrix2xX::from_columns(&[Vector2::new(100.0, 150.0)]);
138    /// # let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> = ProjectionFactor::new(observations, camera);
139    /// let factor = factor.with_fixed_pose(SE3::identity());
140    /// # Ok(())
141    /// # }
142    /// ```
143    pub fn with_fixed_pose(mut self, pose: SE3) -> Self {
144        self.fixed_pose = Some(pose);
145        self
146    }
147
148    /// Set fixed landmarks (required when LANDMARK = false).
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
154    /// # use apex_solver::factors::projection_factor::ProjectionFactor;
155    /// # use apex_solver::factors::BundleAdjustment;
156    /// # use apex_camera_models::PinholeCamera;
157    /// # use nalgebra::{Matrix2xX, Matrix3xX, Vector2, Vector3};
158    /// # let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
159    /// # let observations = Matrix2xX::from_columns(&[Vector2::new(100.0, 150.0)]);
160    /// # let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> = ProjectionFactor::new(observations, camera);
161    /// # let landmarks = Matrix3xX::from_columns(&[Vector3::new(0.1, 0.2, 1.0)]);
162    /// let factor = factor.with_fixed_landmarks(landmarks);
163    /// # Ok(())
164    /// # }
165    /// ```
166    pub fn with_fixed_landmarks(mut self, landmarks: Matrix3xX<f64>) -> Self {
167        self.fixed_landmarks = Some(landmarks);
168        self
169    }
170
171    /// Enable verbose cheirality warnings.
172    ///
173    /// When enabled, logs warnings when landmarks project behind the camera.
174    pub fn with_verbose_cheirality(mut self) -> Self {
175        self.verbose_cheirality = true;
176        self
177    }
178
179    /// Get number of observations.
180    pub fn num_observations(&self) -> usize {
181        self.observations.ncols()
182    }
183
184    /// Internal evaluation function that writes residuals and Jacobians directly
185    /// into the provided buffers — no temporary allocations.
186    fn evaluate_internal(
187        &self,
188        pose: &SE3,
189        landmarks: &Matrix3xX<f64>,
190        camera: &CAM,
191        residual: &mut [f64],
192        mut jacobian: Option<faer::mat::MatMut<'_, f64>>,
193    ) {
194        let n = self.observations.ncols();
195
196        // Process each observation
197        for i in 0..n {
198            let observation = self.observations.column(i);
199            let p_world = landmarks.column(i).into_owned();
200
201            // Transform point to camera frame
202            // World-to-camera convention: pose is T_wc where p_cam = R * p_world + t
203            // This matches BAL dataset format and ReprojectionFactor
204            // pose.act() computes exactly: R * p_world + t = p_cam
205            let p_cam = pose.act(&p_world, None, None);
206
207            // Project point (includes all validity checks)
208            let uv = match camera.project(&p_cam) {
209                Ok(proj) => proj,
210                Err(cam_err) => {
211                    if self.verbose_cheirality {
212                        warn!("Invalid projection for point {}: {}", i, cam_err);
213                    }
214                    // Invalid projection: use zero residual (matches Ceres convention)
215                    residual[i * 2] = 0.0;
216                    residual[i * 2 + 1] = 0.0;
217                    // Jacobian rows remain zero
218                    continue;
219                }
220            };
221
222            // Compute residual
223            residual[i * 2] = uv.x - observation.x;
224            residual[i * 2 + 1] = uv.y - observation.y;
225
226            // Compute Jacobians if requested
227            if let Some(ref mut jac) = jacobian {
228                let mut col_offset = 0;
229
230                // Jacobian w.r.t. pose (world-to-camera convention)
231                if OP::POSE {
232                    let (d_uv_d_pcam, d_pcam_d_pose) = camera.jacobian_pose(&p_world, pose);
233                    let d_uv_d_pose = d_uv_d_pcam * d_pcam_d_pose;
234                    for r in 0..2 {
235                        for c in 0..6 {
236                            *jac.rb_mut().get_mut(i * 2 + r, col_offset + c) = d_uv_d_pose[(r, c)];
237                        }
238                    }
239                    col_offset += 6;
240                }
241
242                // Jacobian w.r.t. landmarks (world-to-camera convention)
243                if OP::LANDMARK {
244                    // For this landmark (3 DOF)
245                    let d_uv_d_pcam = camera.jacobian_point(&p_cam);
246                    // p_cam = R * p_world + t
247                    // ∂p_cam/∂p_world = R
248                    // ∂uv/∂p_world = ∂uv/∂p_cam * R
249                    let rotation = pose.rotation_so3().rotation_matrix();
250                    let d_uv_d_landmark = d_uv_d_pcam * rotation;
251
252                    for r in 0..2 {
253                        for c in 0..3 {
254                            *jac.rb_mut().get_mut(i * 2 + r, col_offset + i * 3 + c) =
255                                d_uv_d_landmark[(r, c)];
256                        }
257                    }
258                }
259
260                // Update column offset for intrinsics (if landmarks are optimized)
261                if OP::LANDMARK {
262                    col_offset += n * 3;
263                }
264
265                // Jacobian w.r.t. intrinsics (shared across all observations)
266                if OP::INTRINSIC {
267                    let d_uv_d_intrinsics = camera.jacobian_intrinsics(&p_cam);
268                    for r in 0..2 {
269                        for c in 0..CAM::INTRINSIC_DIM {
270                            *jac.rb_mut().get_mut(i * 2 + r, col_offset + c) =
271                                d_uv_d_intrinsics[(r, c)];
272                        }
273                    }
274                }
275            }
276        }
277    }
278}
279
280// Factor trait implementation with generic dispatch
281impl<CAM, OP> Factor for ProjectionFactor<CAM, OP>
282where
283    CAM: CameraModel,
284    for<'a> CAM: TryFrom<&'a [f64]>,
285    OP: OptimizationConfig,
286{
287    fn linearize(
288        &self,
289        params: &[&[f64]],
290        residual: &mut [f64],
291        jacobian: Option<faer::mat::MatMut<'_, f64>>,
292    ) {
293        let mut param_idx = 0;
294
295        let pose: SE3 = if OP::POSE {
296            let p = SE3::from_param_slice(params[param_idx]);
297            param_idx += 1;
298            p
299        } else {
300            self.fixed_pose.clone().unwrap_or_else(SE3::identity)
301        };
302
303        let landmarks: Matrix3xX<f64> = if OP::LANDMARK {
304            let flat = params[param_idx];
305            let n = flat.len() / 3;
306            param_idx += 1;
307            Matrix3xX::from_fn(n, |r, c| flat[c * 3 + r])
308        } else {
309            self.fixed_landmarks
310                .clone()
311                .unwrap_or_else(|| Matrix3xX::zeros(0))
312        };
313
314        let camera: CAM = if OP::INTRINSIC {
315            CAM::try_from(params[param_idx])
316                .ok()
317                .unwrap_or_else(|| self.camera.clone())
318        } else {
319            self.camera.clone()
320        };
321
322        let n = self.observations.ncols();
323        assert_eq!(
324            landmarks.ncols(),
325            n,
326            "Number of landmarks ({}) must match observations ({})",
327            landmarks.ncols(),
328            n
329        );
330
331        // Write directly into caller-provided buffers — zero temporary allocation.
332        self.evaluate_internal(&pose, &landmarks, &camera, residual, jacobian);
333    }
334
335    fn residual_dim(&self) -> usize {
336        self.observations.ncols() * 2
337    }
338
339    fn jacobian_shape(&self) -> (usize, usize) {
340        let n = self.observations.ncols();
341        let mut cols = 0;
342        if OP::POSE {
343            cols += 6;
344        }
345        if OP::LANDMARK {
346            cols += n * 3;
347        }
348        if OP::INTRINSIC {
349            cols += CAM::INTRINSIC_DIM;
350        }
351        (n * 2, cols)
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::factors::{BundleAdjustment, OnlyIntrinsics, SelfCalibration};
359    use apex_camera_models::PinholeCamera;
360    use nalgebra::{DMatrix, DVector, Vector2, Vector3};
361
362    type TestResult = Result<(), Box<dyn std::error::Error>>;
363
364    fn call_linearize(
365        factor: &impl Factor,
366        params: &[DVector<f64>],
367        with_jacobian: bool,
368    ) -> (Vec<f64>, Option<DMatrix<f64>>) {
369        let param_slices: Vec<&[f64]> = params.iter().map(|p| p.as_slice()).collect();
370        let mut residual = vec![0.0f64; factor.residual_dim()];
371        if with_jacobian {
372            let (rows, cols) = factor.jacobian_shape();
373            let mut jac_buf = vec![0.0f64; rows * cols];
374            let jac_mut = faer::mat::MatMut::from_column_major_slice_mut(&mut jac_buf, rows, cols);
375            factor.linearize(&param_slices, &mut residual, Some(jac_mut));
376            let jac = DMatrix::from_column_slice(rows, cols, &jac_buf);
377            (residual, Some(jac))
378        } else {
379            factor.linearize(&param_slices, &mut residual, None);
380            (residual, None)
381        }
382    }
383
384    #[test]
385    fn test_projection_factor_creation() -> TestResult {
386        let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
387        let observations = Matrix2xX::from_columns(&[Vector2::new(100.0, 150.0)]);
388
389        let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> =
390            ProjectionFactor::new(observations, camera);
391
392        assert_eq!(factor.num_observations(), 1);
393        assert_eq!(factor.residual_dim(), 2);
394
395        Ok(())
396    }
397
398    #[test]
399    fn test_bundle_adjustment_factor() -> TestResult {
400        let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
401
402        let p_world = Vector3::new(0.1, 0.2, 1.0);
403        let pose = SE3::identity();
404
405        let p_cam = pose.act(&p_world, None, None);
406        let uv = camera.project(&p_cam)?;
407
408        let observations = Matrix2xX::from_columns(&[uv]);
409
410        let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> =
411            ProjectionFactor::new(observations, camera);
412
413        let pose_vec = DVector::from_column_slice(pose.as_param_slice());
414        let landmarks_vec = DVector::from_vec(vec![p_world.x, p_world.y, p_world.z]);
415        let params = vec![pose_vec, landmarks_vec];
416
417        let (residual, jacobian) = call_linearize(&factor, &params, true);
418
419        let res_norm: f64 = residual.iter().map(|x| x * x).sum::<f64>().sqrt();
420        assert!(res_norm < 1e-10, "Residual: {:?}", residual);
421
422        let jac = jacobian.ok_or("Jacobian should be Some")?;
423        assert_eq!(jac.nrows(), 2);
424        assert_eq!(jac.ncols(), 9);
425
426        Ok(())
427    }
428
429    #[test]
430    fn test_self_calibration_factor() -> TestResult {
431        let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
432        let p_world = Vector3::new(0.1, 0.2, 1.0);
433        let pose = SE3::identity();
434
435        let p_cam = pose.act(&p_world, None, None);
436        let uv = camera.project(&p_cam)?;
437
438        let observations = Matrix2xX::from_columns(&[uv]);
439        let factor: ProjectionFactor<PinholeCamera, SelfCalibration> =
440            ProjectionFactor::new(observations, camera);
441
442        let pose_vec = DVector::from_column_slice(pose.as_param_slice());
443        let landmarks_vec = DVector::from_vec(vec![p_world.x, p_world.y, p_world.z]);
444        let intrinsics_vec = DVector::from_vec(vec![500.0, 500.0, 320.0, 240.0]);
445        let params = vec![pose_vec, landmarks_vec, intrinsics_vec];
446
447        let (residual, jacobian) = call_linearize(&factor, &params, true);
448
449        let res_norm: f64 = residual.iter().map(|x| x * x).sum::<f64>().sqrt();
450        assert!(res_norm < 1e-10);
451
452        let jac = jacobian.ok_or("Jacobian should be Some")?;
453        assert_eq!(jac.nrows(), 2);
454        assert_eq!(jac.ncols(), 13);
455
456        Ok(())
457    }
458
459    #[test]
460    fn test_calibration_factor() -> TestResult {
461        let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
462        let pose = SE3::identity();
463        let p_world = Vector3::new(0.1, 0.2, 1.0);
464
465        let p_cam = pose.act(&p_world, None, None);
466        let uv = camera.project(&p_cam)?;
467
468        let observations = Matrix2xX::from_columns(&[uv]);
469        let landmarks = Matrix3xX::from_columns(&[p_world]);
470
471        let factor: ProjectionFactor<PinholeCamera, OnlyIntrinsics> =
472            ProjectionFactor::new(observations, camera)
473                .with_fixed_pose(pose)
474                .with_fixed_landmarks(landmarks);
475
476        let intrinsics_vec = DVector::from_vec(vec![500.0, 500.0, 320.0, 240.0]);
477        let params = vec![intrinsics_vec];
478
479        let (residual, jacobian) = call_linearize(&factor, &params, true);
480
481        let res_norm: f64 = residual.iter().map(|x| x * x).sum::<f64>().sqrt();
482        assert!(res_norm < 1e-10);
483
484        let jac = jacobian.ok_or("Jacobian should be Some")?;
485        assert_eq!(jac.nrows(), 2);
486        assert_eq!(jac.ncols(), 4);
487
488        Ok(())
489    }
490
491    #[test]
492    fn test_invalid_projection_handling() -> TestResult {
493        let camera = PinholeCamera::from([500.0, 500.0, 320.0, 240.0]);
494        let observations = Matrix2xX::from_columns(&[Vector2::new(100.0, 150.0)]);
495
496        let factor: ProjectionFactor<PinholeCamera, BundleAdjustment> =
497            ProjectionFactor::new(observations, camera).with_verbose_cheirality();
498
499        let pose = SE3::identity();
500        let pose_vec = DVector::from_column_slice(pose.as_param_slice());
501        let landmarks_vec = DVector::from_vec(vec![0.0, 0.0, -1.0]);
502        let params = vec![pose_vec, landmarks_vec];
503
504        let (residual, _) = call_linearize(&factor, &params, false);
505
506        assert!(residual[0].abs() < 1e-10);
507        assert!(residual[1].abs() < 1e-10);
508
509        Ok(())
510    }
511}