Skip to main content

apex_camera_models/
ucm.rs

1//! Unified Camera Model (UCM).
2//!
3//! Projects via a single shape parameter α onto a virtual unit sphere, generalising
4//! catadioptric and fisheye cameras. Has 5 intrinsic parameters. See the
5//! [ucm cookbook chapter](../doc/cookbook/src/ucm.html) for the full projection,
6//! unprojection, and Jacobian derivations.
7
8use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
9use nalgebra::{DVector, SMatrix, Vector2, Vector3};
10
11/// Unified Camera Model with 5 parameters.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct UcmCamera {
14    pub pinhole: PinholeParams,
15    pub distortion: DistortionModel,
16}
17
18impl UcmCamera {
19    /// Creates a new UCM camera.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not
24    /// [`DistortionModel::UCM`].
25    ///
26    /// # Example
27    ///
28    /// ```
29    /// use apex_camera_models::{CameraModel, DistortionModel, PinholeParams, UcmCamera};
30    ///
31    /// let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
32    /// let distortion = DistortionModel::UCM { alpha: 0.5 };
33    /// let camera = UcmCamera::new(pinhole, distortion)?;
34    /// assert_eq!(camera.get_model_name(), "ucm");
35    /// # Ok::<(), apex_camera_models::CameraModelError>(())
36    /// ```
37    pub fn new(
38        pinhole: PinholeParams,
39        distortion: DistortionModel,
40    ) -> Result<Self, CameraModelError> {
41        let camera = Self {
42            pinhole,
43            distortion,
44        };
45        camera.validate_params()?;
46        Ok(camera)
47    }
48
49    /// Returns the UCM `alpha` parameter. Returns `0.0` if the model is not UCM.
50    fn distortion_params(&self) -> f64 {
51        match self.distortion {
52            DistortionModel::UCM { alpha } => alpha,
53            _ => 0.0,
54        }
55    }
56
57    /// Returns `true` if the projection is valid for the given `z` and Euclidean
58    /// distance `d = √(x² + y² + z²)`. The condition is `z > -w·d` where
59    /// `w` depends on `alpha`.
60    fn check_projection_condition(&self, z: f64, d: f64) -> bool {
61        let alpha = self.distortion_params();
62        let w = if alpha <= 0.5 {
63            alpha / (1.0 - alpha)
64        } else {
65            (1.0 - alpha) / alpha
66        };
67        z > -w * d
68    }
69
70    /// Returns `true` if the squared normalised radius is within the model's
71    /// unprojection domain (a constraint that only binds for `alpha > 0.5`).
72    fn check_unprojection_condition(&self, r_squared: f64) -> bool {
73        let alpha = self.distortion_params();
74        if alpha > 0.5 {
75            let gamma = 1.0 - alpha;
76            r_squared <= gamma * gamma / (2.0 * alpha - 1.0)
77        } else {
78            true
79        }
80    }
81
82    /// Estimates the `alpha` parameter via linear least-squares given 3D–2D
83    /// correspondences. Requires the intrinsics `[fx, fy, cx, cy]` to already
84    /// be set; needs at least 1 correspondence.
85    pub fn linear_estimation(
86        &mut self,
87        points_3d: &nalgebra::Matrix3xX<f64>,
88        points_2d: &nalgebra::Matrix2xX<f64>,
89    ) -> Result<(), CameraModelError> {
90        if points_2d.ncols() != points_3d.ncols() {
91            return Err(CameraModelError::InvalidParams(
92                "Number of 2D and 3D points must match".to_string(),
93            ));
94        }
95
96        let num_points = points_2d.ncols();
97        let mut a = nalgebra::DMatrix::zeros(num_points * 2, 1);
98        let mut b = nalgebra::DVector::zeros(num_points * 2);
99
100        for i in 0..num_points {
101            let x = points_3d[(0, i)];
102            let y = points_3d[(1, i)];
103            let z = points_3d[(2, i)];
104            let u = points_2d[(0, i)];
105            let v = points_2d[(1, i)];
106
107            let d = (x * x + y * y + z * z).sqrt();
108            let u_cx = u - self.pinhole.cx;
109            let v_cy = v - self.pinhole.cy;
110
111            a[(i * 2, 0)] = u_cx * (d - z);
112            a[(i * 2 + 1, 0)] = v_cy * (d - z);
113
114            b[i * 2] = (self.pinhole.fx * x) - (u_cx * z);
115            b[i * 2 + 1] = (self.pinhole.fy * y) - (v_cy * z);
116        }
117
118        let svd = a.svd(true, true);
119        let alpha = match svd.solve(&b, 1e-10) {
120            Ok(sol) => sol[0],
121            Err(err_msg) => {
122                return Err(CameraModelError::NumericalError {
123                    operation: "svd_solve".to_string(),
124                    details: err_msg.to_string(),
125                });
126            }
127        };
128
129        self.distortion = DistortionModel::UCM { alpha };
130
131        self.validate_params()?;
132
133        Ok(())
134    }
135}
136
137/// Converts the camera to a dynamic vector with layout `[fx, fy, cx, cy, alpha]`.
138impl From<&UcmCamera> for DVector<f64> {
139    fn from(camera: &UcmCamera) -> Self {
140        let alpha = camera.distortion_params();
141        DVector::from_vec(vec![
142            camera.pinhole.fx,
143            camera.pinhole.fy,
144            camera.pinhole.cx,
145            camera.pinhole.cy,
146            alpha,
147        ])
148    }
149}
150
151/// Converts the camera to a fixed-size array with layout `[fx, fy, cx, cy, alpha]`.
152impl From<&UcmCamera> for [f64; 5] {
153    fn from(camera: &UcmCamera) -> Self {
154        let alpha = camera.distortion_params();
155        [
156            camera.pinhole.fx,
157            camera.pinhole.fy,
158            camera.pinhole.cx,
159            camera.pinhole.cy,
160            alpha,
161        ]
162    }
163}
164
165/// Creates a camera from a slice with layout `[fx, fy, cx, cy, alpha]`.
166/// Returns an error if the slice has fewer than 5 elements.
167impl TryFrom<&[f64]> for UcmCamera {
168    type Error = CameraModelError;
169
170    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
171        if params.len() < 5 {
172            return Err(CameraModelError::InvalidParams(format!(
173                "UcmCamera requires at least 5 parameters, got {}",
174                params.len()
175            )));
176        }
177        Ok(Self {
178            pinhole: PinholeParams {
179                fx: params[0],
180                fy: params[1],
181                cx: params[2],
182                cy: params[3],
183            },
184            distortion: DistortionModel::UCM { alpha: params[4] },
185        })
186    }
187}
188
189/// Creates a camera from a fixed-size array with layout `[fx, fy, cx, cy, alpha]`.
190impl From<[f64; 5]> for UcmCamera {
191    fn from(params: [f64; 5]) -> Self {
192        Self {
193            pinhole: PinholeParams {
194                fx: params[0],
195                fy: params[1],
196                cx: params[2],
197                cy: params[3],
198            },
199            distortion: DistortionModel::UCM { alpha: params[4] },
200        }
201    }
202}
203
204/// Creates a `UcmCamera` from a parameter slice with full validation.
205/// Unlike [`<UcmCamera as TryFrom<&[f64]>>::try_from`], this also calls
206/// [`CameraModel::validate_params`] and returns any validation errors.
207pub fn try_from_params(params: &[f64]) -> Result<UcmCamera, CameraModelError> {
208    let camera = UcmCamera::try_from(params)?;
209    camera.validate_params()?;
210    Ok(camera)
211}
212
213impl CameraModel for UcmCamera {
214    const INTRINSIC_DIM: usize = 5;
215    type IntrinsicJacobian = SMatrix<f64, 2, 5>;
216    type PointJacobian = SMatrix<f64, 2, 3>;
217
218    /// Projects a 3D point in the camera frame to 2D image coordinates.
219    /// Returns [`CameraModelError::PointBehindCamera`] / `PointOutsideImage` if the
220    /// point violates the model's domain (`check_projection_condition`).
221    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
222        let x = p_cam[0];
223        let y = p_cam[1];
224        let z = p_cam[2];
225
226        let d = (x * x + y * y + z * z).sqrt();
227        let alpha = self.distortion_params();
228        let denom = alpha * d + (1.0 - alpha) * z;
229
230        // Check projection validity
231        if !self.check_projection_condition(z, d) {
232            return Err(CameraModelError::PointBehindCamera {
233                z,
234                min_z: crate::GEOMETRIC_PRECISION,
235            });
236        }
237
238        if denom < crate::GEOMETRIC_PRECISION {
239            return Err(CameraModelError::DenominatorTooSmall {
240                denom,
241                threshold: crate::GEOMETRIC_PRECISION,
242            });
243        }
244
245        Ok(Vector2::new(
246            self.pinhole.fx * x / denom + self.pinhole.cx,
247            self.pinhole.fy * y / denom + self.pinhole.cy,
248        ))
249    }
250
251    /// Unprojects a 2D image point to a unit 3D ray via the UCM algebraic inverse.
252    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection domain
253    /// (`check_unprojection_condition`) is violated.
254    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
255        let u = point_2d.x;
256        let v = point_2d.y;
257        let alpha = self.distortion_params();
258        let gamma = 1.0 - alpha;
259        let xi = alpha / gamma;
260        let mx = (u - self.pinhole.cx) / self.pinhole.fx * gamma;
261        let my = (v - self.pinhole.cy) / self.pinhole.fy * gamma;
262
263        let r_squared = mx * mx + my * my;
264        if !self.check_unprojection_condition(r_squared) {
265            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
266        }
267
268        // Mei xi-sphere inverse: bx = mx·f, by = my·f, bz = f − ξ
269        // with f = (ξ + √(1 + (1−ξ²)·R²)) / (1 + R²).
270        let num = xi + (1.0 + (1.0 - xi * xi) * r_squared).sqrt();
271        let denom = 1.0 + r_squared;
272
273        if denom < crate::GEOMETRIC_PRECISION {
274            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
275        }
276
277        let coeff = num / denom;
278
279        let point3d = Vector3::new(coeff * mx, coeff * my, coeff) - Vector3::new(0.0, 0.0, xi);
280
281        Ok(point3d.normalize())
282    }
283
284    /// 2×3 Jacobian ∂(u,v)/∂(x,y,z). See the
285    /// [cookbook](../doc/cookbook/src/ucm.html#jacobians) for the full derivation.
286    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
287        let x = p_cam[0];
288        let y = p_cam[1];
289        let z = p_cam[2];
290
291        let rho = (x * x + y * y + z * z).sqrt();
292        let alpha = self.distortion_params();
293
294        // Denominator D = alpha * rho + (1 - alpha) * z
295        // Partial derivatives of D:
296        // ∂D/∂x = alpha * x / rho
297        // ∂D/∂y = alpha * y / rho
298        // ∂D/∂z = alpha * z / rho + (1 - alpha)
299
300        let d_denom_dx = alpha * x / rho;
301        let d_denom_dy = alpha * y / rho;
302        let d_denom_dz = alpha * z / rho + (1.0 - alpha);
303
304        let denom = alpha * rho + (1.0 - alpha) * z;
305
306        // u = fx * x / denom + cx
307        // v = fy * y / denom + cy
308
309        // ∂u/∂x = fx * (denom - x * ∂D/∂x) / denom²
310        // ∂u/∂y = fx * (-x * ∂D/∂y) / denom²
311        // ∂u/∂z = fx * (-x * ∂D/∂z) / denom²
312
313        // ∂v/∂x = fy * (-y * ∂D/∂x) / denom²
314        // ∂v/∂y = fy * (denom - y * ∂D/∂y) / denom²
315        // ∂v/∂z = fy * (-y * ∂D/∂z) / denom²
316
317        let denom2 = denom * denom;
318
319        let mut jac = SMatrix::<f64, 2, 3>::zeros();
320
321        jac[(0, 0)] = self.pinhole.fx * (denom - x * d_denom_dx) / denom2;
322        jac[(0, 1)] = self.pinhole.fx * (-x * d_denom_dy) / denom2;
323        jac[(0, 2)] = self.pinhole.fx * (-x * d_denom_dz) / denom2;
324
325        jac[(1, 0)] = self.pinhole.fy * (-y * d_denom_dx) / denom2;
326        jac[(1, 1)] = self.pinhole.fy * (denom - y * d_denom_dy) / denom2;
327        jac[(1, 2)] = self.pinhole.fy * (-y * d_denom_dz) / denom2;
328
329        jac
330    }
331
332    /// 2×5 Jacobian ∂(u,v)/∂[fx, fy, cx, cy, alpha]. See the
333    /// [cookbook](../doc/cookbook/src/ucm.html#jacobians) for the full derivation.
334    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
335        let x = p_cam[0];
336        let y = p_cam[1];
337        let z = p_cam[2];
338
339        let rho = (x * x + y * y + z * z).sqrt();
340        let alpha = self.distortion_params();
341        let denom = alpha * rho + (1.0 - alpha) * z;
342
343        let x_norm = x / denom;
344        let y_norm = y / denom;
345
346        let u_cx = self.pinhole.fx * x_norm;
347        let v_cy = self.pinhole.fy * y_norm;
348
349        let mut jac = SMatrix::<f64, 2, 5>::zeros();
350
351        jac[(0, 0)] = x_norm;
352        jac[(1, 1)] = y_norm;
353        jac[(0, 2)] = 1.0;
354        jac[(1, 3)] = 1.0;
355
356        let d_denom_d_alpha = rho - z;
357        jac[(0, 4)] = -u_cx * d_denom_d_alpha / denom;
358        jac[(1, 4)] = -v_cy * d_denom_d_alpha / denom;
359
360        jac
361    }
362
363    /// Validates the camera parameters.
364    ///
365    /// # Validation Rules
366    ///
367    /// - `fx`, `fy` must be positive (> 0) and finite
368    /// - `cx`, `cy` must be finite
369    /// - `α` must be in `[0, 1]`
370    ///
371    /// # Errors
372    ///
373    /// Returns [`CameraModelError`] if any rule is violated.
374    fn validate_params(&self) -> Result<(), CameraModelError> {
375        self.pinhole.validate()?;
376        self.get_distortion().validate()
377    }
378
379    /// Returns the pinhole parameters.
380    fn get_pinhole_params(&self) -> PinholeParams {
381        PinholeParams {
382            fx: self.pinhole.fx,
383            fy: self.pinhole.fy,
384            cx: self.pinhole.cx,
385            cy: self.pinhole.cy,
386        }
387    }
388
389    /// Returns the distortion model (must be [`DistortionModel::UCM`]).
390    fn get_distortion(&self) -> DistortionModel {
391        self.distortion
392    }
393
394    /// Returns the model name: `"ucm"`.
395    fn get_model_name(&self) -> &'static str {
396        "ucm"
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use nalgebra::{Matrix2xX, Matrix3xX};
404
405    type TestResult = Result<(), Box<dyn std::error::Error>>;
406
407    #[test]
408    fn test_ucm_camera_creation() -> TestResult {
409        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
410        let distortion = DistortionModel::UCM { alpha: 0.5 };
411        let camera = UcmCamera::new(pinhole, distortion)?;
412
413        assert_eq!(camera.pinhole.fx, 300.0);
414        assert_eq!(camera.distortion_params(), 0.5);
415        Ok(())
416    }
417
418    #[test]
419    fn test_projection_at_optical_axis() -> TestResult {
420        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
421        let distortion = DistortionModel::UCM { alpha: 0.5 };
422        let camera = UcmCamera::new(pinhole, distortion)?;
423
424        let p_cam = Vector3::new(0.0, 0.0, 1.0);
425        let uv = camera.project(&p_cam)?;
426        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
427        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
428        Ok(())
429    }
430
431    #[test]
432    fn test_jacobian_point_numerical() -> TestResult {
433        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
434        let distortion = DistortionModel::UCM { alpha: 0.6 };
435        let camera = UcmCamera::new(pinhole, distortion)?;
436
437        let p_cam = Vector3::new(0.1, 0.2, 1.0);
438
439        let jac_analytical = camera.jacobian_point(&p_cam);
440        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
441
442        for i in 0..3 {
443            let mut p_plus = p_cam;
444            let mut p_minus = p_cam;
445            p_plus[i] += eps;
446            p_minus[i] -= eps;
447
448            let uv_plus = camera.project(&p_plus)?;
449            let uv_minus = camera.project(&p_minus)?;
450            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
451
452            for r in 0..2 {
453                assert!(
454                    jac_analytical[(r, i)].is_finite(),
455                    "Jacobian [{r},{i}] is not finite"
456                );
457                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
458                assert!(
459                    diff < crate::JACOBIAN_TEST_TOLERANCE,
460                    "Mismatch at ({}, {}): {} vs {}",
461                    r,
462                    i,
463                    jac_analytical[(r, i)],
464                    num_jac[r]
465                );
466            }
467        }
468        Ok(())
469    }
470
471    #[test]
472    fn test_jacobian_intrinsics_numerical() -> TestResult {
473        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
474        let distortion = DistortionModel::UCM { alpha: 0.6 };
475        let camera = UcmCamera::new(pinhole, distortion)?;
476
477        let p_cam = Vector3::new(0.1, 0.2, 1.0);
478
479        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
480        let params: DVector<f64> = (&camera).into();
481        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
482
483        for i in 0..5 {
484            let mut params_plus = params.clone();
485            let mut params_minus = params.clone();
486            params_plus[i] += eps;
487            params_minus[i] -= eps;
488
489            let cam_plus = UcmCamera::try_from(params_plus.as_slice())?;
490            let cam_minus = UcmCamera::try_from(params_minus.as_slice())?;
491
492            let uv_plus = cam_plus.project(&p_cam)?;
493            let uv_minus = cam_minus.project(&p_cam)?;
494            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
495
496            for r in 0..2 {
497                assert!(
498                    jac_analytical[(r, i)].is_finite(),
499                    "Jacobian [{r},{i}] is not finite"
500                );
501                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
502                assert!(
503                    diff < crate::JACOBIAN_TEST_TOLERANCE,
504                    "Mismatch at ({}, {}): {} vs {}",
505                    r,
506                    i,
507                    jac_analytical[(r, i)],
508                    num_jac[r]
509                );
510            }
511        }
512        Ok(())
513    }
514
515    #[test]
516    fn test_ucm_from_into_traits() -> TestResult {
517        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
518        let distortion = DistortionModel::UCM { alpha: 0.7 };
519        let camera = UcmCamera::new(pinhole, distortion)?;
520
521        // Test conversion to DVector
522        let params: DVector<f64> = (&camera).into();
523        assert_eq!(params.len(), 5);
524        assert_eq!(params[0], 400.0);
525        assert_eq!(params[1], 410.0);
526        assert_eq!(params[2], 320.0);
527        assert_eq!(params[3], 240.0);
528        assert_eq!(params[4], 0.7);
529
530        // Test conversion to array
531        let arr: [f64; 5] = (&camera).into();
532        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 0.7]);
533
534        // Test conversion from slice
535        let params_slice = [450.0, 460.0, 330.0, 250.0, 0.8];
536        let camera2 = UcmCamera::try_from(&params_slice[..])?;
537        assert_eq!(camera2.pinhole.fx, 450.0);
538        assert_eq!(camera2.pinhole.fy, 460.0);
539        assert_eq!(camera2.pinhole.cx, 330.0);
540        assert_eq!(camera2.pinhole.cy, 250.0);
541        assert_eq!(camera2.distortion_params(), 0.8);
542
543        // Test conversion from array
544        let camera3 = UcmCamera::from([500.0, 510.0, 340.0, 260.0, 0.9]);
545        assert_eq!(camera3.pinhole.fx, 500.0);
546        assert_eq!(camera3.pinhole.fy, 510.0);
547        assert_eq!(camera3.distortion_params(), 0.9);
548
549        Ok(())
550    }
551
552    #[test]
553    fn test_linear_estimation() -> TestResult {
554        // Ground truth UCM camera
555        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
556        let gt_distortion = DistortionModel::UCM { alpha: 0.5 };
557        let gt_camera = UcmCamera::new(gt_pinhole, gt_distortion)?;
558
559        // Generate synthetic 3D points in camera frame
560        let n_points = 50;
561        let mut pts_3d = Matrix3xX::zeros(n_points);
562        let mut pts_2d = Matrix2xX::zeros(n_points);
563        let mut valid = 0;
564
565        for i in 0..n_points {
566            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
567            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
568            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
569
570            if let Ok(p2d) = gt_camera.project(&p3d) {
571                pts_3d.set_column(valid, &p3d);
572                pts_2d.set_column(valid, &p2d);
573                valid += 1;
574            }
575        }
576        let pts_3d = pts_3d.columns(0, valid).into_owned();
577        let pts_2d = pts_2d.columns(0, valid).into_owned();
578
579        // Initial camera with zero alpha
580        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
581        let init_distortion = DistortionModel::UCM { alpha: 0.0 };
582        let mut camera = UcmCamera::new(init_pinhole, init_distortion)?;
583
584        camera.linear_estimation(&pts_3d, &pts_2d)?;
585
586        // Verify reprojection error
587        for i in 0..valid {
588            let col = pts_3d.column(i);
589            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
590            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
591                + (projected.y - pts_2d[(1, i)]).powi(2))
592            .sqrt();
593            assert!(err < 1.0, "Reprojection error too large: {err}");
594        }
595
596        Ok(())
597    }
598
599    #[test]
600    fn test_project_unproject_round_trip() -> TestResult {
601        // Use α = 0.6 so the (1 − ξ²)·R² term in the unprojection is
602        // non-zero — this catches sign-of-denominator regressions that
603        // pass at the degenerate α = 0.5.
604        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
605        let distortion = DistortionModel::UCM { alpha: 0.6 };
606        let camera = UcmCamera::new(pinhole, distortion)?;
607
608        // Include off-axis bearings that reach the fisheye periphery
609        // (around 60° from optical axis), where the round-trip error
610        // dominates if the inverse formula is wrong.
611        let test_points = [
612            Vector3::new(0.1, 0.2, 1.0),
613            Vector3::new(-0.3, 0.1, 2.0),
614            Vector3::new(0.05, -0.1, 0.5),
615            Vector3::new(0.6, 0.0, 0.8),
616            Vector3::new(0.4, -0.5, 0.7),
617        ];
618
619        for p_cam in &test_points {
620            let uv = camera.project(p_cam)?;
621            let ray = camera.unproject(&uv)?;
622            let dot = ray.dot(&p_cam.normalize());
623            assert!(
624                (dot - 1.0).abs() < 1e-8,
625                "Round-trip failed: dot={dot}, expected ~1.0 (p_cam = {p_cam:?})"
626            );
627        }
628
629        Ok(())
630    }
631
632    #[test]
633    fn test_project_returns_error_behind_camera() -> TestResult {
634        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
635        let distortion = DistortionModel::UCM { alpha: 0.5 };
636        let camera = UcmCamera::new(pinhole, distortion)?;
637        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
638        Ok(())
639    }
640
641    #[test]
642    fn test_project_at_min_depth_boundary() -> TestResult {
643        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
644        let distortion = DistortionModel::UCM { alpha: 0.5 };
645        let camera = UcmCamera::new(pinhole, distortion)?;
646        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
647        if let Ok(uv) = camera.project(&p_min) {
648            assert!(uv.x.is_finite() && uv.y.is_finite());
649        }
650        Ok(())
651    }
652
653    #[test]
654    fn test_projection_off_axis() -> TestResult {
655        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
656        let distortion = DistortionModel::UCM { alpha: 0.5 };
657        let camera = UcmCamera::new(pinhole, distortion)?;
658        let p_cam = Vector3::new(0.3, 0.0, 1.0);
659        let uv = camera.project(&p_cam)?;
660        assert!(
661            uv.x > 320.0,
662            "off-axis point should project right of principal point"
663        );
664        assert!(
665            (uv.y - 240.0).abs() < 1.0,
666            "y should be close to cy for horizontal offset"
667        );
668        Ok(())
669    }
670
671    #[test]
672    fn test_unproject_center_pixel() -> TestResult {
673        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
674        let distortion = DistortionModel::UCM { alpha: 0.5 };
675        let camera = UcmCamera::new(pinhole, distortion)?;
676        let uv = Vector2::new(320.0, 240.0);
677        let ray = camera.unproject(&uv)?;
678        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
679        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
680        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
681        Ok(())
682    }
683
684    #[test]
685    fn test_batch_projection_matches_individual() -> TestResult {
686        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
687        let distortion = DistortionModel::UCM { alpha: 0.5 };
688        let camera = UcmCamera::new(pinhole, distortion)?;
689        let pts = Matrix3xX::from_columns(&[
690            Vector3::new(0.0, 0.0, 1.0),
691            Vector3::new(0.3, 0.2, 1.5),
692            Vector3::new(-0.4, 0.1, 2.0),
693        ]);
694        let batch = camera.project_batch(&pts);
695        for i in 0..3 {
696            let col = pts.column(i);
697            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
698            assert!(
699                (batch[(0, i)] - p.x).abs() < 1e-10,
700                "batch u mismatch at col {i}"
701            );
702            assert!(
703                (batch[(1, i)] - p.y).abs() < 1e-10,
704                "batch v mismatch at col {i}"
705            );
706        }
707        Ok(())
708    }
709
710    #[test]
711    fn test_jacobian_dimensions() -> TestResult {
712        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
713        let distortion = DistortionModel::UCM { alpha: 0.5 };
714        let camera = UcmCamera::new(pinhole, distortion)?;
715        let p_cam = Vector3::new(0.1, 0.2, 1.0);
716        let jac_point = camera.jacobian_point(&p_cam);
717        assert_eq!(jac_point.nrows(), 2);
718        assert_eq!(jac_point.ncols(), 3);
719        let jac_intr = camera.jacobian_intrinsics(&p_cam);
720        assert_eq!(jac_intr.nrows(), 2);
721        assert_eq!(jac_intr.ncols(), 5); // UcmCamera::INTRINSIC_DIM = 5
722        Ok(())
723    }
724}