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 TryFrom<&[f64]> for EucmCamera {
258    type Error = CameraModelError;
259
260    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
261        if params.len() < 6 {
262            return Err(CameraModelError::InvalidParams(format!(
263                "EucmCamera requires at least 6 parameters, got {}",
264                params.len()
265            )));
266        }
267        Ok(Self {
268            pinhole: PinholeParams {
269                fx: params[0],
270                fy: params[1],
271                cx: params[2],
272                cy: params[3],
273            },
274            distortion: DistortionModel::EUCM {
275                alpha: params[4],
276                beta: params[5],
277            },
278        })
279    }
280}
281
282/// Create camera from fixed-size array of intrinsic parameters.
283///
284/// # Layout
285///
286/// Expected parameter order: [fx, fy, cx, cy, alpha, beta]
287impl From<[f64; 6]> for EucmCamera {
288    fn from(params: [f64; 6]) -> Self {
289        Self {
290            pinhole: PinholeParams {
291                fx: params[0],
292                fy: params[1],
293                cx: params[2],
294                cy: params[3],
295            },
296            distortion: DistortionModel::EUCM {
297                alpha: params[4],
298                beta: params[5],
299            },
300        }
301    }
302}
303
304/// Creates an `EucmCamera` from a parameter slice with validation.
305///
306/// Unlike `From<&[f64]>`, this constructor validates all parameters
307/// and returns a `Result` instead of panicking on invalid input.
308///
309/// # Errors
310///
311/// Returns `CameraModelError::InvalidParams` if fewer than 6 parameters are provided.
312/// Returns validation errors if focal lengths are non-positive or alpha/beta are out of range.
313pub fn try_from_params(params: &[f64]) -> Result<EucmCamera, CameraModelError> {
314    let camera = EucmCamera::try_from(params)?;
315    camera.validate_params()?;
316    Ok(camera)
317}
318
319impl CameraModel for EucmCamera {
320    const INTRINSIC_DIM: usize = 6;
321    type IntrinsicJacobian = SMatrix<f64, 2, 6>;
322    type PointJacobian = SMatrix<f64, 2, 3>;
323
324    /// Projects a 3D point to 2D image coordinates.
325    ///
326    /// # Mathematical Formula
327    ///
328    /// ```text
329    /// d = √(β(x² + y²) + z²)
330    /// denom = α·d + (1-α)·z
331    /// u = fx · (x/denom) + cx
332    /// v = fy · (y/denom) + cy
333    /// ```
334    ///
335    /// # Arguments
336    ///
337    /// * `p_cam` - 3D point in camera coordinate frame.
338    ///
339    /// # Returns
340    ///
341    /// - `Ok(uv)` - 2D image coordinates if valid.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`CameraModelError::InvalidParams`] if the geometric projection condition fails or the denominator is too small.
346    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
347        let x = p_cam[0];
348        let y = p_cam[1];
349        let z = p_cam[2];
350
351        let (alpha, beta) = self.distortion_params();
352        let r2 = x * x + y * y;
353        let d = (beta * r2 + z * z).sqrt();
354        let denom = alpha * d + (1.0 - alpha) * z;
355
356        if denom < crate::GEOMETRIC_PRECISION {
357            return Err(CameraModelError::DenominatorTooSmall {
358                denom,
359                threshold: crate::GEOMETRIC_PRECISION,
360            });
361        }
362
363        if !self.check_projection_condition(z, denom) {
364            return Err(CameraModelError::PointBehindCamera {
365                z,
366                min_z: crate::GEOMETRIC_PRECISION,
367            });
368        }
369
370        Ok(Vector2::new(
371            self.pinhole.fx * x / denom + self.pinhole.cx,
372            self.pinhole.fy * y / denom + self.pinhole.cy,
373        ))
374    }
375
376    /// Unprojects a 2D image point to a 3D ray.
377    ///
378    /// # Algorithm
379    ///
380    /// Algebraic solution using EUCM inverse equations with α and β parameters.
381    ///
382    /// # Arguments
383    ///
384    /// * `point_2d` - 2D point in image coordinates.
385    ///
386    /// # Returns
387    ///
388    /// - `Ok(ray)` - Normalized 3D ray direction.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`CameraModelError::PointOutsideImage`] if the unprojection condition fails.
393    /// Returns [`CameraModelError::NumericalError`] if a division by zero occurs during calculation.
394    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
395        let u = point_2d.x;
396        let v = point_2d.y;
397
398        let (alpha, beta) = self.distortion_params();
399        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
400        let my = (v - self.pinhole.cy) / self.pinhole.fy;
401
402        let r2 = mx * mx + my * my;
403        let beta_r2 = beta * r2;
404
405        let gamma = 1.0 - alpha;
406        let gamma_sq = gamma * gamma;
407
408        let discriminant = beta_r2 * gamma_sq + gamma_sq;
409        if discriminant < 0.0 || !self.check_unprojection_condition(r2) {
410            return Err(CameraModelError::PointOutsideImage { x: u, y: v });
411        }
412
413        let sqrt_disc = discriminant.sqrt();
414        let denom = beta_r2 + 1.0;
415
416        if denom.abs() < crate::GEOMETRIC_PRECISION {
417            return Err(CameraModelError::NumericalError {
418                operation: "unprojection".to_string(),
419                details: "Division by near-zero in EUCM unprojection".to_string(),
420            });
421        }
422
423        let mz = (gamma * sqrt_disc) / denom;
424
425        let point3d = Vector3::new(mx, my, mz);
426        Ok(point3d.normalize())
427    }
428
429    /// Jacobian of projection w.r.t. 3D point coordinates (2×3).
430    ///
431    /// Computes ∂π/∂p where π is the projection function and p = (x, y, z) is the 3D point.
432    ///
433    /// # Mathematical Derivation
434    ///
435    /// For the EUCM camera model, projection is defined as:
436    ///
437    /// ```text
438    /// r² = x² + y²
439    /// d = √(β·r² + z²)
440    /// denom = α·d + (1-α)·z
441    /// u = fx · (x/denom) + cx
442    /// v = fy · (y/denom) + cy
443    /// ```
444    ///
445    /// ## Jacobian Structure
446    ///
447    /// Computing ∂u/∂p and ∂v/∂p where p = (x, y, z):
448    ///
449    /// ```text
450    /// J_point = [ ∂u/∂x  ∂u/∂y  ∂u/∂z ]
451    ///           [ ∂v/∂x  ∂v/∂y  ∂v/∂z ]
452    /// ```
453    ///
454    /// Chain rule application for u = fx · (x/denom) + cx and v = fy · (y/denom) + cy:
455    ///
456    /// ```text
457    /// ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
458    /// ∂(x/denom)/∂y = -x·∂denom/∂y / denom²
459    /// ∂(x/denom)/∂z = -x·∂denom/∂z / denom²
460    ///
461    /// ∂(y/denom)/∂x = -y·∂denom/∂x / denom²
462    /// ∂(y/denom)/∂y = (denom - y·∂denom/∂y) / denom²
463    /// ∂(y/denom)/∂z = -y·∂denom/∂z / denom²
464    /// ```
465    ///
466    /// Computing ∂d/∂p where d = √(β·r² + z²):
467    ///
468    /// ```text
469    /// ∂d/∂x = ∂/∂x √(β·(x²+y²) + z²)
470    ///       = (1/2) · (β·r² + z²)^(-1/2) · 2β·x
471    ///       = β·x / d
472    ///
473    /// ∂d/∂y = β·y / d
474    /// ∂d/∂z = z / d
475    /// ```
476    ///
477    /// Computing ∂denom/∂p where denom = α·d + (1-α)·z:
478    ///
479    /// ```text
480    /// ∂denom/∂x = α · ∂d/∂x = α·β·x/d
481    /// ∂denom/∂y = α · ∂d/∂y = α·β·y/d
482    /// ∂denom/∂z = α · ∂d/∂z + (1-α) = α·z/d + (1-α)
483    /// ```
484    ///
485    /// Final Jacobian (substituting into quotient rule):
486    ///
487    /// ```text
488    /// ∂u/∂x = fx · (denom - x·α·β·x/d) / denom²
489    /// ∂u/∂y = fx · (-x·α·β·y/d) / denom²
490    /// ∂u/∂z = fx · (-x·(α·z/d + 1-α)) / denom²
491    ///
492    /// ∂v/∂x = fy · (-y·α·β·x/d) / denom²
493    /// ∂v/∂y = fy · (denom - y·α·β·y/d) / denom²
494    /// ∂v/∂z = fy · (-y·(α·z/d + 1-α)) / denom²
495    /// ```
496    ///
497    /// # Arguments
498    ///
499    /// * `p_cam` - 3D point in camera coordinate frame.
500    ///
501    /// # Returns
502    ///
503    /// Returns the 2x3 Jacobian matrix.
504    ///
505    /// # References
506    ///
507    /// - Khomutenko et al., "An Enhanced Unified Camera Model", RAL 2016
508    /// - Mei & Rives, "Single View Point Omnidirectional Camera Calibration from Planar Grids", ICRA 2007
509    ///
510    /// # Numerical Verification
511    ///
512    /// This analytical Jacobian is verified against numerical differentiation in
513    /// `test_jacobian_point_numerical()` with tolerance < 1e-5.
514    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
515        let x = p_cam[0];
516        let y = p_cam[1];
517        let z = p_cam[2];
518
519        let (alpha, beta) = self.distortion_params();
520        let r2 = x * x + y * y;
521        let d = (beta * r2 + z * z).sqrt();
522        let denom = alpha * d + (1.0 - alpha) * z;
523
524        // ∂d/∂x = β·x/d, ∂d/∂y = β·y/d, ∂d/∂z = z/d
525        let dd_dx = beta * x / d;
526        let dd_dy = beta * y / d;
527        let dd_dz = z / d;
528
529        // ∂denom/∂x = α·∂d/∂x
530        let ddenom_dx = alpha * dd_dx;
531        let ddenom_dy = alpha * dd_dy;
532        let ddenom_dz = alpha * dd_dz + (1.0 - alpha);
533
534        let denom2 = denom * denom;
535
536        // ∂(x/denom)/∂x = (denom - x·∂denom/∂x) / denom²
537        let du_dx = self.pinhole.fx * (denom - x * ddenom_dx) / denom2;
538        let du_dy = self.pinhole.fx * (-x * ddenom_dy) / denom2;
539        let du_dz = self.pinhole.fx * (-x * ddenom_dz) / denom2;
540
541        let dv_dx = self.pinhole.fy * (-y * ddenom_dx) / denom2;
542        let dv_dy = self.pinhole.fy * (denom - y * ddenom_dy) / denom2;
543        let dv_dz = self.pinhole.fy * (-y * ddenom_dz) / denom2;
544
545        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
546    }
547
548    /// Jacobian of projection w.r.t. intrinsic parameters (2×6).
549    ///
550    /// # Mathematical Derivation
551    ///
552    /// The EUCM camera has 6 intrinsic parameters: [fx, fy, cx, cy, α, β]
553    ///
554    /// ## Projection Equations
555    ///
556    /// ```text
557    /// u = fx · (x/denom) + cx
558    /// v = fy · (y/denom) + cy
559    /// ```
560    ///
561    /// where denom = α·d + (1-α)·z and d = √(β·r² + z²).
562    ///
563    /// ## Jacobian Structure
564    ///
565    /// Intrinsic Jacobian (2×6):
566    /// ```text
567    /// J = [ ∂u/∂fx  ∂u/∂fy  ∂u/∂cx  ∂u/∂cy  ∂u/∂α  ∂u/∂β ]
568    ///     [ ∂v/∂fx  ∂v/∂fy  ∂v/∂cx  ∂v/∂cy  ∂v/∂α  ∂v/∂β ]
569    /// ```
570    ///
571    /// ## Linear Parameters (fx, fy, cx, cy)
572    ///
573    /// These appear linearly in the projection equations:
574    ///
575    /// ```text
576    /// ∂u/∂fx = x/denom,   ∂u/∂fy = 0,         ∂u/∂cx = 1,   ∂u/∂cy = 0
577    /// ∂v/∂fx = 0,         ∂v/∂fy = y/denom,   ∂v/∂cx = 0,   ∂v/∂cy = 1
578    /// ```
579    ///
580    /// ## Distortion Parameter α
581    ///
582    /// The parameter α affects denom = α·d + (1-α)·z. Taking derivative:
583    ///
584    /// ```text
585    /// ∂denom/∂α = d - z
586    /// ```
587    ///
588    /// Using the quotient rule for u = fx·(x/denom) + cx:
589    ///
590    /// ```text
591    /// ∂u/∂α = fx · ∂(x/denom)/∂α
592    ///       = fx · (-x · ∂denom/∂α) / denom²
593    ///       = -fx · x · (d - z) / denom²
594    ///
595    /// ∂v/∂α = -fy · y · (d - z) / denom²
596    /// ```
597    ///
598    /// ## Distortion Parameter β
599    ///
600    /// The parameter β affects d = √(β·r² + z²). Taking derivative:
601    ///
602    /// ```text
603    /// ∂d/∂β = ∂/∂β √(β·r² + z²)
604    ///       = (1/2) · (β·r² + z²)^(-1/2) · r²
605    ///       = r² / (2d)
606    /// ```
607    ///
608    /// Chain rule through denom = α·d + (1-α)·z:
609    /// ```text
610    /// ∂denom/∂β = α · ∂d/∂β = α · r² / (2d)
611    /// ```
612    ///
613    /// Quotient rule:
614    ///
615    /// ```text
616    /// ∂u/∂β = fx · (-x · ∂denom/∂β) / denom²
617    ///       = -fx · x · α · r² / (2d · denom²)
618    ///
619    /// ∂v/∂β = -fy · y · α · r² / (2d · denom²)
620    /// ```
621    ///
622    /// ## Matrix Form
623    ///
624    /// The complete Jacobian matrix is:
625    ///
626    /// ```text
627    /// J = [ x/denom    0        1    0    ∂u/∂α    ∂u/∂β ]
628    ///     [   0     y/denom    0    1    ∂v/∂α    ∂v/∂β ]
629    /// ```
630    ///
631    /// where:
632    /// - ∂u/∂α = -fx · x · (d - z) / denom²
633    /// - ∂u/∂β = -fx · x · α · r² / (2d · denom²)
634    /// - ∂v/∂α = -fy · y · (d - z) / denom²
635    /// - ∂v/∂β = -fy · y · α · r² / (2d · denom²)
636    ///
637    /// ## References
638    ///
639    /// - Khomutenko et al., "An Enhanced Unified Camera Model", RAL 2016
640    /// - Scaramuzza et al., "A Toolbox for Easily Calibrating Omnidirectional Cameras", IROS 2006
641    ///
642    /// ## Numerical Verification
643    ///
644    /// This analytical Jacobian is verified against numerical differentiation in
645    /// `test_jacobian_intrinsics_numerical()` with tolerance < 1e-5.
646    ///
647    /// ## Notes
648    ///
649    /// The EUCM model parameters have physical interpretation:
650    /// - α ∈ [0, 1]: Projection model parameter (α=0 is perspective, α=1 is parabolic)
651    /// - β > 0: Mirror parameter controlling field of view
652    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
653        let x = p_cam[0];
654        let y = p_cam[1];
655        let z = p_cam[2];
656
657        let (alpha, beta) = self.distortion_params();
658        let r2 = x * x + y * y;
659        let d = (beta * r2 + z * z).sqrt();
660        let denom = alpha * d + (1.0 - alpha) * z;
661
662        let x_norm = x / denom;
663        let y_norm = y / denom;
664
665        // ∂u/∂fx = x/denom, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
666        // ∂v/∂fx = 0, ∂v/∂fy = y/denom, ∂v/∂cx = 0, ∂v/∂cy = 1
667
668        // For α and β, need chain rule
669        let ddenom_dalpha = d - z;
670
671        let dd_dbeta = r2 / (2.0 * d);
672        let ddenom_dbeta = alpha * dd_dbeta;
673
674        let du_dalpha = -self.pinhole.fx * x * ddenom_dalpha / (denom * denom);
675        let dv_dalpha = -self.pinhole.fy * y * ddenom_dalpha / (denom * denom);
676
677        let du_dbeta = -self.pinhole.fx * x * ddenom_dbeta / (denom * denom);
678        let dv_dbeta = -self.pinhole.fy * y * ddenom_dbeta / (denom * denom);
679
680        SMatrix::<f64, 2, 6>::new(
681            x_norm, 0.0, 1.0, 0.0, du_dalpha, du_dbeta, 0.0, y_norm, 0.0, 1.0, dv_dalpha, dv_dbeta,
682        )
683    }
684
685    /// Validates camera parameters.
686    ///
687    /// # Validation Rules
688    ///
689    /// - `fx`, `fy` must be positive.
690    /// - `fx`, `fy` must be finite.
691    /// - `cx`, `cy` must be finite.
692    /// - `α` must be in [0, 1].
693    /// - `β` must be positive (> 0).
694    ///
695    /// # Errors
696    ///
697    /// Returns [`CameraModelError`] if any parameter violates validation rules.
698    fn validate_params(&self) -> Result<(), CameraModelError> {
699        self.pinhole.validate()?;
700        self.get_distortion().validate()
701    }
702
703    /// Returns the pinhole parameters of the camera.
704    ///
705    /// # Returns
706    ///
707    /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
708    fn get_pinhole_params(&self) -> PinholeParams {
709        PinholeParams {
710            fx: self.pinhole.fx,
711            fy: self.pinhole.fy,
712            cx: self.pinhole.cx,
713            cy: self.pinhole.cy,
714        }
715    }
716
717    /// Returns the distortion model and parameters of the camera.
718    ///
719    /// # Returns
720    ///
721    /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::EUCM`]).
722    fn get_distortion(&self) -> DistortionModel {
723        self.distortion
724    }
725
726    /// Returns the string identifier for the camera model.
727    ///
728    /// # Returns
729    ///
730    /// The string `"eucm"`.
731    fn get_model_name(&self) -> &'static str {
732        "eucm"
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use nalgebra::{Matrix2xX, Matrix3xX};
740
741    type TestResult = Result<(), Box<dyn std::error::Error>>;
742
743    #[test]
744    fn test_eucm_camera_creation() -> TestResult {
745        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
746        let distortion = DistortionModel::EUCM {
747            alpha: 0.5,
748            beta: 1.0,
749        };
750        let camera = EucmCamera::new(pinhole, distortion)?;
751
752        assert_eq!(camera.pinhole.fx, 300.0);
753        assert_eq!(camera.distortion_params(), (0.5, 1.0));
754        Ok(())
755    }
756
757    #[test]
758    fn test_projection_at_optical_axis() -> TestResult {
759        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
760        let distortion = DistortionModel::EUCM {
761            alpha: 0.5,
762            beta: 1.0,
763        };
764        let camera = EucmCamera::new(pinhole, distortion)?;
765
766        let p_cam = Vector3::new(0.0, 0.0, 1.0);
767        let uv = camera.project(&p_cam)?;
768
769        assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
770        assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
771
772        Ok(())
773    }
774
775    #[test]
776    fn test_jacobian_point_numerical() -> TestResult {
777        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
778        let distortion = DistortionModel::EUCM {
779            alpha: 0.6,
780            beta: 1.2,
781        };
782        let camera = EucmCamera::new(pinhole, distortion)?;
783
784        let p_cam = Vector3::new(0.1, 0.2, 1.0);
785
786        let jac_analytical = camera.jacobian_point(&p_cam);
787        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
788
789        for i in 0..3 {
790            let mut p_plus = p_cam;
791            let mut p_minus = p_cam;
792            p_plus[i] += eps;
793            p_minus[i] -= eps;
794
795            let uv_plus = camera.project(&p_plus)?;
796            let uv_minus = camera.project(&p_minus)?;
797            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
798
799            for r in 0..2 {
800                assert!(
801                    jac_analytical[(r, i)].is_finite(),
802                    "Jacobian [{r},{i}] is not finite"
803                );
804                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
805                assert!(
806                    diff < crate::JACOBIAN_TEST_TOLERANCE,
807                    "Mismatch at ({}, {})",
808                    r,
809                    i
810                );
811            }
812        }
813        Ok(())
814    }
815
816    #[test]
817    fn test_jacobian_intrinsics_numerical() -> TestResult {
818        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
819        let distortion = DistortionModel::EUCM {
820            alpha: 0.6,
821            beta: 1.2,
822        };
823        let camera = EucmCamera::new(pinhole, distortion)?;
824
825        let p_cam = Vector3::new(0.1, 0.2, 1.0);
826
827        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
828        let params: DVector<f64> = (&camera).into();
829        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
830
831        for i in 0..6 {
832            let mut params_plus = params.clone();
833            let mut params_minus = params.clone();
834            params_plus[i] += eps;
835            params_minus[i] -= eps;
836
837            let cam_plus = EucmCamera::try_from(params_plus.as_slice())?;
838            let cam_minus = EucmCamera::try_from(params_minus.as_slice())?;
839
840            let uv_plus = cam_plus.project(&p_cam)?;
841            let uv_minus = cam_minus.project(&p_cam)?;
842            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
843
844            for r in 0..2 {
845                assert!(
846                    jac_analytical[(r, i)].is_finite(),
847                    "Jacobian [{r},{i}] is not finite"
848                );
849                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
850                assert!(
851                    diff < crate::JACOBIAN_TEST_TOLERANCE,
852                    "Mismatch at ({}, {})",
853                    r,
854                    i
855                );
856            }
857        }
858        Ok(())
859    }
860
861    #[test]
862    fn test_eucm_from_into_traits() -> TestResult {
863        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
864        let distortion = DistortionModel::EUCM {
865            alpha: 0.7,
866            beta: 1.5,
867        };
868        let camera = EucmCamera::new(pinhole, distortion)?;
869
870        // Test conversion to DVector
871        let params: DVector<f64> = (&camera).into();
872        assert_eq!(params.len(), 6);
873        assert_eq!(params[0], 400.0);
874        assert_eq!(params[1], 410.0);
875        assert_eq!(params[2], 320.0);
876        assert_eq!(params[3], 240.0);
877        assert_eq!(params[4], 0.7);
878        assert_eq!(params[5], 1.5);
879
880        // Test conversion to array
881        let arr: [f64; 6] = (&camera).into();
882        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 0.7, 1.5]);
883
884        // Test conversion from slice
885        let params_slice = [450.0, 460.0, 330.0, 250.0, 0.8, 1.8];
886        let camera2 = EucmCamera::try_from(&params_slice[..])?;
887        assert_eq!(camera2.pinhole.fx, 450.0);
888        assert_eq!(camera2.pinhole.fy, 460.0);
889        assert_eq!(camera2.pinhole.cx, 330.0);
890        assert_eq!(camera2.pinhole.cy, 250.0);
891        assert_eq!(camera2.distortion_params(), (0.8, 1.8));
892
893        // Test conversion from array
894        let camera3 = EucmCamera::from([500.0, 510.0, 340.0, 260.0, 0.9, 2.0]);
895        assert_eq!(camera3.pinhole.fx, 500.0);
896        assert_eq!(camera3.pinhole.fy, 510.0);
897        assert_eq!(camera3.distortion_params(), (0.9, 2.0));
898
899        Ok(())
900    }
901
902    #[test]
903    fn test_linear_estimation() -> TestResult {
904        // Ground truth EUCM camera with beta=1.0 (linear_estimation fixes beta=1.0)
905        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
906        let gt_distortion = DistortionModel::EUCM {
907            alpha: 0.5,
908            beta: 1.0,
909        };
910        let gt_camera = EucmCamera::new(gt_pinhole, gt_distortion)?;
911
912        // Generate synthetic 3D points in camera frame
913        let n_points = 50;
914        let mut pts_3d = Matrix3xX::zeros(n_points);
915        let mut pts_2d = Matrix2xX::zeros(n_points);
916        let mut valid = 0;
917
918        for i in 0..n_points {
919            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
920            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
921            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
922
923            if let Ok(p2d) = gt_camera.project(&p3d) {
924                pts_3d.set_column(valid, &p3d);
925                pts_2d.set_column(valid, &p2d);
926                valid += 1;
927            }
928        }
929        let pts_3d = pts_3d.columns(0, valid).into_owned();
930        let pts_2d = pts_2d.columns(0, valid).into_owned();
931
932        // Initial camera with zero alpha and beta=1.0
933        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
934        let init_distortion = DistortionModel::EUCM {
935            alpha: 0.0,
936            beta: 1.0,
937        };
938        let mut camera = EucmCamera::new(init_pinhole, init_distortion)?;
939
940        camera.linear_estimation(&pts_3d, &pts_2d)?;
941
942        // Verify reprojection error
943        for i in 0..valid {
944            let col = pts_3d.column(i);
945            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
946            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
947                + (projected.y - pts_2d[(1, i)]).powi(2))
948            .sqrt();
949            assert!(err < 1.0, "Reprojection error too large: {err}");
950        }
951
952        Ok(())
953    }
954
955    #[test]
956    fn test_project_unproject_round_trip() -> TestResult {
957        // Use points on the optical axis where unproject is exact
958        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
959        let distortion = DistortionModel::EUCM {
960            alpha: 0.5,
961            beta: 1.0,
962        };
963        let camera = EucmCamera::new(pinhole, distortion)?;
964
965        // On-axis point: unproject should be exact
966        let p_cam = Vector3::new(0.0, 0.0, 1.0);
967        let uv = camera.project(&p_cam)?;
968        let ray = camera.unproject(&uv)?;
969        let dot = ray.dot(&p_cam.normalize());
970        assert!(
971            (dot - 1.0).abs() < 1e-6,
972            "Round-trip failed for on-axis point: dot={dot}, expected ~1.0"
973        );
974
975        Ok(())
976    }
977
978    #[test]
979    fn test_project_returns_error_behind_camera() -> TestResult {
980        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
981        let distortion = DistortionModel::EUCM {
982            alpha: 0.5,
983            beta: 1.0,
984        };
985        let camera = EucmCamera::new(pinhole, distortion)?;
986        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
987        Ok(())
988    }
989
990    #[test]
991    fn test_project_at_min_depth_boundary() -> TestResult {
992        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
993        let distortion = DistortionModel::EUCM {
994            alpha: 0.5,
995            beta: 1.0,
996        };
997        let camera = EucmCamera::new(pinhole, distortion)?;
998        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
999        if let Ok(uv) = camera.project(&p_min) {
1000            assert!(uv.x.is_finite() && uv.y.is_finite());
1001        }
1002        Ok(())
1003    }
1004
1005    #[test]
1006    fn test_projection_off_axis() -> TestResult {
1007        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1008        let distortion = DistortionModel::EUCM {
1009            alpha: 0.5,
1010            beta: 1.0,
1011        };
1012        let camera = EucmCamera::new(pinhole, distortion)?;
1013        let p_cam = Vector3::new(0.3, 0.0, 1.0);
1014        let uv = camera.project(&p_cam)?;
1015        assert!(
1016            uv.x > 320.0,
1017            "off-axis point should project right of principal point"
1018        );
1019        assert!(
1020            (uv.y - 240.0).abs() < 1.0,
1021            "y should be close to cy for horizontal offset"
1022        );
1023        Ok(())
1024    }
1025
1026    #[test]
1027    fn test_unproject_center_pixel() -> TestResult {
1028        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1029        let distortion = DistortionModel::EUCM {
1030            alpha: 0.5,
1031            beta: 1.0,
1032        };
1033        let camera = EucmCamera::new(pinhole, distortion)?;
1034        let uv = Vector2::new(320.0, 240.0);
1035        let ray = camera.unproject(&uv)?;
1036        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
1037        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
1038        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
1039        Ok(())
1040    }
1041
1042    #[test]
1043    fn test_batch_projection_matches_individual() -> TestResult {
1044        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1045        let distortion = DistortionModel::EUCM {
1046            alpha: 0.5,
1047            beta: 1.0,
1048        };
1049        let camera = EucmCamera::new(pinhole, distortion)?;
1050        let pts = Matrix3xX::from_columns(&[
1051            Vector3::new(0.0, 0.0, 1.0),
1052            Vector3::new(0.3, 0.2, 1.5),
1053            Vector3::new(-0.4, 0.1, 2.0),
1054        ]);
1055        let batch = camera.project_batch(&pts);
1056        for i in 0..3 {
1057            let col = pts.column(i);
1058            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
1059            assert!(
1060                (batch[(0, i)] - p.x).abs() < 1e-10,
1061                "batch u mismatch at col {i}"
1062            );
1063            assert!(
1064                (batch[(1, i)] - p.y).abs() < 1e-10,
1065                "batch v mismatch at col {i}"
1066            );
1067        }
1068        Ok(())
1069    }
1070
1071    #[test]
1072    fn test_jacobian_dimensions() -> TestResult {
1073        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1074        let distortion = DistortionModel::EUCM {
1075            alpha: 0.5,
1076            beta: 1.0,
1077        };
1078        let camera = EucmCamera::new(pinhole, distortion)?;
1079        let p_cam = Vector3::new(0.1, 0.2, 1.0);
1080        let jac_point = camera.jacobian_point(&p_cam);
1081        assert_eq!(jac_point.nrows(), 2);
1082        assert_eq!(jac_point.ncols(), 3);
1083        let jac_intr = camera.jacobian_intrinsics(&p_cam);
1084        assert_eq!(jac_intr.nrows(), 2);
1085        assert_eq!(jac_intr.ncols(), 6); // EucmCamera::INTRINSIC_DIM = 6
1086        Ok(())
1087    }
1088}