Skip to main content

apex_camera_models/
lib.rs

1//! Camera projection models for bundle adjustment, SLAM, and Structure-from-Motion.
2//!
3//! Every model implements the [`CameraModel`] trait, which exposes projection,
4//! unprojection, and analytic point / pose / intrinsic Jacobians. The
5//! [cookbook](https://github.com/amin-abouee/apex-solver/tree/main/crates/apex-camera-models/doc/cookbook)
6//! has the full mathematical formulations.
7
8use apex_manifolds::LieGroup;
9use apex_manifolds::se3::SE3;
10use nalgebra::{Matrix2xX, Matrix3, Matrix3xX, SMatrix, Vector2, Vector3};
11
12/// Threshold for geometric validity checks (e.g. point in front of camera).
13pub const GEOMETRIC_PRECISION: f64 = 1e-6;
14
15/// Step size for numerical differentiation of Jacobians.
16pub const NUMERICAL_DERIVATIVE_EPS: f64 = 1e-7;
17
18/// Allowed difference between analytic and numerical Jacobians in tests.
19pub const JACOBIAN_TEST_TOLERANCE: f64 = 1e-5;
20
21/// Allowed projection error in projection tests.
22pub const PROJECTION_TEST_TOLERANCE: f64 = 1e-10;
23
24/// Minimum depth (meters) for a 3D point to be projectable.
25pub const MIN_DEPTH: f64 = 1e-6;
26
27/// Convergence threshold for iterative unprojection (e.g. Kannala-Brandt).
28pub const CONVERGENCE_THRESHOLD: f64 = 1e-6;
29
30/// Camera model errors.
31#[derive(thiserror::Error, Debug)]
32pub enum CameraModelError {
33    /// Focal length must be positive: fx={fx}, fy={fy}
34    #[error("Focal length must be positive: fx={fx}, fy={fy}")]
35    FocalLengthNotPositive { fx: f64, fy: f64 },
36
37    /// Focal length must be finite: fx={fx}, fy={fy}
38    #[error("Focal length must be finite: fx={fx}, fy={fy}")]
39    FocalLengthNotFinite { fx: f64, fy: f64 },
40
41    /// Principal point must be finite: cx={cx}, cy={cy}
42    #[error("Principal point must be finite: cx={cx}, cy={cy}")]
43    PrincipalPointNotFinite { cx: f64, cy: f64 },
44
45    /// Distortion coefficient must be finite
46    #[error("Distortion coefficient '{name}' must be finite, got {value}")]
47    DistortionNotFinite { name: String, value: f64 },
48
49    /// Parameter out of range
50    #[error("Parameter '{param}' must be in range [{min}, {max}], got {value}")]
51    ParameterOutOfRange {
52        param: String,
53        value: f64,
54        min: f64,
55        max: f64,
56    },
57
58    /// Point behind camera
59    #[error("Point behind camera: z={z} (must be > {min_z})")]
60    PointBehindCamera { z: f64, min_z: f64 },
61
62    /// Point at camera center
63    #[error("Point at camera center: 3D point too close to optical axis")]
64    PointAtCameraCenter,
65
66    /// Projection denominator too small
67    #[error("Projection denominator too small: denom={denom} (threshold={threshold})")]
68    DenominatorTooSmall { denom: f64, threshold: f64 },
69
70    /// Projection outside valid image region
71    #[error("Projection outside valid image region")]
72    ProjectionOutOfBounds,
73
74    /// Point outside image bounds
75    #[error("Point outside image bounds: ({x}, {y}) not in valid region")]
76    PointOutsideImage { x: f64, y: f64 },
77
78    /// Numerical error
79    #[error("Numerical error in {operation}: {details}")]
80    NumericalError { operation: String, details: String },
81
82    /// Generic invalid parameters
83    #[error("Invalid camera parameters: {0}")]
84    InvalidParams(String),
85}
86
87/// Linear intrinsic parameters shared by every model except F-Theta.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct PinholeParams {
90    /// Focal length, x (pixels)
91    pub fx: f64,
92    /// Focal length, y (pixels)
93    pub fy: f64,
94    /// Principal point, x (pixels)
95    pub cx: f64,
96    /// Principal point, y (pixels)
97    pub cy: f64,
98}
99
100impl PinholeParams {
101    /// Create new pinhole parameters with validation.
102    pub fn new(fx: f64, fy: f64, cx: f64, cy: f64) -> Result<Self, CameraModelError> {
103        let params = Self { fx, fy, cx, cy };
104        params.validate()?;
105        Ok(params)
106    }
107
108    /// Validate pinhole parameters.
109    pub fn validate(&self) -> Result<(), CameraModelError> {
110        if self.fx <= 0.0 || self.fy <= 0.0 {
111            return Err(CameraModelError::FocalLengthNotPositive {
112                fx: self.fx,
113                fy: self.fy,
114            });
115        }
116        if !self.fx.is_finite() || !self.fy.is_finite() {
117            return Err(CameraModelError::FocalLengthNotFinite {
118                fx: self.fx,
119                fy: self.fy,
120            });
121        }
122        if !self.cx.is_finite() || !self.cy.is_finite() {
123            return Err(CameraModelError::PrincipalPointNotFinite {
124                cx: self.cx,
125                cy: self.cy,
126            });
127        }
128        Ok(())
129    }
130}
131
132/// Lens distortion models. The exact parameter ranges are enforced by
133/// [`DistortionModel::validate`].
134#[derive(Debug, Clone, Copy, PartialEq)]
135pub enum DistortionModel {
136    /// No distortion (vanilla pinhole).
137    None,
138
139    /// BAL-style radial distortion (`k1`, `k2`).
140    Radial { k1: f64, k2: f64 },
141
142    /// Brown-Conrady / OpenCV radial-tangential distortion.
143    BrownConrady {
144        k1: f64,
145        k2: f64,
146        p1: f64,
147        p2: f64,
148        k3: f64,
149    },
150
151    /// Kannala-Brandt polynomial fisheye.
152    KannalaBrandt { k1: f64, k2: f64, k3: f64, k4: f64 },
153
154    /// Devernay-Faugeras field-of-view.
155    FOV { w: f64 },
156
157    /// Geyer-Daniilidis unified camera model.
158    UCM { alpha: f64 },
159
160    /// Khomutenko extended UCM.
161    EUCM { alpha: f64, beta: f64 },
162
163    /// Usenko double-sphere.
164    DoubleSphere { xi: f64, alpha: f64 },
165
166    /// NVIDIA f-theta polynomial fisheye.
167    FTheta { k1: f64, k2: f64, k3: f64, k4: f64 },
168}
169
170fn check_finite(name: &str, value: f64) -> Result<(), CameraModelError> {
171    if !value.is_finite() {
172        return Err(CameraModelError::DistortionNotFinite {
173            name: name.to_string(),
174            value,
175        });
176    }
177    Ok(())
178}
179
180impl DistortionModel {
181    /// Validate distortion parameters for the given model variant.
182    pub fn validate(&self) -> Result<(), CameraModelError> {
183        match self {
184            DistortionModel::None => Ok(()),
185            DistortionModel::Radial { k1, k2 } => {
186                check_finite("k1", *k1)?;
187                check_finite("k2", *k2)
188            }
189            DistortionModel::BrownConrady { k1, k2, p1, p2, k3 } => {
190                check_finite("k1", *k1)?;
191                check_finite("k2", *k2)?;
192                check_finite("p1", *p1)?;
193                check_finite("p2", *p2)?;
194                check_finite("k3", *k3)
195            }
196            DistortionModel::KannalaBrandt { k1, k2, k3, k4 } => {
197                check_finite("k1", *k1)?;
198                check_finite("k2", *k2)?;
199                check_finite("k3", *k3)?;
200                check_finite("k4", *k4)
201            }
202            DistortionModel::FOV { w } => {
203                if !w.is_finite() || *w <= 0.0 || *w > std::f64::consts::PI {
204                    return Err(CameraModelError::ParameterOutOfRange {
205                        param: "w".to_string(),
206                        value: *w,
207                        min: 0.0,
208                        max: std::f64::consts::PI,
209                    });
210                }
211                Ok(())
212            }
213            DistortionModel::UCM { alpha } => {
214                if !alpha.is_finite() || !(0.0..=1.0).contains(alpha) {
215                    return Err(CameraModelError::ParameterOutOfRange {
216                        param: "alpha".to_string(),
217                        value: *alpha,
218                        min: 0.0,
219                        max: 1.0,
220                    });
221                }
222                Ok(())
223            }
224            DistortionModel::EUCM { alpha, beta } => {
225                if !alpha.is_finite() || !(0.0..=1.0).contains(alpha) {
226                    return Err(CameraModelError::ParameterOutOfRange {
227                        param: "alpha".to_string(),
228                        value: *alpha,
229                        min: 0.0,
230                        max: 1.0,
231                    });
232                }
233                if !beta.is_finite() || *beta <= 0.0 {
234                    return Err(CameraModelError::ParameterOutOfRange {
235                        param: "beta".to_string(),
236                        value: *beta,
237                        min: 0.0,
238                        max: f64::INFINITY,
239                    });
240                }
241                Ok(())
242            }
243            DistortionModel::DoubleSphere { xi, alpha } => {
244                if !xi.is_finite() || !(-1.0..=1.0).contains(xi) {
245                    return Err(CameraModelError::ParameterOutOfRange {
246                        param: "xi".to_string(),
247                        value: *xi,
248                        min: -1.0,
249                        max: 1.0,
250                    });
251                }
252                if !alpha.is_finite() || *alpha <= 0.0 || *alpha > 1.0 {
253                    return Err(CameraModelError::ParameterOutOfRange {
254                        param: "alpha".to_string(),
255                        value: *alpha,
256                        min: 0.0,
257                        max: 1.0,
258                    });
259                }
260                Ok(())
261            }
262            DistortionModel::FTheta { k1, k2, k3, k4 } => {
263                if !k1.is_finite() || *k1 <= 0.0 {
264                    return Err(CameraModelError::FocalLengthNotPositive { fx: *k1, fy: *k1 });
265                }
266                check_finite("k2", *k2)?;
267                check_finite("k3", *k3)?;
268                check_finite("k4", *k4)
269            }
270        }
271    }
272}
273
274/// Returns `Ok(())` if `z >= GEOMETRIC_PRECISION` (1e-6) and
275/// `Err(PointAtCameraCenter)` otherwise. Used to reject points too close to
276/// the optical axis that would cause numerical instability in the
277/// perspective division.
278pub fn validate_point_in_front(z: f64) -> Result<(), CameraModelError> {
279    if z < crate::GEOMETRIC_PRECISION {
280        return Err(CameraModelError::PointAtCameraCenter);
281    }
282    Ok(())
283}
284
285// Camera model modules
286
287pub mod bal_pinhole;
288pub mod double_sphere;
289pub mod eucm;
290pub mod fov;
291pub mod ftheta;
292pub mod kannala_brandt;
293pub mod pinhole;
294pub mod rad_tan;
295pub mod ucm;
296
297// Re-export camera types
298pub use bal_pinhole::BALPinholeCameraStrict;
299pub use double_sphere::DoubleSphereCamera;
300pub use eucm::EucmCamera;
301pub use fov::FovCamera;
302pub use ftheta::FThetaCamera;
303pub use kannala_brandt::KannalaBrandtCamera;
304pub use pinhole::PinholeCamera;
305pub use rad_tan::RadTanCamera;
306pub use ucm::UcmCamera;
307
308// Camera Model Trait
309
310/// Trait for camera projection models.
311///
312/// Defines the interface for camera models used in bundle adjustment and SfM.
313///
314/// # Type Parameters
315///
316/// - `INTRINSIC_DIM`: Number of intrinsic parameters
317/// - `IntrinsicJacobian`: Jacobian type for intrinsics (2 × INTRINSIC_DIM)
318/// - `PointJacobian`: Jacobian type for 3D point (2 × 3)
319pub trait CameraModel: Send + Sync + Clone + std::fmt::Debug + 'static {
320    /// Number of intrinsic parameters (compile-time constant).
321    const INTRINSIC_DIM: usize;
322
323    /// Jacobian type for intrinsics: 2 × INTRINSIC_DIM.
324    type IntrinsicJacobian: Clone
325        + std::fmt::Debug
326        + Default
327        + std::ops::Index<(usize, usize), Output = f64>;
328
329    /// Jacobian type for 3D point: 2 × 3.
330    type PointJacobian: Clone
331        + std::fmt::Debug
332        + Default
333        + std::ops::Mul<SMatrix<f64, 3, 6>, Output = SMatrix<f64, 2, 6>>
334        + std::ops::Mul<Matrix3<f64>, Output = SMatrix<f64, 2, 3>>
335        + std::ops::Index<(usize, usize), Output = f64>;
336
337    /// Projects a 3D point in camera coordinates to 2D image coordinates.
338    /// See the [cookbook introduction](../doc/cookbook/src/introduction.html)
339    /// for the projection pipeline, and the per-model page for the formula.
340    ///
341    /// # Errors
342    ///
343    /// Returns `PointBehindCamera`, `PointAtCameraCenter`, `DenominatorTooSmall`,
344    /// or `ProjectionOutOfBounds` depending on the model.
345    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError>;
346
347    /// Unprojects a 2D image point to a normalized 3D ray in camera frame.
348    /// Some models use Newton-Raphson for undistortion. See the per-model
349    /// cookbook page for the algorithm.
350    ///
351    /// # Errors
352    ///
353    /// Returns `PointOutsideImage` or `NumericalError` (e.g. when the
354    /// iterative solver fails to converge).
355    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError>;
356
357    /// ∂(u,v)/∂(x,y,z) — 2×3 Jacobian of projection w.r.t. the 3D point.
358    /// Used for structure optimisation, triangulation, and bundle adjustment.
359    /// See the per-model cookbook page for the formula.
360    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian;
361
362    /// ∂(u,v)/∂(δξ) — 2×6 Jacobian of projection w.r.t. the camera pose.
363    ///
364    /// The pose is a world-to-camera transform `T_wc` with right
365    /// perturbation `T' = T · Exp(δξ)`. Returns a pair
366    /// `(J_uv_pcam, J_pcam_pose)` where the caller multiplies to get
367    /// the full 2×6 Jacobian. See the
368    /// [cookbook introduction](../doc/cookbook/src/introduction.html#pose-jacobians-se3)
369    /// for the SE(3) conventions.
370    fn jacobian_pose(
371        &self,
372        p_world: &Vector3<f64>,
373        pose: &SE3,
374    ) -> (Self::PointJacobian, SMatrix<f64, 3, 6>) {
375        let p_cam = pose.act(p_world, None, None);
376        let d_uv_d_pcam = self.jacobian_point(&p_cam);
377
378        let rotation = pose.rotation_so3().rotation_matrix();
379        let p_world_skew = skew_symmetric(p_world);
380
381        let d_pcam_d_pose = SMatrix::<f64, 3, 6>::from_fn(|r, c| {
382            if c < 3 {
383                rotation[(r, c)]
384            } else {
385                let col = c - 3;
386                -(0..3)
387                    .map(|k| rotation[(r, k)] * p_world_skew[(k, col)])
388                    .sum::<f64>()
389            }
390        });
391
392        (d_uv_d_pcam, d_pcam_d_pose)
393    }
394
395    /// ∂(u,v)/∂(params) — 2×N Jacobian of projection w.r.t. intrinsic
396    /// parameters, where `N = INTRINSIC_DIM`. The parameter order is
397    /// model-specific; see the per-model cookbook page.
398    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian;
399
400    /// Projects N 3D points in one call. Invalid projections are replaced
401    /// by the sentinel `(1e6, 1e6)`. Models may override with a vectorised
402    /// implementation.
403    fn project_batch(&self, points_cam: &Matrix3xX<f64>) -> Matrix2xX<f64> {
404        let n = points_cam.ncols();
405        let mut result = Matrix2xX::zeros(n);
406        for i in 0..n {
407            let p = Vector3::new(points_cam[(0, i)], points_cam[(1, i)], points_cam[(2, i)]);
408            match self.project(&p) {
409                Ok(uv) => result.set_column(i, &uv),
410                Err(_) => result.set_column(i, &Vector2::new(1e6, 1e6)),
411            }
412        }
413        result
414    }
415
416    /// Validates camera intrinsic and distortion parameters. The exact
417    /// rules are model-specific; see the per-model cookbook page under
418    /// "Validation Rules".
419    fn validate_params(&self) -> Result<(), CameraModelError>;
420
421    /// Returns the linear intrinsics `(f_x, f_y, c_x, c_y)`.
422    fn get_pinhole_params(&self) -> PinholeParams;
423
424    /// Returns the distortion model and its parameters.
425    fn get_distortion(&self) -> DistortionModel;
426
427    /// Returns the camera model identifier (`"pinhole"`, `"rad_tan"`, ...).
428    fn get_model_name(&self) -> &'static str;
429}
430
431/// Skew-symmetric cross-product matrix `[v]×` such that `[v]× w = v × w`.
432#[inline]
433pub(crate) fn skew_symmetric(v: &Vector3<f64>) -> Matrix3<f64> {
434    Matrix3::new(0.0, -v.z, v.y, v.z, 0.0, -v.x, -v.y, v.x, 0.0)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::pinhole::PinholeCamera;
441    use apex_manifolds::LieGroup;
442    use apex_manifolds::se3::{SE3, SE3Tangent};
443
444    type TestResult = Result<(), Box<dyn std::error::Error>>;
445
446    /// Canonical test for the default `jacobian_pose` implementation (right perturbation).
447    ///
448    /// Since `jacobian_pose` has a single default implementation shared by all models
449    /// except BAL, we test it once here using `PinholeCamera` as a representative model.
450    #[test]
451    fn test_jacobian_pose_numerical() -> TestResult {
452        let pinhole = PinholeParams::new(500.0, 500.0, 320.0, 240.0)?;
453        let camera = PinholeCamera::new(pinhole, DistortionModel::None)?;
454        let pose = SE3::from_translation_euler(0.1, -0.2, 0.3, 0.05, -0.1, 0.15);
455        let p_world = Vector3::new(1.0, 0.5, 3.0);
456
457        let (d_uv_d_pcam, d_pcam_d_pose) = camera.jacobian_pose(&p_world, &pose);
458        let d_uv_d_pose = d_uv_d_pcam * d_pcam_d_pose;
459
460        let eps = NUMERICAL_DERIVATIVE_EPS;
461
462        for i in 0..6 {
463            let mut d = [0.0f64; 6];
464            d[i] = eps;
465            let delta_plus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
466            d[i] = -eps;
467            let delta_minus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
468
469            // Right perturbation on T_wc: pose' = pose · Exp(δ)
470            let p_cam_plus = pose.plus(&delta_plus, None, None).act(&p_world, None, None);
471            let p_cam_minus = pose
472                .plus(&delta_minus, None, None)
473                .act(&p_world, None, None);
474
475            let uv_plus = camera.project(&p_cam_plus)?;
476            let uv_minus = camera.project(&p_cam_minus)?;
477
478            let num_deriv = (uv_plus - uv_minus) / (2.0 * eps);
479
480            for r in 0..2 {
481                let analytical = d_uv_d_pose[(r, i)];
482                let numerical = num_deriv[r];
483                let rel_err = (analytical - numerical).abs() / (1.0 + numerical.abs());
484                assert!(
485                    rel_err < JACOBIAN_TEST_TOLERANCE,
486                    "jacobian_pose mismatch at ({r},{i}): analytical={analytical}, numerical={numerical}"
487                );
488            }
489        }
490        Ok(())
491    }
492
493    #[test]
494    fn test_skew_symmetric() {
495        let v = Vector3::new(1.0, 2.0, 3.0);
496        let skew = skew_symmetric(&v);
497
498        assert_eq!(skew[(0, 0)], 0.0);
499        assert_eq!(skew[(1, 1)], 0.0);
500        assert_eq!(skew[(2, 2)], 0.0);
501
502        assert_eq!(skew[(0, 1)], -skew[(1, 0)]);
503        assert_eq!(skew[(0, 2)], -skew[(2, 0)]);
504        assert_eq!(skew[(1, 2)], -skew[(2, 1)]);
505
506        assert_eq!(skew[(0, 1)], -v.z);
507        assert_eq!(skew[(0, 2)], v.y);
508        assert_eq!(skew[(1, 0)], v.z);
509        assert_eq!(skew[(1, 2)], -v.x);
510        assert_eq!(skew[(2, 0)], -v.y);
511        assert_eq!(skew[(2, 1)], v.x);
512
513        let w = Vector3::new(4.0, 5.0, 6.0);
514        let cross_via_skew = skew * w;
515        let cross_direct = v.cross(&w);
516        assert!((cross_via_skew - cross_direct).norm() < 1e-10);
517    }
518
519    #[test]
520    fn test_pinhole_validate_negative_focal_length() {
521        let result = PinholeParams::new(-1.0, 300.0, 320.0, 240.0);
522        assert!(result.is_err(), "negative fx should fail validation");
523    }
524
525    #[test]
526    fn test_pinhole_validate_zero_focal_length() {
527        let result = PinholeParams::new(0.0, 300.0, 320.0, 240.0);
528        assert!(result.is_err(), "fx = 0 should fail validation");
529    }
530
531    #[test]
532    fn test_pinhole_validate_nan_focal_length() {
533        let result = PinholeParams::new(f64::NAN, 300.0, 320.0, 240.0);
534        assert!(result.is_err(), "NaN fx should fail validation");
535    }
536
537    #[test]
538    fn test_pinhole_validate_infinite_focal_length() {
539        // Inf is > 0 so passes the first check, but fails the is_finite() check
540        let result = PinholeParams::new(f64::INFINITY, 300.0, 320.0, 240.0);
541        assert!(result.is_err(), "Inf fx should fail validation");
542    }
543
544    #[test]
545    fn test_pinhole_validate_nan_principal_point() {
546        let result = PinholeParams::new(300.0, 300.0, f64::NAN, 240.0);
547        assert!(result.is_err(), "NaN cx should fail validation");
548    }
549
550    #[test]
551    fn test_distortion_none_is_valid() {
552        assert!(DistortionModel::None.validate().is_ok());
553    }
554
555    #[test]
556    fn test_distortion_radial_nan_fails() {
557        let d = DistortionModel::Radial {
558            k1: f64::NAN,
559            k2: 0.0,
560        };
561        assert!(d.validate().is_err(), "NaN k1 should fail");
562    }
563
564    #[test]
565    fn test_distortion_brown_conrady_nan_fails() {
566        let d = DistortionModel::BrownConrady {
567            k1: 0.0,
568            k2: f64::NAN,
569            p1: 0.0,
570            p2: 0.0,
571            k3: 0.0,
572        };
573        assert!(d.validate().is_err(), "NaN k2 should fail");
574    }
575
576    #[test]
577    fn test_distortion_kannala_brandt_nan_fails() {
578        let d = DistortionModel::KannalaBrandt {
579            k1: 0.0,
580            k2: 0.0,
581            k3: f64::NAN,
582            k4: 0.0,
583        };
584        assert!(d.validate().is_err(), "NaN k3 should fail");
585    }
586
587    #[test]
588    fn test_distortion_fov_invalid_w_zero() {
589        let d = DistortionModel::FOV { w: 0.0 };
590        assert!(d.validate().is_err(), "w = 0 should fail (must be > 0)");
591    }
592
593    #[test]
594    fn test_distortion_fov_invalid_w_too_large() {
595        let d = DistortionModel::FOV {
596            w: std::f64::consts::PI + 0.1,
597        };
598        assert!(d.validate().is_err(), "w > π should fail");
599    }
600
601    #[test]
602    fn test_distortion_fov_valid() {
603        let d = DistortionModel::FOV { w: 1.0 };
604        assert!(d.validate().is_ok(), "w = 1.0 should be valid");
605    }
606
607    #[test]
608    fn test_distortion_ucm_alpha_out_of_range() {
609        let d = DistortionModel::UCM { alpha: 1.5 };
610        assert!(d.validate().is_err(), "alpha > 1 should fail for UCM");
611    }
612
613    #[test]
614    fn test_distortion_ucm_alpha_valid() {
615        let d = DistortionModel::UCM { alpha: 0.5 };
616        assert!(d.validate().is_ok());
617    }
618
619    #[test]
620    fn test_distortion_eucm_alpha_out_of_range() {
621        let d = DistortionModel::EUCM {
622            alpha: 1.5,
623            beta: 1.0,
624        };
625        assert!(d.validate().is_err(), "alpha > 1 should fail for EUCM");
626    }
627
628    #[test]
629    fn test_distortion_eucm_beta_nonpositive() {
630        let d = DistortionModel::EUCM {
631            alpha: 0.5,
632            beta: -1.0,
633        };
634        assert!(d.validate().is_err(), "beta <= 0 should fail for EUCM");
635    }
636
637    #[test]
638    fn test_distortion_double_sphere_xi_out_of_range() {
639        let d = DistortionModel::DoubleSphere {
640            xi: 2.0,
641            alpha: 0.6,
642        };
643        assert!(d.validate().is_err(), "xi > 1 should fail");
644    }
645
646    #[test]
647    fn test_distortion_double_sphere_alpha_invalid() {
648        let d = DistortionModel::DoubleSphere {
649            xi: 0.0,
650            alpha: 0.0,
651        };
652        assert!(d.validate().is_err(), "alpha = 0 should fail");
653    }
654
655    #[test]
656    fn test_validate_point_in_front_valid_z() {
657        assert!(
658            validate_point_in_front(1.0).is_ok(),
659            "z = 1.0 should be valid"
660        );
661    }
662
663    #[test]
664    fn test_validate_point_in_front_behind_camera() {
665        assert!(
666            validate_point_in_front(-1.0).is_err(),
667            "z = -1.0 should fail"
668        );
669    }
670
671    #[test]
672    fn test_validate_point_in_front_at_center() {
673        // z = 0 < GEOMETRIC_PRECISION (1e-6), should fail
674        assert!(validate_point_in_front(0.0).is_err(), "z = 0 should fail");
675    }
676
677    #[test]
678    fn test_project_batch_default_impl() -> TestResult {
679        let pinhole = PinholeParams::new(500.0, 500.0, 320.0, 240.0)?;
680        let camera = PinholeCamera::new(pinhole, DistortionModel::None)?;
681
682        // 3 valid points + 1 invalid (behind camera)
683        let pts = Matrix3xX::from_columns(&[
684            Vector3::new(0.0, 0.0, 1.0),
685            Vector3::new(0.1, 0.2, 1.0),
686            Vector3::new(-0.1, 0.1, 2.0),
687            Vector3::new(0.0, 0.0, -1.0), // behind camera → sentinel (1e6, 1e6)
688        ]);
689
690        let result = camera.project_batch(&pts);
691        assert_eq!(result.ncols(), 4);
692        assert!(result[(0, 0)].is_finite());
693        assert!(result[(1, 0)].is_finite());
694        assert!(
695            (result[(0, 3)] - 1e6).abs() < 1.0,
696            "Invalid projection should be sentinel 1e6, got {}",
697            result[(0, 3)]
698        );
699        assert!(
700            (result[(1, 3)] - 1e6).abs() < 1.0,
701            "Invalid projection should be sentinel 1e6, got {}",
702            result[(1, 3)]
703        );
704        Ok(())
705    }
706
707    #[test]
708    fn test_camera_model_error_display_focal_length_not_positive() {
709        let e = CameraModelError::FocalLengthNotPositive {
710            fx: -1.0,
711            fy: 300.0,
712        };
713        let s = format!("{e}");
714        assert!(
715            s.contains("fx") && s.contains("-1"),
716            "Display should include parameter values: {s}"
717        );
718    }
719
720    #[test]
721    fn test_camera_model_error_display_point_behind_camera() {
722        let e = CameraModelError::PointBehindCamera {
723            z: -0.5,
724            min_z: 1e-6,
725        };
726        let s = format!("{e}");
727        assert!(s.contains("z="), "Display should include z: {s}");
728    }
729
730    #[test]
731    fn test_camera_model_error_display_parameter_out_of_range() {
732        let e = CameraModelError::ParameterOutOfRange {
733            param: "alpha".to_string(),
734            value: 1.5,
735            min: 0.0,
736            max: 1.0,
737        };
738        let s = format!("{e}");
739        assert!(
740            s.contains("alpha") && s.contains("1.5"),
741            "Display should include param and value: {s}"
742        );
743    }
744}