Skip to main content

apex_camera_models/
bal_pinhole.rs

1//! BAL (Bundle Adjustment in the Large) pinhole camera model.
2//!
3//! Strict 3-parameter pinhole model that follows the BAL dataset / Bundler convention: a
4//! single focal length `f`, no principal point (`cx = cy = 0`), two radial coefficients
5//! `k1`, `k2`, and the camera looks down the `-Z` axis. Compatible with Ceres Solver and
6//! GTSAM bundle adjustment pipelines. See the
7//! [BAL pinhole cookbook chapter](../doc/cookbook/src/bal-pinhole.html) for the projection,
8//! unprojection, and Jacobian derivations.
9
10use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams, skew_symmetric};
11use apex_manifolds::LieGroup;
12use apex_manifolds::se3::SE3;
13use nalgebra::{DVector, SMatrix, Vector2, Vector3};
14
15/// Strict BAL camera model matching Snavely's Bundler convention.
16///
17/// 3 intrinsic parameters: focal length `f` (with `fx = fy = f`), and two radial
18/// distortion coefficients `k1`, `k2`. The principal point is fixed at the origin
19/// (`cx = cy = 0`) and the camera looks down `-Z`. Matches the BAL file format used
20/// by Ceres Solver, GTSAM, and the original Bundler software.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct BALPinholeCameraStrict {
23    /// Single focal length (fx = fy = f)
24    pub f: f64,
25    pub distortion: DistortionModel,
26}
27
28impl BALPinholeCameraStrict {
29    /// Creates a new strict BAL pinhole camera with distortion.
30    ///
31    /// Requires `pinhole.fx == pinhole.fy` and `pinhole.cx == pinhole.cy == 0`. Distortion
32    /// must be [`DistortionModel::Radial`].
33    ///
34    /// # Errors
35    ///
36    /// Returns [`CameraModelError::InvalidParams`] if the strict BAL constraints are
37    /// violated or if the distortion type is wrong.
38    ///
39    /// # Example
40    ///
41    /// ```
42    /// use apex_camera_models::{BALPinholeCameraStrict, PinholeParams, DistortionModel};
43    ///
44    /// let pinhole = PinholeParams::new(500.0, 500.0, 0.0, 0.0)?;
45    /// let distortion = DistortionModel::Radial { k1: -0.1, k2: 0.01 };
46    /// let camera = BALPinholeCameraStrict::new(pinhole, distortion)?;
47    /// # Ok::<(), apex_camera_models::CameraModelError>(())
48    /// ```
49    pub fn new(
50        pinhole: PinholeParams,
51        distortion: DistortionModel,
52    ) -> Result<Self, CameraModelError> {
53        if (pinhole.fx - pinhole.fy).abs() > 1e-10 {
54            return Err(CameraModelError::InvalidParams(
55                "BALPinholeCameraStrict requires fx = fy (single focal length)".to_string(),
56            ));
57        }
58        if pinhole.cx.abs() > 1e-10 || pinhole.cy.abs() > 1e-10 {
59            return Err(CameraModelError::InvalidParams(
60                "BALPinholeCameraStrict requires cx = cy = 0 (no principal point offset)"
61                    .to_string(),
62            ));
63        }
64
65        let camera = Self {
66            f: pinhole.fx,
67            distortion,
68        };
69        camera.validate_params()?;
70        Ok(camera)
71    }
72
73    /// Creates a strict BAL pinhole camera with zero distortion.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`CameraModelError`] if `f` is not positive and finite.
78    pub fn new_no_distortion(f: f64) -> Result<Self, CameraModelError> {
79        let pinhole = PinholeParams::new(f, f, 0.0, 0.0)?;
80        let distortion = DistortionModel::Radial { k1: 0.0, k2: 0.0 };
81        Self::new(pinhole, distortion)
82    }
83
84    /// Returns the radial distortion coefficients `(k1, k2)`.
85    fn distortion_params(&self) -> (f64, f64) {
86        match self.distortion {
87            DistortionModel::Radial { k1, k2 } => (k1, k2),
88            _ => (0.0, 0.0),
89        }
90    }
91
92    /// Returns `true` if `z` is safely in front of the camera (`z < -MIN_DEPTH`).
93    fn check_projection_condition(&self, z: f64) -> bool {
94        z < -crate::MIN_DEPTH
95    }
96}
97
98/// Parameter order: `[f, k1, k2]`.
99impl From<&BALPinholeCameraStrict> for DVector<f64> {
100    fn from(camera: &BALPinholeCameraStrict) -> Self {
101        let (k1, k2) = camera.distortion_params();
102        DVector::from_vec(vec![camera.f, k1, k2])
103    }
104}
105
106/// Parameter order: `[f, k1, k2]`.
107impl From<&BALPinholeCameraStrict> for [f64; 3] {
108    fn from(camera: &BALPinholeCameraStrict) -> Self {
109        let (k1, k2) = camera.distortion_params();
110        [camera.f, k1, k2]
111    }
112}
113
114/// Parameter order: `[f, k1, k2]`.
115impl TryFrom<&[f64]> for BALPinholeCameraStrict {
116    type Error = CameraModelError;
117
118    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
119        if params.len() < 3 {
120            return Err(CameraModelError::InvalidParams(format!(
121                "BALPinholeCameraStrict requires at least 3 parameters, got {}",
122                params.len()
123            )));
124        }
125        Ok(Self {
126            f: params[0],
127            distortion: DistortionModel::Radial {
128                k1: params[1],
129                k2: params[2],
130            },
131        })
132    }
133}
134
135/// Parameter order: `[f, k1, k2]`.
136impl From<[f64; 3]> for BALPinholeCameraStrict {
137    fn from(params: [f64; 3]) -> Self {
138        Self {
139            f: params[0],
140            distortion: DistortionModel::Radial {
141                k1: params[1],
142                k2: params[2],
143            },
144        }
145    }
146}
147
148/// Creates a `BALPinholeCameraStrict` from a parameter slice with validation.
149///
150/// # Errors
151///
152/// Returns [`CameraModelError::InvalidParams`] if the slice has fewer than 3 elements,
153/// or any other [`CameraModelError`] if the resulting parameters are invalid.
154pub fn try_from_params(params: &[f64]) -> Result<BALPinholeCameraStrict, CameraModelError> {
155    let camera = BALPinholeCameraStrict::try_from(params)?;
156    camera.validate_params()?;
157    Ok(camera)
158}
159
160impl CameraModel for BALPinholeCameraStrict {
161    const INTRINSIC_DIM: usize = 3; // f, k1, k2
162    type IntrinsicJacobian = SMatrix<f64, 2, 3>;
163    type PointJacobian = SMatrix<f64, 2, 3>;
164
165    /// Projects a 3D point in camera frame to pixel coordinates.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`CameraModelError::ProjectionOutOfBounds`] if the point is not in
170    /// front of the camera (`z ≥ -MIN_DEPTH`).
171    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
172        if !self.check_projection_condition(p_cam.z) {
173            return Err(CameraModelError::ProjectionOutOfBounds);
174        }
175        let inv_neg_z = -1.0 / p_cam.z;
176        let x_n = p_cam.x * inv_neg_z;
177        let y_n = p_cam.y * inv_neg_z;
178
179        let (k1, k2) = self.distortion_params();
180        let r2 = x_n * x_n + y_n * y_n;
181        let r4 = r2 * r2;
182        let distortion = 1.0 + k1 * r2 + k2 * r4;
183
184        let x_d = x_n * distortion;
185        let y_d = y_n * distortion;
186
187        Ok(Vector2::new(self.f * x_d, self.f * y_d))
188    }
189
190    /// Returns the 2×3 Jacobian of the projection with respect to the 3D point in
191    /// camera frame. See the [BAL pinhole cookbook chapter][chap] for the full
192    /// derivation.
193    ///
194    /// [chap]: ../doc/cookbook/src/bal-pinhole.html
195    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
196        let inv_neg_z = -1.0 / p_cam.z;
197        let x_n = p_cam.x * inv_neg_z;
198        let y_n = p_cam.y * inv_neg_z;
199
200        let (k1, k2) = self.distortion_params();
201        let r2 = x_n * x_n + y_n * y_n;
202        let r4 = r2 * r2;
203        let distortion = 1.0 + k1 * r2 + k2 * r4;
204        let d_dist_dr2 = k1 + 2.0 * k2 * r2;
205
206        let dxn_dz = x_n * inv_neg_z;
207        let dyn_dz = y_n * inv_neg_z;
208
209        let dx_d_dxn = distortion + x_n * d_dist_dr2 * 2.0 * x_n;
210        let dx_d_dyn = x_n * d_dist_dr2 * 2.0 * y_n;
211        let dy_d_dxn = y_n * d_dist_dr2 * 2.0 * x_n;
212        let dy_d_dyn = distortion + y_n * d_dist_dr2 * 2.0 * y_n;
213
214        let du_dx = self.f * (dx_d_dxn * inv_neg_z);
215        let du_dy = self.f * (dx_d_dyn * inv_neg_z);
216        let du_dz = self.f * (dx_d_dxn * dxn_dz + dx_d_dyn * dyn_dz);
217
218        let dv_dx = self.f * (dy_d_dxn * inv_neg_z);
219        let dv_dy = self.f * (dy_d_dyn * inv_neg_z);
220        let dv_dz = self.f * (dy_d_dxn * dxn_dz + dy_d_dyn * dyn_dz);
221
222        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
223    }
224
225    /// Returns the pose Jacobian `(∂(u,v)/∂p_cam, ∂p_cam/∂δξ)` for a 3D point in world
226    /// frame and a camera-to-world pose. Uses right perturbation on `SE(3)` and the
227    /// skew-symmetric cross-product matrix. See the cookbook chapter on
228    /// [SE(3) pose Jacobians][pose] for the general formula.
229    ///
230    /// [pose]: ../doc/cookbook/src/introduction.html#se3-pose-jacobians
231    fn jacobian_pose(
232        &self,
233        p_world: &Vector3<f64>,
234        pose: &SE3,
235    ) -> (Self::PointJacobian, SMatrix<f64, 3, 6>) {
236        let p_cam = pose.act(p_world, None, None);
237
238        let d_uv_d_pcam = self.jacobian_point(&p_cam);
239
240        // Right perturbation on T_wc:
241        //   ∂p_cam/∂δρ = R           (cols 0-2)
242        //   ∂p_cam/∂δθ = -R·[p_world]×  (cols 3-5)
243        let rotation = pose.rotation_so3().rotation_matrix();
244        let p_world_skew = skew_symmetric(p_world);
245
246        let d_pcam_d_pose = SMatrix::<f64, 3, 6>::from_fn(|r, c| {
247            if c < 3 {
248                rotation[(r, c)]
249            } else {
250                let col = c - 3;
251                -(0..3)
252                    .map(|k| rotation[(r, k)] * p_world_skew[(k, col)])
253                    .sum::<f64>()
254            }
255        });
256
257        (d_uv_d_pcam, d_pcam_d_pose)
258    }
259
260    /// Returns the 2×3 intrinsic Jacobian `∂(u,v)/∂[f, k1, k2]`. See the
261    /// [BAL pinhole cookbook chapter][chap] for the derivation.
262    ///
263    /// [chap]: ../doc/cookbook/src/bal-pinhole.html
264    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
265        let inv_neg_z = -1.0 / p_cam.z;
266        let x_n = p_cam.x * inv_neg_z;
267        let y_n = p_cam.y * inv_neg_z;
268
269        let (k1, k2) = self.distortion_params();
270        let r2 = x_n * x_n + y_n * y_n;
271        let r4 = r2 * r2;
272        let distortion = 1.0 + k1 * r2 + k2 * r4;
273
274        let x_d = x_n * distortion;
275        let y_d = y_n * distortion;
276
277        SMatrix::<f64, 2, 3>::new(
278            x_d,
279            self.f * x_n * r2,
280            self.f * x_n * r4,
281            y_d,
282            self.f * y_n * r2,
283            self.f * y_n * r4,
284        )
285    }
286
287    /// Unprojects a 2D pixel to a unit 3D ray in camera frame (BAL convention: ray
288    /// has `z = -1/√(1+r²)`). Uses a fixed 5-iteration fixed-point solve to invert
289    /// the radial distortion.
290    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
291        let x_d = point_2d.x / self.f;
292        let y_d = point_2d.y / self.f;
293
294        let mut x_n = x_d;
295        let mut y_n = y_d;
296
297        let (k1, k2) = self.distortion_params();
298
299        for _ in 0..5 {
300            let r2 = x_n * x_n + y_n * y_n;
301            let distortion = 1.0 + k1 * r2 + k2 * r2 * r2;
302            x_n = x_d / distortion;
303            y_n = y_d / distortion;
304        }
305
306        let norm = (1.0 + x_n * x_n + y_n * y_n).sqrt();
307        Ok(Vector3::new(x_n / norm, y_n / norm, -1.0 / norm))
308    }
309
310    /// Validates that `f` is positive and finite, and that the distortion
311    /// coefficients are finite.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`CameraModelError`] on any violation.
316    fn validate_params(&self) -> Result<(), CameraModelError> {
317        self.get_pinhole_params().validate()?;
318        self.get_distortion().validate()
319    }
320
321    /// Returns `fx = fy = f` and `cx = cy = 0`.
322    fn get_pinhole_params(&self) -> PinholeParams {
323        PinholeParams {
324            fx: self.f,
325            fy: self.f,
326            cx: 0.0,
327            cy: 0.0,
328        }
329    }
330
331    /// Returns the stored distortion model.
332    fn get_distortion(&self) -> DistortionModel {
333        self.distortion
334    }
335
336    /// Returns the model name `"bal_pinhole"`.
337    fn get_model_name(&self) -> &'static str {
338        "bal_pinhole"
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    type TestResult = Result<(), Box<dyn std::error::Error>>;
347
348    #[test]
349    fn test_bal_strict_camera_creation() -> TestResult {
350        let pinhole = PinholeParams::new(500.0, 500.0, 0.0, 0.0)?;
351        let distortion = DistortionModel::Radial { k1: 0.4, k2: -0.3 };
352        let camera = BALPinholeCameraStrict::new(pinhole, distortion)?;
353        let (k1, k2) = camera.distortion_params();
354
355        assert_eq!(camera.f, 500.0);
356        assert_eq!(k1, 0.4);
357        assert_eq!(k2, -0.3);
358        Ok(())
359    }
360
361    #[test]
362    fn test_bal_strict_rejects_different_focal_lengths() {
363        let pinhole = PinholeParams {
364            fx: 500.0,
365            fy: 505.0, // Different from fx
366            cx: 0.0,
367            cy: 0.0,
368        };
369        let distortion = DistortionModel::Radial { k1: 0.0, k2: 0.0 };
370        let result = BALPinholeCameraStrict::new(pinhole, distortion);
371        assert!(result.is_err());
372    }
373
374    #[test]
375    fn test_bal_strict_rejects_non_zero_principal_point() {
376        let pinhole = PinholeParams {
377            fx: 500.0,
378            fy: 500.0,
379            cx: 320.0, // Non-zero
380            cy: 0.0,
381        };
382        let distortion = DistortionModel::Radial { k1: 0.0, k2: 0.0 };
383        let result = BALPinholeCameraStrict::new(pinhole, distortion);
384        assert!(result.is_err());
385    }
386
387    #[test]
388    fn test_bal_strict_projection_at_optical_axis() -> TestResult {
389        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
390        let p_cam = Vector3::new(0.0, 0.0, -1.0);
391
392        let uv = camera.project(&p_cam)?;
393
394        // Point on optical axis projects to origin (no principal point offset)
395        assert!(uv.x.abs() < 1e-10);
396        assert!(uv.y.abs() < 1e-10);
397
398        Ok(())
399    }
400
401    #[test]
402    fn test_bal_strict_projection_off_axis() -> TestResult {
403        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
404        let p_cam = Vector3::new(0.1, 0.2, -1.0);
405
406        let uv = camera.project(&p_cam)?;
407
408        // u = 500 * 0.1 = 50 (no principal point offset)
409        // v = 500 * 0.2 = 100
410        assert!((uv.x - 50.0).abs() < 1e-10);
411        assert!((uv.y - 100.0).abs() < 1e-10);
412
413        Ok(())
414    }
415
416    #[test]
417    fn test_bal_strict_from_into_traits() -> TestResult {
418        let camera = BALPinholeCameraStrict::new_no_distortion(400.0)?;
419
420        // Test conversion to DVector
421        let params: DVector<f64> = (&camera).into();
422        assert_eq!(params.len(), 3);
423        assert_eq!(params[0], 400.0);
424        assert_eq!(params[1], 0.0);
425        assert_eq!(params[2], 0.0);
426
427        // Test conversion to array
428        let arr: [f64; 3] = (&camera).into();
429        assert_eq!(arr, [400.0, 0.0, 0.0]);
430
431        // Test conversion from slice
432        let params_slice = [450.0, 0.1, 0.01];
433        let camera2 = BALPinholeCameraStrict::try_from(&params_slice[..])?;
434        let (cam2_k1, cam2_k2) = camera2.distortion_params();
435        assert_eq!(camera2.f, 450.0);
436        assert_eq!(cam2_k1, 0.1);
437        assert_eq!(cam2_k2, 0.01);
438
439        // Test conversion from array
440        let camera3 = BALPinholeCameraStrict::from([500.0, 0.2, 0.02]);
441        let (cam3_k1, cam3_k2) = camera3.distortion_params();
442        assert_eq!(camera3.f, 500.0);
443        assert_eq!(cam3_k1, 0.2);
444        assert_eq!(cam3_k2, 0.02);
445
446        Ok(())
447    }
448
449    #[test]
450    fn test_project_unproject_round_trip() -> TestResult {
451        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
452
453        // BAL uses -Z convention: points in front of camera have z < 0
454        let test_points = [
455            Vector3::new(0.1, 0.2, -1.0),
456            Vector3::new(-0.3, 0.1, -2.0),
457            Vector3::new(0.05, -0.1, -0.5),
458        ];
459
460        for p_cam in &test_points {
461            let uv = camera.project(p_cam)?;
462            let ray = camera.unproject(&uv)?;
463            let dot = ray.dot(&p_cam.normalize());
464            assert!(
465                (dot - 1.0).abs() < 1e-6,
466                "Round-trip failed: dot={dot}, expected ~1.0"
467            );
468        }
469
470        Ok(())
471    }
472
473    #[test]
474    fn test_jacobian_pose_numerical() -> TestResult {
475        use apex_manifolds::LieGroup;
476        use apex_manifolds::se3::{SE3, SE3Tangent};
477
478        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
479
480        // BAL uses -Z convention. Use a pose and world point such that
481        // pose_inv.act(p_world) has z < 0.
482        let pose = SE3::from_translation_euler(0.1, -0.05, 0.2, 0.0, 0.0, 0.0);
483        let p_world = Vector3::new(0.1, 0.05, -3.0);
484
485        let (d_uv_d_pcam, d_pcam_d_pose) = camera.jacobian_pose(&p_world, &pose);
486        let d_uv_d_pose = d_uv_d_pcam * d_pcam_d_pose;
487
488        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
489
490        for i in 0..6 {
491            let mut d = [0.0f64; 6];
492            d[i] = eps;
493            let delta_plus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
494            d[i] = -eps;
495            let delta_minus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
496
497            // Right perturbation on T_wc: pose' = pose · Exp(δ)
498            let p_cam_plus = pose.plus(&delta_plus, None, None).act(&p_world, None, None);
499            let p_cam_minus = pose
500                .plus(&delta_minus, None, None)
501                .act(&p_world, None, None);
502
503            let uv_plus = camera.project(&p_cam_plus)?;
504            let uv_minus = camera.project(&p_cam_minus)?;
505
506            let num_deriv = (uv_plus - uv_minus) / (2.0 * eps);
507
508            for r in 0..2 {
509                let analytical = d_uv_d_pose[(r, i)];
510                let numerical = num_deriv[r];
511                assert!(
512                    analytical.is_finite(),
513                    "jacobian_pose[{r},{i}] is not finite"
514                );
515                let rel_err = (analytical - numerical).abs() / (1.0 + numerical.abs());
516                assert!(
517                    rel_err < crate::JACOBIAN_TEST_TOLERANCE,
518                    "jacobian_pose mismatch at ({r},{i}): analytical={analytical}, numerical={numerical}"
519                );
520            }
521        }
522
523        Ok(())
524    }
525
526    #[test]
527    fn test_project_returns_error_behind_camera() -> TestResult {
528        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
529        // BAL: z > 0 is behind camera
530        assert!(camera.project(&Vector3::new(0.0, 0.0, 1.0)).is_err());
531        Ok(())
532    }
533
534    #[test]
535    fn test_project_at_min_depth_boundary() -> TestResult {
536        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
537        // BAL: min depth is in negative-z direction
538        let p_min = Vector3::new(0.0, 0.0, -crate::MIN_DEPTH);
539        if let Ok(uv) = camera.project(&p_min) {
540            assert!(uv.x.is_finite() && uv.y.is_finite());
541        }
542        Ok(())
543    }
544}