Skip to main content

apex_camera_models/
lib.rs

1//! Camera projection models for computer vision applications.
2//!
3//! This crate provides a comprehensive collection of camera projection models commonly used in
4//! bundle adjustment, SLAM, visual odometry, and Structure-from-Motion (SfM). All models implement
5//! the [`CameraModel`] trait providing a unified interface for projection, unprojection, and
6//! analytic Jacobian computation.
7//!
8//! # Core Architecture
9//!
10//! ## CameraModel Trait
11//!
12//! The [`CameraModel`] trait defines the interface that all camera models must implement:
13//!
14//! - **Projection**: 3D point (x,y,z) → 2D pixel (u,v)
15//! - **Unprojection**: 2D pixel (u,v) → 3D unit ray
16//! - **Jacobians**: Analytic derivatives for optimization
17//!   - Point Jacobian: ∂(u,v)/∂(x,y,z) — 2×3 matrix
18//!   - Pose Jacobian: ∂(u,v)/∂(pose) — 2×6 matrix (SE3 tangent space)
19//!   - Intrinsic Jacobian: ∂(u,v)/∂(params) — 2×N matrix (N = parameter count)
20//!
21//! ## Error Handling
22//!
23//! All operations return [`Result`] with [`CameraModelError`] providing structured error variants
24//! that include actual parameter values for debugging:
25//!
26//! - Parameter validation: `FocalLengthNotPositive`, `FocalLengthNotFinite`, `ParameterOutOfRange`
27//! - Projection errors: `PointBehindCamera`, `ProjectionOutOfBounds`, `DenominatorTooSmall`
28//! - Numerical errors: `NumericalError` with operation context
29//!
30//! # Available Camera Models
31//!
32//! ## Standard Models (FOV < 90°)
33//! - **Pinhole**: Standard perspective projection (4 params: fx, fy, cx, cy)
34//! - **Radial-Tangential**: OpenCV Brown-Conrady model (9 params with k1,k2,k3,p1,p2)
35//!
36//! ## Wide-Angle Models (FOV 90°-180°)
37//! - **Kannala-Brandt**: Polynomial fisheye model (8 params, θ-based distortion)
38//! - **FOV**: Field-of-view model (5 params, atan-based)
39//!
40//! ## Omnidirectional Models (FOV > 180°)
41//! - **UCM**: Unified Camera Model (5 params, α parameter)
42//! - **EUCM**: Extended Unified Camera Model (6 params, α, β parameters)
43//! - **Double Sphere**: Two-sphere projection (6 params, ξ, α parameters)
44//!
45//! ## Specialized Models
46//! - **BAL Pinhole**: Bundle Adjustment in the Large format (6 params, -Z convention)
47
48use apex_manifolds::LieGroup;
49use apex_manifolds::se3::SE3;
50use nalgebra::{Matrix2xX, Matrix3, Matrix3xX, SMatrix, Vector2, Vector3};
51
52/// Precision constant for geometric validity checks (e.g., point in front of camera).
53///
54/// Used to determine if a 3D point is geometrically valid for projection.
55/// Default: 1e-6 (micrometers at meter scale)
56pub const GEOMETRIC_PRECISION: f64 = 1e-6;
57
58/// Epsilon for numerical differentiation in Jacobian computation.
59///
60/// Used when computing numerical derivatives for validation and testing.
61/// Default: 1e-7 (provides good balance between truncation and round-off error)
62pub const NUMERICAL_DERIVATIVE_EPS: f64 = 1e-7;
63
64/// Tolerance for numerical Jacobian validation in tests.
65///
66/// Maximum allowed difference between analytical and numerical Jacobians.
67/// Default: 1e-5 (allows for small numerical errors in finite differences)
68pub const JACOBIAN_TEST_TOLERANCE: f64 = 1e-5;
69
70/// Tolerance for projection/unprojection test assertions.
71///
72/// Maximum allowed error in pixel coordinates for test assertions.
73/// Default: 1e-10 (essentially exact for double precision)
74pub const PROJECTION_TEST_TOLERANCE: f64 = 1e-10;
75
76/// Minimum depth for valid 3D points (meters).
77///
78/// Points closer than this to the camera are considered invalid.
79/// Default: 1e-6 meters (1 micrometer)
80pub const MIN_DEPTH: f64 = 1e-6;
81
82/// Convergence threshold for iterative unprojection algorithms.
83///
84/// Used in camera models that require iterative solving (e.g., Kannala-Brandt).
85/// Default: 1e-6
86pub const CONVERGENCE_THRESHOLD: f64 = 1e-6;
87
88/// Camera model errors.
89#[derive(thiserror::Error, Debug)]
90pub enum CameraModelError {
91    /// Focal length must be positive: fx={fx}, fy={fy}
92    #[error("Focal length must be positive: fx={fx}, fy={fy}")]
93    FocalLengthNotPositive { fx: f64, fy: f64 },
94
95    /// Focal length must be finite: fx={fx}, fy={fy}
96    #[error("Focal length must be finite: fx={fx}, fy={fy}")]
97    FocalLengthNotFinite { fx: f64, fy: f64 },
98
99    /// Principal point must be finite: cx={cx}, cy={cy}
100    #[error("Principal point must be finite: cx={cx}, cy={cy}")]
101    PrincipalPointNotFinite { cx: f64, cy: f64 },
102
103    /// Distortion coefficient must be finite
104    #[error("Distortion coefficient '{name}' must be finite, got {value}")]
105    DistortionNotFinite { name: String, value: f64 },
106
107    /// Parameter out of range
108    #[error("Parameter '{param}' must be in range [{min}, {max}], got {value}")]
109    ParameterOutOfRange {
110        param: String,
111        value: f64,
112        min: f64,
113        max: f64,
114    },
115
116    /// Point behind camera
117    #[error("Point behind camera: z={z} (must be > {min_z})")]
118    PointBehindCamera { z: f64, min_z: f64 },
119
120    /// Point at camera center
121    #[error("Point at camera center: 3D point too close to optical axis")]
122    PointAtCameraCenter,
123
124    /// Projection denominator too small
125    #[error("Projection denominator too small: denom={denom} (threshold={threshold})")]
126    DenominatorTooSmall { denom: f64, threshold: f64 },
127
128    /// Projection outside valid image region
129    #[error("Projection outside valid image region")]
130    ProjectionOutOfBounds,
131
132    /// Point outside image bounds
133    #[error("Point outside image bounds: ({x}, {y}) not in valid region")]
134    PointOutsideImage { x: f64, y: f64 },
135
136    /// Numerical error
137    #[error("Numerical error in {operation}: {details}")]
138    NumericalError { operation: String, details: String },
139
140    /// Generic invalid parameters
141    #[error("Invalid camera parameters: {0}")]
142    InvalidParams(String),
143}
144
145/// The "Common 4" - Linear intrinsic parameters.
146///
147/// These define the projection matrix K for the pinhole camera model.
148#[derive(Debug, Clone, Copy, PartialEq)]
149pub struct PinholeParams {
150    /// Focal length in x direction (pixels)
151    pub fx: f64,
152    /// Focal length in y direction (pixels)
153    pub fy: f64,
154    /// Principal point x-coordinate (pixels)
155    pub cx: f64,
156    /// Principal point y-coordinate (pixels)
157    pub cy: f64,
158}
159
160impl PinholeParams {
161    /// Create new pinhole parameters with validation.
162    pub fn new(fx: f64, fy: f64, cx: f64, cy: f64) -> Result<Self, CameraModelError> {
163        let params = Self { fx, fy, cx, cy };
164        params.validate()?;
165        Ok(params)
166    }
167
168    /// Validate pinhole parameters.
169    pub fn validate(&self) -> Result<(), CameraModelError> {
170        if self.fx <= 0.0 || self.fy <= 0.0 {
171            return Err(CameraModelError::FocalLengthNotPositive {
172                fx: self.fx,
173                fy: self.fy,
174            });
175        }
176        if !self.fx.is_finite() || !self.fy.is_finite() {
177            return Err(CameraModelError::FocalLengthNotFinite {
178                fx: self.fx,
179                fy: self.fy,
180            });
181        }
182        if !self.cx.is_finite() || !self.cy.is_finite() {
183            return Err(CameraModelError::PrincipalPointNotFinite {
184                cx: self.cx,
185                cy: self.cy,
186            });
187        }
188        Ok(())
189    }
190}
191
192/// Lens distortion models.
193///
194/// This enum captures all supported distortion models with their parameters.
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub enum DistortionModel {
197    /// Perfect pinhole (no distortion)
198    None,
199
200    /// BAL-style radial distortion (2 parameters: k1, k2)
201    ///
202    /// Used in Bundle Adjustment in the Large datasets.
203    Radial { k1: f64, k2: f64 },
204
205    /// Brown-Conrady / OpenCV model (5 parameters)
206    ///
207    /// Standard model used in OpenCV: k1, k2, k3 (radial), p1, p2 (tangential)
208    BrownConrady {
209        k1: f64,
210        k2: f64,
211        p1: f64,
212        p2: f64,
213        k3: f64,
214    },
215
216    /// Kannala-Brandt fisheye model (4 parameters)
217    ///
218    /// Polynomial distortion model for fisheye lenses.
219    KannalaBrandt { k1: f64, k2: f64, k3: f64, k4: f64 },
220
221    /// Field-of-View model (1 parameter)
222    ///
223    /// Single-parameter fisheye model based on field of view.
224    FOV { w: f64 },
225
226    /// Unified Camera Model (1 parameter)
227    ///
228    /// Single-viewpoint catadioptric camera model.
229    UCM { alpha: f64 },
230
231    /// Extended Unified Camera Model (2 parameters)
232    ///
233    /// Extension of UCM with improved accuracy.
234    EUCM { alpha: f64, beta: f64 },
235
236    /// Double Sphere model (2 parameters)
237    ///
238    /// Two-parameter fisheye model with improved wide-angle accuracy.
239    DoubleSphere { xi: f64, alpha: f64 },
240
241    /// NVIDIA f-theta fisheye model (4 polynomial coefficients)
242    ///
243    /// Maps angle θ to image-plane radius via `f(θ) = k₁θ + k₂θ² + k₃θ³ + k₄θ⁴`.
244    /// k₁ acts as the nominal focal length (pixels per radian at small angles).
245    FTheta { k1: f64, k2: f64, k3: f64, k4: f64 },
246}
247
248fn check_finite(name: &str, value: f64) -> Result<(), CameraModelError> {
249    if !value.is_finite() {
250        return Err(CameraModelError::DistortionNotFinite {
251            name: name.to_string(),
252            value,
253        });
254    }
255    Ok(())
256}
257
258impl DistortionModel {
259    /// Validate distortion parameters for the given model variant.
260    pub fn validate(&self) -> Result<(), CameraModelError> {
261        match self {
262            DistortionModel::None => Ok(()),
263            DistortionModel::Radial { k1, k2 } => {
264                check_finite("k1", *k1)?;
265                check_finite("k2", *k2)
266            }
267            DistortionModel::BrownConrady { k1, k2, p1, p2, k3 } => {
268                check_finite("k1", *k1)?;
269                check_finite("k2", *k2)?;
270                check_finite("p1", *p1)?;
271                check_finite("p2", *p2)?;
272                check_finite("k3", *k3)
273            }
274            DistortionModel::KannalaBrandt { k1, k2, k3, k4 } => {
275                check_finite("k1", *k1)?;
276                check_finite("k2", *k2)?;
277                check_finite("k3", *k3)?;
278                check_finite("k4", *k4)
279            }
280            DistortionModel::FOV { w } => {
281                if !w.is_finite() || *w <= 0.0 || *w > std::f64::consts::PI {
282                    return Err(CameraModelError::ParameterOutOfRange {
283                        param: "w".to_string(),
284                        value: *w,
285                        min: 0.0,
286                        max: std::f64::consts::PI,
287                    });
288                }
289                Ok(())
290            }
291            DistortionModel::UCM { alpha } => {
292                if !alpha.is_finite() || !(0.0..=1.0).contains(alpha) {
293                    return Err(CameraModelError::ParameterOutOfRange {
294                        param: "alpha".to_string(),
295                        value: *alpha,
296                        min: 0.0,
297                        max: 1.0,
298                    });
299                }
300                Ok(())
301            }
302            DistortionModel::EUCM { alpha, beta } => {
303                if !alpha.is_finite() || !(0.0..=1.0).contains(alpha) {
304                    return Err(CameraModelError::ParameterOutOfRange {
305                        param: "alpha".to_string(),
306                        value: *alpha,
307                        min: 0.0,
308                        max: 1.0,
309                    });
310                }
311                if !beta.is_finite() || *beta <= 0.0 {
312                    return Err(CameraModelError::ParameterOutOfRange {
313                        param: "beta".to_string(),
314                        value: *beta,
315                        min: 0.0,
316                        max: f64::INFINITY,
317                    });
318                }
319                Ok(())
320            }
321            DistortionModel::DoubleSphere { xi, alpha } => {
322                if !xi.is_finite() || !(-1.0..=1.0).contains(xi) {
323                    return Err(CameraModelError::ParameterOutOfRange {
324                        param: "xi".to_string(),
325                        value: *xi,
326                        min: -1.0,
327                        max: 1.0,
328                    });
329                }
330                if !alpha.is_finite() || *alpha <= 0.0 || *alpha > 1.0 {
331                    return Err(CameraModelError::ParameterOutOfRange {
332                        param: "alpha".to_string(),
333                        value: *alpha,
334                        min: 0.0,
335                        max: 1.0,
336                    });
337                }
338                Ok(())
339            }
340            DistortionModel::FTheta { k1, k2, k3, k4 } => {
341                if !k1.is_finite() || *k1 <= 0.0 {
342                    return Err(CameraModelError::FocalLengthNotPositive { fx: *k1, fy: *k1 });
343                }
344                check_finite("k2", *k2)?;
345                check_finite("k3", *k3)?;
346                check_finite("k4", *k4)
347            }
348        }
349    }
350}
351
352/// Validates that a 3D point is in front of the camera.
353///
354/// A point must have positive z-coordinate (in camera frame) to be valid for projection.
355/// Points too close to the camera center (z ≈ 0) are rejected to avoid numerical instability.
356///
357/// # Arguments
358///
359/// * `z` - Z-coordinate of the point in camera frame (meters)
360///
361/// # Returns
362///
363/// - `Ok(())` if z > √ε (approximately 1.5e-8)
364/// - `Err(CameraModelError::PointAtCameraCenter)` if z is too small
365///
366/// # Mathematical Condition
367///
368/// The validation ensures the point is geometrically in front of the camera:
369/// ```text
370/// z > √ε ≈ 1.49 × 10^-8
371/// ```
372/// where ε is machine epsilon for f64 (≈ 2.22 × 10^-16).
373pub fn validate_point_in_front(z: f64) -> Result<(), CameraModelError> {
374    if z < f64::EPSILON.sqrt() {
375        return Err(CameraModelError::PointAtCameraCenter);
376    }
377    Ok(())
378}
379
380// Camera model modules
381
382pub mod bal_pinhole;
383pub mod double_sphere;
384pub mod eucm;
385pub mod fov;
386pub mod ftheta;
387pub mod kannala_brandt;
388pub mod pinhole;
389pub mod rad_tan;
390pub mod ucm;
391
392// Re-export camera types
393pub use bal_pinhole::BALPinholeCameraStrict;
394pub use double_sphere::DoubleSphereCamera;
395pub use eucm::EucmCamera;
396pub use fov::FovCamera;
397pub use ftheta::FThetaCamera;
398pub use kannala_brandt::KannalaBrandtCamera;
399pub use pinhole::PinholeCamera;
400pub use rad_tan::RadTanCamera;
401pub use ucm::UcmCamera;
402
403// Camera Model Trait
404
405/// Trait for camera projection models.
406///
407/// Defines the interface for camera models used in bundle adjustment and SfM.
408///
409/// # Type Parameters
410///
411/// - `INTRINSIC_DIM`: Number of intrinsic parameters
412/// - `IntrinsicJacobian`: Jacobian type for intrinsics (2 × INTRINSIC_DIM)
413/// - `PointJacobian`: Jacobian type for 3D point (2 × 3)
414pub trait CameraModel: Send + Sync + Clone + std::fmt::Debug + 'static {
415    /// Number of intrinsic parameters (compile-time constant).
416    const INTRINSIC_DIM: usize;
417
418    /// Jacobian type for intrinsics: 2 × INTRINSIC_DIM.
419    type IntrinsicJacobian: Clone
420        + std::fmt::Debug
421        + Default
422        + std::ops::Index<(usize, usize), Output = f64>;
423
424    /// Jacobian type for 3D point: 2 × 3.
425    type PointJacobian: Clone
426        + std::fmt::Debug
427        + Default
428        + std::ops::Mul<SMatrix<f64, 3, 6>, Output = SMatrix<f64, 2, 6>>
429        + std::ops::Mul<Matrix3<f64>, Output = SMatrix<f64, 2, 3>>
430        + std::ops::Index<(usize, usize), Output = f64>;
431
432    /// Projects a 3D point in camera coordinates to 2D image coordinates.
433    ///
434    /// The projection pipeline is: 3D point → normalized coordinates → distortion → pixel coordinates.
435    ///
436    /// # Mathematical Formula
437    ///
438    /// For a 3D point p = (x, y, z) in camera frame:
439    /// ```text
440    /// (u, v) = K · distort(x/z, y/z)
441    /// ```
442    /// where K is the intrinsic matrix [fx 0 cx; 0 fy cy; 0 0 1].
443    ///
444    /// # Arguments
445    ///
446    /// * `p_cam` - 3D point in camera coordinate frame (x, y, z) in meters
447    ///
448    /// # Returns
449    ///
450    /// - `Ok(Vector2)` - 2D image coordinates (u, v) in pixels if projection is valid
451    /// - `Err(CameraModelError)` - If point is behind camera, at center, or causes numerical issues
452    ///
453    /// # Errors
454    ///
455    /// - `PointBehindCamera` - If z ≤ GEOMETRIC_PRECISION (point behind or too close)
456    /// - `PointAtCameraCenter` - If point is too close to optical axis
457    /// - `DenominatorTooSmall` - If projection causes numerical instability
458    /// - `ProjectionOutOfBounds` - If projection falls outside valid image region
459    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError>;
460
461    /// Unprojects a 2D image point to a normalized 3D ray in camera frame.
462    ///
463    /// The inverse of projection: 2D pixel → undistortion → normalized 3D ray.
464    /// Some models use iterative methods (e.g., Newton-Raphson) for undistortion.
465    ///
466    /// # Mathematical Formula
467    ///
468    /// For a 2D point (u, v) in image coordinates:
469    /// ```text
470    /// (mx, my) = ((u - cx)/fx, (v - cy)/fy)
471    /// ray = normalize(undistort(mx, my))
472    /// ```
473    ///
474    /// # Arguments
475    ///
476    /// * `point_2d` - 2D point in image coordinates (u, v) in pixels
477    ///
478    /// # Returns
479    ///
480    /// - `Ok(Vector3)` - Normalized 3D ray direction (unit vector)
481    /// - `Err(CameraModelError)` - If point is outside valid image region or numerical issues occur
482    ///
483    /// # Errors
484    ///
485    /// - `PointOutsideImage` - If 2D point is outside valid unprojection region
486    /// - `NumericalError` - If iterative solver fails to converge
487    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError>;
488
489    /// Jacobian of projection with respect to 3D point coordinates.
490    ///
491    /// Returns the 2×3 matrix J where J[i,j] = ∂(u,v)[i] / ∂(x,y,z)[j].
492    ///
493    /// # Mathematical Formula
494    ///
495    /// ```text
496    /// J = ∂(u,v)/∂(x,y,z) = [ ∂u/∂x  ∂u/∂y  ∂u/∂z ]
497    ///                       [ ∂v/∂x  ∂v/∂y  ∂v/∂z ]
498    /// ```
499    ///
500    /// This Jacobian is used for:
501    /// - Structure optimization (adjusting 3D landmark positions)
502    /// - Triangulation refinement
503    /// - Bundle adjustment with landmark optimization
504    ///
505    /// # Arguments
506    ///
507    /// * `p_cam` - 3D point in camera coordinate frame (x, y, z) in meters
508    ///
509    /// # Returns
510    ///
511    /// 2×3 Jacobian matrix of projection w.r.t. point coordinates
512    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian;
513
514    /// Jacobian of projection w.r.t. camera pose (SE3).
515    ///
516    /// # Mathematical Derivation
517    ///
518    /// The pose is treated as a **world-to-camera** transform T_wc so that:
519    ///
520    /// ```text
521    /// p_cam = T_wc · p_world = R · p_world + t
522    /// ```
523    ///
524    /// ## Perturbation Model (Right Jacobian)
525    ///
526    /// We perturb the pose with a right perturbation:
527    ///
528    /// ```text
529    /// T'(δξ) = T · Exp(δξ),  δξ = [δρ; δθ] ∈ ℝ⁶
530    /// ```
531    ///
532    /// The perturbed camera-frame point becomes:
533    ///
534    /// ```text
535    /// p_cam' = R·(I + [δθ]×)·p_world + (t + R·δρ)
536    ///        = p_cam + R·δρ - R·[p_world]×·δθ
537    /// ```
538    ///
539    /// Taking derivatives:
540    ///
541    /// ```text
542    /// ∂p_cam/∂δρ = R
543    /// ∂p_cam/∂δθ = -R · [p_world]×
544    /// ```
545    ///
546    /// ## Return Value
547    ///
548    /// Returns a tuple `(J_pixel_point, J_point_pose)`:
549    /// - `J_pixel_point`: 2×3 Jacobian ∂uv/∂p_cam (from jacobian_point)
550    /// - `J_point_pose`: 3×6 Jacobian ∂p_cam/∂δξ = [R | -R·[p_world]×]
551    ///
552    /// The caller multiplies these to get the full 2×6 Jacobian ∂uv/∂δξ.
553    ///
554    /// ## SE(3) Conventions
555    ///
556    /// - **Pose meaning**: world-to-camera T_wc (p_cam = R·p_world + t)
557    /// - **Parameterization**: δξ = [δρ_x, δρ_y, δρ_z, δθ_x, δθ_y, δθ_z]
558    /// - **Perturbation**: Right perturbation T' = T · Exp(δξ)
559    /// - **Result**: ∂p_cam/∂δρ = R, ∂p_cam/∂δθ = -R·[p_world]×
560    fn jacobian_pose(
561        &self,
562        p_world: &Vector3<f64>,
563        pose: &SE3,
564    ) -> (Self::PointJacobian, SMatrix<f64, 3, 6>) {
565        // Transform world point to camera frame (world-to-camera convention)
566        let p_cam = pose.act(p_world, None, None);
567
568        // 2×3 projection Jacobian ∂(u,v)/∂(p_cam)
569        let d_uv_d_pcam = self.jacobian_point(&p_cam);
570
571        // 3×6 transformation Jacobian ∂(p_cam)/∂(pose) for right perturbation on T_wc:
572        //   ∂p_cam/∂δρ = R        (cols 0-2)
573        //   ∂p_cam/∂δθ = -R·[p_world]×  (cols 3-5)
574        let rotation = pose.rotation_so3().rotation_matrix();
575        let p_world_skew = skew_symmetric(p_world);
576
577        let d_pcam_d_pose = SMatrix::<f64, 3, 6>::from_fn(|r, c| {
578            if c < 3 {
579                rotation[(r, c)]
580            } else {
581                let col = c - 3;
582                -(0..3)
583                    .map(|k| rotation[(r, k)] * p_world_skew[(k, col)])
584                    .sum::<f64>()
585            }
586        });
587
588        (d_uv_d_pcam, d_pcam_d_pose)
589    }
590
591    /// Jacobian of projection with respect to intrinsic parameters.
592    ///
593    /// Returns the 2×N matrix where N = INTRINSIC_DIM (model-dependent).
594    ///
595    /// # Mathematical Formula
596    ///
597    /// ```text
598    /// J = ∂(u,v)/∂(params) = [ ∂u/∂fx  ∂u/∂fy  ∂u/∂cx  ∂u/∂cy  ∂u/∂k1  ... ]
599    ///                       [ ∂v/∂fx  ∂v/∂fy  ∂v/∂cx  ∂v/∂cy  ∂v/∂k1  ... ]
600    /// ```
601    ///
602    /// Intrinsic parameters vary by model:
603    /// - Pinhole: [fx, fy, cx, cy] (4 params)
604    /// - RadTan: [fx, fy, cx, cy, k1, k2, p1, p2, k3] (9 params)
605    /// - Kannala-Brandt: [fx, fy, cx, cy, k1, k2, k3, k4] (8 params)
606    ///
607    /// # Arguments
608    ///
609    /// * `p_cam` - 3D point in camera coordinate frame (x, y, z) in meters
610    ///
611    /// # Returns
612    ///
613    /// 2×N Jacobian matrix of projection w.r.t. intrinsic parameters
614    ///
615    /// # Usage
616    ///
617    /// Used in camera calibration and self-calibration bundle adjustment.
618    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian;
619
620    /// Batch projection of multiple 3D points to 2D image coordinates.
621    ///
622    /// Projects N 3D points efficiently. Invalid projections are marked with a sentinel
623    /// value (1e6, 1e6) rather than returning an error.
624    ///
625    /// # Arguments
626    ///
627    /// * `points_cam` - 3×N matrix where each column is a 3D point (x, y, z) in camera frame
628    ///
629    /// # Returns
630    ///
631    /// 2×N matrix where each column is the projected 2D point (u, v) in pixels.
632    /// Invalid projections are set to (1e6, 1e6).
633    ///
634    /// # Performance
635    ///
636    /// Default implementation iterates over points. Camera models may override
637    /// with vectorized implementations for better performance.
638    fn project_batch(&self, points_cam: &Matrix3xX<f64>) -> Matrix2xX<f64> {
639        let n = points_cam.ncols();
640        let mut result = Matrix2xX::zeros(n);
641        for i in 0..n {
642            let p = Vector3::new(points_cam[(0, i)], points_cam[(1, i)], points_cam[(2, i)]);
643            match self.project(&p) {
644                Ok(uv) => result.set_column(i, &uv),
645                Err(_) => result.set_column(i, &Vector2::new(1e6, 1e6)),
646            }
647        }
648        result
649    }
650
651    /// Validates camera intrinsic and distortion parameters.
652    ///
653    /// Performs comprehensive validation including:
654    /// - Focal lengths: must be positive and finite
655    /// - Principal point: must be finite
656    /// - Distortion coefficients: must be finite
657    /// - Model-specific constraints (e.g., UCM α ∈ [0,1], Double Sphere ξ ∈ [-1,1])
658    ///
659    /// # Validation Rules
660    ///
661    /// Common validations across all models:
662    /// - `fx > 0`, `fy > 0` (focal lengths must be positive)
663    /// - `fx`, `fy` finite (no NaN or Inf)
664    /// - `cx`, `cy` finite (principal point must be valid)
665    ///
666    /// Model-specific validations:
667    /// - **UCM**: α ∈ [0, 1]
668    /// - **EUCM**: α ∈ [0, 1], β > 0
669    /// - **Double Sphere**: ξ ∈ [-1, 1], α ∈ (0, 1]
670    /// - **FOV**: w ∈ (0, π]
671    ///
672    /// # Returns
673    ///
674    /// - `Ok(())` - All parameters satisfy validation rules
675    /// - `Err(CameraModelError)` - Specific error indicating which parameter is invalid
676    fn validate_params(&self) -> Result<(), CameraModelError>;
677
678    /// Returns the pinhole parameters (fx, fy, cx, cy).
679    ///
680    /// # Returns
681    ///
682    /// [`PinholeParams`] struct containing focal lengths and principal point.
683    fn get_pinhole_params(&self) -> PinholeParams;
684
685    /// Returns the distortion model and parameters.
686    ///
687    /// # Returns
688    ///
689    /// [`DistortionModel`] enum variant with model-specific parameters.
690    /// Returns `DistortionModel::None` for pinhole cameras without distortion.
691    fn get_distortion(&self) -> DistortionModel;
692
693    /// Returns the camera model name identifier.
694    ///
695    /// # Returns
696    ///
697    /// Static string identifier for the camera model type:
698    /// - `"pinhole"`, `"rad_tan"`, `"kannala_brandt"`, etc.
699    fn get_model_name(&self) -> &'static str;
700}
701
702/// Compute skew-symmetric matrix from a 3D vector.
703///
704/// Returns the cross-product matrix [v]× such that [v]× w = v × w.
705///
706/// # Mathematical Form
707///
708/// ```text
709/// [  0  -vz   vy ]
710/// [ vz    0  -vx ]
711/// [-vy   vx    0 ]
712/// ```
713#[inline]
714pub(crate) fn skew_symmetric(v: &Vector3<f64>) -> Matrix3<f64> {
715    Matrix3::new(0.0, -v.z, v.y, v.z, 0.0, -v.x, -v.y, v.x, 0.0)
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::pinhole::PinholeCamera;
722    use apex_manifolds::LieGroup;
723    use apex_manifolds::se3::{SE3, SE3Tangent};
724
725    type TestResult = Result<(), Box<dyn std::error::Error>>;
726
727    /// Canonical test for the default `jacobian_pose` implementation (right perturbation).
728    ///
729    /// Since `jacobian_pose` has a single default implementation shared by all models
730    /// except BAL, we test it once here using `PinholeCamera` as a representative model.
731    #[test]
732    fn test_jacobian_pose_numerical() -> TestResult {
733        let pinhole = PinholeParams::new(500.0, 500.0, 320.0, 240.0)?;
734        let camera = PinholeCamera::new(pinhole, DistortionModel::None)?;
735        let pose = SE3::from_translation_euler(0.1, -0.2, 0.3, 0.05, -0.1, 0.15);
736        let p_world = Vector3::new(1.0, 0.5, 3.0);
737
738        let (d_uv_d_pcam, d_pcam_d_pose) = camera.jacobian_pose(&p_world, &pose);
739        let d_uv_d_pose = d_uv_d_pcam * d_pcam_d_pose;
740
741        let eps = NUMERICAL_DERIVATIVE_EPS;
742
743        for i in 0..6 {
744            let mut d = [0.0f64; 6];
745            d[i] = eps;
746            let delta_plus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
747            d[i] = -eps;
748            let delta_minus = SE3Tangent::from_components(d[0], d[1], d[2], d[3], d[4], d[5]);
749
750            // Right perturbation on T_wc: pose' = pose · Exp(δ)
751            let p_cam_plus = pose.plus(&delta_plus, None, None).act(&p_world, None, None);
752            let p_cam_minus = pose
753                .plus(&delta_minus, None, None)
754                .act(&p_world, None, None);
755
756            let uv_plus = camera.project(&p_cam_plus)?;
757            let uv_minus = camera.project(&p_cam_minus)?;
758
759            let num_deriv = (uv_plus - uv_minus) / (2.0 * eps);
760
761            for r in 0..2 {
762                let analytical = d_uv_d_pose[(r, i)];
763                let numerical = num_deriv[r];
764                let rel_err = (analytical - numerical).abs() / (1.0 + numerical.abs());
765                assert!(
766                    rel_err < JACOBIAN_TEST_TOLERANCE,
767                    "jacobian_pose mismatch at ({r},{i}): analytical={analytical}, numerical={numerical}"
768                );
769            }
770        }
771        Ok(())
772    }
773
774    #[test]
775    fn test_skew_symmetric() {
776        let v = Vector3::new(1.0, 2.0, 3.0);
777        let skew = skew_symmetric(&v);
778
779        assert_eq!(skew[(0, 0)], 0.0);
780        assert_eq!(skew[(1, 1)], 0.0);
781        assert_eq!(skew[(2, 2)], 0.0);
782
783        assert_eq!(skew[(0, 1)], -skew[(1, 0)]);
784        assert_eq!(skew[(0, 2)], -skew[(2, 0)]);
785        assert_eq!(skew[(1, 2)], -skew[(2, 1)]);
786
787        assert_eq!(skew[(0, 1)], -v.z);
788        assert_eq!(skew[(0, 2)], v.y);
789        assert_eq!(skew[(1, 0)], v.z);
790        assert_eq!(skew[(1, 2)], -v.x);
791        assert_eq!(skew[(2, 0)], -v.y);
792        assert_eq!(skew[(2, 1)], v.x);
793
794        let w = Vector3::new(4.0, 5.0, 6.0);
795        let cross_via_skew = skew * w;
796        let cross_direct = v.cross(&w);
797        assert!((cross_via_skew - cross_direct).norm() < 1e-10);
798    }
799
800    #[test]
801    fn test_pinhole_validate_negative_focal_length() {
802        let result = PinholeParams::new(-1.0, 300.0, 320.0, 240.0);
803        assert!(result.is_err(), "negative fx should fail validation");
804    }
805
806    #[test]
807    fn test_pinhole_validate_zero_focal_length() {
808        let result = PinholeParams::new(0.0, 300.0, 320.0, 240.0);
809        assert!(result.is_err(), "fx = 0 should fail validation");
810    }
811
812    #[test]
813    fn test_pinhole_validate_nan_focal_length() {
814        let result = PinholeParams::new(f64::NAN, 300.0, 320.0, 240.0);
815        assert!(result.is_err(), "NaN fx should fail validation");
816    }
817
818    #[test]
819    fn test_pinhole_validate_infinite_focal_length() {
820        // Inf is > 0 so passes the first check, but fails the is_finite() check
821        let result = PinholeParams::new(f64::INFINITY, 300.0, 320.0, 240.0);
822        assert!(result.is_err(), "Inf fx should fail validation");
823    }
824
825    #[test]
826    fn test_pinhole_validate_nan_principal_point() {
827        let result = PinholeParams::new(300.0, 300.0, f64::NAN, 240.0);
828        assert!(result.is_err(), "NaN cx should fail validation");
829    }
830
831    #[test]
832    fn test_distortion_none_is_valid() {
833        assert!(DistortionModel::None.validate().is_ok());
834    }
835
836    #[test]
837    fn test_distortion_radial_nan_fails() {
838        let d = DistortionModel::Radial {
839            k1: f64::NAN,
840            k2: 0.0,
841        };
842        assert!(d.validate().is_err(), "NaN k1 should fail");
843    }
844
845    #[test]
846    fn test_distortion_brown_conrady_nan_fails() {
847        let d = DistortionModel::BrownConrady {
848            k1: 0.0,
849            k2: f64::NAN,
850            p1: 0.0,
851            p2: 0.0,
852            k3: 0.0,
853        };
854        assert!(d.validate().is_err(), "NaN k2 should fail");
855    }
856
857    #[test]
858    fn test_distortion_kannala_brandt_nan_fails() {
859        let d = DistortionModel::KannalaBrandt {
860            k1: 0.0,
861            k2: 0.0,
862            k3: f64::NAN,
863            k4: 0.0,
864        };
865        assert!(d.validate().is_err(), "NaN k3 should fail");
866    }
867
868    #[test]
869    fn test_distortion_fov_invalid_w_zero() {
870        let d = DistortionModel::FOV { w: 0.0 };
871        assert!(d.validate().is_err(), "w = 0 should fail (must be > 0)");
872    }
873
874    #[test]
875    fn test_distortion_fov_invalid_w_too_large() {
876        let d = DistortionModel::FOV {
877            w: std::f64::consts::PI + 0.1,
878        };
879        assert!(d.validate().is_err(), "w > π should fail");
880    }
881
882    #[test]
883    fn test_distortion_fov_valid() {
884        let d = DistortionModel::FOV { w: 1.0 };
885        assert!(d.validate().is_ok(), "w = 1.0 should be valid");
886    }
887
888    #[test]
889    fn test_distortion_ucm_alpha_out_of_range() {
890        let d = DistortionModel::UCM { alpha: 1.5 };
891        assert!(d.validate().is_err(), "alpha > 1 should fail for UCM");
892    }
893
894    #[test]
895    fn test_distortion_ucm_alpha_valid() {
896        let d = DistortionModel::UCM { alpha: 0.5 };
897        assert!(d.validate().is_ok());
898    }
899
900    #[test]
901    fn test_distortion_eucm_alpha_out_of_range() {
902        let d = DistortionModel::EUCM {
903            alpha: 1.5,
904            beta: 1.0,
905        };
906        assert!(d.validate().is_err(), "alpha > 1 should fail for EUCM");
907    }
908
909    #[test]
910    fn test_distortion_eucm_beta_nonpositive() {
911        let d = DistortionModel::EUCM {
912            alpha: 0.5,
913            beta: -1.0,
914        };
915        assert!(d.validate().is_err(), "beta <= 0 should fail for EUCM");
916    }
917
918    #[test]
919    fn test_distortion_double_sphere_xi_out_of_range() {
920        let d = DistortionModel::DoubleSphere {
921            xi: 2.0,
922            alpha: 0.6,
923        };
924        assert!(d.validate().is_err(), "xi > 1 should fail");
925    }
926
927    #[test]
928    fn test_distortion_double_sphere_alpha_invalid() {
929        let d = DistortionModel::DoubleSphere {
930            xi: 0.0,
931            alpha: 0.0,
932        };
933        assert!(d.validate().is_err(), "alpha = 0 should fail");
934    }
935
936    #[test]
937    fn test_validate_point_in_front_valid_z() {
938        assert!(
939            validate_point_in_front(1.0).is_ok(),
940            "z = 1.0 should be valid"
941        );
942    }
943
944    #[test]
945    fn test_validate_point_in_front_behind_camera() {
946        assert!(
947            validate_point_in_front(-1.0).is_err(),
948            "z = -1.0 should fail"
949        );
950    }
951
952    #[test]
953    fn test_validate_point_in_front_at_center() {
954        // z = 0 < sqrt(EPSILON) ≈ 1.49e-8, should fail
955        assert!(validate_point_in_front(0.0).is_err(), "z = 0 should fail");
956    }
957
958    #[test]
959    fn test_project_batch_default_impl() -> TestResult {
960        let pinhole = PinholeParams::new(500.0, 500.0, 320.0, 240.0)?;
961        let camera = PinholeCamera::new(pinhole, DistortionModel::None)?;
962
963        // 3 valid points + 1 invalid (behind camera)
964        let pts = Matrix3xX::from_columns(&[
965            Vector3::new(0.0, 0.0, 1.0),
966            Vector3::new(0.1, 0.2, 1.0),
967            Vector3::new(-0.1, 0.1, 2.0),
968            Vector3::new(0.0, 0.0, -1.0), // behind camera → sentinel (1e6, 1e6)
969        ]);
970
971        let result = camera.project_batch(&pts);
972        assert_eq!(result.ncols(), 4);
973        assert!(result[(0, 0)].is_finite());
974        assert!(result[(1, 0)].is_finite());
975        assert!(
976            (result[(0, 3)] - 1e6).abs() < 1.0,
977            "Invalid projection should be sentinel 1e6, got {}",
978            result[(0, 3)]
979        );
980        assert!(
981            (result[(1, 3)] - 1e6).abs() < 1.0,
982            "Invalid projection should be sentinel 1e6, got {}",
983            result[(1, 3)]
984        );
985        Ok(())
986    }
987
988    #[test]
989    fn test_camera_model_error_display_focal_length_not_positive() {
990        let e = CameraModelError::FocalLengthNotPositive {
991            fx: -1.0,
992            fy: 300.0,
993        };
994        let s = format!("{e}");
995        assert!(
996            s.contains("fx") && s.contains("-1"),
997            "Display should include parameter values: {s}"
998        );
999    }
1000
1001    #[test]
1002    fn test_camera_model_error_display_point_behind_camera() {
1003        let e = CameraModelError::PointBehindCamera {
1004            z: -0.5,
1005            min_z: 1e-6,
1006        };
1007        let s = format!("{e}");
1008        assert!(s.contains("z="), "Display should include z: {s}");
1009    }
1010
1011    #[test]
1012    fn test_camera_model_error_display_parameter_out_of_range() {
1013        let e = CameraModelError::ParameterOutOfRange {
1014            param: "alpha".to_string(),
1015            value: 1.5,
1016            min: 0.0,
1017            max: 1.0,
1018        };
1019        let s = format!("{e}");
1020        assert!(
1021            s.contains("alpha") && s.contains("1.5"),
1022            "Display should include param and value: {s}"
1023        );
1024    }
1025}