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}