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(¶ms_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}