Skip to main content

apex_camera_models/
double_sphere.rs

1//! Double Sphere Camera Model
2//!
3//! A two-parameter fisheye model that provides improved accuracy over
4//! the Unified Camera Model by using two sphere projections.
5//!
6//! # Mathematical Model
7//!
8//! ## Projection (3D → 2D)
9//!
10//! For a 3D point p = (x, y, z) in camera coordinates:
11//!
12//! ```text
13//! d₁ = √(x² + y² + z²)
14//! d₂ = √(x² + y² + (ξ·d₁ + z)²)
15//! denom = α·d₂ + (1-α)·(ξ·d₁ + z)
16//! u = fx · (x/denom) + cx
17//! v = fy · (y/denom) + cy
18//! ```
19//!
20//! where:
21//! - ξ (xi) is the first distortion parameter
22//! - α (alpha) ∈ (0, 1] is the second distortion parameter
23//!
24//! ## Unprojection (2D → 3D)
25//!
26//! Algebraic solution using the double sphere inverse equations.
27//!
28//! # Parameters
29//!
30//! - **Intrinsics**: fx, fy, cx, cy
31//! - **Distortion**: ξ (xi), α (alpha) (6 parameters total)
32//!
33//! # Use Cases
34//!
35//! - High-quality fisheye calibration
36//! - Wide field-of-view cameras
37//! - More accurate than UCM for extreme wide-angle lenses
38//!
39//! # References
40//!
41//! - Usenko et al., "The Double Sphere Camera Model", 3DV 2018
42
43use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
44use nalgebra::{DVector, SMatrix, Vector2, Vector3};
45use std::fmt;
46
47/// Double Sphere camera model with 6 parameters.
48#[derive(Clone, Copy, PartialEq)]
49pub struct DoubleSphereCamera {
50    pub pinhole: PinholeParams,
51    pub distortion: DistortionModel,
52}
53
54impl DoubleSphereCamera {
55    /// Creates a new Double Sphere camera model.
56    ///
57    /// # Arguments
58    ///
59    /// * `pinhole` - Pinhole camera parameters (fx, fy, cx, cy).
60    /// * `distortion` - Distortion model (must be [`DistortionModel::DoubleSphere`]).
61    ///
62    /// # Returns
63    ///
64    /// Returns a new `DoubleSphereCamera` instance if the parameters are valid.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`CameraModelError`] if:
69    /// - The distortion model is not `DoubleSphere`.
70    /// - Parameters are invalid (e.g., negative focal length, invalid alpha).
71    ///
72    /// # Example
73    ///
74    /// ```
75    /// use apex_camera_models::{DoubleSphereCamera, PinholeParams, DistortionModel};
76    ///
77    /// let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
78    /// let distortion = DistortionModel::DoubleSphere { xi: -0.2, alpha: 0.6 };
79    /// let camera = DoubleSphereCamera::new(pinhole, distortion)?;
80    /// # Ok::<(), apex_camera_models::CameraModelError>(())
81    /// ```
82    pub fn new(
83        pinhole: PinholeParams,
84        distortion: DistortionModel,
85    ) -> Result<Self, CameraModelError> {
86        let model = Self {
87            pinhole,
88            distortion,
89        };
90        model.validate_params()?;
91        Ok(model)
92    }
93
94    /// Helper method to extract Double Sphere distortion parameters.
95    ///
96    /// This method assumes the caller has already verified the camera model.
97    ///
98    /// # Returns
99    ///
100    /// Returns a tuple `(xi, alpha)` containing the Double Sphere parameters.
101    fn distortion_params(&self) -> (f64, f64) {
102        match self.distortion {
103            DistortionModel::DoubleSphere { xi, alpha } => (xi, alpha),
104            _ => (0.0, 0.0),
105        }
106    }
107
108    /// Checks the geometric condition for a valid projection.
109    ///
110    /// # Arguments
111    ///
112    /// * `z` - The z-coordinate of the point in the camera frame.
113    /// * `d1` - The Euclidean distance to the point.
114    ///
115    /// # Returns
116    ///
117    /// Returns `Ok(true)` if the point satisfies the projection condition, or `Ok(false)` otherwise.
118    fn check_projection_condition(&self, z: f64, d1: f64) -> Result<bool, CameraModelError> {
119        let (xi, alpha) = self.distortion_params();
120        let w1 = if alpha > 0.5 {
121            (1.0 - alpha) / alpha
122        } else {
123            alpha / (1.0 - alpha)
124        };
125        let w2 = (w1 + xi) / (2.0 * w1 * xi + xi * xi + 1.0).sqrt();
126        Ok(z > -w2 * d1)
127    }
128
129    /// Checks the geometric condition for a valid unprojection.
130    ///
131    /// # Arguments
132    ///
133    /// * `r_squared` - The squared radius of the point in normalized image coordinates.
134    ///
135    /// # Returns
136    ///
137    /// Returns `Ok(true)` if the point satisfies the unprojection condition, or `Ok(false)` otherwise.
138    fn check_unprojection_condition(&self, r_squared: f64) -> Result<bool, CameraModelError> {
139        let (alpha, _) = self.distortion_params();
140        if alpha > 0.5 && r_squared > 1.0 / (2.0 * alpha - 1.0) {
141            return Ok(false);
142        }
143        Ok(true)
144    }
145
146    /// Performs linear estimation to initialize distortion parameters from point correspondences.
147    ///
148    /// This method estimates the `alpha` parameter using a linear least squares approach
149    /// given 3D-2D point correspondences. It assumes the intrinsic parameters (fx, fy, cx, cy)
150    /// are already set. The `xi` parameter is initialized to 0.0.
151    ///
152    /// # Arguments
153    ///
154    /// * `points_3d`: Matrix3xX<f64> - 3D points in camera coordinates (each column is a point)
155    /// * `points_2d`: Matrix2xX<f64> - Corresponding 2D points in image coordinates
156    ///
157    /// # Returns
158    ///
159    /// Returns `Ok(())` on success or a `CameraModelError` if the estimation fails.
160    pub fn linear_estimation(
161        &mut self,
162        points_3d: &nalgebra::Matrix3xX<f64>,
163        points_2d: &nalgebra::Matrix2xX<f64>,
164    ) -> Result<(), CameraModelError> {
165        if points_2d.ncols() != points_3d.ncols() {
166            return Err(CameraModelError::InvalidParams(
167                "Number of 2D and 3D points must match".to_string(),
168            ));
169        }
170
171        let num_points = points_2d.ncols();
172        let mut a = nalgebra::DMatrix::zeros(num_points * 2, 1);
173        let mut b = nalgebra::DVector::zeros(num_points * 2);
174
175        for i in 0..num_points {
176            let x = points_3d[(0, i)];
177            let y = points_3d[(1, i)];
178            let z = points_3d[(2, i)];
179            let u = points_2d[(0, i)];
180            let v = points_2d[(1, i)];
181
182            let d = (x * x + y * y + z * z).sqrt();
183            let u_cx = u - self.pinhole.cx;
184            let v_cy = v - self.pinhole.cy;
185
186            a[(i * 2, 0)] = u_cx * (d - z);
187            a[(i * 2 + 1, 0)] = v_cy * (d - z);
188
189            b[i * 2] = (self.pinhole.fx * x) - (u_cx * z);
190            b[i * 2 + 1] = (self.pinhole.fy * y) - (v_cy * z);
191        }
192
193        let svd = a.svd(true, true);
194        let alpha = match svd.solve(&b, 1e-10) {
195            Ok(sol) => sol[0],
196            Err(err_msg) => {
197                return Err(CameraModelError::NumericalError {
198                    operation: "svd_solve".to_string(),
199                    details: err_msg.to_string(),
200                });
201            }
202        };
203
204        // Update distortion with estimated alpha, set xi to 0.0
205        self.distortion = DistortionModel::DoubleSphere { xi: 0.0, alpha };
206
207        // Validate parameters
208        self.validate_params()?;
209
210        Ok(())
211    }
212}
213
214/// Provides a debug string representation for [`DoubleSphereModel`].
215impl fmt::Debug for DoubleSphereCamera {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        let (xi, alpha) = self.distortion_params();
218        write!(
219            f,
220            "DoubleSphere [fx: {} fy: {} cx: {} cy: {} alpha: {} xi: {}]",
221            self.pinhole.fx, self.pinhole.fy, self.pinhole.cx, self.pinhole.cy, alpha, xi
222        )
223    }
224}
225
226/// Convert DoubleSphereCamera to parameter vector.
227///
228/// Returns intrinsic parameters in the order: [fx, fy, cx, cy, xi, alpha]
229impl From<&DoubleSphereCamera> for DVector<f64> {
230    fn from(camera: &DoubleSphereCamera) -> Self {
231        let (xi, alpha) = camera.distortion_params();
232        DVector::from_vec(vec![
233            camera.pinhole.fx,
234            camera.pinhole.fy,
235            camera.pinhole.cx,
236            camera.pinhole.cy,
237            xi,
238            alpha,
239        ])
240    }
241}
242
243/// Convert DoubleSphereCamera to fixed-size parameter array.
244///
245/// Returns intrinsic parameters as [fx, fy, cx, cy, xi, alpha]
246impl From<&DoubleSphereCamera> for [f64; 6] {
247    fn from(camera: &DoubleSphereCamera) -> Self {
248        let (xi, alpha) = camera.distortion_params();
249        [
250            camera.pinhole.fx,
251            camera.pinhole.fy,
252            camera.pinhole.cx,
253            camera.pinhole.cy,
254            xi,
255            alpha,
256        ]
257    }
258}
259
260/// Create DoubleSphereCamera from parameter slice.
261///
262/// # Panics
263///
264/// Panics if the slice has fewer than 6 elements.
265///
266/// # Parameter Order
267///
268/// params = [fx, fy, cx, cy, xi, alpha]
269impl TryFrom<&[f64]> for DoubleSphereCamera {
270    type Error = CameraModelError;
271
272    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
273        if params.len() < 6 {
274            return Err(CameraModelError::InvalidParams(format!(
275                "DoubleSphereCamera requires at least 6 parameters, got {}",
276                params.len()
277            )));
278        }
279        Ok(Self {
280            pinhole: PinholeParams {
281                fx: params[0],
282                fy: params[1],
283                cx: params[2],
284                cy: params[3],
285            },
286            distortion: DistortionModel::DoubleSphere {
287                xi: params[4],
288                alpha: params[5],
289            },
290        })
291    }
292}
293
294/// Create DoubleSphereCamera from fixed-size parameter array.
295///
296/// # Parameter Order
297///
298/// params = [fx, fy, cx, cy, xi, alpha]
299impl From<[f64; 6]> for DoubleSphereCamera {
300    fn from(params: [f64; 6]) -> Self {
301        Self {
302            pinhole: PinholeParams {
303                fx: params[0],
304                fy: params[1],
305                cx: params[2],
306                cy: params[3],
307            },
308            distortion: DistortionModel::DoubleSphere {
309                xi: params[4],
310                alpha: params[5],
311            },
312        }
313    }
314}
315
316/// Creates a `DoubleSphereCamera` from a parameter slice with validation.
317///
318/// Unlike `From<&[f64]>`, this constructor validates all parameters
319/// and returns a `Result` instead of panicking on invalid input.
320///
321/// # Errors
322///
323/// Returns `CameraModelError::InvalidParams` if fewer than 6 parameters are provided.
324/// Returns validation errors if focal lengths are non-positive or xi/alpha are out of range.
325pub fn try_from_params(params: &[f64]) -> Result<DoubleSphereCamera, CameraModelError> {
326    let camera = DoubleSphereCamera::try_from(params)?;
327    camera.validate_params()?;
328    Ok(camera)
329}
330
331impl CameraModel for DoubleSphereCamera {
332    const INTRINSIC_DIM: usize = 6;
333    type IntrinsicJacobian = SMatrix<f64, 2, 6>;
334    type PointJacobian = SMatrix<f64, 2, 3>;
335
336    /// Projects a 3D point to 2D image coordinates.
337    ///
338    /// # Mathematical Formula
339    ///
340    /// ```text
341    /// d₁ = √(x² + y² + z²)
342    /// d₂ = √(x² + y² + (ξ·d₁ + z)²)
343    /// denom = α·d₂ + (1-α)·(ξ·d₁ + z)
344    /// u = fx · (x/denom) + cx
345    /// v = fy · (y/denom) + cy
346    /// ```
347    ///
348    /// # Arguments
349    ///
350    /// * `p_cam` - 3D point in camera coordinate frame.
351    ///
352    /// # Returns
353    ///
354    /// Returns the 2D image coordinates if valid.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`CameraModelError`] if:
359    /// - The point fails the geometric projection condition (`ProjectionOutOfBounds`).
360    /// - The denominator is too small, indicating the point is at the camera center (`PointAtCameraCenter`).
361    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
362        let x = p_cam[0];
363        let y = p_cam[1];
364        let z = p_cam[2];
365
366        let (xi, alpha) = self.distortion_params();
367        let r2 = x * x + y * y;
368        let d1 = (r2 + z * z).sqrt();
369
370        // Check projection condition using the helper
371        if !self.check_projection_condition(z, d1)? {
372            return Err(CameraModelError::ProjectionOutOfBounds);
373        }
374
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        if denom < crate::GEOMETRIC_PRECISION {
380            return Err(CameraModelError::DenominatorTooSmall {
381                denom,
382                threshold: crate::GEOMETRIC_PRECISION,
383            });
384        }
385
386        Ok(Vector2::new(
387            self.pinhole.fx * x / denom + self.pinhole.cx,
388            self.pinhole.fy * y / denom + self.pinhole.cy,
389        ))
390    }
391
392    /// Unprojects a 2D image point to a 3D ray.
393    ///
394    /// # Algorithm
395    ///
396    /// Algebraic solution for double sphere inverse projection.
397    ///
398    /// # Arguments
399    ///
400    /// * `point_2d` - 2D point in image coordinates.
401    ///
402    /// # Returns
403    ///
404    /// Returns the normalized 3D ray direction.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection condition fails.
409    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
410        let u = point_2d.x;
411        let v = point_2d.y;
412
413        let (xi, alpha) = self.distortion_params();
414        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
415        let my = (v - self.pinhole.cy) / self.pinhole.fy;
416        let r2 = mx * mx + my * my;
417
418        if !self.check_unprojection_condition(r2)? {
419            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
420        }
421
422        let mz_num = 1.0 - alpha * alpha * r2;
423        let mz_denom = alpha * (1.0 - (2.0 * alpha - 1.0) * r2).sqrt() + (1.0 - alpha);
424        let mz = mz_num / mz_denom;
425
426        let mz2 = mz * mz;
427
428        let num_term = mz * xi + (mz2 + (1.0 - xi * xi) * r2).sqrt();
429        let denom_term = mz2 + r2;
430
431        if denom_term < crate::GEOMETRIC_PRECISION {
432            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
433        }
434
435        let k = num_term / denom_term;
436
437        let x = k * mx;
438        let y = k * my;
439        let z = k * mz - xi;
440
441        // Manual normalization to reuse computed norm
442        let norm = (x * x + y * y + z * z).sqrt();
443        Ok(Vector3::new(x / norm, y / norm, z / norm))
444    }
445
446    /// Checks if a 3D point can be validly projected.
447    /// Jacobian of projection w.r.t. 3D point coordinates (2×3).
448    ///
449    /// Computes ∂π/∂p where π is the projection function and p = (x, y, z) is the 3D point.
450    ///
451    /// # Mathematical Derivation
452    ///
453    /// Given the Double Sphere projection model:
454    /// ```text
455    /// d₁ = √(x² + y² + z²)              // Distance to origin
456    /// w = ξ·d₁ + z                      // Intermediate value
457    /// d₂ = √(x² + y² + w²)              // Second sphere distance
458    /// denom = α·d₂ + (1-α)·w            // Denominator
459    /// u = fx · (x/denom) + cx           // Pixel u-coordinate
460    /// v = fy · (y/denom) + cy           // Pixel v-coordinate
461    /// ```
462    ///
463    /// Derivatives of intermediate quantities:
464    /// ```text
465    /// ∂d₁/∂x = x/d₁,  ∂d₁/∂y = y/d₁,  ∂d₁/∂z = z/d₁
466    /// ∂w/∂x = ξ·(∂d₁/∂x) = ξx/d₁
467    /// ∂w/∂y = ξ·(∂d₁/∂y) = ξy/d₁
468    /// ∂w/∂z = ξ·(∂d₁/∂z) + 1 = ξz/d₁ + 1
469    /// ```
470    ///
471    /// Derivative of d₂:
472    ///
473    /// Since d₂ = √(x² + y² + w²), using chain rule:
474    /// ```text
475    /// ∂d₂/∂x = (x + w·∂w/∂x) / d₂ = (x + w·ξx/d₁) / d₂
476    /// ∂d₂/∂y = (y + w·∂w/∂y) / d₂ = (y + w·ξy/d₁) / d₂
477    /// ∂d₂/∂z = (w·∂w/∂z) / d₂ = w·(ξz/d₁ + 1) / d₂
478    /// ```
479    ///
480    /// Derivative of denominator:
481    /// ```text
482    /// ∂denom/∂x = α·∂d₂/∂x + (1-α)·∂w/∂x
483    /// ∂denom/∂y = α·∂d₂/∂y + (1-α)·∂w/∂y
484    /// ∂denom/∂z = α·∂d₂/∂z + (1-α)·∂w/∂z
485    /// ```
486    ///
487    /// Derivatives of pixel coordinates (quotient rule):
488    ///
489    /// For u = fx·(x/denom) + cx:
490    /// ```text
491    /// ∂u/∂x = fx · ∂(x/denom)/∂x
492    ///       = fx · (denom·1 - x·∂denom/∂x) / denom²
493    ///       = fx · (denom - x·∂denom/∂x) / denom²
494    ///
495    /// ∂u/∂y = fx · (0 - x·∂denom/∂y) / denom²
496    ///       = -fx·x·∂denom/∂y / denom²
497    ///
498    /// ∂u/∂z = -fx·x·∂denom/∂z / denom²
499    /// ```
500    ///
501    /// Similarly for v = fy·(y/denom) + cy:
502    /// ```text
503    /// ∂v/∂x = -fy·y·∂denom/∂x / denom²
504    /// ∂v/∂y = fy · (denom - y·∂denom/∂y) / denom²
505    /// ∂v/∂z = -fy·y·∂denom/∂z / denom²
506    /// ```
507    ///
508    /// Final Jacobian matrix (2×3):
509    ///
510    /// ```text
511    /// J = [ ∂u/∂x  ∂u/∂y  ∂u/∂z ]
512    ///     [ ∂v/∂x  ∂v/∂y  ∂v/∂z ]
513    /// ```
514    ///
515    /// # Arguments
516    ///
517    /// * `p_cam` - 3D point in camera coordinate frame.
518    ///
519    /// # Returns
520    ///
521    /// Returns the 2x3 Jacobian matrix.
522    ///
523    /// # References
524    ///
525    /// - Usenko et al., "The Double Sphere Camera Model", 3DV 2018 (Supplementary Material)
526    /// - Verified against numerical differentiation in tests
527    ///
528    /// # Implementation Note
529    ///
530    /// The implementation uses the chain rule systematically through intermediate quantities
531    /// d₁, w, d₂, and denom to ensure numerical stability and code clarity.
532    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
533        let x = p_cam[0];
534        let y = p_cam[1];
535        let z = p_cam[2];
536
537        let (xi, alpha) = self.distortion_params();
538        let r2 = x * x + y * y;
539        let d1 = (r2 + z * z).sqrt();
540        let xi_d1_z = xi * d1 + z;
541        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
542        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
543
544        // Cache reciprocals to avoid repeated divisions
545        let inv_d1 = 1.0 / d1;
546        let inv_d2 = 1.0 / d2;
547
548        // ∂d₁/∂x = x/d₁, ∂d₁/∂y = y/d₁, ∂d₁/∂z = z/d₁
549        let dd1_dx = x * inv_d1;
550        let dd1_dy = y * inv_d1;
551        let dd1_dz = z * inv_d1;
552
553        // ∂(ξ·d₁+z)/∂x = ξ·∂d₁/∂x
554        let d_xi_d1_z_dx = xi * dd1_dx;
555        let d_xi_d1_z_dy = xi * dd1_dy;
556        let d_xi_d1_z_dz = xi * dd1_dz + 1.0;
557
558        // ∂d₂/∂x = (x + (ξ·d₁+z)·∂(ξ·d₁+z)/∂x) / d₂
559        let dd2_dx = (x + xi_d1_z * d_xi_d1_z_dx) * inv_d2;
560        let dd2_dy = (y + xi_d1_z * d_xi_d1_z_dy) * inv_d2;
561        let dd2_dz = (xi_d1_z * d_xi_d1_z_dz) * inv_d2;
562
563        // ∂denom/∂x = α·∂d₂/∂x + (1-α)·∂(ξ·d₁+z)/∂x
564        let ddenom_dx = alpha * dd2_dx + (1.0 - alpha) * d_xi_d1_z_dx;
565        let ddenom_dy = alpha * dd2_dy + (1.0 - alpha) * d_xi_d1_z_dy;
566        let ddenom_dz = alpha * dd2_dz + (1.0 - alpha) * d_xi_d1_z_dz;
567
568        let denom2 = denom * denom;
569
570        // ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
571        let du_dx = self.pinhole.fx * (denom - x * ddenom_dx) / denom2;
572        let du_dy = self.pinhole.fx * (-x * ddenom_dy) / denom2;
573        let du_dz = self.pinhole.fx * (-x * ddenom_dz) / denom2;
574
575        let dv_dx = self.pinhole.fy * (-y * ddenom_dx) / denom2;
576        let dv_dy = self.pinhole.fy * (denom - y * ddenom_dy) / denom2;
577        let dv_dz = self.pinhole.fy * (-y * ddenom_dz) / denom2;
578
579        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
580    }
581
582    /// Jacobian of projection w.r.t. intrinsic parameters (2×6).
583    ///
584    /// Computes ∂π/∂K where K = [fx, fy, cx, cy, ξ, α] are the intrinsic parameters.
585    ///
586    /// # Mathematical Derivation
587    ///
588    /// The intrinsic parameters consist of:
589    /// 1. **Linear parameters**: fx, fy, cx, cy (pinhole projection)
590    /// 2. **Distortion parameters**: ξ (xi), α (alpha) (Double Sphere specific)
591    ///
592    /// ## Projection Model Recap
593    ///
594    /// ```text
595    /// d₁ = √(x² + y² + z²)
596    /// w = ξ·d₁ + z
597    /// d₂ = √(x² + y² + w²)
598    /// denom = α·d₂ + (1-α)·w
599    /// u = fx · (x/denom) + cx
600    /// v = fy · (y/denom) + cy
601    /// ```
602    ///
603    /// ## Part 1: Linear Parameters (fx, fy, cx, cy)
604    ///
605    /// These have direct, simple derivatives:
606    ///
607    /// ### Focal lengths (fx, fy):
608    /// ```text
609    /// ∂u/∂fx = x/denom    (coefficient of fx in u)
610    /// ∂u/∂fy = 0          (fy doesn't affect u)
611    /// ∂v/∂fx = 0          (fx doesn't affect v)
612    /// ∂v/∂fy = y/denom    (coefficient of fy in v)
613    /// ```
614    ///
615    /// ### Principal point (cx, cy):
616    /// ```text
617    /// ∂u/∂cx = 1          (additive constant)
618    /// ∂u/∂cy = 0          (cy doesn't affect u)
619    /// ∂v/∂cx = 0          (cx doesn't affect v)
620    /// ∂v/∂cy = 1          (additive constant)
621    /// ```
622    ///
623    /// ## Part 2: Distortion Parameters (ξ, α)
624    ///
625    /// These affect the projection through the denominator term.
626    ///
627    /// ### Derivative w.r.t. ξ (xi):
628    ///
629    /// Since w = ξ·d₁ + z and d₂ = √(x² + y² + w²), we have:
630    /// ```text
631    /// ∂w/∂ξ = d₁
632    ///
633    /// ∂d₂/∂ξ = ∂d₂/∂w · ∂w/∂ξ
634    ///        = (w/d₂) · d₁
635    ///        = w·d₁/d₂
636    ///
637    /// ∂denom/∂ξ = α·∂d₂/∂ξ + (1-α)·∂w/∂ξ
638    ///           = α·(w·d₁/d₂) + (1-α)·d₁
639    ///           = d₁·[α·w/d₂ + (1-α)]
640    /// ```
641    ///
642    /// Using the quotient rule on u = fx·(x/denom) + cx:
643    /// ```text
644    /// ∂u/∂ξ = fx · ∂(x/denom)/∂ξ
645    ///       = fx · (-x/denom²) · ∂denom/∂ξ
646    ///       = -fx·x·∂denom/∂ξ / denom²
647    /// ```
648    ///
649    /// Similarly:
650    /// ```text
651    /// ∂v/∂ξ = -fy·y·∂denom/∂ξ / denom²
652    /// ```
653    ///
654    /// ### Derivative w.r.t. α (alpha):
655    ///
656    /// Since denom = α·d₂ + (1-α)·w:
657    /// ```text
658    /// ∂denom/∂α = d₂ - w
659    ///
660    /// ∂u/∂α = -fx·x·(d₂ - w) / denom²
661    /// ∂v/∂α = -fy·y·(d₂ - w) / denom²
662    /// ```
663    ///
664    /// ## Final Jacobian Matrix (2×6)
665    ///
666    /// ```text
667    /// J = [ ∂u/∂fx  ∂u/∂y  ∂u/∂cx  ∂u/∂cy  ∂u/∂ξ  ∂u/∂α ]
668    ///     [ ∂v/∂x  ∂v/∂y  ∂v/∂cx  ∂v/∂cy  ∂v/∂ξ  ∂v/∂α ]
669    ///
670    ///   = [ x/denom    0       1       0      -fx·x·∂denom/∂ξ/denom²  -fx·x·(d₂-w)/denom² ]
671    ///     [   0     y/denom    0       1      -fy·y·∂denom/∂ξ/denom²  -fy·y·(d₂-w)/denom² ]
672    /// ```
673    ///
674    /// # Arguments
675    ///
676    /// * `p_cam` - 3D point in camera coordinate frame.
677    ///
678    /// # Returns
679    ///
680    /// Returns the 2x6 Intrinsic Jacobian matrix.
681    ///
682    /// # References
683    ///
684    /// - Usenko et al., "The Double Sphere Camera Model", 3DV 2018
685    /// - Verified against numerical differentiation in tests
686    ///
687    /// # Implementation Note
688    ///
689    /// The implementation computes all intermediate values (d₁, w, d₂, denom)
690    /// first, then applies the chain rule derivatives systematically.
691    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
692        let x = p_cam[0];
693        let y = p_cam[1];
694        let z = p_cam[2];
695
696        let (xi, alpha) = self.distortion_params();
697        let r2 = x * x + y * y;
698        let d1 = (r2 + z * z).sqrt();
699        let xi_d1_z = xi * d1 + z;
700        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
701        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
702
703        // Cache reciprocals to avoid repeated divisions
704        let inv_denom = 1.0 / denom;
705        let inv_d2 = 1.0 / d2;
706
707        let x_norm = x * inv_denom;
708        let y_norm = y * inv_denom;
709
710        // ∂u/∂fx = x/denom, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
711        // ∂v/∂fx = 0, ∂v/∂fy = y/denom, ∂v/∂cx = 0, ∂v/∂cy = 1
712
713        // For ξ and α derivatives
714        let d_xi_d1_z_dxi = d1;
715        let dd2_dxi = (xi_d1_z * d_xi_d1_z_dxi) * inv_d2;
716        let ddenom_dxi = alpha * dd2_dxi + (1.0 - alpha) * d_xi_d1_z_dxi;
717
718        let ddenom_dalpha = d2 - xi_d1_z;
719
720        let inv_denom2 = inv_denom * inv_denom;
721
722        let du_dxi = -self.pinhole.fx * x * ddenom_dxi * inv_denom2;
723        let dv_dxi = -self.pinhole.fy * y * ddenom_dxi * inv_denom2;
724
725        let du_dalpha = -self.pinhole.fx * x * ddenom_dalpha * inv_denom2;
726        let dv_dalpha = -self.pinhole.fy * y * ddenom_dalpha * inv_denom2;
727
728        SMatrix::<f64, 2, 6>::new(
729            x_norm, 0.0, 1.0, 0.0, du_dxi, du_dalpha, 0.0, y_norm, 0.0, 1.0, dv_dxi, dv_dalpha,
730        )
731    }
732
733    /// Validates camera parameters.
734    ///
735    /// # Validation Rules
736    ///
737    /// - fx, fy must be positive (> 0)
738    /// - fx, fy must be finite
739    /// - cx, cy must be finite
740    /// - ξ must be finite
741    /// - α must be in (0, 1]
742    ///
743    /// # Errors
744    ///
745    /// Returns [`CameraModelError`] if any parameter violates the validation rules.
746    fn validate_params(&self) -> Result<(), CameraModelError> {
747        self.pinhole.validate()?;
748        self.get_distortion().validate()
749    }
750
751    /// Returns the pinhole parameters of the camera.
752    ///
753    /// # Returns
754    ///
755    /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
756    fn get_pinhole_params(&self) -> PinholeParams {
757        PinholeParams {
758            fx: self.pinhole.fx,
759            fy: self.pinhole.fy,
760            cx: self.pinhole.cx,
761            cy: self.pinhole.cy,
762        }
763    }
764
765    /// Returns the distortion model and parameters of the camera.
766    ///
767    /// # Returns
768    ///
769    /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::DoubleSphere`]).
770    fn get_distortion(&self) -> DistortionModel {
771        self.distortion
772    }
773
774    /// Returns the string identifier for the camera model.
775    ///
776    /// # Returns
777    ///
778    /// The string `"double_sphere"`.
779    fn get_model_name(&self) -> &'static str {
780        "double_sphere"
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use nalgebra::{Matrix2xX, Matrix3xX};
788
789    type TestResult = Result<(), Box<dyn std::error::Error>>;
790
791    #[test]
792    fn test_double_sphere_camera_creation() -> TestResult {
793        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
794        let distortion = DistortionModel::DoubleSphere {
795            xi: -0.2,
796            alpha: 0.6,
797        };
798        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
799        assert_eq!(camera.pinhole.fx, 300.0);
800        let (xi, alpha) = camera.distortion_params();
801        assert_eq!(alpha, 0.6);
802        assert_eq!(xi, -0.2);
803
804        Ok(())
805    }
806
807    #[test]
808    fn test_projection_at_optical_axis() -> TestResult {
809        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
810        let distortion = DistortionModel::DoubleSphere {
811            xi: -0.2,
812            alpha: 0.6,
813        };
814        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
815        let p_cam = Vector3::new(0.0, 0.0, 1.0);
816        let uv = camera.project(&p_cam)?;
817
818        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
819        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
820
821        Ok(())
822    }
823
824    #[test]
825    fn test_jacobian_point_numerical() -> TestResult {
826        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
827        let distortion = DistortionModel::DoubleSphere {
828            xi: -0.2,
829            alpha: 0.6,
830        };
831        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
832        let p_cam = Vector3::new(0.1, 0.2, 1.0);
833
834        let jac_analytical = camera.jacobian_point(&p_cam);
835        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
836
837        for i in 0..3 {
838            let mut p_plus = p_cam;
839            let mut p_minus = p_cam;
840            p_plus[i] += eps;
841            p_minus[i] -= eps;
842
843            let uv_plus = camera.project(&p_plus)?;
844            let uv_minus = camera.project(&p_minus)?;
845            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
846
847            for r in 0..2 {
848                assert!(
849                    jac_analytical[(r, i)].is_finite(),
850                    "Jacobian [{r},{i}] is not finite"
851                );
852                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
853                assert!(
854                    diff < crate::JACOBIAN_TEST_TOLERANCE,
855                    "Mismatch at ({}, {})",
856                    r,
857                    i
858                );
859            }
860        }
861        Ok(())
862    }
863
864    #[test]
865    fn test_jacobian_intrinsics_numerical() -> TestResult {
866        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
867        let distortion = DistortionModel::DoubleSphere {
868            xi: -0.2,
869            alpha: 0.6,
870        };
871        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
872        let p_cam = Vector3::new(0.1, 0.2, 1.0);
873
874        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
875        let params: DVector<f64> = (&camera).into();
876        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
877
878        for i in 0..6 {
879            let mut params_plus = params.clone();
880            let mut params_minus = params.clone();
881            params_plus[i] += eps;
882            params_minus[i] -= eps;
883
884            let cam_plus = DoubleSphereCamera::try_from(params_plus.as_slice())?;
885            let cam_minus = DoubleSphereCamera::try_from(params_minus.as_slice())?;
886
887            let uv_plus = cam_plus.project(&p_cam)?;
888            let uv_minus = cam_minus.project(&p_cam)?;
889            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
890
891            for r in 0..2 {
892                assert!(
893                    jac_analytical[(r, i)].is_finite(),
894                    "Jacobian [{r},{i}] is not finite"
895                );
896                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
897                assert!(
898                    diff < crate::JACOBIAN_TEST_TOLERANCE,
899                    "Mismatch at ({}, {})",
900                    r,
901                    i
902                );
903            }
904        }
905        Ok(())
906    }
907
908    #[test]
909    fn test_linear_estimation() -> TestResult {
910        // Ground truth DoubleSphere camera with xi=0.0 (linear_estimation fixes xi=0.0)
911        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
912        let gt_distortion = DistortionModel::DoubleSphere {
913            xi: 0.0,
914            alpha: 0.6,
915        };
916        let gt_camera = DoubleSphereCamera::new(gt_pinhole, gt_distortion)?;
917
918        // Generate synthetic 3D points in camera frame
919        let n_points = 50;
920        let mut pts_3d = Matrix3xX::zeros(n_points);
921        let mut pts_2d = Matrix2xX::zeros(n_points);
922        let mut valid = 0;
923
924        for i in 0..n_points {
925            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
926            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
927            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
928
929            if let Ok(p2d) = gt_camera.project(&p3d) {
930                pts_3d.set_column(valid, &p3d);
931                pts_2d.set_column(valid, &p2d);
932                valid += 1;
933            }
934        }
935        let pts_3d = pts_3d.columns(0, valid).into_owned();
936        let pts_2d = pts_2d.columns(0, valid).into_owned();
937
938        // Initial camera with small alpha (alpha must be > 0 for validation)
939        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
940        let init_distortion = DistortionModel::DoubleSphere {
941            xi: 0.0,
942            alpha: 0.1,
943        };
944        let mut camera = DoubleSphereCamera::new(init_pinhole, init_distortion)?;
945
946        camera.linear_estimation(&pts_3d, &pts_2d)?;
947
948        // Verify reprojection error
949        for i in 0..valid {
950            let col = pts_3d.column(i);
951            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
952            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
953                + (projected.y - pts_2d[(1, i)]).powi(2))
954            .sqrt();
955            assert!(err < 1.0, "Reprojection error too large: {err}");
956        }
957
958        Ok(())
959    }
960
961    #[test]
962    fn test_project_unproject_round_trip() -> TestResult {
963        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
964        let distortion = DistortionModel::DoubleSphere {
965            xi: -0.2,
966            alpha: 0.6,
967        };
968        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
969
970        let test_points = [
971            Vector3::new(0.1, 0.2, 1.0),
972            Vector3::new(-0.3, 0.1, 2.0),
973            Vector3::new(0.05, -0.1, 0.5),
974        ];
975
976        for p_cam in &test_points {
977            let uv = camera.project(p_cam)?;
978            let ray = camera.unproject(&uv)?;
979            let dot = ray.dot(&p_cam.normalize());
980            assert!(
981                (dot - 1.0).abs() < 1e-6,
982                "Round-trip failed: dot={dot}, expected ~1.0"
983            );
984        }
985
986        Ok(())
987    }
988
989    #[test]
990    fn test_project_returns_error_behind_camera() -> TestResult {
991        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
992        let distortion = DistortionModel::DoubleSphere {
993            xi: -0.2,
994            alpha: 0.6,
995        };
996        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
997        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
998        Ok(())
999    }
1000
1001    #[test]
1002    fn test_project_at_min_depth_boundary() -> TestResult {
1003        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1004        let distortion = DistortionModel::DoubleSphere {
1005            xi: -0.2,
1006            alpha: 0.6,
1007        };
1008        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1009        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
1010        if let Ok(uv) = camera.project(&p_min) {
1011            assert!(uv.x.is_finite() && uv.y.is_finite());
1012        }
1013        Ok(())
1014    }
1015
1016    #[test]
1017    fn test_debug_format() -> TestResult {
1018        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1019        let distortion = DistortionModel::DoubleSphere {
1020            xi: -0.2,
1021            alpha: 0.6,
1022        };
1023        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1024        let s = format!("{:?}", camera);
1025        assert!(
1026            s.contains("DoubleSphere"),
1027            "Debug output should contain 'DoubleSphere', got: {s}"
1028        );
1029        assert!(
1030            s.contains("300"),
1031            "Debug output should contain focal length, got: {s}"
1032        );
1033        Ok(())
1034    }
1035
1036    #[test]
1037    fn test_from_camera_to_fixed_array() -> TestResult {
1038        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1039        let distortion = DistortionModel::DoubleSphere {
1040            xi: -0.2,
1041            alpha: 0.6,
1042        };
1043        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1044        let arr: [f64; 6] = (&camera).into();
1045        assert_eq!(arr[0], 300.0); // fx
1046        assert_eq!(arr[1], 300.0); // fy
1047        assert_eq!(arr[2], 320.0); // cx
1048        assert_eq!(arr[3], 240.0); // cy
1049        assert!((arr[4] - (-0.2)).abs() < 1e-15); // xi
1050        assert!((arr[5] - 0.6).abs() < 1e-15); // alpha
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn test_from_fixed_array_to_camera() {
1056        let arr = [300.0f64, 300.0, 320.0, 240.0, -0.2, 0.6];
1057        let camera = DoubleSphereCamera::from(arr);
1058        assert_eq!(camera.pinhole.fx, 300.0);
1059        let (xi, alpha) = camera.distortion_params();
1060        assert!((xi - (-0.2)).abs() < 1e-15);
1061        assert!((alpha - 0.6).abs() < 1e-15);
1062    }
1063
1064    #[test]
1065    fn test_try_from_params_valid() -> TestResult {
1066        let params = [300.0f64, 300.0, 320.0, 240.0, -0.2, 0.6];
1067        let camera = try_from_params(&params)?;
1068        assert_eq!(camera.pinhole.fx, 300.0);
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn test_try_from_params_too_few() {
1074        let params = [300.0f64, 300.0, 320.0];
1075        let result = try_from_params(&params);
1076        assert!(result.is_err(), "Should fail with fewer than 6 params");
1077    }
1078
1079    #[test]
1080    fn test_try_from_params_invalid_alpha() {
1081        let params = [300.0f64, 300.0, 320.0, 240.0, 0.0, 0.0]; // alpha = 0 is invalid
1082        let result = try_from_params(&params);
1083        assert!(result.is_err(), "Should fail with alpha = 0 (must be > 0)");
1084    }
1085
1086    #[test]
1087    fn test_get_pinhole_params() -> TestResult {
1088        let pinhole = PinholeParams::new(300.0, 301.0, 320.0, 241.0)?;
1089        let distortion = DistortionModel::DoubleSphere {
1090            xi: -0.2,
1091            alpha: 0.6,
1092        };
1093        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1094        let p = camera.get_pinhole_params();
1095        assert_eq!(p.fx, 300.0);
1096        assert_eq!(p.fy, 301.0);
1097        assert_eq!(p.cx, 320.0);
1098        assert_eq!(p.cy, 241.0);
1099        Ok(())
1100    }
1101
1102    #[test]
1103    fn test_get_distortion() -> TestResult {
1104        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1105        let distortion = DistortionModel::DoubleSphere {
1106            xi: -0.2,
1107            alpha: 0.6,
1108        };
1109        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1110        let d = camera.get_distortion();
1111        assert_eq!(
1112            d,
1113            DistortionModel::DoubleSphere {
1114                xi: -0.2,
1115                alpha: 0.6
1116            }
1117        );
1118        Ok(())
1119    }
1120
1121    #[test]
1122    fn test_get_model_name() -> TestResult {
1123        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1124        let distortion = DistortionModel::DoubleSphere {
1125            xi: -0.2,
1126            alpha: 0.6,
1127        };
1128        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1129        assert_eq!(camera.get_model_name(), "double_sphere");
1130        Ok(())
1131    }
1132
1133    #[test]
1134    fn test_validate_params_invalid_alpha_zero() -> TestResult {
1135        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1136        let distortion = DistortionModel::DoubleSphere {
1137            xi: 0.0,
1138            alpha: 0.0,
1139        };
1140        let result = DoubleSphereCamera::new(pinhole, distortion);
1141        assert!(result.is_err(), "alpha = 0 should be invalid");
1142        Ok(())
1143    }
1144
1145    #[test]
1146    fn test_validate_params_invalid_xi_out_of_range() -> TestResult {
1147        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1148        let distortion = DistortionModel::DoubleSphere {
1149            xi: 2.0,
1150            alpha: 0.6,
1151        };
1152        let result = DoubleSphereCamera::new(pinhole, distortion);
1153        assert!(result.is_err(), "xi = 2.0 should be invalid");
1154        Ok(())
1155    }
1156
1157    #[test]
1158    fn test_validate_params_invalid_focal_length() {
1159        let pinhole = PinholeParams {
1160            fx: -1.0,
1161            fy: 300.0,
1162            cx: 320.0,
1163            cy: 240.0,
1164        };
1165        let distortion = DistortionModel::DoubleSphere {
1166            xi: 0.0,
1167            alpha: 0.6,
1168        };
1169        let result = DoubleSphereCamera::new(pinhole, distortion);
1170        assert!(result.is_err(), "negative focal length should be invalid");
1171    }
1172
1173    #[test]
1174    fn test_unproject_outside_image_returns_error() -> TestResult {
1175        // check_unprojection_condition reads distortion_params() as (alpha, _),
1176        // where distortion_params() returns (xi, alpha). So the condition triggers
1177        // when xi > 0.5 and r² > 1/(2*xi - 1).
1178        // With xi=0.6: threshold = 1/(2*0.6-1) = 5.0.
1179        // Use fx=fy=1, cx=cy=0 so mx=u, my=v.  u=3 → r²=9 > 5.0 ✓
1180        let pinhole = PinholeParams::new(1.0, 1.0, 0.0, 0.0)?;
1181        let distortion = DistortionModel::DoubleSphere {
1182            xi: 0.6,
1183            alpha: 0.9,
1184        };
1185        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1186        let result = camera.unproject(&Vector2::new(3.0, 0.0));
1187        assert!(
1188            result.is_err(),
1189            "Point with r² > threshold should return PointOutsideImage"
1190        );
1191        Ok(())
1192    }
1193}