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