Skip to main content

apex_camera_models/
ucm.rs

1//! Unified Camera Model (UCM)
2//!
3//! A generic camera model for catadioptric and fisheye cameras using
4//! a single parameter α for projection onto a unit sphere.
5//!
6//! # Mathematical Model
7//!
8//! ## Projection (3D → 2D)
9//!
10//! For a 3D point p = (x, y, z) in camera coordinates:
11//!
12//! ```text
13//! d = √(x² + y² + z²)
14//! denom = α·d + (1-α)·z
15//! u = fx · (x/denom) + cx
16//! v = fy · (y/denom) + cy
17//! ```
18//!
19//! where α is the projection parameter (typically α ∈ [0, 1]).
20//!
21//! ## Unprojection (2D → 3D)
22//!
23//! Algebraic solution using the UCM inverse equations.
24//!
25//! # Parameters
26//!
27//! - **Intrinsics**: fx, fy, cx, cy
28//! - **Distortion**: α (projection parameter) (5 parameters total)
29//!
30//! # Use Cases
31//!
32//! - Catadioptric systems (mirror-based omnidirectional cameras)
33//! - Fisheye lenses
34//! - Wide-angle cameras
35//!
36//! # References
37//!
38//! - Geyer & Daniilidis, "A Unifying Theory for Central Panoramic Systems"
39
40use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
41use nalgebra::{DVector, SMatrix, Vector2, Vector3};
42
43/// Unified Camera Model with 5 parameters.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct UcmCamera {
46    pub pinhole: PinholeParams,
47    pub distortion: DistortionModel,
48}
49
50impl UcmCamera {
51    /// Create a new Unified Camera Model (UCM) camera.
52    ///
53    /// # Arguments
54    ///
55    /// * `pinhole` - Pinhole parameters (fx, fy, cx, cy).
56    /// * `distortion` - MUST be [`DistortionModel::UCM`] with `alpha`.
57    ///
58    /// # Returns
59    ///
60    /// Returns a new `UcmCamera` instance if the distortion model matches.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not [`DistortionModel::UCM`].
65    pub fn new(
66        pinhole: PinholeParams,
67        distortion: DistortionModel,
68    ) -> Result<Self, CameraModelError> {
69        let camera = Self {
70            pinhole,
71            distortion,
72        };
73        camera.validate_params()?;
74        Ok(camera)
75    }
76
77    /// Helper method to extract distortion parameter.
78    ///
79    /// # Returns
80    ///
81    /// Returns the `alpha` parameter for UCM.
82    /// If the distortion model is incorrect (which shouldn't happen for valid instances), returns `0.0`.
83    fn distortion_params(&self) -> f64 {
84        match self.distortion {
85            DistortionModel::UCM { alpha } => alpha,
86            _ => 0.0,
87        }
88    }
89
90    /// Checks the geometric condition for a valid projection.
91    ///
92    /// # Arguments
93    ///
94    /// * `z` - The z-coordinate of the point.
95    /// * `d` - The Euclidean distance `√(x² + y² + z²)`.
96    ///
97    /// # Returns
98    ///
99    /// Returns `true` if `z > -w * d`, where `w` depends on `alpha`.
100    fn check_projection_condition(&self, z: f64, d: f64) -> bool {
101        let alpha = self.distortion_params();
102        let w = if alpha <= 0.5 {
103            alpha / (1.0 - alpha)
104        } else {
105            (1.0 - alpha) / alpha
106        };
107        z > -w * d
108    }
109
110    /// Checks the geometric condition for a valid unprojection.
111    ///
112    /// # Arguments
113    ///
114    /// * `r_squared` - The squared radius in normalized image coordinates.
115    ///
116    /// # Returns
117    ///
118    /// Returns `true` if the point satisfies the unprojection condition.
119    fn check_unprojection_condition(&self, r_squared: f64) -> bool {
120        let alpha = self.distortion_params();
121        if alpha > 0.5 {
122            let gamma = 1.0 - alpha;
123            r_squared <= gamma * gamma / (2.0 * alpha - 1.0)
124        } else {
125            true
126        }
127    }
128
129    /// Performs linear estimation to initialize the alpha parameter from point correspondences.
130    ///
131    /// This method estimates the `alpha` parameter using a linear least squares approach
132    /// given 3D-2D point correspondences. It assumes the intrinsic parameters (fx, fy, cx, cy)
133    /// are already set.
134    ///
135    /// # Arguments
136    ///
137    /// * `points_3d`: Matrix3xX<f64> - 3D points in camera coordinates (each column is a point)
138    /// * `points_2d`: Matrix2xX<f64> - Corresponding 2D points in image coordinates
139    ///
140    /// # Returns
141    ///
142    /// Returns `Ok(())` on success or a `CameraModelError` if the estimation fails.
143    pub fn linear_estimation(
144        &mut self,
145        points_3d: &nalgebra::Matrix3xX<f64>,
146        points_2d: &nalgebra::Matrix2xX<f64>,
147    ) -> Result<(), CameraModelError> {
148        // Check if the number of 2D and 3D points match
149        if points_2d.ncols() != points_3d.ncols() {
150            return Err(CameraModelError::InvalidParams(
151                "Number of 2D and 3D points must match".to_string(),
152            ));
153        }
154
155        // Set up the linear system to solve for alpha
156        let num_points = points_2d.ncols();
157        let mut a = nalgebra::DMatrix::zeros(num_points * 2, 1);
158        let mut b = nalgebra::DVector::zeros(num_points * 2);
159
160        for i in 0..num_points {
161            let x = points_3d[(0, i)];
162            let y = points_3d[(1, i)];
163            let z = points_3d[(2, i)];
164            let u = points_2d[(0, i)];
165            let v = points_2d[(1, i)];
166
167            let d = (x * x + y * y + z * z).sqrt();
168            let u_cx = u - self.pinhole.cx;
169            let v_cy = v - self.pinhole.cy;
170
171            a[(i * 2, 0)] = u_cx * (d - z);
172            a[(i * 2 + 1, 0)] = v_cy * (d - z);
173
174            b[i * 2] = (self.pinhole.fx * x) - (u_cx * z);
175            b[i * 2 + 1] = (self.pinhole.fy * y) - (v_cy * z);
176        }
177
178        // Solve the linear system using SVD
179        let svd = a.svd(true, true);
180        let alpha = match svd.solve(&b, 1e-10) {
181            Ok(sol) => sol[0],
182            Err(err_msg) => {
183                return Err(CameraModelError::NumericalError {
184                    operation: "svd_solve".to_string(),
185                    details: err_msg.to_string(),
186                });
187            }
188        };
189
190        self.distortion = DistortionModel::UCM { alpha };
191
192        self.validate_params()?;
193
194        Ok(())
195    }
196}
197
198/// Convert camera to dynamic vector of intrinsic parameters.
199///
200/// # Layout
201///
202/// The parameters are ordered as: [fx, fy, cx, cy, alpha]
203impl From<&UcmCamera> for DVector<f64> {
204    fn from(camera: &UcmCamera) -> Self {
205        let alpha = camera.distortion_params();
206        DVector::from_vec(vec![
207            camera.pinhole.fx,
208            camera.pinhole.fy,
209            camera.pinhole.cx,
210            camera.pinhole.cy,
211            alpha,
212        ])
213    }
214}
215
216/// Convert camera to fixed-size array of intrinsic parameters.
217///
218/// # Layout
219///
220/// The parameters are ordered as: [fx, fy, cx, cy, alpha]
221impl From<&UcmCamera> for [f64; 5] {
222    fn from(camera: &UcmCamera) -> Self {
223        let alpha = camera.distortion_params();
224        [
225            camera.pinhole.fx,
226            camera.pinhole.fy,
227            camera.pinhole.cx,
228            camera.pinhole.cy,
229            alpha,
230        ]
231    }
232}
233
234/// Create camera from slice of intrinsic parameters.
235///
236/// # Layout
237///
238/// Expected parameter order: [fx, fy, cx, cy, alpha]
239///
240/// # Panics
241///
242/// Panics if the slice has fewer than 5 elements.
243impl From<&[f64]> for UcmCamera {
244    fn from(params: &[f64]) -> Self {
245        assert!(
246            params.len() >= 5,
247            "UcmCamera requires at least 5 parameters, got {}",
248            params.len()
249        );
250        Self {
251            pinhole: PinholeParams {
252                fx: params[0],
253                fy: params[1],
254                cx: params[2],
255                cy: params[3],
256            },
257            distortion: DistortionModel::UCM { alpha: params[4] },
258        }
259    }
260}
261
262/// Create camera from fixed-size array of intrinsic parameters.
263///
264/// # Layout
265///
266/// Expected parameter order: [fx, fy, cx, cy, alpha]
267impl From<[f64; 5]> for UcmCamera {
268    fn from(params: [f64; 5]) -> Self {
269        Self {
270            pinhole: PinholeParams {
271                fx: params[0],
272                fy: params[1],
273                cx: params[2],
274                cy: params[3],
275            },
276            distortion: DistortionModel::UCM { alpha: params[4] },
277        }
278    }
279}
280
281/// Creates a `UcmCamera` from a parameter slice with validation.
282///
283/// Unlike `From<&[f64]>`, this constructor validates all parameters
284/// and returns a `Result` instead of panicking on invalid input.
285///
286/// # Errors
287///
288/// Returns `CameraModelError::InvalidParams` if fewer than 5 parameters are provided.
289/// Returns validation errors if focal lengths are non-positive or alpha is out of range.
290pub fn try_from_params(params: &[f64]) -> Result<UcmCamera, CameraModelError> {
291    if params.len() < 5 {
292        return Err(CameraModelError::InvalidParams(format!(
293            "UcmCamera requires at least 5 parameters, got {}",
294            params.len()
295        )));
296    }
297    let camera = UcmCamera::from(params);
298    camera.validate_params()?;
299    Ok(camera)
300}
301
302impl CameraModel for UcmCamera {
303    const INTRINSIC_DIM: usize = 5;
304    type IntrinsicJacobian = SMatrix<f64, 2, 5>;
305    type PointJacobian = SMatrix<f64, 2, 3>;
306
307    /// Projects a 3D point to 2D image coordinates.
308    ///
309    /// # Mathematical Formula
310    ///
311    /// ```text
312    /// d = √(x² + y² + z²)
313    /// denom = α·d + (1-α)·z
314    /// u = fx · (x/denom) + cx
315    /// v = fy · (y/denom) + cy
316    /// ```
317    ///
318    /// # Arguments
319    ///
320    /// * `p_cam` - 3D point in camera coordinate frame.
321    ///
322    /// # Returns
323    ///
324    /// - `Ok(uv)` - 2D image coordinates if valid.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`CameraModelError::PointAtCameraCenter`] if the projection condition fails or the denominator is too small.
329    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
330        let x = p_cam[0];
331        let y = p_cam[1];
332        let z = p_cam[2];
333
334        let d = (x * x + y * y + z * z).sqrt();
335        let alpha = self.distortion_params();
336        let denom = alpha * d + (1.0 - alpha) * z;
337
338        // Check projection validity
339        if !self.check_projection_condition(z, d) {
340            return Err(CameraModelError::PointBehindCamera {
341                z,
342                min_z: crate::GEOMETRIC_PRECISION,
343            });
344        }
345
346        if denom < crate::GEOMETRIC_PRECISION {
347            return Err(CameraModelError::DenominatorTooSmall {
348                denom,
349                threshold: crate::GEOMETRIC_PRECISION,
350            });
351        }
352
353        Ok(Vector2::new(
354            self.pinhole.fx * x / denom + self.pinhole.cx,
355            self.pinhole.fy * y / denom + self.pinhole.cy,
356        ))
357    }
358
359    /// Unprojects a 2D image point to a 3D ray.
360    ///
361    /// # Algorithm
362    ///
363    /// Algebraic solution for UCM inverse projection.
364    ///
365    /// # Arguments
366    ///
367    /// * `point_2d` - 2D point in image coordinates.
368    ///
369    /// # Returns
370    ///
371    /// - `Ok(ray)` - Normalized 3D ray direction.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection condition fails.
376    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
377        let u = point_2d.x;
378        let v = point_2d.y;
379        let alpha = self.distortion_params();
380        let gamma = 1.0 - alpha;
381        let xi = alpha / gamma;
382        let mx = (u - self.pinhole.cx) / self.pinhole.fx * gamma;
383        let my = (v - self.pinhole.cy) / self.pinhole.fy * gamma;
384
385        let r_squared = mx * mx + my * my;
386
387        // Check unprojection condition
388        if !self.check_unprojection_condition(r_squared) {
389            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
390        }
391
392        let num = xi + (1.0 + (1.0 - xi * xi) * r_squared).sqrt();
393        let denom = 1.0 - r_squared;
394
395        if denom < crate::GEOMETRIC_PRECISION {
396            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
397        }
398
399        let coeff = num / denom;
400
401        let point3d = Vector3::new(coeff * mx, coeff * my, coeff) - Vector3::new(0.0, 0.0, xi);
402
403        Ok(point3d.normalize())
404    }
405
406    /// Checks if a 3D point can be validly projected.
407    ///
408    /// Computes the Jacobian of the projection function with respect to the 3D point in camera frame.
409    ///
410    /// # Mathematical Derivation
411    ///
412    /// The UCM projection model maps a 3D point p = (x, y, z) to 2D pixel coordinates (u, v).
413    ///
414    /// Projection:
415    /// ```text
416    /// ρ = √(x² + y² + z²)
417    /// D = α·ρ + (1-α)·z
418    /// u = fx · (x/D) + cx
419    /// v = fy · (y/D) + cy
420    /// ```
421    ///
422    /// Jacobian:
423    ///
424    /// Derivatives of D with respect to (x, y, z):
425    /// ```text
426    /// ∂D/∂x = α · (x/ρ)
427    /// ∂D/∂y = α · (y/ρ)
428    /// ∂D/∂z = α · (z/ρ) + (1-α)
429    /// ```
430    ///
431    /// Using the quotient rule for u = fx · (x/D):
432    /// ```text
433    /// ∂u/∂x = fx · (D - x·∂D/∂x) / D²
434    /// ∂u/∂y = fx · (-x·∂D/∂y) / D²
435    /// ∂u/∂z = fx · (-x·∂D/∂z) / D²
436    /// ```
437    ///
438    /// Similarly for v:
439    /// ```text
440    /// ∂v/∂x = fy · (-y·∂D/∂x) / D²
441    /// ∂v/∂y = fy · (D - y·∂D/∂y) / D²
442    /// ∂v/∂z = fy · (-y·∂D/∂z) / D²
443    /// ```
444    ///
445    /// # Arguments
446    ///
447    /// * `p_cam` - 3D point in camera coordinate frame.
448    ///
449    /// # Returns
450    ///
451    /// Returns the 2x3 Jacobian matrix.
452    ///
453    /// # References
454    ///
455    /// - Geyer & Daniilidis, "A Unifying Theory for Central Panoramic Systems", ICCV 2000
456    /// - Mei & Rives, "Single View Point Omnidirectional Camera Calibration from Planar Grids", ICRA 2007
457    ///
458    /// # Verification
459    ///
460    /// This Jacobian is verified against numerical differentiation in tests.
461    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
462        let x = p_cam[0];
463        let y = p_cam[1];
464        let z = p_cam[2];
465
466        let rho = (x * x + y * y + z * z).sqrt();
467        let alpha = self.distortion_params();
468
469        // Denominator D = alpha * rho + (1 - alpha) * z
470        // Partial derivatives of D:
471        // ∂D/∂x = alpha * x / rho
472        // ∂D/∂y = alpha * y / rho
473        // ∂D/∂z = alpha * z / rho + (1 - alpha)
474
475        let d_denom_dx = alpha * x / rho;
476        let d_denom_dy = alpha * y / rho;
477        let d_denom_dz = alpha * z / rho + (1.0 - alpha);
478
479        let denom = alpha * rho + (1.0 - alpha) * z;
480
481        // u = fx * x / denom + cx
482        // v = fy * y / denom + cy
483
484        // ∂u/∂x = fx * (denom - x * ∂D/∂x) / denom²
485        // ∂u/∂y = fx * (-x * ∂D/∂y) / denom²
486        // ∂u/∂z = fx * (-x * ∂D/∂z) / denom²
487
488        // ∂v/∂x = fy * (-y * ∂D/∂x) / denom²
489        // ∂v/∂y = fy * (denom - y * ∂D/∂y) / denom²
490        // ∂v/∂z = fy * (-y * ∂D/∂z) / denom²
491
492        let denom2 = denom * denom;
493
494        let mut jac = SMatrix::<f64, 2, 3>::zeros();
495
496        jac[(0, 0)] = self.pinhole.fx * (denom - x * d_denom_dx) / denom2;
497        jac[(0, 1)] = self.pinhole.fx * (-x * d_denom_dy) / denom2;
498        jac[(0, 2)] = self.pinhole.fx * (-x * d_denom_dz) / denom2;
499
500        jac[(1, 0)] = self.pinhole.fy * (-y * d_denom_dx) / denom2;
501        jac[(1, 1)] = self.pinhole.fy * (denom - y * d_denom_dy) / denom2;
502        jac[(1, 2)] = self.pinhole.fy * (-y * d_denom_dz) / denom2;
503
504        jac
505    }
506
507    /// Computes the Jacobian of the projection function with respect to intrinsic parameters.
508    ///
509    /// # Mathematical Derivation
510    ///
511    /// The UCM camera has 5 intrinsic parameters: θ = [fx, fy, cx, cy, α]
512    ///
513    /// ## Projection Model
514    ///
515    /// ```text
516    /// u = fx · (x/D) + cx
517    /// v = fy · (y/D) + cy
518    /// ```
519    ///
520    /// Where D = α·ρ + (1-α)·z and ρ = √(x²+y²+z²)
521    ///
522    /// ## Jacobian Structure
523    ///
524    /// Linear parameters (fx, fy, cx, cy):
525    /// ```text
526    /// ∂u/∂fx = x/D,  ∂u/∂fy = 0,    ∂u/∂cx = 1,    ∂u/∂cy = 0
527    /// ∂v/∂fx = 0,    ∂v/∂fy = y/D,  ∂v/∂cx = 0,    ∂v/∂cy = 1
528    /// ```
529    ///
530    /// Projection parameter α:
531    /// ```text
532    /// ∂D/∂α = ρ - z
533    /// ∂u/∂α = -fx · (x/D²) · (ρ - z)
534    /// ∂v/∂α = -fy · (y/D²) · (ρ - z)
535    /// ```
536    ///
537    /// # Arguments
538    ///
539    /// * `p_cam` - 3D point in camera coordinate frame.
540    ///
541    /// # Returns
542    ///
543    /// Returns the 2x5 Intrinsic Jacobian matrix.
544    ///
545    /// # References
546    ///
547    /// - Geyer & Daniilidis, "A Unifying Theory for Central Panoramic Systems", ICCV 2000
548    ///
549    /// # Verification
550    ///
551    /// This Jacobian is verified against numerical differentiation in tests.
552    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
553        let x = p_cam[0];
554        let y = p_cam[1];
555        let z = p_cam[2];
556
557        let rho = (x * x + y * y + z * z).sqrt();
558        let alpha = self.distortion_params();
559        let denom = alpha * rho + (1.0 - alpha) * z;
560
561        let x_norm = x / denom;
562        let y_norm = y / denom;
563
564        let u_cx = self.pinhole.fx * x_norm;
565        let v_cy = self.pinhole.fy * y_norm;
566
567        let mut jac = SMatrix::<f64, 2, 5>::zeros();
568
569        // ∂u/∂fx = x / denom
570        jac[(0, 0)] = x_norm;
571
572        // ∂v/∂fy = y / denom
573        jac[(1, 1)] = y_norm;
574
575        // ∂u/∂cx = 1
576        jac[(0, 2)] = 1.0;
577
578        // ∂v/∂cy = 1
579        jac[(1, 3)] = 1.0;
580
581        // ∂denom/∂alpha = rho - z
582        let d_denom_d_alpha = rho - z;
583
584        // ∂u/∂alpha = -fx * x / denom² * (rho - z) = -u_cx * (rho - z) / denom
585        jac[(0, 4)] = -u_cx * d_denom_d_alpha / denom;
586        jac[(1, 4)] = -v_cy * d_denom_d_alpha / denom;
587
588        jac
589    }
590
591    /// Validates camera parameters.
592    ///
593    /// # Validation Rules
594    ///
595    /// - `fx`, `fy` must be positive.
596    /// - `fx`, `fy` must be finite.
597    /// - `cx`, `cy` must be finite.
598    /// - `α` must be in [0, 1].
599    ///
600    /// # Errors
601    ///
602    /// Returns [`CameraModelError`] if any parameter violates validation rules.
603    fn validate_params(&self) -> Result<(), CameraModelError> {
604        self.pinhole.validate()?;
605        self.get_distortion().validate()
606    }
607
608    /// Returns the pinhole parameters of the camera.
609    ///
610    /// # Returns
611    ///
612    /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
613    fn get_pinhole_params(&self) -> PinholeParams {
614        PinholeParams {
615            fx: self.pinhole.fx,
616            fy: self.pinhole.fy,
617            cx: self.pinhole.cx,
618            cy: self.pinhole.cy,
619        }
620    }
621
622    /// Returns the distortion model and parameters of the camera.
623    ///
624    /// # Returns
625    ///
626    /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::UCM`]).
627    fn get_distortion(&self) -> DistortionModel {
628        self.distortion
629    }
630
631    /// Returns the string identifier for the camera model.
632    ///
633    /// # Returns
634    ///
635    /// The string `"ucm"`.
636    fn get_model_name(&self) -> &'static str {
637        "ucm"
638    }
639}
640
641// ============================================================================
642// From/Into Trait Implementations for UcmCamera
643// ============================================================================
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use nalgebra::{Matrix2xX, Matrix3xX};
649
650    type TestResult = Result<(), Box<dyn std::error::Error>>;
651
652    #[test]
653    fn test_ucm_camera_creation() -> TestResult {
654        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
655        let distortion = DistortionModel::UCM { alpha: 0.5 };
656        let camera = UcmCamera::new(pinhole, distortion)?;
657
658        assert_eq!(camera.pinhole.fx, 300.0);
659        assert_eq!(camera.distortion_params(), 0.5);
660        Ok(())
661    }
662
663    #[test]
664    fn test_projection_at_optical_axis() -> TestResult {
665        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
666        let distortion = DistortionModel::UCM { alpha: 0.5 };
667        let camera = UcmCamera::new(pinhole, distortion)?;
668
669        let p_cam = Vector3::new(0.0, 0.0, 1.0);
670        let uv = camera.project(&p_cam)?;
671        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
672        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
673        Ok(())
674    }
675
676    #[test]
677    fn test_jacobian_point_numerical() -> TestResult {
678        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
679        let distortion = DistortionModel::UCM { alpha: 0.6 };
680        let camera = UcmCamera::new(pinhole, distortion)?;
681
682        let p_cam = Vector3::new(0.1, 0.2, 1.0);
683
684        let jac_analytical = camera.jacobian_point(&p_cam);
685        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
686
687        for i in 0..3 {
688            let mut p_plus = p_cam;
689            let mut p_minus = p_cam;
690            p_plus[i] += eps;
691            p_minus[i] -= eps;
692
693            let uv_plus = camera.project(&p_plus)?;
694            let uv_minus = camera.project(&p_minus)?;
695            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
696
697            for r in 0..2 {
698                assert!(
699                    jac_analytical[(r, i)].is_finite(),
700                    "Jacobian [{r},{i}] is not finite"
701                );
702                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
703                assert!(
704                    diff < crate::JACOBIAN_TEST_TOLERANCE,
705                    "Mismatch at ({}, {}): {} vs {}",
706                    r,
707                    i,
708                    jac_analytical[(r, i)],
709                    num_jac[r]
710                );
711            }
712        }
713        Ok(())
714    }
715
716    #[test]
717    fn test_jacobian_intrinsics_numerical() -> TestResult {
718        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
719        let distortion = DistortionModel::UCM { alpha: 0.6 };
720        let camera = UcmCamera::new(pinhole, distortion)?;
721
722        let p_cam = Vector3::new(0.1, 0.2, 1.0);
723
724        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
725        let params: DVector<f64> = (&camera).into();
726        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
727
728        for i in 0..5 {
729            let mut params_plus = params.clone();
730            let mut params_minus = params.clone();
731            params_plus[i] += eps;
732            params_minus[i] -= eps;
733
734            let cam_plus = UcmCamera::from(params_plus.as_slice());
735            let cam_minus = UcmCamera::from(params_minus.as_slice());
736
737            let uv_plus = cam_plus.project(&p_cam)?;
738            let uv_minus = cam_minus.project(&p_cam)?;
739            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
740
741            for r in 0..2 {
742                assert!(
743                    jac_analytical[(r, i)].is_finite(),
744                    "Jacobian [{r},{i}] is not finite"
745                );
746                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
747                assert!(
748                    diff < crate::JACOBIAN_TEST_TOLERANCE,
749                    "Mismatch at ({}, {}): {} vs {}",
750                    r,
751                    i,
752                    jac_analytical[(r, i)],
753                    num_jac[r]
754                );
755            }
756        }
757        Ok(())
758    }
759
760    #[test]
761    fn test_ucm_from_into_traits() -> TestResult {
762        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
763        let distortion = DistortionModel::UCM { alpha: 0.7 };
764        let camera = UcmCamera::new(pinhole, distortion)?;
765
766        // Test conversion to DVector
767        let params: DVector<f64> = (&camera).into();
768        assert_eq!(params.len(), 5);
769        assert_eq!(params[0], 400.0);
770        assert_eq!(params[1], 410.0);
771        assert_eq!(params[2], 320.0);
772        assert_eq!(params[3], 240.0);
773        assert_eq!(params[4], 0.7);
774
775        // Test conversion to array
776        let arr: [f64; 5] = (&camera).into();
777        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 0.7]);
778
779        // Test conversion from slice
780        let params_slice = [450.0, 460.0, 330.0, 250.0, 0.8];
781        let camera2 = UcmCamera::from(&params_slice[..]);
782        assert_eq!(camera2.pinhole.fx, 450.0);
783        assert_eq!(camera2.pinhole.fy, 460.0);
784        assert_eq!(camera2.pinhole.cx, 330.0);
785        assert_eq!(camera2.pinhole.cy, 250.0);
786        assert_eq!(camera2.distortion_params(), 0.8);
787
788        // Test conversion from array
789        let camera3 = UcmCamera::from([500.0, 510.0, 340.0, 260.0, 0.9]);
790        assert_eq!(camera3.pinhole.fx, 500.0);
791        assert_eq!(camera3.pinhole.fy, 510.0);
792        assert_eq!(camera3.distortion_params(), 0.9);
793
794        Ok(())
795    }
796
797    #[test]
798    fn test_linear_estimation() -> TestResult {
799        // Ground truth UCM camera
800        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
801        let gt_distortion = DistortionModel::UCM { alpha: 0.5 };
802        let gt_camera = UcmCamera::new(gt_pinhole, gt_distortion)?;
803
804        // Generate synthetic 3D points in camera frame
805        let n_points = 50;
806        let mut pts_3d = Matrix3xX::zeros(n_points);
807        let mut pts_2d = Matrix2xX::zeros(n_points);
808        let mut valid = 0;
809
810        for i in 0..n_points {
811            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
812            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
813            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
814
815            if let Ok(p2d) = gt_camera.project(&p3d) {
816                pts_3d.set_column(valid, &p3d);
817                pts_2d.set_column(valid, &p2d);
818                valid += 1;
819            }
820        }
821        let pts_3d = pts_3d.columns(0, valid).into_owned();
822        let pts_2d = pts_2d.columns(0, valid).into_owned();
823
824        // Initial camera with zero alpha
825        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
826        let init_distortion = DistortionModel::UCM { alpha: 0.0 };
827        let mut camera = UcmCamera::new(init_pinhole, init_distortion)?;
828
829        camera.linear_estimation(&pts_3d, &pts_2d)?;
830
831        // Verify reprojection error
832        for i in 0..valid {
833            let p3d = pts_3d.column(i).into_owned();
834            let projected = camera.project(&Vector3::new(p3d.x, p3d.y, p3d.z))?;
835            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
836                + (projected.y - pts_2d[(1, i)]).powi(2))
837            .sqrt();
838            assert!(err < 1.0, "Reprojection error too large: {err}");
839        }
840
841        Ok(())
842    }
843
844    #[test]
845    fn test_project_unproject_round_trip() -> TestResult {
846        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
847        let distortion = DistortionModel::UCM { alpha: 0.5 };
848        let camera = UcmCamera::new(pinhole, distortion)?;
849
850        let test_points = [
851            Vector3::new(0.1, 0.2, 1.0),
852            Vector3::new(-0.3, 0.1, 2.0),
853            Vector3::new(0.05, -0.1, 0.5),
854        ];
855
856        for p_cam in &test_points {
857            let uv = camera.project(p_cam)?;
858            let ray = camera.unproject(&uv)?;
859            let dot = ray.dot(&p_cam.normalize());
860            assert!(
861                (dot - 1.0).abs() < 1e-4,
862                "Round-trip failed: dot={dot}, expected ~1.0"
863            );
864        }
865
866        Ok(())
867    }
868
869    #[test]
870    fn test_project_returns_error_behind_camera() -> TestResult {
871        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
872        let distortion = DistortionModel::UCM { alpha: 0.5 };
873        let camera = UcmCamera::new(pinhole, distortion)?;
874        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
875        Ok(())
876    }
877
878    #[test]
879    fn test_project_at_min_depth_boundary() -> TestResult {
880        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
881        let distortion = DistortionModel::UCM { alpha: 0.5 };
882        let camera = UcmCamera::new(pinhole, distortion)?;
883        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
884        if let Ok(uv) = camera.project(&p_min) {
885            assert!(uv.x.is_finite() && uv.y.is_finite());
886        }
887        Ok(())
888    }
889}