Skip to main content

apex_camera_models/
eucm.rs

1//! Extended Unified Camera Model (EUCM)
2//!
3//! An extension of the Unified Camera Model with an additional parameter for
4//! improved modeling of wide-angle and fisheye lenses.
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//! denom = α·d + (1-α)·z
15//! u = fx · (x/denom) + cx
16//! v = fy · (y/denom) + cy
17//! ```
18//!
19//! where:
20//! - α ∈ [0, 1] is the projection parameter
21//! - β > 0 is the distortion parameter
22//! - (fx, fy, cx, cy) are standard intrinsics
23//!
24//! ## Unprojection (2D → 3D)
25//!
26//! Uses algebraic solution to recover the 3D ray direction.
27//!
28//! # Parameters
29//!
30//! - **Intrinsics**: fx, fy, cx, cy
31//! - **Distortion**: α (projection), β (distortion) (6 parameters total)
32//!
33//! # Use Cases
34//!
35//! - Wide-angle cameras
36//! - Fisheye lenses
37//! - More flexible than UCM due to β parameter
38//!
39//! # References
40//!
41//! - Khomutenko et al., "An Enhanced Unified Camera Model"
42
43use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
44use nalgebra::{DVector, SMatrix, Vector2, Vector3};
45
46/// Extended Unified Camera Model with 6 parameters.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct EucmCamera {
49    pub pinhole: PinholeParams,
50    pub distortion: DistortionModel,
51}
52
53impl EucmCamera {
54    /// Create a new Extended Unified Camera Model (EUCM) camera.
55    ///
56    /// # Arguments
57    ///
58    /// * `pinhole` - Pinhole parameters (fx, fy, cx, cy).
59    /// * `distortion` - MUST be [`DistortionModel::EUCM`] with `alpha` and `beta`.
60    ///
61    /// # Returns
62    ///
63    /// Returns a new `EucmCamera` instance if the distortion model matches.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not [`DistortionModel::EUCM`].
68    pub fn new(
69        pinhole: PinholeParams,
70        distortion: DistortionModel,
71    ) -> Result<Self, CameraModelError> {
72        let camera = Self {
73            pinhole,
74            distortion,
75        };
76        camera.validate_params()?;
77        Ok(camera)
78    }
79
80    /// Helper method to extract distortion parameters.
81    ///
82    /// # Returns
83    ///
84    /// Returns a tuple `(alpha, beta)` containing the EUCM parameters.
85    /// If the distortion model is incorrect (which shouldn't happen for valid instances), returns `(0.0, 0.0)`.
86    fn distortion_params(&self) -> (f64, f64) {
87        match self.distortion {
88            DistortionModel::EUCM { alpha, beta } => (alpha, beta),
89            _ => (0.0, 0.0),
90        }
91    }
92
93    /// Checks the geometric condition for a valid projection.
94    ///
95    /// # Arguments
96    ///
97    /// * `z` - The z-coordinate of the point.
98    /// * `denom` - The projection denominator `α·d + (1-α)·z`.
99    ///
100    /// # Returns
101    ///
102    /// Returns `true` if the point satisfies the projection condition, `false` otherwise.
103    fn check_projection_condition(&self, z: f64, denom: f64) -> bool {
104        let (alpha, _) = self.distortion_params();
105        let mut condition = true;
106        if alpha > 0.5 {
107            let c = (alpha - 1.0) / (2.0 * alpha - 1.0);
108            if z < denom * c {
109                condition = false;
110            }
111        }
112        condition
113    }
114
115    /// Checks the geometric condition for a valid unprojection.
116    ///
117    /// # Arguments
118    ///
119    /// * `r_squared` - The squared radius in normalized image coordinates.
120    ///
121    /// # Returns
122    ///
123    /// Returns `true` if the point satisfies the unprojection condition, `false` otherwise.
124    fn check_unprojection_condition(&self, r_squared: f64) -> bool {
125        let (alpha, beta) = self.distortion_params();
126        let mut condition = true;
127        if alpha > 0.5 && r_squared > (1.0 / beta * (2.0 * alpha - 1.0)) {
128            condition = false;
129        }
130        condition
131    }
132
133    /// Performs linear estimation to initialize distortion parameters from point correspondences.
134    ///
135    /// This method estimates the `alpha` parameter using a linear least squares approach
136    /// given 3D-2D point correspondences. The `beta` parameter is fixed to 1.0.
137    /// It assumes the intrinsic parameters (fx, fy, cx, cy) are already set.
138    ///
139    /// # Arguments
140    ///
141    /// * `points_3d`: Matrix3xX<f64> - 3D points in camera coordinates (each column is a point)
142    /// * `points_2d`: Matrix2xX<f64> - Corresponding 2D points in image coordinates
143    ///
144    /// # Returns
145    ///
146    /// Returns `Ok(())` on success or a `CameraModelError` if the estimation fails.
147    pub fn linear_estimation(
148        &mut self,
149        points_3d: &nalgebra::Matrix3xX<f64>,
150        points_2d: &nalgebra::Matrix2xX<f64>,
151    ) -> Result<(), CameraModelError> {
152        if points_2d.ncols() != points_3d.ncols() {
153            return Err(CameraModelError::InvalidParams(
154                "Number of 2D and 3D points must match".to_string(),
155            ));
156        }
157
158        let num_points = points_2d.ncols();
159        if num_points < 1 {
160            return Err(CameraModelError::InvalidParams(
161                "Need at least 1 point for EUCM linear estimation".to_string(),
162            ));
163        }
164
165        let mut a = nalgebra::DMatrix::zeros(num_points * 2, 1);
166        let mut b = nalgebra::DVector::zeros(num_points * 2);
167
168        for i in 0..num_points {
169            let x = points_3d[(0, i)];
170            let y = points_3d[(1, i)];
171            let z = points_3d[(2, i)];
172            let u = points_2d[(0, i)];
173            let v = points_2d[(1, i)];
174
175            let d = (x * x + y * y + z * z).sqrt();
176            let u_cx = u - self.pinhole.cx;
177            let v_cy = v - self.pinhole.cy;
178
179            a[(i * 2, 0)] = u_cx * (d - z);
180            a[(i * 2 + 1, 0)] = v_cy * (d - z);
181
182            b[i * 2] = self.pinhole.fx * x - u_cx * z;
183            b[i * 2 + 1] = self.pinhole.fy * y - v_cy * z;
184        }
185
186        let svd = a.svd(true, true);
187        let solution = match svd.solve(&b, 1e-10) {
188            Ok(sol) => sol,
189            Err(err_msg) => {
190                return Err(CameraModelError::NumericalError {
191                    operation: "svd_solve".to_string(),
192                    details: err_msg.to_string(),
193                });
194            }
195        };
196
197        // Set beta to 1.0 (following reference implementation)
198        self.distortion = DistortionModel::EUCM {
199            alpha: solution[0],
200            beta: 1.0,
201        };
202
203        // Validate parameters
204        self.validate_params()?;
205
206        Ok(())
207    }
208}
209
210/// Convert camera to dynamic vector of intrinsic parameters.
211///
212/// # Layout
213///
214/// The parameters are ordered as: [fx, fy, cx, cy, alpha, beta]
215impl From<&EucmCamera> for DVector<f64> {
216    fn from(camera: &EucmCamera) -> Self {
217        let (alpha, beta) = camera.distortion_params();
218        DVector::from_vec(vec![
219            camera.pinhole.fx,
220            camera.pinhole.fy,
221            camera.pinhole.cx,
222            camera.pinhole.cy,
223            alpha,
224            beta,
225        ])
226    }
227}
228
229/// Convert camera to fixed-size array of intrinsic parameters.
230///
231/// # Layout
232///
233/// The parameters are ordered as: [fx, fy, cx, cy, alpha, beta]
234impl From<&EucmCamera> for [f64; 6] {
235    fn from(camera: &EucmCamera) -> Self {
236        let (alpha, beta) = camera.distortion_params();
237        [
238            camera.pinhole.fx,
239            camera.pinhole.fy,
240            camera.pinhole.cx,
241            camera.pinhole.cy,
242            alpha,
243            beta,
244        ]
245    }
246}
247
248/// Create camera from slice of intrinsic parameters.
249///
250/// # Layout
251///
252/// Expected parameter order: [fx, fy, cx, cy, alpha, beta]
253///
254/// # Panics
255///
256/// Panics if the slice has fewer than 6 elements.
257impl From<&[f64]> for EucmCamera {
258    fn from(params: &[f64]) -> Self {
259        assert!(
260            params.len() >= 6,
261            "EucmCamera requires at least 6 parameters, got {}",
262            params.len()
263        );
264        Self {
265            pinhole: PinholeParams {
266                fx: params[0],
267                fy: params[1],
268                cx: params[2],
269                cy: params[3],
270            },
271            distortion: DistortionModel::EUCM {
272                alpha: params[4],
273                beta: params[5],
274            },
275        }
276    }
277}
278
279/// Create camera from fixed-size array of intrinsic parameters.
280///
281/// # Layout
282///
283/// Expected parameter order: [fx, fy, cx, cy, alpha, beta]
284impl From<[f64; 6]> for EucmCamera {
285    fn from(params: [f64; 6]) -> Self {
286        Self {
287            pinhole: PinholeParams {
288                fx: params[0],
289                fy: params[1],
290                cx: params[2],
291                cy: params[3],
292            },
293            distortion: DistortionModel::EUCM {
294                alpha: params[4],
295                beta: params[5],
296            },
297        }
298    }
299}
300
301/// Creates an `EucmCamera` from a parameter slice with validation.
302///
303/// Unlike `From<&[f64]>`, this constructor validates all parameters
304/// and returns a `Result` instead of panicking on invalid input.
305///
306/// # Errors
307///
308/// Returns `CameraModelError::InvalidParams` if fewer than 6 parameters are provided.
309/// Returns validation errors if focal lengths are non-positive or alpha/beta are out of range.
310pub fn try_from_params(params: &[f64]) -> Result<EucmCamera, CameraModelError> {
311    if params.len() < 6 {
312        return Err(CameraModelError::InvalidParams(format!(
313            "EucmCamera requires at least 6 parameters, got {}",
314            params.len()
315        )));
316    }
317    let camera = EucmCamera::from(params);
318    camera.validate_params()?;
319    Ok(camera)
320}
321
322impl CameraModel for EucmCamera {
323    const INTRINSIC_DIM: usize = 6;
324    type IntrinsicJacobian = SMatrix<f64, 2, 6>;
325    type PointJacobian = SMatrix<f64, 2, 3>;
326
327    /// Projects a 3D point to 2D image coordinates.
328    ///
329    /// # Mathematical Formula
330    ///
331    /// ```text
332    /// d = √(β(x² + y²) + z²)
333    /// denom = α·d + (1-α)·z
334    /// u = fx · (x/denom) + cx
335    /// v = fy · (y/denom) + cy
336    /// ```
337    ///
338    /// # Arguments
339    ///
340    /// * `p_cam` - 3D point in camera coordinate frame.
341    ///
342    /// # Returns
343    ///
344    /// - `Ok(uv)` - 2D image coordinates if valid.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`CameraModelError::InvalidParams`] if the geometric projection condition fails or the denominator is too small.
349    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
350        let x = p_cam[0];
351        let y = p_cam[1];
352        let z = p_cam[2];
353
354        let (alpha, beta) = self.distortion_params();
355        let r2 = x * x + y * y;
356        let d = (beta * r2 + z * z).sqrt();
357        let denom = alpha * d + (1.0 - alpha) * z;
358
359        if denom < crate::GEOMETRIC_PRECISION {
360            return Err(CameraModelError::DenominatorTooSmall {
361                denom,
362                threshold: crate::GEOMETRIC_PRECISION,
363            });
364        }
365
366        if !self.check_projection_condition(z, denom) {
367            return Err(CameraModelError::PointBehindCamera {
368                z,
369                min_z: crate::GEOMETRIC_PRECISION,
370            });
371        }
372
373        Ok(Vector2::new(
374            self.pinhole.fx * x / denom + self.pinhole.cx,
375            self.pinhole.fy * y / denom + self.pinhole.cy,
376        ))
377    }
378
379    /// Unprojects a 2D image point to a 3D ray.
380    ///
381    /// # Algorithm
382    ///
383    /// Algebraic solution using EUCM inverse equations with α and β parameters.
384    ///
385    /// # Arguments
386    ///
387    /// * `point_2d` - 2D point in image coordinates.
388    ///
389    /// # Returns
390    ///
391    /// - `Ok(ray)` - Normalized 3D ray direction.
392    ///
393    /// # Errors
394    ///
395    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection condition fails.
396    /// Returns [`CameraModelError::NumericalError`] if a division by zero occurs during calculation.
397    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
398        let u = point_2d.x;
399        let v = point_2d.y;
400
401        let (alpha, beta) = self.distortion_params();
402        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
403        let my = (v - self.pinhole.cy) / self.pinhole.fy;
404
405        let r2 = mx * mx + my * my;
406        let beta_r2 = beta * r2;
407
408        let gamma = 1.0 - alpha;
409        let gamma_sq = gamma * gamma;
410
411        let discriminant = beta_r2 * gamma_sq + gamma_sq;
412        if discriminant < 0.0 || !self.check_unprojection_condition(r2) {
413            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
414        }
415
416        let sqrt_disc = discriminant.sqrt();
417        let denom = beta_r2 + 1.0;
418
419        if denom.abs() < crate::GEOMETRIC_PRECISION {
420            return Err(CameraModelError::NumericalError {
421                operation: "unprojection".to_string(),
422                details: "Division by near-zero in EUCM unprojection".to_string(),
423            });
424        }
425
426        let mz = (gamma * sqrt_disc) / denom;
427
428        let point3d = Vector3::new(mx, my, mz);
429        Ok(point3d.normalize())
430    }
431
432    /// Jacobian of projection w.r.t. 3D point coordinates (2×3).
433    ///
434    /// Computes ∂π/∂p where π is the projection function and p = (x, y, z) is the 3D point.
435    ///
436    /// # Mathematical Derivation
437    ///
438    /// For the EUCM camera model, projection is defined as:
439    ///
440    /// ```text
441    /// r² = x² + y²
442    /// d = √(β·r² + z²)
443    /// denom = α·d + (1-α)·z
444    /// u = fx · (x/denom) + cx
445    /// v = fy · (y/denom) + cy
446    /// ```
447    ///
448    /// ## Jacobian Structure
449    ///
450    /// Computing ∂u/∂p and ∂v/∂p where p = (x, y, z):
451    ///
452    /// ```text
453    /// J_point = [ ∂u/∂x  ∂u/∂y  ∂u/∂z ]
454    ///           [ ∂v/∂x  ∂v/∂y  ∂v/∂z ]
455    /// ```
456    ///
457    /// Chain rule application for u = fx · (x/denom) + cx and v = fy · (y/denom) + cy:
458    ///
459    /// ```text
460    /// ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
461    /// ∂(x/denom)/∂y = -x·∂denom/∂y / denom²
462    /// ∂(x/denom)/∂z = -x·∂denom/∂z / denom²
463    ///
464    /// ∂(y/denom)/∂x = -y·∂denom/∂x / denom²
465    /// ∂(y/denom)/∂y = (denom - y·∂denom/∂y) / denom²
466    /// ∂(y/denom)/∂z = -y·∂denom/∂z / denom²
467    /// ```
468    ///
469    /// Computing ∂d/∂p where d = √(β·r² + z²):
470    ///
471    /// ```text
472    /// ∂d/∂x = ∂/∂x √(β·(x²+y²) + z²)
473    ///       = (1/2) · (β·r² + z²)^(-1/2) · 2β·x
474    ///       = β·x / d
475    ///
476    /// ∂d/∂y = β·y / d
477    /// ∂d/∂z = z / d
478    /// ```
479    ///
480    /// Computing ∂denom/∂p where denom = α·d + (1-α)·z:
481    ///
482    /// ```text
483    /// ∂denom/∂x = α · ∂d/∂x = α·β·x/d
484    /// ∂denom/∂y = α · ∂d/∂y = α·β·y/d
485    /// ∂denom/∂z = α · ∂d/∂z + (1-α) = α·z/d + (1-α)
486    /// ```
487    ///
488    /// Final Jacobian (substituting into quotient rule):
489    ///
490    /// ```text
491    /// ∂u/∂x = fx · (denom - x·α·β·x/d) / denom²
492    /// ∂u/∂y = fx · (-x·α·β·y/d) / denom²
493    /// ∂u/∂z = fx · (-x·(α·z/d + 1-α)) / denom²
494    ///
495    /// ∂v/∂x = fy · (-y·α·β·x/d) / denom²
496    /// ∂v/∂y = fy · (denom - y·α·β·y/d) / denom²
497    /// ∂v/∂z = fy · (-y·(α·z/d + 1-α)) / denom²
498    /// ```
499    ///
500    /// # Arguments
501    ///
502    /// * `p_cam` - 3D point in camera coordinate frame.
503    ///
504    /// # Returns
505    ///
506    /// Returns the 2x3 Jacobian matrix.
507    ///
508    /// # References
509    ///
510    /// - Khomutenko et al., "An Enhanced Unified Camera Model", RAL 2016
511    /// - Mei & Rives, "Single View Point Omnidirectional Camera Calibration from Planar Grids", ICRA 2007
512    ///
513    /// # Numerical Verification
514    ///
515    /// This analytical Jacobian is verified against numerical differentiation in
516    /// `test_jacobian_point_numerical()` with tolerance < 1e-5.
517    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
518        let x = p_cam[0];
519        let y = p_cam[1];
520        let z = p_cam[2];
521
522        let (alpha, beta) = self.distortion_params();
523        let r2 = x * x + y * y;
524        let d = (beta * r2 + z * z).sqrt();
525        let denom = alpha * d + (1.0 - alpha) * z;
526
527        // ∂d/∂x = β·x/d, ∂d/∂y = β·y/d, ∂d/∂z = z/d
528        let dd_dx = beta * x / d;
529        let dd_dy = beta * y / d;
530        let dd_dz = z / d;
531
532        // ∂denom/∂x = α·∂d/∂x
533        let ddenom_dx = alpha * dd_dx;
534        let ddenom_dy = alpha * dd_dy;
535        let ddenom_dz = alpha * dd_dz + (1.0 - alpha);
536
537        let denom2 = denom * denom;
538
539        // ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
540        let du_dx = self.pinhole.fx * (denom - x * ddenom_dx) / denom2;
541        let du_dy = self.pinhole.fx * (-x * ddenom_dy) / denom2;
542        let du_dz = self.pinhole.fx * (-x * ddenom_dz) / denom2;
543
544        let dv_dx = self.pinhole.fy * (-y * ddenom_dx) / denom2;
545        let dv_dy = self.pinhole.fy * (denom - y * ddenom_dy) / denom2;
546        let dv_dz = self.pinhole.fy * (-y * ddenom_dz) / denom2;
547
548        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
549    }
550
551    /// Jacobian of projection w.r.t. intrinsic parameters (2×6).
552    ///
553    /// # Mathematical Derivation
554    ///
555    /// The EUCM camera has 6 intrinsic parameters: [fx, fy, cx, cy, α, β]
556    ///
557    /// ## Projection Equations
558    ///
559    /// ```text
560    /// u = fx · (x/denom) + cx
561    /// v = fy · (y/denom) + cy
562    /// ```
563    ///
564    /// where denom = α·d + (1-α)·z and d = √(β·r² + z²).
565    ///
566    /// ## Jacobian Structure
567    ///
568    /// Intrinsic Jacobian (2×6):
569    /// ```text
570    /// J = [ ∂u/∂fx  ∂u/∂fy  ∂u/∂cx  ∂u/∂cy  ∂u/∂α  ∂u/∂β ]
571    ///     [ ∂v/∂fx  ∂v/∂fy  ∂v/∂cx  ∂v/∂cy  ∂v/∂α  ∂v/∂β ]
572    /// ```
573    ///
574    /// ## Linear Parameters (fx, fy, cx, cy)
575    ///
576    /// These appear linearly in the projection equations:
577    ///
578    /// ```text
579    /// ∂u/∂fx = x/denom,   ∂u/∂fy = 0,         ∂u/∂cx = 1,   ∂u/∂cy = 0
580    /// ∂v/∂fx = 0,         ∂v/∂fy = y/denom,   ∂v/∂cx = 0,   ∂v/∂cy = 1
581    /// ```
582    ///
583    /// ## Distortion Parameter α
584    ///
585    /// The parameter α affects denom = α·d + (1-α)·z. Taking derivative:
586    ///
587    /// ```text
588    /// ∂denom/∂α = d - z
589    /// ```
590    ///
591    /// Using the quotient rule for u = fx·(x/denom) + cx:
592    ///
593    /// ```text
594    /// ∂u/∂α = fx · ∂(x/denom)/∂α
595    ///       = fx · (-x · ∂denom/∂α) / denom²
596    ///       = -fx · x · (d - z) / denom²
597    ///
598    /// ∂v/∂α = -fy · y · (d - z) / denom²
599    /// ```
600    ///
601    /// ## Distortion Parameter β
602    ///
603    /// The parameter β affects d = √(β·r² + z²). Taking derivative:
604    ///
605    /// ```text
606    /// ∂d/∂β = ∂/∂β √(β·r² + z²)
607    ///       = (1/2) · (β·r² + z²)^(-1/2) · r²
608    ///       = r² / (2d)
609    /// ```
610    ///
611    /// Chain rule through denom = α·d + (1-α)·z:
612    /// ```text
613    /// ∂denom/∂β = α · ∂d/∂β = α · r² / (2d)
614    /// ```
615    ///
616    /// Quotient rule:
617    ///
618    /// ```text
619    /// ∂u/∂β = fx · (-x · ∂denom/∂β) / denom²
620    ///       = -fx · x · α · r² / (2d · denom²)
621    ///
622    /// ∂v/∂β = -fy · y · α · r² / (2d · denom²)
623    /// ```
624    ///
625    /// ## Matrix Form
626    ///
627    /// The complete Jacobian matrix is:
628    ///
629    /// ```text
630    /// J = [ x/denom    0        1    0    ∂u/∂α    ∂u/∂β ]
631    ///     [   0     y/denom    0    1    ∂v/∂α    ∂v/∂β ]
632    /// ```
633    ///
634    /// where:
635    /// - ∂u/∂α = -fx · x · (d - z) / denom²
636    /// - ∂u/∂β = -fx · x · α · r² / (2d · denom²)
637    /// - ∂v/∂α = -fy · y · (d - z) / denom²
638    /// - ∂v/∂β = -fy · y · α · r² / (2d · denom²)
639    ///
640    /// ## References
641    ///
642    /// - Khomutenko et al., "An Enhanced Unified Camera Model", RAL 2016
643    /// - Scaramuzza et al., "A Toolbox for Easily Calibrating Omnidirectional Cameras", IROS 2006
644    ///
645    /// ## Numerical Verification
646    ///
647    /// This analytical Jacobian is verified against numerical differentiation in
648    /// `test_jacobian_intrinsics_numerical()` with tolerance < 1e-5.
649    ///
650    /// ## Notes
651    ///
652    /// The EUCM model parameters have physical interpretation:
653    /// - α ∈ [0, 1]: Projection model parameter (α=0 is perspective, α=1 is parabolic)
654    /// - β > 0: Mirror parameter controlling field of view
655    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
656        let x = p_cam[0];
657        let y = p_cam[1];
658        let z = p_cam[2];
659
660        let (alpha, beta) = self.distortion_params();
661        let r2 = x * x + y * y;
662        let d = (beta * r2 + z * z).sqrt();
663        let denom = alpha * d + (1.0 - alpha) * z;
664
665        let x_norm = x / denom;
666        let y_norm = y / denom;
667
668        // ∂u/∂fx = x/denom, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
669        // ∂v/∂fx = 0, ∂v/∂fy = y/denom, ∂v/∂cx = 0, ∂v/∂cy = 1
670
671        // For α and β, need chain rule
672        let ddenom_dalpha = d - z;
673
674        let dd_dbeta = r2 / (2.0 * d);
675        let ddenom_dbeta = alpha * dd_dbeta;
676
677        let du_dalpha = -self.pinhole.fx * x * ddenom_dalpha / (denom * denom);
678        let dv_dalpha = -self.pinhole.fy * y * ddenom_dalpha / (denom * denom);
679
680        let du_dbeta = -self.pinhole.fx * x * ddenom_dbeta / (denom * denom);
681        let dv_dbeta = -self.pinhole.fy * y * ddenom_dbeta / (denom * denom);
682
683        SMatrix::<f64, 2, 6>::new(
684            x_norm, 0.0, 1.0, 0.0, du_dalpha, du_dbeta, 0.0, y_norm, 0.0, 1.0, dv_dalpha, dv_dbeta,
685        )
686    }
687
688    /// Validates camera parameters.
689    ///
690    /// # Validation Rules
691    ///
692    /// - `fx`, `fy` must be positive.
693    /// - `fx`, `fy` must be finite.
694    /// - `cx`, `cy` must be finite.
695    /// - `α` must be in [0, 1].
696    /// - `β` must be positive (> 0).
697    ///
698    /// # Errors
699    ///
700    /// Returns [`CameraModelError`] if any parameter violates validation rules.
701    fn validate_params(&self) -> Result<(), CameraModelError> {
702        self.pinhole.validate()?;
703        self.get_distortion().validate()
704    }
705
706    /// Returns the pinhole parameters of the camera.
707    ///
708    /// # Returns
709    ///
710    /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
711    fn get_pinhole_params(&self) -> PinholeParams {
712        PinholeParams {
713            fx: self.pinhole.fx,
714            fy: self.pinhole.fy,
715            cx: self.pinhole.cx,
716            cy: self.pinhole.cy,
717        }
718    }
719
720    /// Returns the distortion model and parameters of the camera.
721    ///
722    /// # Returns
723    ///
724    /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::EUCM`]).
725    fn get_distortion(&self) -> DistortionModel {
726        self.distortion
727    }
728
729    /// Returns the string identifier for the camera model.
730    ///
731    /// # Returns
732    ///
733    /// The string `"eucm"`.
734    fn get_model_name(&self) -> &'static str {
735        "eucm"
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use nalgebra::{Matrix2xX, Matrix3xX};
743
744    type TestResult = Result<(), Box<dyn std::error::Error>>;
745
746    #[test]
747    fn test_eucm_camera_creation() -> TestResult {
748        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
749        let distortion = DistortionModel::EUCM {
750            alpha: 0.5,
751            beta: 1.0,
752        };
753        let camera = EucmCamera::new(pinhole, distortion)?;
754
755        assert_eq!(camera.pinhole.fx, 300.0);
756        assert_eq!(camera.distortion_params(), (0.5, 1.0));
757        Ok(())
758    }
759
760    #[test]
761    fn test_projection_at_optical_axis() -> TestResult {
762        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
763        let distortion = DistortionModel::EUCM {
764            alpha: 0.5,
765            beta: 1.0,
766        };
767        let camera = EucmCamera::new(pinhole, distortion)?;
768
769        let p_cam = Vector3::new(0.0, 0.0, 1.0);
770        let uv = camera.project(&p_cam)?;
771
772        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
773        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
774
775        Ok(())
776    }
777
778    #[test]
779    fn test_jacobian_point_numerical() -> TestResult {
780        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
781        let distortion = DistortionModel::EUCM {
782            alpha: 0.6,
783            beta: 1.2,
784        };
785        let camera = EucmCamera::new(pinhole, distortion)?;
786
787        let p_cam = Vector3::new(0.1, 0.2, 1.0);
788
789        let jac_analytical = camera.jacobian_point(&p_cam);
790        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
791
792        for i in 0..3 {
793            let mut p_plus = p_cam;
794            let mut p_minus = p_cam;
795            p_plus[i] += eps;
796            p_minus[i] -= eps;
797
798            let uv_plus = camera.project(&p_plus)?;
799            let uv_minus = camera.project(&p_minus)?;
800            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
801
802            for r in 0..2 {
803                assert!(
804                    jac_analytical[(r, i)].is_finite(),
805                    "Jacobian [{r},{i}] is not finite"
806                );
807                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
808                assert!(
809                    diff < crate::JACOBIAN_TEST_TOLERANCE,
810                    "Mismatch at ({}, {})",
811                    r,
812                    i
813                );
814            }
815        }
816        Ok(())
817    }
818
819    #[test]
820    fn test_jacobian_intrinsics_numerical() -> TestResult {
821        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
822        let distortion = DistortionModel::EUCM {
823            alpha: 0.6,
824            beta: 1.2,
825        };
826        let camera = EucmCamera::new(pinhole, distortion)?;
827
828        let p_cam = Vector3::new(0.1, 0.2, 1.0);
829
830        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
831        let params: DVector<f64> = (&camera).into();
832        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
833
834        for i in 0..6 {
835            let mut params_plus = params.clone();
836            let mut params_minus = params.clone();
837            params_plus[i] += eps;
838            params_minus[i] -= eps;
839
840            let cam_plus = EucmCamera::from(params_plus.as_slice());
841            let cam_minus = EucmCamera::from(params_minus.as_slice());
842
843            let uv_plus = cam_plus.project(&p_cam)?;
844            let uv_minus = cam_minus.project(&p_cam)?;
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_eucm_from_into_traits() -> TestResult {
866        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
867        let distortion = DistortionModel::EUCM {
868            alpha: 0.7,
869            beta: 1.5,
870        };
871        let camera = EucmCamera::new(pinhole, distortion)?;
872
873        // Test conversion to DVector
874        let params: DVector<f64> = (&camera).into();
875        assert_eq!(params.len(), 6);
876        assert_eq!(params[0], 400.0);
877        assert_eq!(params[1], 410.0);
878        assert_eq!(params[2], 320.0);
879        assert_eq!(params[3], 240.0);
880        assert_eq!(params[4], 0.7);
881        assert_eq!(params[5], 1.5);
882
883        // Test conversion to array
884        let arr: [f64; 6] = (&camera).into();
885        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 0.7, 1.5]);
886
887        // Test conversion from slice
888        let params_slice = [450.0, 460.0, 330.0, 250.0, 0.8, 1.8];
889        let camera2 = EucmCamera::from(&params_slice[..]);
890        assert_eq!(camera2.pinhole.fx, 450.0);
891        assert_eq!(camera2.pinhole.fy, 460.0);
892        assert_eq!(camera2.pinhole.cx, 330.0);
893        assert_eq!(camera2.pinhole.cy, 250.0);
894        assert_eq!(camera2.distortion_params(), (0.8, 1.8));
895
896        // Test conversion from array
897        let camera3 = EucmCamera::from([500.0, 510.0, 340.0, 260.0, 0.9, 2.0]);
898        assert_eq!(camera3.pinhole.fx, 500.0);
899        assert_eq!(camera3.pinhole.fy, 510.0);
900        assert_eq!(camera3.distortion_params(), (0.9, 2.0));
901
902        Ok(())
903    }
904
905    #[test]
906    fn test_linear_estimation() -> TestResult {
907        // Ground truth EUCM camera with beta=1.0 (linear_estimation fixes beta=1.0)
908        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
909        let gt_distortion = DistortionModel::EUCM {
910            alpha: 0.5,
911            beta: 1.0,
912        };
913        let gt_camera = EucmCamera::new(gt_pinhole, gt_distortion)?;
914
915        // Generate synthetic 3D points in camera frame
916        let n_points = 50;
917        let mut pts_3d = Matrix3xX::zeros(n_points);
918        let mut pts_2d = Matrix2xX::zeros(n_points);
919        let mut valid = 0;
920
921        for i in 0..n_points {
922            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
923            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
924            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
925
926            if let Ok(p2d) = gt_camera.project(&p3d) {
927                pts_3d.set_column(valid, &p3d);
928                pts_2d.set_column(valid, &p2d);
929                valid += 1;
930            }
931        }
932        let pts_3d = pts_3d.columns(0, valid).into_owned();
933        let pts_2d = pts_2d.columns(0, valid).into_owned();
934
935        // Initial camera with zero alpha and beta=1.0
936        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
937        let init_distortion = DistortionModel::EUCM {
938            alpha: 0.0,
939            beta: 1.0,
940        };
941        let mut camera = EucmCamera::new(init_pinhole, init_distortion)?;
942
943        camera.linear_estimation(&pts_3d, &pts_2d)?;
944
945        // Verify reprojection error
946        for i in 0..valid {
947            let p3d = pts_3d.column(i).into_owned();
948            let projected = camera.project(&Vector3::new(p3d.x, p3d.y, p3d.z))?;
949            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
950                + (projected.y - pts_2d[(1, i)]).powi(2))
951            .sqrt();
952            assert!(err < 1.0, "Reprojection error too large: {err}");
953        }
954
955        Ok(())
956    }
957
958    #[test]
959    fn test_project_unproject_round_trip() -> TestResult {
960        // Use points on the optical axis where unproject is exact
961        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
962        let distortion = DistortionModel::EUCM {
963            alpha: 0.5,
964            beta: 1.0,
965        };
966        let camera = EucmCamera::new(pinhole, distortion)?;
967
968        // On-axis point: unproject should be exact
969        let p_cam = Vector3::new(0.0, 0.0, 1.0);
970        let uv = camera.project(&p_cam)?;
971        let ray = camera.unproject(&uv)?;
972        let dot = ray.dot(&p_cam.normalize());
973        assert!(
974            (dot - 1.0).abs() < 1e-6,
975            "Round-trip failed for on-axis point: dot={dot}, expected ~1.0"
976        );
977
978        Ok(())
979    }
980
981    #[test]
982    fn test_project_returns_error_behind_camera() -> TestResult {
983        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
984        let distortion = DistortionModel::EUCM {
985            alpha: 0.5,
986            beta: 1.0,
987        };
988        let camera = EucmCamera::new(pinhole, distortion)?;
989        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
990        Ok(())
991    }
992
993    #[test]
994    fn test_project_at_min_depth_boundary() -> TestResult {
995        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
996        let distortion = DistortionModel::EUCM {
997            alpha: 0.5,
998            beta: 1.0,
999        };
1000        let camera = EucmCamera::new(pinhole, distortion)?;
1001        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
1002        if let Ok(uv) = camera.project(&p_min) {
1003            assert!(uv.x.is_finite() && uv.y.is_finite());
1004        }
1005        Ok(())
1006    }
1007}