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 TryFrom<&[f64]> for UcmCamera {
244    type Error = CameraModelError;
245
246    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
247        if params.len() < 5 {
248            return Err(CameraModelError::InvalidParams(format!(
249                "UcmCamera requires at least 5 parameters, got {}",
250                params.len()
251            )));
252        }
253        Ok(Self {
254            pinhole: PinholeParams {
255                fx: params[0],
256                fy: params[1],
257                cx: params[2],
258                cy: params[3],
259            },
260            distortion: DistortionModel::UCM { alpha: params[4] },
261        })
262    }
263}
264
265/// Create camera from fixed-size array of intrinsic parameters.
266///
267/// # Layout
268///
269/// Expected parameter order: [fx, fy, cx, cy, alpha]
270impl From<[f64; 5]> for UcmCamera {
271    fn from(params: [f64; 5]) -> Self {
272        Self {
273            pinhole: PinholeParams {
274                fx: params[0],
275                fy: params[1],
276                cx: params[2],
277                cy: params[3],
278            },
279            distortion: DistortionModel::UCM { alpha: params[4] },
280        }
281    }
282}
283
284/// Creates a `UcmCamera` from a parameter slice with validation.
285///
286/// Unlike `From<&[f64]>`, this constructor validates all parameters
287/// and returns a `Result` instead of panicking on invalid input.
288///
289/// # Errors
290///
291/// Returns `CameraModelError::InvalidParams` if fewer than 5 parameters are provided.
292/// Returns validation errors if focal lengths are non-positive or alpha is out of range.
293pub fn try_from_params(params: &[f64]) -> Result<UcmCamera, CameraModelError> {
294    let camera = UcmCamera::try_from(params)?;
295    camera.validate_params()?;
296    Ok(camera)
297}
298
299impl CameraModel for UcmCamera {
300    const INTRINSIC_DIM: usize = 5;
301    type IntrinsicJacobian = SMatrix<f64, 2, 5>;
302    type PointJacobian = SMatrix<f64, 2, 3>;
303
304    /// Projects a 3D point to 2D image coordinates.
305    ///
306    /// # Mathematical Formula
307    ///
308    /// ```text
309    /// d = √(x² + y² + z²)
310    /// denom = α·d + (1-α)·z
311    /// u = fx · (x/denom) + cx
312    /// v = fy · (y/denom) + cy
313    /// ```
314    ///
315    /// # Arguments
316    ///
317    /// * `p_cam` - 3D point in camera coordinate frame.
318    ///
319    /// # Returns
320    ///
321    /// - `Ok(uv)` - 2D image coordinates if valid.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`CameraModelError::PointAtCameraCenter`] if the projection condition fails or the denominator is too small.
326    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
327        let x = p_cam[0];
328        let y = p_cam[1];
329        let z = p_cam[2];
330
331        let d = (x * x + y * y + z * z).sqrt();
332        let alpha = self.distortion_params();
333        let denom = alpha * d + (1.0 - alpha) * z;
334
335        // Check projection validity
336        if !self.check_projection_condition(z, d) {
337            return Err(CameraModelError::PointBehindCamera {
338                z,
339                min_z: crate::GEOMETRIC_PRECISION,
340            });
341        }
342
343        if denom < crate::GEOMETRIC_PRECISION {
344            return Err(CameraModelError::DenominatorTooSmall {
345                denom,
346                threshold: crate::GEOMETRIC_PRECISION,
347            });
348        }
349
350        Ok(Vector2::new(
351            self.pinhole.fx * x / denom + self.pinhole.cx,
352            self.pinhole.fy * y / denom + self.pinhole.cy,
353        ))
354    }
355
356    /// Unprojects a 2D image point to a 3D ray.
357    ///
358    /// # Algorithm
359    ///
360    /// Algebraic solution for UCM inverse projection.
361    ///
362    /// # Arguments
363    ///
364    /// * `point_2d` - 2D point in image coordinates.
365    ///
366    /// # Returns
367    ///
368    /// - `Ok(ray)` - Normalized 3D ray direction.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection condition fails.
373    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
374        let u = point_2d.x;
375        let v = point_2d.y;
376        let alpha = self.distortion_params();
377        let gamma = 1.0 - alpha;
378        let xi = alpha / gamma;
379        let mx = (u - self.pinhole.cx) / self.pinhole.fx * gamma;
380        let my = (v - self.pinhole.cy) / self.pinhole.fy * gamma;
381
382        let r_squared = mx * mx + my * my;
383
384        // Check unprojection condition
385        if !self.check_unprojection_condition(r_squared) {
386            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
387        }
388
389        let num = xi + (1.0 + (1.0 - xi * xi) * r_squared).sqrt();
390        let denom = 1.0 - r_squared;
391
392        if denom < crate::GEOMETRIC_PRECISION {
393            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
394        }
395
396        let coeff = num / denom;
397
398        let point3d = Vector3::new(coeff * mx, coeff * my, coeff) - Vector3::new(0.0, 0.0, xi);
399
400        Ok(point3d.normalize())
401    }
402
403    /// Checks if a 3D point can be validly projected.
404    ///
405    /// Computes the Jacobian of the projection function with respect to the 3D point in camera frame.
406    ///
407    /// # Mathematical Derivation
408    ///
409    /// The UCM projection model maps a 3D point p = (x, y, z) to 2D pixel coordinates (u, v).
410    ///
411    /// Projection:
412    /// ```text
413    /// ρ = √(x² + y² + z²)
414    /// D = α·ρ + (1-α)·z
415    /// u = fx · (x/D) + cx
416    /// v = fy · (y/D) + cy
417    /// ```
418    ///
419    /// Jacobian:
420    ///
421    /// Derivatives of D with respect to (x, y, z):
422    /// ```text
423    /// ∂D/∂x = α · (x/ρ)
424    /// ∂D/∂y = α · (y/ρ)
425    /// ∂D/∂z = α · (z/ρ) + (1-α)
426    /// ```
427    ///
428    /// Using the quotient rule for u = fx · (x/D):
429    /// ```text
430    /// ∂u/∂x = fx · (D - x·∂D/∂x) / D²
431    /// ∂u/∂y = fx · (-x·∂D/∂y) / D²
432    /// ∂u/∂z = fx · (-x·∂D/∂z) / D²
433    /// ```
434    ///
435    /// Similarly for v:
436    /// ```text
437    /// ∂v/∂x = fy · (-y·∂D/∂x) / D²
438    /// ∂v/∂y = fy · (D - y·∂D/∂y) / D²
439    /// ∂v/∂z = fy · (-y·∂D/∂z) / D²
440    /// ```
441    ///
442    /// # Arguments
443    ///
444    /// * `p_cam` - 3D point in camera coordinate frame.
445    ///
446    /// # Returns
447    ///
448    /// Returns the 2x3 Jacobian matrix.
449    ///
450    /// # References
451    ///
452    /// - Geyer & Daniilidis, "A Unifying Theory for Central Panoramic Systems", ICCV 2000
453    /// - Mei & Rives, "Single View Point Omnidirectional Camera Calibration from Planar Grids", ICRA 2007
454    ///
455    /// # Verification
456    ///
457    /// This Jacobian is verified against numerical differentiation in tests.
458    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
459        let x = p_cam[0];
460        let y = p_cam[1];
461        let z = p_cam[2];
462
463        let rho = (x * x + y * y + z * z).sqrt();
464        let alpha = self.distortion_params();
465
466        // Denominator D = alpha * rho + (1 - alpha) * z
467        // Partial derivatives of D:
468        // ∂D/∂x = alpha * x / rho
469        // ∂D/∂y = alpha * y / rho
470        // ∂D/∂z = alpha * z / rho + (1 - alpha)
471
472        let d_denom_dx = alpha * x / rho;
473        let d_denom_dy = alpha * y / rho;
474        let d_denom_dz = alpha * z / rho + (1.0 - alpha);
475
476        let denom = alpha * rho + (1.0 - alpha) * z;
477
478        // u = fx * x / denom + cx
479        // v = fy * y / denom + cy
480
481        // ∂u/∂x = fx * (denom - x * ∂D/∂x) / denom²
482        // ∂u/∂y = fx * (-x * ∂D/∂y) / denom²
483        // ∂u/∂z = fx * (-x * ∂D/∂z) / denom²
484
485        // ∂v/∂x = fy * (-y * ∂D/∂x) / denom²
486        // ∂v/∂y = fy * (denom - y * ∂D/∂y) / denom²
487        // ∂v/∂z = fy * (-y * ∂D/∂z) / denom²
488
489        let denom2 = denom * denom;
490
491        let mut jac = SMatrix::<f64, 2, 3>::zeros();
492
493        jac[(0, 0)] = self.pinhole.fx * (denom - x * d_denom_dx) / denom2;
494        jac[(0, 1)] = self.pinhole.fx * (-x * d_denom_dy) / denom2;
495        jac[(0, 2)] = self.pinhole.fx * (-x * d_denom_dz) / denom2;
496
497        jac[(1, 0)] = self.pinhole.fy * (-y * d_denom_dx) / denom2;
498        jac[(1, 1)] = self.pinhole.fy * (denom - y * d_denom_dy) / denom2;
499        jac[(1, 2)] = self.pinhole.fy * (-y * d_denom_dz) / denom2;
500
501        jac
502    }
503
504    /// Computes the Jacobian of the projection function with respect to intrinsic parameters.
505    ///
506    /// # Mathematical Derivation
507    ///
508    /// The UCM camera has 5 intrinsic parameters: θ = [fx, fy, cx, cy, α]
509    ///
510    /// ## Projection Model
511    ///
512    /// ```text
513    /// u = fx · (x/D) + cx
514    /// v = fy · (y/D) + cy
515    /// ```
516    ///
517    /// Where D = α·ρ + (1-α)·z and ρ = √(x²+y²+z²)
518    ///
519    /// ## Jacobian Structure
520    ///
521    /// Linear parameters (fx, fy, cx, cy):
522    /// ```text
523    /// ∂u/∂fx = x/D,  ∂u/∂fy = 0,    ∂u/∂cx = 1,    ∂u/∂cy = 0
524    /// ∂v/∂fx = 0,    ∂v/∂fy = y/D,  ∂v/∂cx = 0,    ∂v/∂cy = 1
525    /// ```
526    ///
527    /// Projection parameter α:
528    /// ```text
529    /// ∂D/∂α = ρ - z
530    /// ∂u/∂α = -fx · (x/D²) · (ρ - z)
531    /// ∂v/∂α = -fy · (y/D²) · (ρ - z)
532    /// ```
533    ///
534    /// # Arguments
535    ///
536    /// * `p_cam` - 3D point in camera coordinate frame.
537    ///
538    /// # Returns
539    ///
540    /// Returns the 2x5 Intrinsic Jacobian matrix.
541    ///
542    /// # References
543    ///
544    /// - Geyer & Daniilidis, "A Unifying Theory for Central Panoramic Systems", ICCV 2000
545    ///
546    /// # Verification
547    ///
548    /// This Jacobian is verified against numerical differentiation in tests.
549    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
550        let x = p_cam[0];
551        let y = p_cam[1];
552        let z = p_cam[2];
553
554        let rho = (x * x + y * y + z * z).sqrt();
555        let alpha = self.distortion_params();
556        let denom = alpha * rho + (1.0 - alpha) * z;
557
558        let x_norm = x / denom;
559        let y_norm = y / denom;
560
561        let u_cx = self.pinhole.fx * x_norm;
562        let v_cy = self.pinhole.fy * y_norm;
563
564        let mut jac = SMatrix::<f64, 2, 5>::zeros();
565
566        // ∂u/∂fx = x / denom
567        jac[(0, 0)] = x_norm;
568
569        // ∂v/∂fy = y / denom
570        jac[(1, 1)] = y_norm;
571
572        // ∂u/∂cx = 1
573        jac[(0, 2)] = 1.0;
574
575        // ∂v/∂cy = 1
576        jac[(1, 3)] = 1.0;
577
578        // ∂denom/∂alpha = rho - z
579        let d_denom_d_alpha = rho - z;
580
581        // ∂u/∂alpha = -fx * x / denom² * (rho - z) = -u_cx * (rho - z) / denom
582        jac[(0, 4)] = -u_cx * d_denom_d_alpha / denom;
583        jac[(1, 4)] = -v_cy * d_denom_d_alpha / denom;
584
585        jac
586    }
587
588    /// Validates camera parameters.
589    ///
590    /// # Validation Rules
591    ///
592    /// - `fx`, `fy` must be positive.
593    /// - `fx`, `fy` must be finite.
594    /// - `cx`, `cy` must be finite.
595    /// - `α` must be in [0, 1].
596    ///
597    /// # Errors
598    ///
599    /// Returns [`CameraModelError`] if any parameter violates validation rules.
600    fn validate_params(&self) -> Result<(), CameraModelError> {
601        self.pinhole.validate()?;
602        self.get_distortion().validate()
603    }
604
605    /// Returns the pinhole parameters of the camera.
606    ///
607    /// # Returns
608    ///
609    /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
610    fn get_pinhole_params(&self) -> PinholeParams {
611        PinholeParams {
612            fx: self.pinhole.fx,
613            fy: self.pinhole.fy,
614            cx: self.pinhole.cx,
615            cy: self.pinhole.cy,
616        }
617    }
618
619    /// Returns the distortion model and parameters of the camera.
620    ///
621    /// # Returns
622    ///
623    /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::UCM`]).
624    fn get_distortion(&self) -> DistortionModel {
625        self.distortion
626    }
627
628    /// Returns the string identifier for the camera model.
629    ///
630    /// # Returns
631    ///
632    /// The string `"ucm"`.
633    fn get_model_name(&self) -> &'static str {
634        "ucm"
635    }
636}
637
638// ============================================================================
639// From/Into Trait Implementations for UcmCamera
640// ============================================================================
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use nalgebra::{Matrix2xX, Matrix3xX};
646
647    type TestResult = Result<(), Box<dyn std::error::Error>>;
648
649    #[test]
650    fn test_ucm_camera_creation() -> TestResult {
651        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
652        let distortion = DistortionModel::UCM { alpha: 0.5 };
653        let camera = UcmCamera::new(pinhole, distortion)?;
654
655        assert_eq!(camera.pinhole.fx, 300.0);
656        assert_eq!(camera.distortion_params(), 0.5);
657        Ok(())
658    }
659
660    #[test]
661    fn test_projection_at_optical_axis() -> TestResult {
662        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
663        let distortion = DistortionModel::UCM { alpha: 0.5 };
664        let camera = UcmCamera::new(pinhole, distortion)?;
665
666        let p_cam = Vector3::new(0.0, 0.0, 1.0);
667        let uv = camera.project(&p_cam)?;
668        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
669        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
670        Ok(())
671    }
672
673    #[test]
674    fn test_jacobian_point_numerical() -> TestResult {
675        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
676        let distortion = DistortionModel::UCM { alpha: 0.6 };
677        let camera = UcmCamera::new(pinhole, distortion)?;
678
679        let p_cam = Vector3::new(0.1, 0.2, 1.0);
680
681        let jac_analytical = camera.jacobian_point(&p_cam);
682        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
683
684        for i in 0..3 {
685            let mut p_plus = p_cam;
686            let mut p_minus = p_cam;
687            p_plus[i] += eps;
688            p_minus[i] -= eps;
689
690            let uv_plus = camera.project(&p_plus)?;
691            let uv_minus = camera.project(&p_minus)?;
692            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
693
694            for r in 0..2 {
695                assert!(
696                    jac_analytical[(r, i)].is_finite(),
697                    "Jacobian [{r},{i}] is not finite"
698                );
699                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
700                assert!(
701                    diff < crate::JACOBIAN_TEST_TOLERANCE,
702                    "Mismatch at ({}, {}): {} vs {}",
703                    r,
704                    i,
705                    jac_analytical[(r, i)],
706                    num_jac[r]
707                );
708            }
709        }
710        Ok(())
711    }
712
713    #[test]
714    fn test_jacobian_intrinsics_numerical() -> TestResult {
715        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
716        let distortion = DistortionModel::UCM { alpha: 0.6 };
717        let camera = UcmCamera::new(pinhole, distortion)?;
718
719        let p_cam = Vector3::new(0.1, 0.2, 1.0);
720
721        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
722        let params: DVector<f64> = (&camera).into();
723        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
724
725        for i in 0..5 {
726            let mut params_plus = params.clone();
727            let mut params_minus = params.clone();
728            params_plus[i] += eps;
729            params_minus[i] -= eps;
730
731            let cam_plus = UcmCamera::try_from(params_plus.as_slice())?;
732            let cam_minus = UcmCamera::try_from(params_minus.as_slice())?;
733
734            let uv_plus = cam_plus.project(&p_cam)?;
735            let uv_minus = cam_minus.project(&p_cam)?;
736            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
737
738            for r in 0..2 {
739                assert!(
740                    jac_analytical[(r, i)].is_finite(),
741                    "Jacobian [{r},{i}] is not finite"
742                );
743                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
744                assert!(
745                    diff < crate::JACOBIAN_TEST_TOLERANCE,
746                    "Mismatch at ({}, {}): {} vs {}",
747                    r,
748                    i,
749                    jac_analytical[(r, i)],
750                    num_jac[r]
751                );
752            }
753        }
754        Ok(())
755    }
756
757    #[test]
758    fn test_ucm_from_into_traits() -> TestResult {
759        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
760        let distortion = DistortionModel::UCM { alpha: 0.7 };
761        let camera = UcmCamera::new(pinhole, distortion)?;
762
763        // Test conversion to DVector
764        let params: DVector<f64> = (&camera).into();
765        assert_eq!(params.len(), 5);
766        assert_eq!(params[0], 400.0);
767        assert_eq!(params[1], 410.0);
768        assert_eq!(params[2], 320.0);
769        assert_eq!(params[3], 240.0);
770        assert_eq!(params[4], 0.7);
771
772        // Test conversion to array
773        let arr: [f64; 5] = (&camera).into();
774        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 0.7]);
775
776        // Test conversion from slice
777        let params_slice = [450.0, 460.0, 330.0, 250.0, 0.8];
778        let camera2 = UcmCamera::try_from(&params_slice[..])?;
779        assert_eq!(camera2.pinhole.fx, 450.0);
780        assert_eq!(camera2.pinhole.fy, 460.0);
781        assert_eq!(camera2.pinhole.cx, 330.0);
782        assert_eq!(camera2.pinhole.cy, 250.0);
783        assert_eq!(camera2.distortion_params(), 0.8);
784
785        // Test conversion from array
786        let camera3 = UcmCamera::from([500.0, 510.0, 340.0, 260.0, 0.9]);
787        assert_eq!(camera3.pinhole.fx, 500.0);
788        assert_eq!(camera3.pinhole.fy, 510.0);
789        assert_eq!(camera3.distortion_params(), 0.9);
790
791        Ok(())
792    }
793
794    #[test]
795    fn test_linear_estimation() -> TestResult {
796        // Ground truth UCM camera
797        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
798        let gt_distortion = DistortionModel::UCM { alpha: 0.5 };
799        let gt_camera = UcmCamera::new(gt_pinhole, gt_distortion)?;
800
801        // Generate synthetic 3D points in camera frame
802        let n_points = 50;
803        let mut pts_3d = Matrix3xX::zeros(n_points);
804        let mut pts_2d = Matrix2xX::zeros(n_points);
805        let mut valid = 0;
806
807        for i in 0..n_points {
808            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
809            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
810            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
811
812            if let Ok(p2d) = gt_camera.project(&p3d) {
813                pts_3d.set_column(valid, &p3d);
814                pts_2d.set_column(valid, &p2d);
815                valid += 1;
816            }
817        }
818        let pts_3d = pts_3d.columns(0, valid).into_owned();
819        let pts_2d = pts_2d.columns(0, valid).into_owned();
820
821        // Initial camera with zero alpha
822        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
823        let init_distortion = DistortionModel::UCM { alpha: 0.0 };
824        let mut camera = UcmCamera::new(init_pinhole, init_distortion)?;
825
826        camera.linear_estimation(&pts_3d, &pts_2d)?;
827
828        // Verify reprojection error
829        for i in 0..valid {
830            let col = pts_3d.column(i);
831            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
832            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
833                + (projected.y - pts_2d[(1, i)]).powi(2))
834            .sqrt();
835            assert!(err < 1.0, "Reprojection error too large: {err}");
836        }
837
838        Ok(())
839    }
840
841    #[test]
842    fn test_project_unproject_round_trip() -> TestResult {
843        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
844        let distortion = DistortionModel::UCM { alpha: 0.5 };
845        let camera = UcmCamera::new(pinhole, distortion)?;
846
847        let test_points = [
848            Vector3::new(0.1, 0.2, 1.0),
849            Vector3::new(-0.3, 0.1, 2.0),
850            Vector3::new(0.05, -0.1, 0.5),
851        ];
852
853        for p_cam in &test_points {
854            let uv = camera.project(p_cam)?;
855            let ray = camera.unproject(&uv)?;
856            let dot = ray.dot(&p_cam.normalize());
857            assert!(
858                (dot - 1.0).abs() < 1e-4,
859                "Round-trip failed: dot={dot}, expected ~1.0"
860            );
861        }
862
863        Ok(())
864    }
865
866    #[test]
867    fn test_project_returns_error_behind_camera() -> TestResult {
868        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
869        let distortion = DistortionModel::UCM { alpha: 0.5 };
870        let camera = UcmCamera::new(pinhole, distortion)?;
871        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
872        Ok(())
873    }
874
875    #[test]
876    fn test_project_at_min_depth_boundary() -> TestResult {
877        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
878        let distortion = DistortionModel::UCM { alpha: 0.5 };
879        let camera = UcmCamera::new(pinhole, distortion)?;
880        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
881        if let Ok(uv) = camera.project(&p_min) {
882            assert!(uv.x.is_finite() && uv.y.is_finite());
883        }
884        Ok(())
885    }
886
887    #[test]
888    fn test_projection_off_axis() -> TestResult {
889        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
890        let distortion = DistortionModel::UCM { alpha: 0.5 };
891        let camera = UcmCamera::new(pinhole, distortion)?;
892        let p_cam = Vector3::new(0.3, 0.0, 1.0);
893        let uv = camera.project(&p_cam)?;
894        assert!(
895            uv.x > 320.0,
896            "off-axis point should project right of principal point"
897        );
898        assert!(
899            (uv.y - 240.0).abs() < 1.0,
900            "y should be close to cy for horizontal offset"
901        );
902        Ok(())
903    }
904
905    #[test]
906    fn test_unproject_center_pixel() -> TestResult {
907        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
908        let distortion = DistortionModel::UCM { alpha: 0.5 };
909        let camera = UcmCamera::new(pinhole, distortion)?;
910        let uv = Vector2::new(320.0, 240.0);
911        let ray = camera.unproject(&uv)?;
912        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
913        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
914        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
915        Ok(())
916    }
917
918    #[test]
919    fn test_batch_projection_matches_individual() -> TestResult {
920        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
921        let distortion = DistortionModel::UCM { alpha: 0.5 };
922        let camera = UcmCamera::new(pinhole, distortion)?;
923        let pts = Matrix3xX::from_columns(&[
924            Vector3::new(0.0, 0.0, 1.0),
925            Vector3::new(0.3, 0.2, 1.5),
926            Vector3::new(-0.4, 0.1, 2.0),
927        ]);
928        let batch = camera.project_batch(&pts);
929        for i in 0..3 {
930            let col = pts.column(i);
931            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
932            assert!(
933                (batch[(0, i)] - p.x).abs() < 1e-10,
934                "batch u mismatch at col {i}"
935            );
936            assert!(
937                (batch[(1, i)] - p.y).abs() < 1e-10,
938                "batch v mismatch at col {i}"
939            );
940        }
941        Ok(())
942    }
943
944    #[test]
945    fn test_jacobian_dimensions() -> TestResult {
946        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
947        let distortion = DistortionModel::UCM { alpha: 0.5 };
948        let camera = UcmCamera::new(pinhole, distortion)?;
949        let p_cam = Vector3::new(0.1, 0.2, 1.0);
950        let jac_point = camera.jacobian_point(&p_cam);
951        assert_eq!(jac_point.nrows(), 2);
952        assert_eq!(jac_point.ncols(), 3);
953        let jac_intr = camera.jacobian_intrinsics(&p_cam);
954        assert_eq!(jac_intr.nrows(), 2);
955        assert_eq!(jac_intr.ncols(), 5); // UcmCamera::INTRINSIC_DIM = 5
956        Ok(())
957    }
958}