Skip to main content

apex_camera_models/
eucm.rs

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