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}