1use apex_manifolds::LieGroup;
9use apex_manifolds::se3::SE3;
10use nalgebra::{Matrix2xX, Matrix3, Matrix3xX, SMatrix, Vector2, Vector3};
11
12pub const GEOMETRIC_PRECISION: f64 = 1e-6;
14
15pub const NUMERICAL_DERIVATIVE_EPS: f64 = 1e-7;
17
18pub const JACOBIAN_TEST_TOLERANCE: f64 = 1e-5;
20
21pub const PROJECTION_TEST_TOLERANCE: f64 = 1e-10;
23
24pub const MIN_DEPTH: f64 = 1e-6;
26
27pub const CONVERGENCE_THRESHOLD: f64 = 1e-6;
29
30#[derive(thiserror::Error, Debug)]
32pub enum CameraModelError {
33 #[error("Focal length must be positive: fx={fx}, fy={fy}")]
35 FocalLengthNotPositive { fx: f64, fy: f64 },
36
37 #[error("Focal length must be finite: fx={fx}, fy={fy}")]
39 FocalLengthNotFinite { fx: f64, fy: f64 },
40
41 #[error("Principal point must be finite: cx={cx}, cy={cy}")]
43 PrincipalPointNotFinite { cx: f64, cy: f64 },
44
45 #[error("Distortion coefficient '{name}' must be finite, got {value}")]
47 DistortionNotFinite { name: String, value: f64 },
48
49 #[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 #[error("Point behind camera: z={z} (must be > {min_z})")]
60 PointBehindCamera { z: f64, min_z: f64 },
61
62 #[error("Point at camera center: 3D point too close to optical axis")]
64 PointAtCameraCenter,
65
66 #[error("Projection denominator too small: denom={denom} (threshold={threshold})")]
68 DenominatorTooSmall { denom: f64, threshold: f64 },
69
70 #[error("Projection outside valid image region")]
72 ProjectionOutOfBounds,
73
74 #[error("Point outside image bounds: ({x}, {y}) not in valid region")]
76 PointOutsideImage { x: f64, y: f64 },
77
78 #[error("Numerical error in {operation}: {details}")]
80 NumericalError { operation: String, details: String },
81
82 #[error("Invalid camera parameters: {0}")]
84 InvalidParams(String),
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct PinholeParams {
90 pub fx: f64,
92 pub fy: f64,
94 pub cx: f64,
96 pub cy: f64,
98}
99
100impl PinholeParams {
101 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 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#[derive(Debug, Clone, Copy, PartialEq)]
135pub enum DistortionModel {
136 None,
138
139 Radial { k1: f64, k2: f64 },
141
142 BrownConrady {
144 k1: f64,
145 k2: f64,
146 p1: f64,
147 p2: f64,
148 k3: f64,
149 },
150
151 KannalaBrandt { k1: f64, k2: f64, k3: f64, k4: f64 },
153
154 FOV { w: f64 },
156
157 UCM { alpha: f64 },
159
160 EUCM { alpha: f64, beta: f64 },
162
163 DoubleSphere { xi: f64, alpha: f64 },
165
166 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 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
274pub 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
285pub 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
297pub 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
308pub trait CameraModel: Send + Sync + Clone + std::fmt::Debug + 'static {
320 const INTRINSIC_DIM: usize;
322
323 type IntrinsicJacobian: Clone
325 + std::fmt::Debug
326 + Default
327 + std::ops::Index<(usize, usize), Output = f64>;
328
329 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 fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError>;
346
347 fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError>;
356
357 fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian;
361
362 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 fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian;
399
400 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 fn validate_params(&self) -> Result<(), CameraModelError>;
420
421 fn get_pinhole_params(&self) -> PinholeParams;
423
424 fn get_distortion(&self) -> DistortionModel;
426
427 fn get_model_name(&self) -> &'static str;
429}
430
431#[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 #[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 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 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 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 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), ]);
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}