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 From<&[f64]> for DoubleSphereCamera {
270    fn from(params: &[f64]) -> Self {
271        assert!(
272            params.len() >= 6,
273            "DoubleSphereCamera requires at least 6 parameters, got {}",
274            params.len()
275        );
276        Self {
277            pinhole: PinholeParams {
278                fx: params[0],
279                fy: params[1],
280                cx: params[2],
281                cy: params[3],
282            },
283            distortion: DistortionModel::DoubleSphere {
284                xi: params[4],
285                alpha: params[5],
286            },
287        }
288    }
289}
290
291/// Create DoubleSphereCamera from fixed-size parameter array.
292///
293/// # Parameter Order
294///
295/// params = [fx, fy, cx, cy, xi, alpha]
296impl From<[f64; 6]> for DoubleSphereCamera {
297    fn from(params: [f64; 6]) -> Self {
298        Self {
299            pinhole: PinholeParams {
300                fx: params[0],
301                fy: params[1],
302                cx: params[2],
303                cy: params[3],
304            },
305            distortion: DistortionModel::DoubleSphere {
306                xi: params[4],
307                alpha: params[5],
308            },
309        }
310    }
311}
312
313/// Creates a `DoubleSphereCamera` from a parameter slice with validation.
314///
315/// Unlike `From<&[f64]>`, this constructor validates all parameters
316/// and returns a `Result` instead of panicking on invalid input.
317///
318/// # Errors
319///
320/// Returns `CameraModelError::InvalidParams` if fewer than 6 parameters are provided.
321/// Returns validation errors if focal lengths are non-positive or xi/alpha are out of range.
322pub fn try_from_params(params: &[f64]) -> Result<DoubleSphereCamera, CameraModelError> {
323    if params.len() < 6 {
324        return Err(CameraModelError::InvalidParams(format!(
325            "DoubleSphereCamera requires at least 6 parameters, got {}",
326            params.len()
327        )));
328    }
329    let camera = DoubleSphereCamera::from(params);
330    camera.validate_params()?;
331    Ok(camera)
332}
333
334impl CameraModel for DoubleSphereCamera {
335    const INTRINSIC_DIM: usize = 6;
336    type IntrinsicJacobian = SMatrix<f64, 2, 6>;
337    type PointJacobian = SMatrix<f64, 2, 3>;
338
339    /// Projects a 3D point to 2D image coordinates.
340    ///
341    /// # Mathematical Formula
342    ///
343    /// ```text
344    /// d₁ = √(x² + y² + z²)
345    /// d₂ = √(x² + y² + (ξ·d₁ + z)²)
346    /// denom = α·d₂ + (1-α)·(ξ·d₁ + z)
347    /// u = fx · (x/denom) + cx
348    /// v = fy · (y/denom) + cy
349    /// ```
350    ///
351    /// # Arguments
352    ///
353    /// * `p_cam` - 3D point in camera coordinate frame.
354    ///
355    /// # Returns
356    ///
357    /// Returns the 2D image coordinates if valid.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`CameraModelError`] if:
362    /// - The point fails the geometric projection condition (`ProjectionOutOfBounds`).
363    /// - The denominator is too small, indicating the point is at the camera center (`PointAtCameraCenter`).
364    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
365        let x = p_cam[0];
366        let y = p_cam[1];
367        let z = p_cam[2];
368
369        let (xi, alpha) = self.distortion_params();
370        let r2 = x * x + y * y;
371        let d1 = (r2 + z * z).sqrt();
372
373        // Check projection condition using the helper
374        if !self.check_projection_condition(z, d1)? {
375            return Err(CameraModelError::ProjectionOutOfBounds);
376        }
377
378        let xi_d1_z = xi * d1 + z;
379        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
380        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
381
382        if denom < crate::GEOMETRIC_PRECISION {
383            return Err(CameraModelError::DenominatorTooSmall {
384                denom,
385                threshold: crate::GEOMETRIC_PRECISION,
386            });
387        }
388
389        Ok(Vector2::new(
390            self.pinhole.fx * x / denom + self.pinhole.cx,
391            self.pinhole.fy * y / denom + self.pinhole.cy,
392        ))
393    }
394
395    /// Unprojects a 2D image point to a 3D ray.
396    ///
397    /// # Algorithm
398    ///
399    /// Algebraic solution for double sphere inverse projection.
400    ///
401    /// # Arguments
402    ///
403    /// * `point_2d` - 2D point in image coordinates.
404    ///
405    /// # Returns
406    ///
407    /// Returns the normalized 3D ray direction.
408    ///
409    /// # Errors
410    ///
411    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection condition fails.
412    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
413        let u = point_2d.x;
414        let v = point_2d.y;
415
416        let (xi, alpha) = self.distortion_params();
417        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
418        let my = (v - self.pinhole.cy) / self.pinhole.fy;
419        let r2 = mx * mx + my * my;
420
421        if !self.check_unprojection_condition(r2)? {
422            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
423        }
424
425        let mz_num = 1.0 - alpha * alpha * r2;
426        let mz_denom = alpha * (1.0 - (2.0 * alpha - 1.0) * r2).sqrt() + (1.0 - alpha);
427        let mz = mz_num / mz_denom;
428
429        let mz2 = mz * mz;
430
431        let num_term = mz * xi + (mz2 + (1.0 - xi * xi) * r2).sqrt();
432        let denom_term = mz2 + r2;
433
434        if denom_term < crate::GEOMETRIC_PRECISION {
435            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
436        }
437
438        let k = num_term / denom_term;
439
440        let x = k * mx;
441        let y = k * my;
442        let z = k * mz - xi;
443
444        // Manual normalization to reuse computed norm
445        let norm = (x * x + y * y + z * z).sqrt();
446        Ok(Vector3::new(x / norm, y / norm, z / norm))
447    }
448
449    /// Checks if a 3D point can be validly projected.
450    /// Jacobian of projection w.r.t. 3D point coordinates (2×3).
451    ///
452    /// Computes ∂π/∂p where π is the projection function and p = (x, y, z) is the 3D point.
453    ///
454    /// # Mathematical Derivation
455    ///
456    /// Given the Double Sphere projection model:
457    /// ```text
458    /// d₁ = √(x² + y² + z²)              // Distance to origin
459    /// w = ξ·d₁ + z                      // Intermediate value
460    /// d₂ = √(x² + y² + w²)              // Second sphere distance
461    /// denom = α·d₂ + (1-α)·w            // Denominator
462    /// u = fx · (x/denom) + cx           // Pixel u-coordinate
463    /// v = fy · (y/denom) + cy           // Pixel v-coordinate
464    /// ```
465    ///
466    /// Derivatives of intermediate quantities:
467    /// ```text
468    /// ∂d₁/∂x = x/d₁,  ∂d₁/∂y = y/d₁,  ∂d₁/∂z = z/d₁
469    /// ∂w/∂x = ξ·(∂d₁/∂x) = ξx/d₁
470    /// ∂w/∂y = ξ·(∂d₁/∂y) = ξy/d₁
471    /// ∂w/∂z = ξ·(∂d₁/∂z) + 1 = ξz/d₁ + 1
472    /// ```
473    ///
474    /// Derivative of d₂:
475    ///
476    /// Since d₂ = √(x² + y² + w²), using chain rule:
477    /// ```text
478    /// ∂d₂/∂x = (x + w·∂w/∂x) / d₂ = (x + w·ξx/d₁) / d₂
479    /// ∂d₂/∂y = (y + w·∂w/∂y) / d₂ = (y + w·ξy/d₁) / d₂
480    /// ∂d₂/∂z = (w·∂w/∂z) / d₂ = w·(ξz/d₁ + 1) / d₂
481    /// ```
482    ///
483    /// Derivative of denominator:
484    /// ```text
485    /// ∂denom/∂x = α·∂d₂/∂x + (1-α)·∂w/∂x
486    /// ∂denom/∂y = α·∂d₂/∂y + (1-α)·∂w/∂y
487    /// ∂denom/∂z = α·∂d₂/∂z + (1-α)·∂w/∂z
488    /// ```
489    ///
490    /// Derivatives of pixel coordinates (quotient rule):
491    ///
492    /// For u = fx·(x/denom) + cx:
493    /// ```text
494    /// ∂u/∂x = fx · ∂(x/denom)/∂x
495    ///       = fx · (denom·1 - x·∂denom/∂x) / denom²
496    ///       = fx · (denom - x·∂denom/∂x) / denom²
497    ///
498    /// ∂u/∂y = fx · (0 - x·∂denom/∂y) / denom²
499    ///       = -fx·x·∂denom/∂y / denom²
500    ///
501    /// ∂u/∂z = -fx·x·∂denom/∂z / denom²
502    /// ```
503    ///
504    /// Similarly for v = fy·(y/denom) + cy:
505    /// ```text
506    /// ∂v/∂x = -fy·y·∂denom/∂x / denom²
507    /// ∂v/∂y = fy · (denom - y·∂denom/∂y) / denom²
508    /// ∂v/∂z = -fy·y·∂denom/∂z / denom²
509    /// ```
510    ///
511    /// Final Jacobian matrix (2×3):
512    ///
513    /// ```text
514    /// J = [ ∂u/∂x  ∂u/∂y  ∂u/∂z ]
515    ///     [ ∂v/∂x  ∂v/∂y  ∂v/∂z ]
516    /// ```
517    ///
518    /// # Arguments
519    ///
520    /// * `p_cam` - 3D point in camera coordinate frame.
521    ///
522    /// # Returns
523    ///
524    /// Returns the 2x3 Jacobian matrix.
525    ///
526    /// # References
527    ///
528    /// - Usenko et al., "The Double Sphere Camera Model", 3DV 2018 (Supplementary Material)
529    /// - Verified against numerical differentiation in tests
530    ///
531    /// # Implementation Note
532    ///
533    /// The implementation uses the chain rule systematically through intermediate quantities
534    /// d₁, w, d₂, and denom to ensure numerical stability and code clarity.
535    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
536        let x = p_cam[0];
537        let y = p_cam[1];
538        let z = p_cam[2];
539
540        let (xi, alpha) = self.distortion_params();
541        let r2 = x * x + y * y;
542        let d1 = (r2 + z * z).sqrt();
543        let xi_d1_z = xi * d1 + z;
544        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
545        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
546
547        // Cache reciprocals to avoid repeated divisions
548        let inv_d1 = 1.0 / d1;
549        let inv_d2 = 1.0 / d2;
550
551        // ∂d₁/∂x = x/d₁, ∂d₁/∂y = y/d₁, ∂d₁/∂z = z/d₁
552        let dd1_dx = x * inv_d1;
553        let dd1_dy = y * inv_d1;
554        let dd1_dz = z * inv_d1;
555
556        // ∂(ξ·d₁+z)/∂x = ξ·∂d₁/∂x
557        let d_xi_d1_z_dx = xi * dd1_dx;
558        let d_xi_d1_z_dy = xi * dd1_dy;
559        let d_xi_d1_z_dz = xi * dd1_dz + 1.0;
560
561        // ∂d₂/∂x = (x + (ξ·d₁+z)·∂(ξ·d₁+z)/∂x) / d₂
562        let dd2_dx = (x + xi_d1_z * d_xi_d1_z_dx) * inv_d2;
563        let dd2_dy = (y + xi_d1_z * d_xi_d1_z_dy) * inv_d2;
564        let dd2_dz = (xi_d1_z * d_xi_d1_z_dz) * inv_d2;
565
566        // ∂denom/∂x = α·∂d₂/∂x + (1-α)·∂(ξ·d₁+z)/∂x
567        let ddenom_dx = alpha * dd2_dx + (1.0 - alpha) * d_xi_d1_z_dx;
568        let ddenom_dy = alpha * dd2_dy + (1.0 - alpha) * d_xi_d1_z_dy;
569        let ddenom_dz = alpha * dd2_dz + (1.0 - alpha) * d_xi_d1_z_dz;
570
571        let denom2 = denom * denom;
572
573        // ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
574        let du_dx = self.pinhole.fx * (denom - x * ddenom_dx) / denom2;
575        let du_dy = self.pinhole.fx * (-x * ddenom_dy) / denom2;
576        let du_dz = self.pinhole.fx * (-x * ddenom_dz) / denom2;
577
578        let dv_dx = self.pinhole.fy * (-y * ddenom_dx) / denom2;
579        let dv_dy = self.pinhole.fy * (denom - y * ddenom_dy) / denom2;
580        let dv_dz = self.pinhole.fy * (-y * ddenom_dz) / denom2;
581
582        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
583    }
584
585    /// Jacobian of projection w.r.t. intrinsic parameters (2×6).
586    ///
587    /// Computes ∂π/∂K where K = [fx, fy, cx, cy, ξ, α] are the intrinsic parameters.
588    ///
589    /// # Mathematical Derivation
590    ///
591    /// The intrinsic parameters consist of:
592    /// 1. **Linear parameters**: fx, fy, cx, cy (pinhole projection)
593    /// 2. **Distortion parameters**: ξ (xi), α (alpha) (Double Sphere specific)
594    ///
595    /// ## Projection Model Recap
596    ///
597    /// ```text
598    /// d₁ = √(x² + y² + z²)
599    /// w = ξ·d₁ + z
600    /// d₂ = √(x² + y² + w²)
601    /// denom = α·d₂ + (1-α)·w
602    /// u = fx · (x/denom) + cx
603    /// v = fy · (y/denom) + cy
604    /// ```
605    ///
606    /// ## Part 1: Linear Parameters (fx, fy, cx, cy)
607    ///
608    /// These have direct, simple derivatives:
609    ///
610    /// ### Focal lengths (fx, fy):
611    /// ```text
612    /// ∂u/∂fx = x/denom    (coefficient of fx in u)
613    /// ∂u/∂fy = 0          (fy doesn't affect u)
614    /// ∂v/∂fx = 0          (fx doesn't affect v)
615    /// ∂v/∂fy = y/denom    (coefficient of fy in v)
616    /// ```
617    ///
618    /// ### Principal point (cx, cy):
619    /// ```text
620    /// ∂u/∂cx = 1          (additive constant)
621    /// ∂u/∂cy = 0          (cy doesn't affect u)
622    /// ∂v/∂cx = 0          (cx doesn't affect v)
623    /// ∂v/∂cy = 1          (additive constant)
624    /// ```
625    ///
626    /// ## Part 2: Distortion Parameters (ξ, α)
627    ///
628    /// These affect the projection through the denominator term.
629    ///
630    /// ### Derivative w.r.t. ξ (xi):
631    ///
632    /// Since w = ξ·d₁ + z and d₂ = √(x² + y² + w²), we have:
633    /// ```text
634    /// ∂w/∂ξ = d₁
635    ///
636    /// ∂d₂/∂ξ = ∂d₂/∂w · ∂w/∂ξ
637    ///        = (w/d₂) · d₁
638    ///        = w·d₁/d₂
639    ///
640    /// ∂denom/∂ξ = α·∂d₂/∂ξ + (1-α)·∂w/∂ξ
641    ///           = α·(w·d₁/d₂) + (1-α)·d₁
642    ///           = d₁·[α·w/d₂ + (1-α)]
643    /// ```
644    ///
645    /// Using the quotient rule on u = fx·(x/denom) + cx:
646    /// ```text
647    /// ∂u/∂ξ = fx · ∂(x/denom)/∂ξ
648    ///       = fx · (-x/denom²) · ∂denom/∂ξ
649    ///       = -fx·x·∂denom/∂ξ / denom²
650    /// ```
651    ///
652    /// Similarly:
653    /// ```text
654    /// ∂v/∂ξ = -fy·y·∂denom/∂ξ / denom²
655    /// ```
656    ///
657    /// ### Derivative w.r.t. α (alpha):
658    ///
659    /// Since denom = α·d₂ + (1-α)·w:
660    /// ```text
661    /// ∂denom/∂α = d₂ - w
662    ///
663    /// ∂u/∂α = -fx·x·(d₂ - w) / denom²
664    /// ∂v/∂α = -fy·y·(d₂ - w) / denom²
665    /// ```
666    ///
667    /// ## Final Jacobian Matrix (2×6)
668    ///
669    /// ```text
670    /// J = [ ∂u/∂fx  ∂u/∂y  ∂u/∂cx  ∂u/∂cy  ∂u/∂ξ  ∂u/∂α ]
671    ///     [ ∂v/∂x  ∂v/∂y  ∂v/∂cx  ∂v/∂cy  ∂v/∂ξ  ∂v/∂α ]
672    ///
673    ///   = [ x/denom    0       1       0      -fx·x·∂denom/∂ξ/denom²  -fx·x·(d₂-w)/denom² ]
674    ///     [   0     y/denom    0       1      -fy·y·∂denom/∂ξ/denom²  -fy·y·(d₂-w)/denom² ]
675    /// ```
676    ///
677    /// # Arguments
678    ///
679    /// * `p_cam` - 3D point in camera coordinate frame.
680    ///
681    /// # Returns
682    ///
683    /// Returns the 2x6 Intrinsic Jacobian matrix.
684    ///
685    /// # References
686    ///
687    /// - Usenko et al., "The Double Sphere Camera Model", 3DV 2018
688    /// - Verified against numerical differentiation in tests
689    ///
690    /// # Implementation Note
691    ///
692    /// The implementation computes all intermediate values (d₁, w, d₂, denom)
693    /// first, then applies the chain rule derivatives systematically.
694    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
695        let x = p_cam[0];
696        let y = p_cam[1];
697        let z = p_cam[2];
698
699        let (xi, alpha) = self.distortion_params();
700        let r2 = x * x + y * y;
701        let d1 = (r2 + z * z).sqrt();
702        let xi_d1_z = xi * d1 + z;
703        let d2 = (r2 + xi_d1_z * xi_d1_z).sqrt();
704        let denom = alpha * d2 + (1.0 - alpha) * xi_d1_z;
705
706        // Cache reciprocals to avoid repeated divisions
707        let inv_denom = 1.0 / denom;
708        let inv_d2 = 1.0 / d2;
709
710        let x_norm = x * inv_denom;
711        let y_norm = y * inv_denom;
712
713        // ∂u/∂fx = x/denom, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
714        // ∂v/∂fx = 0, ∂v/∂fy = y/denom, ∂v/∂cx = 0, ∂v/∂cy = 1
715
716        // For ξ and α derivatives
717        let d_xi_d1_z_dxi = d1;
718        let dd2_dxi = (xi_d1_z * d_xi_d1_z_dxi) * inv_d2;
719        let ddenom_dxi = alpha * dd2_dxi + (1.0 - alpha) * d_xi_d1_z_dxi;
720
721        let ddenom_dalpha = d2 - xi_d1_z;
722
723        let inv_denom2 = inv_denom * inv_denom;
724
725        let du_dxi = -self.pinhole.fx * x * ddenom_dxi * inv_denom2;
726        let dv_dxi = -self.pinhole.fy * y * ddenom_dxi * inv_denom2;
727
728        let du_dalpha = -self.pinhole.fx * x * ddenom_dalpha * inv_denom2;
729        let dv_dalpha = -self.pinhole.fy * y * ddenom_dalpha * inv_denom2;
730
731        SMatrix::<f64, 2, 6>::new(
732            x_norm, 0.0, 1.0, 0.0, du_dxi, du_dalpha, 0.0, y_norm, 0.0, 1.0, dv_dxi, dv_dalpha,
733        )
734    }
735
736    /// Validates camera parameters.
737    ///
738    /// # Validation Rules
739    ///
740    /// - fx, fy must be positive (> 0)
741    /// - fx, fy must be finite
742    /// - cx, cy must be finite
743    /// - ξ must be finite
744    /// - α must be in (0, 1]
745    ///
746    /// # Errors
747    ///
748    /// Returns [`CameraModelError`] if any parameter violates the validation rules.
749    fn validate_params(&self) -> Result<(), CameraModelError> {
750        self.pinhole.validate()?;
751        self.get_distortion().validate()
752    }
753
754    /// Returns the pinhole parameters of the camera.
755    ///
756    /// # Returns
757    ///
758    /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
759    fn get_pinhole_params(&self) -> PinholeParams {
760        PinholeParams {
761            fx: self.pinhole.fx,
762            fy: self.pinhole.fy,
763            cx: self.pinhole.cx,
764            cy: self.pinhole.cy,
765        }
766    }
767
768    /// Returns the distortion model and parameters of the camera.
769    ///
770    /// # Returns
771    ///
772    /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::DoubleSphere`]).
773    fn get_distortion(&self) -> DistortionModel {
774        self.distortion
775    }
776
777    /// Returns the string identifier for the camera model.
778    ///
779    /// # Returns
780    ///
781    /// The string `"double_sphere"`.
782    fn get_model_name(&self) -> &'static str {
783        "double_sphere"
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use nalgebra::{Matrix2xX, Matrix3xX};
791
792    type TestResult = Result<(), Box<dyn std::error::Error>>;
793
794    #[test]
795    fn test_double_sphere_camera_creation() -> TestResult {
796        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
797        let distortion = DistortionModel::DoubleSphere {
798            xi: -0.2,
799            alpha: 0.6,
800        };
801        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
802        assert_eq!(camera.pinhole.fx, 300.0);
803        let (xi, alpha) = camera.distortion_params();
804        assert_eq!(alpha, 0.6);
805        assert_eq!(xi, -0.2);
806
807        Ok(())
808    }
809
810    #[test]
811    fn test_projection_at_optical_axis() -> TestResult {
812        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
813        let distortion = DistortionModel::DoubleSphere {
814            xi: -0.2,
815            alpha: 0.6,
816        };
817        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
818        let p_cam = Vector3::new(0.0, 0.0, 1.0);
819        let uv = camera.project(&p_cam)?;
820
821        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
822        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
823
824        Ok(())
825    }
826
827    #[test]
828    fn test_jacobian_point_numerical() -> TestResult {
829        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
830        let distortion = DistortionModel::DoubleSphere {
831            xi: -0.2,
832            alpha: 0.6,
833        };
834        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
835        let p_cam = Vector3::new(0.1, 0.2, 1.0);
836
837        let jac_analytical = camera.jacobian_point(&p_cam);
838        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
839
840        for i in 0..3 {
841            let mut p_plus = p_cam;
842            let mut p_minus = p_cam;
843            p_plus[i] += eps;
844            p_minus[i] -= eps;
845
846            let uv_plus = camera.project(&p_plus)?;
847            let uv_minus = camera.project(&p_minus)?;
848            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
849
850            for r in 0..2 {
851                assert!(
852                    jac_analytical[(r, i)].is_finite(),
853                    "Jacobian [{r},{i}] is not finite"
854                );
855                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
856                assert!(
857                    diff < crate::JACOBIAN_TEST_TOLERANCE,
858                    "Mismatch at ({}, {})",
859                    r,
860                    i
861                );
862            }
863        }
864        Ok(())
865    }
866
867    #[test]
868    fn test_jacobian_intrinsics_numerical() -> TestResult {
869        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
870        let distortion = DistortionModel::DoubleSphere {
871            xi: -0.2,
872            alpha: 0.6,
873        };
874        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
875        let p_cam = Vector3::new(0.1, 0.2, 1.0);
876
877        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
878        let params: DVector<f64> = (&camera).into();
879        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
880
881        for i in 0..6 {
882            let mut params_plus = params.clone();
883            let mut params_minus = params.clone();
884            params_plus[i] += eps;
885            params_minus[i] -= eps;
886
887            let cam_plus = DoubleSphereCamera::from(params_plus.as_slice());
888            let cam_minus = DoubleSphereCamera::from(params_minus.as_slice());
889
890            let uv_plus = cam_plus.project(&p_cam)?;
891            let uv_minus = cam_minus.project(&p_cam)?;
892            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
893
894            for r in 0..2 {
895                assert!(
896                    jac_analytical[(r, i)].is_finite(),
897                    "Jacobian [{r},{i}] is not finite"
898                );
899                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
900                assert!(
901                    diff < crate::JACOBIAN_TEST_TOLERANCE,
902                    "Mismatch at ({}, {})",
903                    r,
904                    i
905                );
906            }
907        }
908        Ok(())
909    }
910
911    #[test]
912    fn test_linear_estimation() -> TestResult {
913        // Ground truth DoubleSphere camera with xi=0.0 (linear_estimation fixes xi=0.0)
914        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
915        let gt_distortion = DistortionModel::DoubleSphere {
916            xi: 0.0,
917            alpha: 0.6,
918        };
919        let gt_camera = DoubleSphereCamera::new(gt_pinhole, gt_distortion)?;
920
921        // Generate synthetic 3D points in camera frame
922        let n_points = 50;
923        let mut pts_3d = Matrix3xX::zeros(n_points);
924        let mut pts_2d = Matrix2xX::zeros(n_points);
925        let mut valid = 0;
926
927        for i in 0..n_points {
928            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
929            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
930            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
931
932            if let Ok(p2d) = gt_camera.project(&p3d) {
933                pts_3d.set_column(valid, &p3d);
934                pts_2d.set_column(valid, &p2d);
935                valid += 1;
936            }
937        }
938        let pts_3d = pts_3d.columns(0, valid).into_owned();
939        let pts_2d = pts_2d.columns(0, valid).into_owned();
940
941        // Initial camera with small alpha (alpha must be > 0 for validation)
942        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
943        let init_distortion = DistortionModel::DoubleSphere {
944            xi: 0.0,
945            alpha: 0.1,
946        };
947        let mut camera = DoubleSphereCamera::new(init_pinhole, init_distortion)?;
948
949        camera.linear_estimation(&pts_3d, &pts_2d)?;
950
951        // Verify reprojection error
952        for i in 0..valid {
953            let p3d = pts_3d.column(i).into_owned();
954            let projected = camera.project(&Vector3::new(p3d.x, p3d.y, p3d.z))?;
955            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
956                + (projected.y - pts_2d[(1, i)]).powi(2))
957            .sqrt();
958            assert!(err < 1.0, "Reprojection error too large: {err}");
959        }
960
961        Ok(())
962    }
963
964    #[test]
965    fn test_project_unproject_round_trip() -> TestResult {
966        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
967        let distortion = DistortionModel::DoubleSphere {
968            xi: -0.2,
969            alpha: 0.6,
970        };
971        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
972
973        let test_points = [
974            Vector3::new(0.1, 0.2, 1.0),
975            Vector3::new(-0.3, 0.1, 2.0),
976            Vector3::new(0.05, -0.1, 0.5),
977        ];
978
979        for p_cam in &test_points {
980            let uv = camera.project(p_cam)?;
981            let ray = camera.unproject(&uv)?;
982            let dot = ray.dot(&p_cam.normalize());
983            assert!(
984                (dot - 1.0).abs() < 1e-6,
985                "Round-trip failed: dot={dot}, expected ~1.0"
986            );
987        }
988
989        Ok(())
990    }
991
992    #[test]
993    fn test_project_returns_error_behind_camera() -> TestResult {
994        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
995        let distortion = DistortionModel::DoubleSphere {
996            xi: -0.2,
997            alpha: 0.6,
998        };
999        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1000        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
1001        Ok(())
1002    }
1003
1004    #[test]
1005    fn test_project_at_min_depth_boundary() -> TestResult {
1006        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1007        let distortion = DistortionModel::DoubleSphere {
1008            xi: -0.2,
1009            alpha: 0.6,
1010        };
1011        let camera = DoubleSphereCamera::new(pinhole, distortion)?;
1012        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
1013        if let Ok(uv) = camera.project(&p_min) {
1014            assert!(uv.x.is_finite() && uv.y.is_finite());
1015        }
1016        Ok(())
1017    }
1018}