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    /// BAL is a **z-backward** convention: `project` accepts a point only
191    /// while `z < -MIN_DEPTH`, so the invalid region is `z ≥ -MIN_DEPTH` and
192    /// the deficit must *grow* with `+z`. The trait default (`-z`, gradient
193    /// `-e_z`) would be actively harmful here — it would push points further
194    /// behind the camera instead of back in front of it. Note this is also
195    /// why `project` reports the condition as `ProjectionOutOfBounds` rather
196    /// than `PointBehindCamera`: the `PointBehindCamera { z, min_z }` shape
197    /// assumes a z-forward inequality.
198    fn projection_deficit(&self, p_cam: &Vector3<f64>) -> (f64, Vector3<f64>) {
199        (p_cam.z + crate::MIN_DEPTH, Vector3::new(0.0, 0.0, 1.0))
200    }
201
202    /// Returns the 2×3 Jacobian of the projection with respect to the 3D point in
203    /// camera frame. See the [BAL pinhole cookbook chapter][chap] for the full
204    /// derivation.
205    ///
206    /// [chap]: ../doc/cookbook/src/bal-pinhole.html
207    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
208        let inv_neg_z = -1.0 / p_cam.z;
209        let x_n = p_cam.x * inv_neg_z;
210        let y_n = p_cam.y * inv_neg_z;
211
212        let (k1, k2) = self.distortion_params();
213        let r2 = x_n * x_n + y_n * y_n;
214        let r4 = r2 * r2;
215        let distortion = 1.0 + k1 * r2 + k2 * r4;
216        let d_dist_dr2 = k1 + 2.0 * k2 * r2;
217
218        let dxn_dz = x_n * inv_neg_z;
219        let dyn_dz = y_n * inv_neg_z;
220
221        let dx_d_dxn = distortion + x_n * d_dist_dr2 * 2.0 * x_n;
222        let dx_d_dyn = x_n * d_dist_dr2 * 2.0 * y_n;
223        let dy_d_dxn = y_n * d_dist_dr2 * 2.0 * x_n;
224        let dy_d_dyn = distortion + y_n * d_dist_dr2 * 2.0 * y_n;
225
226        let du_dx = self.f * (dx_d_dxn * inv_neg_z);
227        let du_dy = self.f * (dx_d_dyn * inv_neg_z);
228        let du_dz = self.f * (dx_d_dxn * dxn_dz + dx_d_dyn * dyn_dz);
229
230        let dv_dx = self.f * (dy_d_dxn * inv_neg_z);
231        let dv_dy = self.f * (dy_d_dyn * inv_neg_z);
232        let dv_dz = self.f * (dy_d_dxn * dxn_dz + dy_d_dyn * dyn_dz);
233
234        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
235    }
236
237    /// Returns the pose Jacobian `(∂(u,v)/∂p_cam, ∂p_cam/∂δξ)` for a 3D point in world
238    /// frame and a camera-to-world pose. Uses right perturbation on `SE(3)` and the
239    /// skew-symmetric cross-product matrix. See the cookbook chapter on
240    /// [SE(3) pose Jacobians][pose] for the general formula.
241    ///
242    /// [pose]: ../doc/cookbook/src/introduction.html#se3-pose-jacobians
243    fn jacobian_pose(
244        &self,
245        p_world: &Vector3<f64>,
246        pose: &SE3,
247    ) -> (Self::PointJacobian, SMatrix<f64, 3, 6>) {
248        let p_cam = pose.act(p_world, None, None);
249
250        let d_uv_d_pcam = self.jacobian_point(&p_cam);
251
252        // Right perturbation on T_wc:
253        //   ∂p_cam/∂δρ = R           (cols 0-2)
254        //   ∂p_cam/∂δθ = -R·[p_world]×  (cols 3-5)
255        let rotation = pose.rotation_so3().rotation_matrix();
256        let p_world_skew = skew_symmetric(p_world);
257
258        let d_pcam_d_pose = SMatrix::<f64, 3, 6>::from_fn(|r, c| {
259            if c < 3 {
260                rotation[(r, c)]
261            } else {
262                let col = c - 3;
263                -(0..3)
264                    .map(|k| rotation[(r, k)] * p_world_skew[(k, col)])
265                    .sum::<f64>()
266            }
267        });
268
269        (d_uv_d_pcam, d_pcam_d_pose)
270    }
271
272    /// Returns the 2×3 intrinsic Jacobian `∂(u,v)/∂[f, k1, k2]`. See the
273    /// [BAL pinhole cookbook chapter][chap] for the derivation.
274    ///
275    /// [chap]: ../doc/cookbook/src/bal-pinhole.html
276    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
277        let inv_neg_z = -1.0 / p_cam.z;
278        let x_n = p_cam.x * inv_neg_z;
279        let y_n = p_cam.y * inv_neg_z;
280
281        let (k1, k2) = self.distortion_params();
282        let r2 = x_n * x_n + y_n * y_n;
283        let r4 = r2 * r2;
284        let distortion = 1.0 + k1 * r2 + k2 * r4;
285
286        let x_d = x_n * distortion;
287        let y_d = y_n * distortion;
288
289        SMatrix::<f64, 2, 3>::new(
290            x_d,
291            self.f * x_n * r2,
292            self.f * x_n * r4,
293            y_d,
294            self.f * y_n * r2,
295            self.f * y_n * r4,
296        )
297    }
298
299    /// Unprojects a 2D pixel to a unit 3D ray in camera frame (BAL convention: ray
300    /// has `z = -1/√(1+r²)`). Uses a fixed 5-iteration fixed-point solve to invert
301    /// the radial distortion.
302    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
303        let x_d = point_2d.x / self.f;
304        let y_d = point_2d.y / self.f;
305
306        let mut x_n = x_d;
307        let mut y_n = y_d;
308
309        let (k1, k2) = self.distortion_params();
310
311        for _ in 0..5 {
312            let r2 = x_n * x_n + y_n * y_n;
313            let distortion = 1.0 + k1 * r2 + k2 * r2 * r2;
314            x_n = x_d / distortion;
315            y_n = y_d / distortion;
316        }
317
318        let norm = (1.0 + x_n * x_n + y_n * y_n).sqrt();
319        Ok(Vector3::new(x_n / norm, y_n / norm, -1.0 / norm))
320    }
321
322    /// Validates that `f` is positive and finite, and that the distortion
323    /// coefficients are finite.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`CameraModelError`] on any violation.
328    fn validate_params(&self) -> Result<(), CameraModelError> {
329        self.get_pinhole_params().validate()?;
330        self.get_distortion().validate()
331    }
332
333    /// Returns `fx = fy = f` and `cx = cy = 0`.
334    fn get_pinhole_params(&self) -> PinholeParams {
335        PinholeParams {
336            fx: self.f,
337            fy: self.f,
338            cx: 0.0,
339            cy: 0.0,
340        }
341    }
342
343    /// Returns the stored distortion model.
344    fn get_distortion(&self) -> DistortionModel {
345        self.distortion
346    }
347
348    /// Returns the model name `"bal_pinhole"`.
349    fn get_model_name(&self) -> &'static str {
350        "bal_pinhole"
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    type TestResult = Result<(), Box<dyn std::error::Error>>;
359
360    #[test]
361    fn test_bal_strict_camera_creation() -> TestResult {
362        let pinhole = PinholeParams::new(500.0, 500.0, 0.0, 0.0)?;
363        let distortion = DistortionModel::Radial { k1: 0.4, k2: -0.3 };
364        let camera = BALPinholeCameraStrict::new(pinhole, distortion)?;
365        let (k1, k2) = camera.distortion_params();
366
367        assert_eq!(camera.f, 500.0);
368        assert_eq!(k1, 0.4);
369        assert_eq!(k2, -0.3);
370        Ok(())
371    }
372
373    #[test]
374    fn test_bal_strict_rejects_different_focal_lengths() {
375        let pinhole = PinholeParams {
376            fx: 500.0,
377            fy: 505.0, // Different from fx
378            cx: 0.0,
379            cy: 0.0,
380        };
381        let distortion = DistortionModel::Radial { k1: 0.0, k2: 0.0 };
382        let result = BALPinholeCameraStrict::new(pinhole, distortion);
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn test_bal_strict_rejects_non_zero_principal_point() {
388        let pinhole = PinholeParams {
389            fx: 500.0,
390            fy: 500.0,
391            cx: 320.0, // Non-zero
392            cy: 0.0,
393        };
394        let distortion = DistortionModel::Radial { k1: 0.0, k2: 0.0 };
395        let result = BALPinholeCameraStrict::new(pinhole, distortion);
396        assert!(result.is_err());
397    }
398
399    #[test]
400    fn test_bal_strict_projection_at_optical_axis() -> TestResult {
401        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
402        let p_cam = Vector3::new(0.0, 0.0, -1.0);
403
404        let uv = camera.project(&p_cam)?;
405
406        // Point on optical axis projects to origin (no principal point offset)
407        assert!(uv.x.abs() < 1e-10);
408        assert!(uv.y.abs() < 1e-10);
409
410        Ok(())
411    }
412
413    #[test]
414    fn test_bal_strict_projection_off_axis() -> TestResult {
415        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
416        let p_cam = Vector3::new(0.1, 0.2, -1.0);
417
418        let uv = camera.project(&p_cam)?;
419
420        // u = 500 * 0.1 = 50 (no principal point offset)
421        // v = 500 * 0.2 = 100
422        assert!((uv.x - 50.0).abs() < 1e-10);
423        assert!((uv.y - 100.0).abs() < 1e-10);
424
425        Ok(())
426    }
427
428    #[test]
429    fn test_bal_strict_from_into_traits() -> TestResult {
430        let camera = BALPinholeCameraStrict::new_no_distortion(400.0)?;
431
432        // Test conversion to DVector
433        let params: DVector<f64> = (&camera).into();
434        assert_eq!(params.len(), 3);
435        assert_eq!(params[0], 400.0);
436        assert_eq!(params[1], 0.0);
437        assert_eq!(params[2], 0.0);
438
439        // Test conversion to array
440        let arr: [f64; 3] = (&camera).into();
441        assert_eq!(arr, [400.0, 0.0, 0.0]);
442
443        // Test conversion from slice
444        let params_slice = [450.0, 0.1, 0.01];
445        let camera2 = BALPinholeCameraStrict::try_from(&params_slice[..])?;
446        let (cam2_k1, cam2_k2) = camera2.distortion_params();
447        assert_eq!(camera2.f, 450.0);
448        assert_eq!(cam2_k1, 0.1);
449        assert_eq!(cam2_k2, 0.01);
450
451        // Test conversion from array
452        let camera3 = BALPinholeCameraStrict::from([500.0, 0.2, 0.02]);
453        let (cam3_k1, cam3_k2) = camera3.distortion_params();
454        assert_eq!(camera3.f, 500.0);
455        assert_eq!(cam3_k1, 0.2);
456        assert_eq!(cam3_k2, 0.02);
457
458        Ok(())
459    }
460
461    #[test]
462    fn test_project_unproject_round_trip() -> TestResult {
463        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
464
465        // BAL uses -Z convention: points in front of camera have z < 0
466        let test_points = [
467            Vector3::new(0.1, 0.2, -1.0),
468            Vector3::new(-0.3, 0.1, -2.0),
469            Vector3::new(0.05, -0.1, -0.5),
470        ];
471
472        for p_cam in &test_points {
473            let uv = camera.project(p_cam)?;
474            let ray = camera.unproject(&uv)?;
475            let dot = ray.dot(&p_cam.normalize());
476            assert!(
477                (dot - 1.0).abs() < 1e-6,
478                "Round-trip failed: dot={dot}, expected ~1.0"
479            );
480        }
481
482        Ok(())
483    }
484
485    #[test]
486    fn test_jacobian_pose_numerical() -> TestResult {
487        use apex_manifolds::LieGroup;
488        use apex_manifolds::se3::{SE3, SE3Tangent};
489
490        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
491
492        // BAL uses -Z convention. Use a pose and world point such that
493        // pose_inv.act(p_world) has z < 0.
494        let pose = SE3::from_translation_euler(0.1, -0.05, 0.2, 0.0, 0.0, 0.0);
495        let p_world = Vector3::new(0.1, 0.05, -3.0);
496
497        let (d_uv_d_pcam, d_pcam_d_pose) = camera.jacobian_pose(&p_world, &pose);
498        let d_uv_d_pose = d_uv_d_pcam * d_pcam_d_pose;
499
500        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
501
502        for i in 0..6 {
503            let mut d = [0.0f64; 6];
504            d[i] = eps;
505            let delta_plus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
506            d[i] = -eps;
507            let delta_minus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
508
509            // Right perturbation on T_wc: pose' = pose · Exp(δ)
510            let p_cam_plus = pose.plus(&delta_plus, None, None).act(&p_world, None, None);
511            let p_cam_minus = pose
512                .plus(&delta_minus, None, None)
513                .act(&p_world, None, None);
514
515            let uv_plus = camera.project(&p_cam_plus)?;
516            let uv_minus = camera.project(&p_cam_minus)?;
517
518            let num_deriv = (uv_plus - uv_minus) / (2.0 * eps);
519
520            for r in 0..2 {
521                let analytical = d_uv_d_pose[(r, i)];
522                let numerical = num_deriv[r];
523                assert!(
524                    analytical.is_finite(),
525                    "jacobian_pose[{r},{i}] is not finite"
526                );
527                let rel_err = (analytical - numerical).abs() / (1.0 + numerical.abs());
528                assert!(
529                    rel_err < crate::JACOBIAN_TEST_TOLERANCE,
530                    "jacobian_pose mismatch at ({r},{i}): analytical={analytical}, numerical={numerical}"
531                );
532            }
533        }
534
535        Ok(())
536    }
537
538    #[test]
539    fn test_project_returns_error_behind_camera() -> TestResult {
540        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
541        // BAL: z > 0 is behind camera
542        assert!(camera.project(&Vector3::new(0.0, 0.0, 1.0)).is_err());
543        Ok(())
544    }
545
546    #[test]
547    fn test_project_at_min_depth_boundary() -> TestResult {
548        let camera = BALPinholeCameraStrict::new_no_distortion(500.0)?;
549        // BAL: min depth is in negative-z direction
550        let p_min = Vector3::new(0.0, 0.0, -crate::MIN_DEPTH);
551        if let Ok(uv) = camera.project(&p_min) {
552            assert!(uv.x.is_finite() && uv.y.is_finite());
553        }
554        Ok(())
555    }
556}