Skip to main content

apex_camera_models/
double_sphere.rs

1//! Double Sphere camera model.
2//!
3//! Two-parameter fisheye model that combines two consecutive sphere projections, providing
4//! better accuracy than UCM at extreme wide angles. Has 6 intrinsic parameters. See the
5//! [double-sphere cookbook chapter](../doc/cookbook/src/double-sphere.html) for the full
6//! projection, unprojection, and Jacobian derivations.
7
8use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
9use nalgebra::{DVector, SMatrix, Vector2, Vector3};
10use std::fmt;
11
12/// Double Sphere camera model with 6 parameters.
13#[derive(Clone, Copy, PartialEq)]
14pub struct DoubleSphereCamera {
15    pub pinhole: PinholeParams,
16    pub distortion: DistortionModel,
17}
18
19impl DoubleSphereCamera {
20    /// Creates a new Double Sphere camera.
21    ///
22    /// # Errors
23    ///
24    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not
25    /// [`DistortionModel::DoubleSphere`], or if validation fails (e.g. non-positive
26    /// focal length, alpha out of range).
27    ///
28    /// # Example
29    ///
30    /// ```
31    /// use apex_camera_models::{CameraModel, DistortionModel, DoubleSphereCamera, PinholeParams};
32    ///
33    /// let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
34    /// let distortion = DistortionModel::DoubleSphere { xi: -0.2, alpha: 0.6 };
35    /// let camera = DoubleSphereCamera::new(pinhole, distortion)?;
36    /// assert_eq!(camera.get_model_name(), "double_sphere");
37    /// # Ok::<(), apex_camera_models::CameraModelError>(())
38    /// ```
39    pub fn new(
40        pinhole: PinholeParams,
41        distortion: DistortionModel,
42    ) -> Result<Self, CameraModelError> {
43        let model = Self {
44            pinhole,
45            distortion,
46        };
47        model.validate_params()?;
48        Ok(model)
49    }
50
51    /// Returns the Double Sphere parameters as `(xi, alpha)`. Returns `(0.0, 0.0)` if
52    /// the model is not Double Sphere.
53    fn distortion_params(&self) -> (f64, f64) {
54        match self.distortion {
55            DistortionModel::DoubleSphere { xi, alpha } => (xi, alpha),
56            _ => (0.0, 0.0),
57        }
58    }
59
60    /// Returns `Ok(true)` if the projection is valid for the given `z` and Euclidean
61    /// distance `d1`; `Ok(false)` if not. Returns an error only for unrecoverable
62    /// numerical failures.
63    fn check_projection_condition(&self, z: f64, d1: f64) -> Result<bool, CameraModelError> {
64        let (xi, alpha) = self.distortion_params();
65        let w1 = if alpha > 0.5 {
66            (1.0 - alpha) / alpha
67        } else {
68            alpha / (1.0 - alpha)
69        };
70        let w2 = (w1 + xi) / (2.0 * w1 * xi + xi * xi + 1.0).sqrt();
71        Ok(z > -w2 * d1)
72    }
73
74    /// Returns `Ok(true)` if the squared normalised radius is within the unprojection
75    /// domain (only constrains for `alpha > 0.5`).
76    fn check_unprojection_condition(&self, r_squared: f64) -> Result<bool, CameraModelError> {
77        let (_, alpha) = self.distortion_params();
78        if alpha > 0.5 && r_squared > 1.0 / (2.0 * alpha - 1.0) {
79            return Ok(false);
80        }
81        Ok(true)
82    }
83
84    /// Estimates the `alpha` parameter via linear least-squares given 3D–2D
85    /// correspondences. `xi` is reset to `0.0`. Requires the intrinsics
86    /// `[fx, fy, cx, cy]` to already be set; needs at least 1 correspondence.
87    pub fn linear_estimation(
88        &mut self,
89        points_3d: &nalgebra::Matrix3xX<f64>,
90        points_2d: &nalgebra::Matrix2xX<f64>,
91    ) -> Result<(), CameraModelError> {
92        if points_2d.ncols() != points_3d.ncols() {
93            return Err(CameraModelError::InvalidParams(
94                "Number of 2D and 3D points must match".to_string(),
95            ));
96        }
97
98        let num_points = points_2d.ncols();
99        let mut a = nalgebra::DMatrix::zeros(num_points * 2, 1);
100        let mut b = nalgebra::DVector::zeros(num_points * 2);
101
102        for i in 0..num_points {
103            let x = points_3d[(0, i)];
104            let y = points_3d[(1, i)];
105            let z = points_3d[(2, i)];
106            let u = points_2d[(0, i)];
107            let v = points_2d[(1, i)];
108
109            let d = (x * x + y * y + z * z).sqrt();
110            let u_cx = u - self.pinhole.cx;
111            let v_cy = v - self.pinhole.cy;
112
113            a[(i * 2, 0)] = u_cx * (d - z);
114            a[(i * 2 + 1, 0)] = v_cy * (d - z);
115
116            b[i * 2] = (self.pinhole.fx * x) - (u_cx * z);
117            b[i * 2 + 1] = (self.pinhole.fy * y) - (v_cy * z);
118        }
119
120        let svd = a.svd(true, true);
121        let alpha = match svd.solve(&b, 1e-10) {
122            Ok(sol) => sol[0],
123            Err(err_msg) => {
124                return Err(CameraModelError::NumericalError {
125                    operation: "svd_solve".to_string(),
126                    details: err_msg.to_string(),
127                });
128            }
129        };
130
131        self.distortion = DistortionModel::DoubleSphere { xi: 0.0, alpha };
132
133        self.validate_params()?;
134
135        Ok(())
136    }
137}
138
139/// Debug formatter for [`DoubleSphereCamera`].
140impl fmt::Debug for DoubleSphereCamera {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        let (xi, alpha) = self.distortion_params();
143        write!(
144            f,
145            "DoubleSphere [fx: {} fy: {} cx: {} cy: {} alpha: {} xi: {}]",
146            self.pinhole.fx, self.pinhole.fy, self.pinhole.cx, self.pinhole.cy, alpha, xi
147        )
148    }
149}
150
151/// Converts the camera to a dynamic vector with layout `[fx, fy, cx, cy, xi, alpha]`.
152impl From<&DoubleSphereCamera> for DVector<f64> {
153    fn from(camera: &DoubleSphereCamera) -> Self {
154        let (xi, alpha) = camera.distortion_params();
155        DVector::from_vec(vec![
156            camera.pinhole.fx,
157            camera.pinhole.fy,
158            camera.pinhole.cx,
159            camera.pinhole.cy,
160            xi,
161            alpha,
162        ])
163    }
164}
165
166/// Converts the camera to a fixed-size array with layout `[fx, fy, cx, cy, xi, alpha]`.
167impl From<&DoubleSphereCamera> for [f64; 6] {
168    fn from(camera: &DoubleSphereCamera) -> Self {
169        let (xi, alpha) = camera.distortion_params();
170        [
171            camera.pinhole.fx,
172            camera.pinhole.fy,
173            camera.pinhole.cx,
174            camera.pinhole.cy,
175            xi,
176            alpha,
177        ]
178    }
179}
180
181/// Creates a camera from a slice with layout `[fx, fy, cx, cy, xi, alpha]`.
182/// Returns an error if the slice has fewer than 6 elements.
183impl TryFrom<&[f64]> for DoubleSphereCamera {
184    type Error = CameraModelError;
185
186    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
187        if params.len() < 6 {
188            return Err(CameraModelError::InvalidParams(format!(
189                "DoubleSphereCamera requires at least 6 parameters, got {}",
190                params.len()
191            )));
192        }
193        Ok(Self {
194            pinhole: PinholeParams {
195                fx: params[0],
196                fy: params[1],
197                cx: params[2],
198                cy: params[3],
199            },
200            distortion: DistortionModel::DoubleSphere {
201                xi: params[4],
202                alpha: params[5],
203            },
204        })
205    }
206}
207
208/// Creates a camera from a fixed-size array with layout `[fx, fy, cx, cy, xi, alpha]`.
209impl From<[f64; 6]> for DoubleSphereCamera {
210    fn from(params: [f64; 6]) -> Self {
211        Self {
212            pinhole: PinholeParams {
213                fx: params[0],
214                fy: params[1],
215                cx: params[2],
216                cy: params[3],
217            },
218            distortion: DistortionModel::DoubleSphere {
219                xi: params[4],
220                alpha: params[5],
221            },
222        }
223    }
224}
225
226/// Creates a `DoubleSphereCamera` from a parameter slice with full validation.
227/// Unlike [`<DoubleSphereCamera as TryFrom<&[f64]>>::try_from`], this also calls
228/// [`CameraModel::validate_params`] and returns any validation errors.
229pub fn try_from_params(params: &[f64]) -> Result<DoubleSphereCamera, CameraModelError> {
230    let camera = DoubleSphereCamera::try_from(params)?;
231    camera.validate_params()?;
232    Ok(camera)
233}
234
235impl CameraModel for DoubleSphereCamera {
236    const INTRINSIC_DIM: usize = 6;
237    type IntrinsicJacobian = SMatrix<f64, 2, 6>;
238    type PointJacobian = SMatrix<f64, 2, 3>;
239
240    /// Projects a 3D point in the camera frame to 2D image coordinates.
241    /// Returns [`CameraModelError::PointBehindCamera`] / `PointOutsideImage` if the
242    /// point violates the model's domain (`check_projection_condition`).
243    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
244        let x = p_cam[0];
245        let y = p_cam[1];
246        let z = p_cam[2];
247
248        let (xi, alpha) = self.distortion_params();
249        let r2 = x * x + y * y;
250        let d1 = (r2 + z * z).sqrt();
251
252        if !self.check_projection_condition(z, d1)? {
253            return Err(CameraModelError::ProjectionOutOfBounds);
254        }
255
256        let xi_d1_z = xi * d1 + z;
257        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
258        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
259
260        if denom < crate::GEOMETRIC_PRECISION {
261            return Err(CameraModelError::DenominatorTooSmall {
262                denom,
263                threshold: crate::GEOMETRIC_PRECISION,
264            });
265        }
266
267        Ok(Vector2::new(
268            self.pinhole.fx * x / denom + self.pinhole.cx,
269            self.pinhole.fy * y / denom + self.pinhole.cy,
270        ))
271    }
272
273    /// Unprojects a 2D image point to a unit 3D ray via the double-sphere algebraic
274    /// inverse. Returns [`CameraModelError::PointOutsideImage`] if the unprojection
275    /// domain (`check_unprojection_condition`) is violated.
276    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
277        let u = point_2d.x;
278        let v = point_2d.y;
279
280        let (xi, alpha) = self.distortion_params();
281        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
282        let my = (v - self.pinhole.cy) / self.pinhole.fy;
283        let r2 = mx * mx + my * my;
284
285        if !self.check_unprojection_condition(r2)? {
286            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
287        }
288
289        let mz_num = 1.0 - alpha * alpha * r2;
290        let mz_denom = alpha * (1.0 - (2.0 * alpha - 1.0) * r2).sqrt() + (1.0 - alpha);
291        let mz = mz_num / mz_denom;
292
293        let mz2 = mz * mz;
294
295        let num_term = mz * xi + (mz2 + (1.0 - xi * xi) * r2).sqrt();
296        let denom_term = mz2 + r2;
297
298        if denom_term < crate::GEOMETRIC_PRECISION {
299            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
300        }
301
302        let k = num_term / denom_term;
303
304        let x = k * mx;
305        let y = k * my;
306        let z = k * mz - xi;
307
308        // Manual normalization to reuse computed norm
309        let norm = (x * x + y * y + z * z).sqrt();
310        Ok(Vector3::new(x / norm, y / norm, z / norm))
311    }
312
313    /// 2×3 Jacobian ∂(u,v)/∂(x,y,z). See the
314    /// [cookbook](../doc/cookbook/src/double-sphere.html#jacobians) for the full derivation.
315    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
316        let x = p_cam[0];
317        let y = p_cam[1];
318        let z = p_cam[2];
319
320        let (xi, alpha) = self.distortion_params();
321        let r2 = x * x + y * y;
322        let d1 = (r2 + z * z).sqrt();
323        let xi_d1_z = xi * d1 + z;
324        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
325        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
326
327        // Cache reciprocals to avoid repeated divisions
328        let inv_d1 = 1.0 / d1;
329        let inv_d2 = 1.0 / d2;
330
331        // ∂d₁/∂x = x/d₁, ∂d₁/∂y = y/d₁, ∂d₁/∂z = z/d₁
332        let dd1_dx = x * inv_d1;
333        let dd1_dy = y * inv_d1;
334        let dd1_dz = z * inv_d1;
335
336        // ∂(ξ·d₁+z)/∂x = ξ·∂d₁/∂x
337        let d_xi_d1_z_dx = xi * dd1_dx;
338        let d_xi_d1_z_dy = xi * dd1_dy;
339        let d_xi_d1_z_dz = xi * dd1_dz + 1.0;
340
341        // ∂d₂/∂x = (x + (ξ·d₁+z)·∂(ξ·d₁+z)/∂x) / d₂
342        let dd2_dx = (x + xi_d1_z * d_xi_d1_z_dx) * inv_d2;
343        let dd2_dy = (y + xi_d1_z * d_xi_d1_z_dy) * inv_d2;
344        let dd2_dz = (xi_d1_z * d_xi_d1_z_dz) * inv_d2;
345
346        // ∂denom/∂x = α·∂d₂/∂x + (1-α)·∂(ξ·d₁+z)/∂x
347        let ddenom_dx = alpha * dd2_dx + (1.0 - alpha) * d_xi_d1_z_dx;
348        let ddenom_dy = alpha * dd2_dy + (1.0 - alpha) * d_xi_d1_z_dy;
349        let ddenom_dz = alpha * dd2_dz + (1.0 - alpha) * d_xi_d1_z_dz;
350
351        let denom2 = denom * denom;
352
353        // ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
354        let du_dx = self.pinhole.fx * (denom - x * ddenom_dx) / denom2;
355        let du_dy = self.pinhole.fx * (-x * ddenom_dy) / denom2;
356        let du_dz = self.pinhole.fx * (-x * ddenom_dz) / denom2;
357
358        let dv_dx = self.pinhole.fy * (-y * ddenom_dx) / denom2;
359        let dv_dy = self.pinhole.fy * (denom - y * ddenom_dy) / denom2;
360        let dv_dz = self.pinhole.fy * (-y * ddenom_dz) / denom2;
361
362        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
363    }
364
365    /// 2×6 Jacobian ∂(u,v)/∂[fx, fy, cx, cy, xi, alpha]. See the
366    /// [cookbook](../doc/cookbook/src/double-sphere.html#jacobians) for the full derivation.
367    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
368        let x = p_cam[0];
369        let y = p_cam[1];
370        let z = p_cam[2];
371
372        let (xi, alpha) = self.distortion_params();
373        let r2 = x * x + y * y;
374        let d1 = (r2 + z * z).sqrt();
375        let xi_d1_z = xi * d1 + z;
376        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
377        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
378
379        // Cache reciprocals to avoid repeated divisions
380        let inv_denom = 1.0 / denom;
381        let inv_d2 = 1.0 / d2;
382
383        let x_norm = x * inv_denom;
384        let y_norm = y * inv_denom;
385
386        // ∂u/∂fx = x/denom, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
387        // ∂v/∂fx = 0, ∂v/∂fy = y/denom, ∂v/∂cx = 0, ∂v/∂cy = 1
388
389        // For ξ and α derivatives
390        let d_xi_d1_z_dxi = d1;
391        let dd2_dxi = (xi_d1_z * d_xi_d1_z_dxi) * inv_d2;
392        let ddenom_dxi = alpha * dd2_dxi + (1.0 - alpha) * d_xi_d1_z_dxi;
393
394        let ddenom_dalpha = d2 - xi_d1_z;
395
396        let inv_denom2 = inv_denom * inv_denom;
397
398        let du_dxi = -self.pinhole.fx * x * ddenom_dxi * inv_denom2;
399        let dv_dxi = -self.pinhole.fy * y * ddenom_dxi * inv_denom2;
400
401        let du_dalpha = -self.pinhole.fx * x * ddenom_dalpha * inv_denom2;
402        let dv_dalpha = -self.pinhole.fy * y * ddenom_dalpha * inv_denom2;
403
404        SMatrix::<f64, 2, 6>::new(
405            x_norm, 0.0, 1.0, 0.0, du_dxi, du_dalpha, 0.0, y_norm, 0.0, 1.0, dv_dxi, dv_dalpha,
406        )
407    }
408
409    /// Validates the camera parameters.
410    ///
411    /// # Validation Rules
412    ///
413    /// - `fx`, `fy` must be positive (> 0) and finite
414    /// - `cx`, `cy` must be finite
415    /// - `ξ` must be finite
416    /// - `α` must be in `(0, 1]`
417    ///
418    /// # Errors
419    ///
420    /// Returns [`CameraModelError`] if any rule is violated.
421    fn validate_params(&self) -> Result<(), CameraModelError> {
422        self.pinhole.validate()?;
423        self.get_distortion().validate()
424    }
425
426    /// Returns the pinhole parameters.
427    fn get_pinhole_params(&self) -> PinholeParams {
428        PinholeParams {
429            fx: self.pinhole.fx,
430            fy: self.pinhole.fy,
431            cx: self.pinhole.cx,
432            cy: self.pinhole.cy,
433        }
434    }
435
436    /// Returns the distortion model (must be [`DistortionModel::DoubleSphere`]).
437    fn get_distortion(&self) -> DistortionModel {
438        self.distortion
439    }
440
441    /// Returns the model name: `"double_sphere"`.
442    fn get_model_name(&self) -> &'static str {
443        "double_sphere"
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use nalgebra::{Matrix2xX, Matrix3xX};
451
452    type TestResult = Result<(), Box<dyn std::error::Error>>;
453
454    #[test]
455    fn test_double_sphere_camera_creation() -> TestResult {
456        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
457        let distortion = DistortionModel::DoubleSphere {
458            xi: -0.2,
459            alpha: 0.6,
460        };
461        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
462        assert_eq!(camera.pinhole.fx, 300.0);
463        let (xi, alpha) = camera.distortion_params();
464        assert_eq!(alpha, 0.6);
465        assert_eq!(xi, -0.2);
466
467        Ok(())
468    }
469
470    #[test]
471    fn test_projection_at_optical_axis() -> TestResult {
472        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
473        let distortion = DistortionModel::DoubleSphere {
474            xi: -0.2,
475            alpha: 0.6,
476        };
477        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
478        let p_cam = Vector3::new(0.0, 0.0, 1.0);
479        let uv = camera.project(&p_cam)?;
480
481        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
482        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
483
484        Ok(())
485    }
486
487    #[test]
488    fn test_jacobian_point_numerical() -> TestResult {
489        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
490        let distortion = DistortionModel::DoubleSphere {
491            xi: -0.2,
492            alpha: 0.6,
493        };
494        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
495        let p_cam = Vector3::new(0.1, 0.2, 1.0);
496
497        let jac_analytical = camera.jacobian_point(&p_cam);
498        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
499
500        for i in 0..3 {
501            let mut p_plus = p_cam;
502            let mut p_minus = p_cam;
503            p_plus[i] += eps;
504            p_minus[i] -= eps;
505
506            let uv_plus = camera.project(&p_plus)?;
507            let uv_minus = camera.project(&p_minus)?;
508            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
509
510            for r in 0..2 {
511                assert!(
512                    jac_analytical[(r, i)].is_finite(),
513                    "Jacobian [{r},{i}] is not finite"
514                );
515                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
516                assert!(
517                    diff < crate::JACOBIAN_TEST_TOLERANCE,
518                    "Mismatch at ({}, {})",
519                    r,
520                    i
521                );
522            }
523        }
524        Ok(())
525    }
526
527    #[test]
528    fn test_jacobian_intrinsics_numerical() -> TestResult {
529        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
530        let distortion = DistortionModel::DoubleSphere {
531            xi: -0.2,
532            alpha: 0.6,
533        };
534        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
535        let p_cam = Vector3::new(0.1, 0.2, 1.0);
536
537        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
538        let params: DVector<f64> = (&camera).into();
539        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
540
541        for i in 0..6 {
542            let mut params_plus = params.clone();
543            let mut params_minus = params.clone();
544            params_plus[i] += eps;
545            params_minus[i] -= eps;
546
547            let cam_plus = DoubleSphereCamera::try_from(params_plus.as_slice())?;
548            let cam_minus = DoubleSphereCamera::try_from(params_minus.as_slice())?;
549
550            let uv_plus = cam_plus.project(&p_cam)?;
551            let uv_minus = cam_minus.project(&p_cam)?;
552            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
553
554            for r in 0..2 {
555                assert!(
556                    jac_analytical[(r, i)].is_finite(),
557                    "Jacobian [{r},{i}] is not finite"
558                );
559                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
560                assert!(
561                    diff < crate::JACOBIAN_TEST_TOLERANCE,
562                    "Mismatch at ({}, {})",
563                    r,
564                    i
565                );
566            }
567        }
568        Ok(())
569    }
570
571    #[test]
572    fn test_linear_estimation() -> TestResult {
573        // Ground truth DoubleSphere camera with xi=0.0 (linear_estimation fixes xi=0.0)
574        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
575        let gt_distortion = DistortionModel::DoubleSphere {
576            xi: 0.0,
577            alpha: 0.6,
578        };
579        let gt_camera = DoubleSphereCamera::new(gt_pinhole, gt_distortion)?;
580
581        // Generate synthetic 3D points in camera frame
582        let n_points = 50;
583        let mut pts_3d = Matrix3xX::zeros(n_points);
584        let mut pts_2d = Matrix2xX::zeros(n_points);
585        let mut valid = 0;
586
587        for i in 0..n_points {
588            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
589            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
590            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
591
592            if let Ok(p2d) = gt_camera.project(&p3d) {
593                pts_3d.set_column(valid, &p3d);
594                pts_2d.set_column(valid, &p2d);
595                valid += 1;
596            }
597        }
598        let pts_3d = pts_3d.columns(0, valid).into_owned();
599        let pts_2d = pts_2d.columns(0, valid).into_owned();
600
601        // Initial camera with small alpha (alpha must be > 0 for validation)
602        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
603        let init_distortion = DistortionModel::DoubleSphere {
604            xi: 0.0,
605            alpha: 0.1,
606        };
607        let mut camera = DoubleSphereCamera::new(init_pinhole, init_distortion)?;
608
609        camera.linear_estimation(&pts_3d, &pts_2d)?;
610
611        // Verify reprojection error
612        for i in 0..valid {
613            let col = pts_3d.column(i);
614            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
615            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
616                + (projected.y - pts_2d[(1, i)]).powi(2))
617            .sqrt();
618            assert!(err < 1.0, "Reprojection error too large: {err}");
619        }
620
621        Ok(())
622    }
623
624    #[test]
625    fn test_project_unproject_round_trip() -> TestResult {
626        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
627        let distortion = DistortionModel::DoubleSphere {
628            xi: -0.2,
629            alpha: 0.6,
630        };
631        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
632
633        let test_points = [
634            Vector3::new(0.1, 0.2, 1.0),
635            Vector3::new(-0.3, 0.1, 2.0),
636            Vector3::new(0.05, -0.1, 0.5),
637        ];
638
639        for p_cam in &test_points {
640            let uv = camera.project(p_cam)?;
641            let ray = camera.unproject(&uv)?;
642            let dot = ray.dot(&p_cam.normalize());
643            assert!(
644                (dot - 1.0).abs() < 1e-6,
645                "Round-trip failed: dot={dot}, expected ~1.0"
646            );
647        }
648
649        Ok(())
650    }
651
652    #[test]
653    fn test_project_returns_error_behind_camera() -> TestResult {
654        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
655        let distortion = DistortionModel::DoubleSphere {
656            xi: -0.2,
657            alpha: 0.6,
658        };
659        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
660        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
661        Ok(())
662    }
663
664    #[test]
665    fn test_project_at_min_depth_boundary() -> TestResult {
666        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
667        let distortion = DistortionModel::DoubleSphere {
668            xi: -0.2,
669            alpha: 0.6,
670        };
671        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
672        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
673        if let Ok(uv) = camera.project(&p_min) {
674            assert!(uv.x.is_finite() && uv.y.is_finite());
675        }
676        Ok(())
677    }
678
679    #[test]
680    fn test_debug_format() -> TestResult {
681        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
682        let distortion = DistortionModel::DoubleSphere {
683            xi: -0.2,
684            alpha: 0.6,
685        };
686        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
687        let s = format!("{:?}", camera);
688        assert!(
689            s.contains("DoubleSphere"),
690            "Debug output should contain 'DoubleSphere', got: {s}"
691        );
692        assert!(
693            s.contains("300"),
694            "Debug output should contain focal length, got: {s}"
695        );
696        Ok(())
697    }
698
699    #[test]
700    fn test_from_camera_to_fixed_array() -> TestResult {
701        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
702        let distortion = DistortionModel::DoubleSphere {
703            xi: -0.2,
704            alpha: 0.6,
705        };
706        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
707        let arr: [f64; 6] = (&camera).into();
708        assert_eq!(arr[0], 300.0); // fx
709        assert_eq!(arr[1], 300.0); // fy
710        assert_eq!(arr[2], 320.0); // cx
711        assert_eq!(arr[3], 240.0); // cy
712        assert!((arr[4] - (-0.2)).abs() < 1e-15); // xi
713        assert!((arr[5] - 0.6).abs() < 1e-15); // alpha
714        Ok(())
715    }
716
717    #[test]
718    fn test_from_fixed_array_to_camera() {
719        let arr = [300.0f64, 300.0, 320.0, 240.0, -0.2, 0.6];
720        let camera = DoubleSphereCamera::from(arr);
721        assert_eq!(camera.pinhole.fx, 300.0);
722        let (xi, alpha) = camera.distortion_params();
723        assert!((xi - (-0.2)).abs() < 1e-15);
724        assert!((alpha - 0.6).abs() < 1e-15);
725    }
726
727    #[test]
728    fn test_try_from_params_valid() -> TestResult {
729        let params = [300.0f64, 300.0, 320.0, 240.0, -0.2, 0.6];
730        let camera = try_from_params(&params)?;
731        assert_eq!(camera.pinhole.fx, 300.0);
732        Ok(())
733    }
734
735    #[test]
736    fn test_try_from_params_too_few() {
737        let params = [300.0f64, 300.0, 320.0];
738        let result = try_from_params(&params);
739        assert!(result.is_err(), "Should fail with fewer than 6 params");
740    }
741
742    #[test]
743    fn test_try_from_params_invalid_alpha() {
744        let params = [300.0f64, 300.0, 320.0, 240.0, 0.0, 0.0]; // alpha = 0 is invalid
745        let result = try_from_params(&params);
746        assert!(result.is_err(), "Should fail with alpha = 0 (must be > 0)");
747    }
748
749    #[test]
750    fn test_get_pinhole_params() -> TestResult {
751        let pinhole = PinholeParams::new(300.0, 301.0, 320.0, 241.0)?;
752        let distortion = DistortionModel::DoubleSphere {
753            xi: -0.2,
754            alpha: 0.6,
755        };
756        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
757        let p = camera.get_pinhole_params();
758        assert_eq!(p.fx, 300.0);
759        assert_eq!(p.fy, 301.0);
760        assert_eq!(p.cx, 320.0);
761        assert_eq!(p.cy, 241.0);
762        Ok(())
763    }
764
765    #[test]
766    fn test_get_distortion() -> TestResult {
767        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
768        let distortion = DistortionModel::DoubleSphere {
769            xi: -0.2,
770            alpha: 0.6,
771        };
772        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
773        let d = camera.get_distortion();
774        assert_eq!(
775            d,
776            DistortionModel::DoubleSphere {
777                xi: -0.2,
778                alpha: 0.6
779            }
780        );
781        Ok(())
782    }
783
784    #[test]
785    fn test_get_model_name() -> TestResult {
786        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
787        let distortion = DistortionModel::DoubleSphere {
788            xi: -0.2,
789            alpha: 0.6,
790        };
791        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
792        assert_eq!(camera.get_model_name(), "double_sphere");
793        Ok(())
794    }
795
796    #[test]
797    fn test_validate_params_invalid_alpha_zero() -> TestResult {
798        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
799        let distortion = DistortionModel::DoubleSphere {
800            xi: 0.0,
801            alpha: 0.0,
802        };
803        let result = DoubleSphereCamera::new(pinhole, distortion);
804        assert!(result.is_err(), "alpha = 0 should be invalid");
805        Ok(())
806    }
807
808    #[test]
809    fn test_validate_params_invalid_xi_out_of_range() -> TestResult {
810        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
811        let distortion = DistortionModel::DoubleSphere {
812            xi: 2.0,
813            alpha: 0.6,
814        };
815        let result = DoubleSphereCamera::new(pinhole, distortion);
816        assert!(result.is_err(), "xi = 2.0 should be invalid");
817        Ok(())
818    }
819
820    #[test]
821    fn test_validate_params_invalid_focal_length() {
822        let pinhole = PinholeParams {
823            fx: -1.0,
824            fy: 300.0,
825            cx: 320.0,
826            cy: 240.0,
827        };
828        let distortion = DistortionModel::DoubleSphere {
829            xi: 0.0,
830            alpha: 0.6,
831        };
832        let result = DoubleSphereCamera::new(pinhole, distortion);
833        assert!(result.is_err(), "negative focal length should be invalid");
834    }
835
836    #[test]
837    fn test_unproject_outside_image_returns_error() -> TestResult {
838        // check_unprojection_condition reads distortion_params() as (_, alpha),
839        // where distortion_params() returns (xi, alpha). The guard fires when
840        // alpha > 0.5 and r² > 1/(2*alpha - 1).
841        // With alpha=0.9: threshold = 1/(2*0.9-1) = 1.25.
842        // Use xi=0.3 (≤ 0.5) so that the OLD buggy guard (which tested xi instead
843        // of alpha) would NOT fire — this ensures the test only passes with the fix.
844        // Use fx=fy=1, cx=cy=0 so mx=u, my=v.  u=2 → r²=4 > 1.25 ✓
845        let pinhole = PinholeParams::new(1.0, 1.0, 0.0, 0.0)?;
846        let distortion = DistortionModel::DoubleSphere {
847            xi: 0.3,
848            alpha: 0.9,
849        };
850        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
851        let result = camera.unproject(&Vector2::new(2.0, 0.0));
852        assert!(
853            result.is_err(),
854            "Point with r² > 1/(2*alpha - 1) should return PointOutsideImage"
855        );
856        Ok(())
857    }
858}