apex_camera_models/rad_tan.rs
1//! Radial-Tangential Distortion Camera Model.
2//!
3//! The standard OpenCV camera model combining radial and tangential distortion.
4//! This model is widely used for narrow to moderate field-of-view cameras.
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//! x' = x/z, y' = y/z (normalized coordinates)
14//! r² = x'² + y'²
15//!
16//! Radial distortion:
17//! r' = 1 + k₁·r² + k₂·r⁴ + k₃·r⁶
18//!
19//! Tangential distortion:
20//! dx = 2·p₁·x'·y' + p₂·(r² + 2·x'²)
21//! dy = p₁·(r² + 2·y'²) + 2·p₂·x'·y'
22//!
23//! Distorted coordinates:
24//! x'' = r'·x' + dx
25//! y'' = r'·y' + dy
26//!
27//! Final projection:
28//! u = fx·x'' + cx
29//! v = fy·y'' + cy
30//! ```
31//!
32//! ## Unprojection (2D → 3D)
33//!
34//! Iterative Jacobian-based method to solve the non-linear inverse equations.
35//!
36//! # Parameters
37//!
38//! - **Intrinsics**: fx, fy, cx, cy
39//! - **Distortion**: k₁, k₂, p₁, p₂, k₃ (9 parameters total)
40//!
41//! # Use Cases
42//!
43//! - Standard narrow FOV cameras
44//! - OpenCV-calibrated cameras
45//! - Robotics and AR/VR applications
46//! - Most conventional lenses
47//!
48//! # References
49//!
50//! - Brown, "Decentering Distortion of Lenses", 1966
51//! - OpenCV Camera Calibration Documentation
52
53use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
54use nalgebra::{DVector, Matrix2, SMatrix, Vector2, Vector3};
55
56/// A Radial-Tangential camera model with 9 intrinsic parameters.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct RadTanCamera {
59 pub pinhole: PinholeParams,
60 pub distortion: DistortionModel,
61}
62
63impl RadTanCamera {
64 /// Creates a new Radial-Tangential (Brown-Conrady) camera.
65 ///
66 /// # Arguments
67 ///
68 /// * `pinhole` - The pinhole parameters (fx, fy, cx, cy).
69 /// * `distortion` - The distortion model. This must be `DistortionModel::BrownConrady` with parameters { k1, k2, p1, p2, k3 }.
70 ///
71 /// # Errors
72 ///
73 /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not [`DistortionModel::BrownConrady`].
74 pub fn new(
75 pinhole: PinholeParams,
76 distortion: DistortionModel,
77 ) -> Result<Self, CameraModelError> {
78 let camera = Self {
79 pinhole,
80 distortion,
81 };
82 camera.validate_params()?;
83 Ok(camera)
84 }
85
86 /// Checks if a 3D point satisfies the projection condition.
87 ///
88 /// # Arguments
89 ///
90 /// * `z` - The z-coordinate of the point in the camera frame.
91 ///
92 /// # Returns
93 ///
94 /// Returns `true` if `z >= crate::GEOMETRIC_PRECISION`, `false` otherwise.
95 fn check_projection_condition(&self, z: f64) -> bool {
96 z >= crate::GEOMETRIC_PRECISION
97 }
98
99 /// Helper method to extract distortion parameters.
100 ///
101 /// # Returns
102 ///
103 /// Returns a tuple `(k1, k2, p1, p2, k3)` containing the Brown-Conrady distortion parameters.
104 /// If the distortion model is not Brown-Conrady, returns `(0.0, 0.0, 0.0, 0.0, 0.0)`.
105 fn distortion_params(&self) -> (f64, f64, f64, f64, f64) {
106 match self.distortion {
107 DistortionModel::BrownConrady { k1, k2, p1, p2, k3 } => (k1, k2, p1, p2, k3),
108 _ => (0.0, 0.0, 0.0, 0.0, 0.0),
109 }
110 }
111
112 /// Performs linear estimation to initialize distortion parameters from point correspondences.
113 ///
114 /// This method estimates the radial distortion coefficients [k1, k2, k3] using a linear
115 /// least squares approach given 3D-2D point correspondences. Tangential distortion parameters
116 /// (p1, p2) are set to 0.0. It assumes the intrinsic parameters (fx, fy, cx, cy) are already set.
117 ///
118 /// # Arguments
119 ///
120 /// * `points_3d`: Matrix3xX<f64> - 3D points in camera coordinates (each column is a point)
121 /// * `points_2d`: Matrix2xX<f64> - Corresponding 2D points in image coordinates
122 ///
123 /// # Returns
124 ///
125 /// Returns `Ok(())` on success or a `CameraModelError` if the estimation fails.
126 pub fn linear_estimation(
127 &mut self,
128 points_3d: &nalgebra::Matrix3xX<f64>,
129 points_2d: &nalgebra::Matrix2xX<f64>,
130 ) -> Result<(), CameraModelError> {
131 if points_2d.ncols() != points_3d.ncols() {
132 return Err(CameraModelError::InvalidParams(
133 "Number of 2D and 3D points must match".to_string(),
134 ));
135 }
136
137 let num_points = points_2d.ncols();
138 if num_points < 3 {
139 return Err(CameraModelError::InvalidParams(
140 "Need at least 3 points for RadTan linear estimation".to_string(),
141 ));
142 }
143
144 // Set up the linear system to estimate distortion coefficients
145 let mut a = nalgebra::DMatrix::zeros(num_points * 2, 3); // Only estimate k1, k2, k3
146 let mut b = nalgebra::DVector::zeros(num_points * 2);
147
148 // Extract intrinsics
149 let fx = self.pinhole.fx;
150 let fy = self.pinhole.fy;
151 let cx = self.pinhole.cx;
152 let cy = self.pinhole.cy;
153
154 for i in 0..num_points {
155 let x = points_3d[(0, i)];
156 let y = points_3d[(1, i)];
157 let z = points_3d[(2, i)];
158 let u = points_2d[(0, i)];
159 let v = points_2d[(1, i)];
160
161 // Project to normalized coordinates
162 let x_norm = x / z;
163 let y_norm = y / z;
164 let r2 = x_norm * x_norm + y_norm * y_norm;
165 let r4 = r2 * r2;
166 let r6 = r4 * r2;
167
168 // Predicted undistorted pixel coordinates
169 let u_undist = fx * x_norm + cx;
170 let v_undist = fy * y_norm + cy;
171
172 // Set up linear system for distortion coefficients
173 a[(i * 2, 0)] = fx * x_norm * r2; // k1 term
174 a[(i * 2, 1)] = fx * x_norm * r4; // k2 term
175 a[(i * 2, 2)] = fx * x_norm * r6; // k3 term
176
177 a[(i * 2 + 1, 0)] = fy * y_norm * r2; // k1 term
178 a[(i * 2 + 1, 1)] = fy * y_norm * r4; // k2 term
179 a[(i * 2 + 1, 2)] = fy * y_norm * r6; // k3 term
180
181 b[i * 2] = u - u_undist;
182 b[i * 2 + 1] = v - v_undist;
183 }
184
185 // Solve the linear system using SVD
186 let svd = a.svd(true, true);
187 let distortion_coeffs = 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 // Update distortion coefficients (keep p1, p2 as zero for linear estimation)
198 self.distortion = DistortionModel::BrownConrady {
199 k1: distortion_coeffs[0],
200 k2: distortion_coeffs[1],
201 p1: 0.0,
202 p2: 0.0,
203 k3: distortion_coeffs[2],
204 };
205
206 self.validate_params()?;
207 Ok(())
208 }
209}
210
211/// Converts the camera parameters to a dynamic vector.
212///
213/// # Layout
214///
215/// The parameters are ordered as: `[fx, fy, cx, cy, k1, k2, p1, p2, k3]`
216impl From<&RadTanCamera> for DVector<f64> {
217 fn from(camera: &RadTanCamera) -> Self {
218 let (k1, k2, p1, p2, k3) = camera.distortion_params();
219 DVector::from_vec(vec![
220 camera.pinhole.fx,
221 camera.pinhole.fy,
222 camera.pinhole.cx,
223 camera.pinhole.cy,
224 k1,
225 k2,
226 p1,
227 p2,
228 k3,
229 ])
230 }
231}
232
233/// Converts the camera parameters to a fixed-size array.
234///
235/// # Layout
236///
237/// The parameters are ordered as: `[fx, fy, cx, cy, k1, k2, p1, p2, k3]`
238impl From<&RadTanCamera> for [f64; 9] {
239 fn from(camera: &RadTanCamera) -> Self {
240 let (k1, k2, p1, p2, k3) = camera.distortion_params();
241 [
242 camera.pinhole.fx,
243 camera.pinhole.fy,
244 camera.pinhole.cx,
245 camera.pinhole.cy,
246 k1,
247 k2,
248 p1,
249 p2,
250 k3,
251 ]
252 }
253}
254
255/// Creates a camera from a slice of intrinsic parameters.
256///
257/// # Layout
258///
259/// Expected parameter order: `[fx, fy, cx, cy, k1, k2, p1, p2, k3]`
260///
261/// # Panics
262///
263/// Panics if the slice has fewer than 9 elements.
264impl From<&[f64]> for RadTanCamera {
265 fn from(params: &[f64]) -> Self {
266 assert!(
267 params.len() >= 9,
268 "RadTanCamera requires at least 9 parameters, got {}",
269 params.len()
270 );
271 Self {
272 pinhole: PinholeParams {
273 fx: params[0],
274 fy: params[1],
275 cx: params[2],
276 cy: params[3],
277 },
278 distortion: DistortionModel::BrownConrady {
279 k1: params[4],
280 k2: params[5],
281 p1: params[6],
282 p2: params[7],
283 k3: params[8],
284 },
285 }
286 }
287}
288
289/// Creates a camera from a fixed-size array of intrinsic parameters.
290///
291/// # Layout
292///
293/// Expected parameter order: `[fx, fy, cx, cy, k1, k2, p1, p2, k3]`
294impl From<[f64; 9]> for RadTanCamera {
295 fn from(params: [f64; 9]) -> Self {
296 Self {
297 pinhole: PinholeParams {
298 fx: params[0],
299 fy: params[1],
300 cx: params[2],
301 cy: params[3],
302 },
303 distortion: DistortionModel::BrownConrady {
304 k1: params[4],
305 k2: params[5],
306 p1: params[6],
307 p2: params[7],
308 k3: params[8],
309 },
310 }
311 }
312}
313
314/// Creates a `RadTanCamera` from a parameter slice with validation.
315///
316/// Unlike `From<&[f64]>`, this constructor validates all parameters
317/// and returns a `Result` instead of panicking on invalid input.
318///
319/// # Errors
320///
321/// Returns `CameraModelError::InvalidParams` if fewer than 9 parameters are provided.
322/// Returns validation errors if focal lengths are non-positive or parameters are non-finite.
323pub fn try_from_params(params: &[f64]) -> Result<RadTanCamera, CameraModelError> {
324 if params.len() < 9 {
325 return Err(CameraModelError::InvalidParams(format!(
326 "RadTanCamera requires at least 9 parameters, got {}",
327 params.len()
328 )));
329 }
330 let camera = RadTanCamera::from(params);
331 camera.validate_params()?;
332 Ok(camera)
333}
334
335impl CameraModel for RadTanCamera {
336 const INTRINSIC_DIM: usize = 9;
337 type IntrinsicJacobian = SMatrix<f64, 2, 9>;
338 type PointJacobian = SMatrix<f64, 2, 3>;
339
340 /// Projects a 3D point in the camera frame to 2D image coordinates.
341 ///
342 /// # Mathematical Formula
343 ///
344 /// Combines radial distortion (k₁, k₂, k₃) and tangential distortion (p₁, p₂).
345 ///
346 /// # Arguments
347 ///
348 /// * `p_cam` - The 3D point in the camera coordinate frame.
349 ///
350 /// # Returns
351 ///
352 /// - `Ok(uv)` - The 2D image coordinates if valid.
353 /// - `Err` - If the point is at or behind the camera.
354 fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
355 if !self.check_projection_condition(p_cam.z) {
356 return Err(CameraModelError::PointBehindCamera {
357 z: p_cam.z,
358 min_z: crate::GEOMETRIC_PRECISION,
359 });
360 }
361
362 let inv_z = 1.0 / p_cam.z;
363 let x_prime = p_cam.x * inv_z;
364 let y_prime = p_cam.y * inv_z;
365
366 let r2 = x_prime * x_prime + y_prime * y_prime;
367 let r4 = r2 * r2;
368 let r6 = r4 * r2;
369
370 let (k1, k2, p1, p2, k3) = self.distortion_params();
371
372 // Radial distortion: r' = 1 + k₁·r² + k₂·r⁴ + k₃·r⁶
373 let radial = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
374
375 // Tangential distortion
376 let xy = x_prime * y_prime;
377 let dx = 2.0 * p1 * xy + p2 * (r2 + 2.0 * x_prime * x_prime);
378 let dy = p1 * (r2 + 2.0 * y_prime * y_prime) + 2.0 * p2 * xy;
379
380 // Distorted coordinates
381 let x_distorted = radial * x_prime + dx;
382 let y_distorted = radial * y_prime + dy;
383
384 Ok(Vector2::new(
385 self.pinhole.fx * x_distorted + self.pinhole.cx,
386 self.pinhole.fy * y_distorted + self.pinhole.cy,
387 ))
388 }
389
390 /// Unprojects a 2D image point to a 3D ray.
391 ///
392 /// # Algorithm
393 ///
394 /// Iterative Newton-Raphson with Jacobian matrix:
395 /// 1. Start with the undistorted estimate.
396 /// 2. Compute distortion and Jacobian.
397 /// 3. Update estimate: p′ = p′ − J⁻¹ · f(p′).
398 /// 4. Repeat until convergence.
399 ///
400 /// # Arguments
401 ///
402 /// * `point_2d` - The 2D point in image coordinates.
403 ///
404 /// # Returns
405 ///
406 /// - `Ok(ray)` - The normalized 3D ray direction.
407 /// - `Err` - If the iteration fails to converge.
408 fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
409 // Validate unprojection condition if needed (always true for RadTan generally)
410 let u = point_2d.x;
411 let v = point_2d.y;
412
413 // Initial estimate (undistorted)
414 let x_distorted = (u - self.pinhole.cx) / self.pinhole.fx;
415 let y_distorted = (v - self.pinhole.cy) / self.pinhole.fy;
416 let target_distorted_point = Vector2::new(x_distorted, y_distorted);
417
418 let mut point = target_distorted_point;
419
420 const CONVERGENCE_EPS: f64 = crate::CONVERGENCE_THRESHOLD;
421 const MAX_ITERATIONS: u32 = 100;
422
423 let (k1, k2, p1, p2, k3) = self.distortion_params();
424
425 for iteration in 0..MAX_ITERATIONS {
426 let x = point.x;
427 let y = point.y;
428
429 let r2 = x * x + y * y;
430 let r4 = r2 * r2;
431 let r6 = r4 * r2;
432
433 // Radial distortion
434 let radial = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
435
436 // Tangential distortion
437 let xy = x * y;
438 let dx = 2.0 * p1 * xy + p2 * (r2 + 2.0 * x * x);
439 let dy = p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * xy;
440
441 // Distorted point
442 let x_dist = radial * x + dx;
443 let y_dist = radial * y + dy;
444
445 // Residual
446 let fx = x_dist - target_distorted_point.x;
447 let fy = y_dist - target_distorted_point.y;
448
449 if fx.abs() < CONVERGENCE_EPS && fy.abs() < CONVERGENCE_EPS {
450 break;
451 }
452
453 // Jacobian matrix
454 let dradial_dr2 = k1 + 2.0 * k2 * r2 + 3.0 * k3 * r4;
455
456 // ∂(radial·x + dx)/∂x
457 let dfx_dx = radial + 2.0 * x * dradial_dr2 * x + 2.0 * p1 * y + 2.0 * p2 * (3.0 * x);
458
459 // ∂(radial·x + dx)/∂y
460 let dfx_dy = 2.0 * x * dradial_dr2 * y + 2.0 * p1 * x + 2.0 * p2 * y;
461
462 // ∂(radial·y + dy)/∂x
463 let dfy_dx = 2.0 * y * dradial_dr2 * x + 2.0 * p1 * x + 2.0 * p2 * y;
464
465 // ∂(radial·y + dy)/∂y
466 let dfy_dy = radial + 2.0 * y * dradial_dr2 * y + 2.0 * p1 * (3.0 * y) + 2.0 * p2 * x;
467
468 let jacobian = Matrix2::new(dfx_dx, dfx_dy, dfy_dx, dfy_dy);
469
470 // Solve: J·Δp = -f
471 let det = jacobian[(0, 0)] * jacobian[(1, 1)] - jacobian[(0, 1)] * jacobian[(1, 0)];
472
473 if det.abs() < crate::GEOMETRIC_PRECISION {
474 return Err(CameraModelError::NumericalError {
475 operation: "unprojection".to_string(),
476 details: "Singular Jacobian in RadTan unprojection".to_string(),
477 });
478 }
479
480 let inv_det = 1.0 / det;
481 let delta_x = inv_det * (jacobian[(1, 1)] * (-fx) - jacobian[(0, 1)] * (-fy));
482 let delta_y = inv_det * (-jacobian[(1, 0)] * (-fx) + jacobian[(0, 0)] * (-fy));
483
484 point.x += delta_x;
485 point.y += delta_y;
486
487 if iteration == MAX_ITERATIONS - 1 {
488 return Err(CameraModelError::NumericalError {
489 operation: "unprojection".to_string(),
490 details: "RadTan unprojection did not converge".to_string(),
491 });
492 }
493 }
494
495 // Normalize to unit ray
496 let r2 = point.x * point.x + point.y * point.y;
497 let norm = (1.0 + r2).sqrt();
498 let norm_inv = 1.0 / norm;
499
500 Ok(Vector3::new(
501 point.x * norm_inv,
502 point.y * norm_inv,
503 norm_inv,
504 ))
505 }
506
507 /// Computes the Jacobian of the projection with respect to the 3D point coordinates (2×3).
508 ///
509 /// # Mathematical Derivation
510 ///
511 /// For the Radial-Tangential (Brown-Conrady) model, projection is:
512 ///
513 /// ```text
514 /// x' = x/z, y' = y/z (normalized coordinates)
515 /// r² = x'² + y'²
516 /// radial = 1 + k₁·r² + k₂·r⁴ + k₃·r⁶
517 /// dx = 2·p₁·x'·y' + p₂·(r² + 2·x'²) (tangential distortion)
518 /// dy = p₁·(r² + 2·y'²) + 2·p₂·x'·y' (tangential distortion)
519 /// x'' = radial·x' + dx
520 /// y'' = radial·y' + dy
521 /// u = fx·x'' + cx
522 /// v = fy·y'' + cy
523 /// ```
524 ///
525 /// ## Chain Rule Application
526 ///
527 /// This is the most complex Jacobian due to coupled radial + tangential distortion.
528 /// The chain rule must be applied through multiple stages:
529 ///
530 /// 1. ∂(x',y')/∂(x,y,z): Normalized coordinate derivatives
531 /// 2. ∂(x'',y'')/∂(x',y'): Distortion derivatives (radial + tangential)
532 /// 3. ∂(u,v)/∂(x'',y''): Final projection derivatives
533 ///
534 /// Normalized coordinate derivatives (x' = x/z, y' = y/z):
535 /// ```text
536 /// ∂x'/∂x = 1/z, ∂x'/∂y = 0, ∂x'/∂z = -x/z²
537 /// ∂y'/∂x = 0, ∂y'/∂y = 1/z, ∂y'/∂z = -y/z²
538 /// ```
539 ///
540 /// Distortion derivatives:
541 ///
542 /// The distorted coordinates are: x'' = radial·x' + dx, y'' = radial·y' + dy
543 ///
544 /// #### Radial Distortion Component
545 ///
546 /// ```text
547 /// radial = 1 + k₁·r² + k₂·r⁴ + k₃·r⁶
548 /// ∂radial/∂r² = k₁ + 2k₂·r² + 3k₃·r⁴
549 /// ∂r²/∂x' = 2x', ∂r²/∂y' = 2y'
550 /// ```
551 ///
552 /// #### Tangential Distortion Component
553 ///
554 /// For dx = 2p₁x'y' + p₂(r² + 2x'²):
555 ///
556 /// ```text
557 /// ∂dx/∂x' = 2p₁y' + p₂(2x' + 4x') = 2p₁y' + 6p₂x'
558 /// ∂dx/∂y' = 2p₁x' + 2p₂y'
559 /// ```
560 ///
561 /// For dy = p₁(r² + 2y'²) + 2p₂x'y':
562 ///
563 /// ```text
564 /// ∂dy/∂x' = 2p₁x' + 2p₂y'
565 /// ∂dy/∂y' = p₁(2y' + 4y') + 2p₂x' = 6p₁y' + 2p₂x'
566 /// ```
567 ///
568 /// #### Combined Distorted Coordinate Derivatives
569 ///
570 /// For x'' = radial·x' + dx:
571 ///
572 /// ```text
573 /// ∂x''/∂x' = radial + x'·∂radial/∂r²·∂r²/∂x' + ∂dx/∂x'
574 /// = radial + x'·dradial_dr2·2x' + 2p₁y' + 6p₂x'
575 /// = radial + 2x'²·dradial_dr2 + 2p₁y' + 6p₂x'
576 ///
577 /// ∂x''/∂y' = x'·∂radial/∂r²·∂r²/∂y' + ∂dx/∂y'
578 /// = x'·dradial_dr2·2y' + 2p₁x' + 2p₂y'
579 /// = 2x'y'·dradial_dr2 + 2p₁x' + 2p₂y'
580 /// ```
581 ///
582 /// For y'' = radial·y' + dy:
583 ///
584 /// ```text
585 /// ∂y''/∂x' = y'·∂radial/∂r²·∂r²/∂x' + ∂dy/∂x'
586 /// = y'·dradial_dr2·2x' + 2p₁x' + 2p₂y'
587 /// = 2x'y'·dradial_dr2 + 2p₁x' + 2p₂y'
588 ///
589 /// ∂y''/∂y' = radial + y'·∂radial/∂r²·∂r²/∂y' + ∂dy/∂y'
590 /// = radial + y'·dradial_dr2·2y' + 6p₁y' + 2p₂x'
591 /// = radial + 2y'²·dradial_dr2 + 6p₁y' + 2p₂x'
592 /// ```
593 ///
594 /// Final projection derivatives (u = fx·x'' + cx, v = fy·y'' + cy):
595 /// ```text
596 /// ∂u/∂x'' = fx, ∂u/∂y'' = 0
597 /// ∂v/∂x'' = 0, ∂v/∂y'' = fy
598 /// ```
599 ///
600 /// Chain rule:
601 ///
602 /// ```text
603 /// ∂u/∂x = fx·(∂x''/∂x'·∂x'/∂x + ∂x''/∂y'·∂y'/∂x)
604 /// = fx·(∂x''/∂x'·1/z + 0)
605 /// = fx·∂x''/∂x'·1/z
606 ///
607 /// ∂u/∂y = fx·(∂x''/∂x'·∂x'/∂y + ∂x''/∂y'·∂y'/∂y)
608 /// = fx·(0 + ∂x''/∂y'·1/z)
609 /// = fx·∂x''/∂y'·1/z
610 ///
611 /// ∂u/∂z = fx·(∂x''/∂x'·∂x'/∂z + ∂x''/∂y'·∂y'/∂z)
612 /// = fx·(∂x''/∂x'·(-x'/z) + ∂x''/∂y'·(-y'/z))
613 /// ```
614 ///
615 /// Similar derivations apply for ∂v/∂x, ∂v/∂y, ∂v/∂z.
616 ///
617 /// ## Matrix Form
618 ///
619 /// ```text
620 /// J = [ ∂u/∂x ∂u/∂y ∂u/∂z ]
621 /// [ ∂v/∂x ∂v/∂y ∂v/∂z ]
622 /// ```
623 ///
624 /// ## References
625 ///
626 /// - Brown, "Decentering Distortion of Lenses", Photogrammetric Engineering 1966
627 /// - OpenCV Camera Calibration Documentation
628 /// - Hartley & Zisserman, "Multiple View Geometry", Chapter 7
629 ///
630 /// ## Numerical Verification
631 ///
632 /// Verified against numerical differentiation in `test_jacobian_point_numerical()`.
633 fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
634 let inv_z = 1.0 / p_cam.z;
635 let x_prime = p_cam.x * inv_z;
636 let y_prime = p_cam.y * inv_z;
637
638 let r2 = x_prime * x_prime + y_prime * y_prime;
639 let r4 = r2 * r2;
640 let r6 = r4 * r2;
641
642 let (k1, k2, p1, p2, k3) = self.distortion_params();
643
644 let radial = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
645 let dradial_dr2 = k1 + 2.0 * k2 * r2 + 3.0 * k3 * r4;
646
647 // Derivatives of distorted coordinates w.r.t. normalized coordinates
648 // x_dist = radial·x' + dx where dx = 2p₁x'y' + p₂(r² + 2x'²)
649 // ∂x_dist/∂x' = radial + x'·∂radial/∂r²·∂r²/∂x' + ∂dx/∂x'
650 // = radial + x'·dradial_dr2·2x' + (2p₁y' + p₂·(2x' + 4x'))
651 // = radial + 2x'²·dradial_dr2 + 2p₁y' + 6p₂x'
652 let dx_dist_dx_prime = radial
653 + 2.0 * x_prime * x_prime * dradial_dr2
654 + 2.0 * p1 * y_prime
655 + 6.0 * p2 * x_prime;
656
657 // ∂x_dist/∂y' = x'·∂radial/∂r²·∂r²/∂y' + ∂dx/∂y'
658 // = x'·dradial_dr2·2y' + (2p₁x' + 2p₂y')
659 let dx_dist_dy_prime =
660 2.0 * x_prime * y_prime * dradial_dr2 + 2.0 * p1 * x_prime + 2.0 * p2 * y_prime;
661
662 // y_dist = radial·y' + dy where dy = p₁(r² + 2y'²) + 2p₂x'y'
663 // ∂y_dist/∂x' = y'·∂radial/∂r²·∂r²/∂x' + ∂dy/∂x'
664 // = y'·dradial_dr2·2x' + (p₁·2x' + 2p₂y')
665 let dy_dist_dx_prime =
666 2.0 * y_prime * x_prime * dradial_dr2 + 2.0 * p1 * x_prime + 2.0 * p2 * y_prime;
667
668 // ∂y_dist/∂y' = radial + y'·∂radial/∂r²·∂r²/∂y' + ∂dy/∂y'
669 // = radial + y'·dradial_dr2·2y' + (p₁·(2y' + 4y') + 2p₂x')
670 // = radial + 2y'²·dradial_dr2 + 6p₁y' + 2p₂x'
671 let dy_dist_dy_prime = radial
672 + 2.0 * y_prime * y_prime * dradial_dr2
673 + 6.0 * p1 * y_prime
674 + 2.0 * p2 * x_prime;
675
676 // Derivatives of normalized coordinates w.r.t. camera coordinates
677 // x' = x/z => ∂x'/∂x = 1/z, ∂x'/∂y = 0, ∂x'/∂z = -x/z²
678 // y' = y/z => ∂y'/∂x = 0, ∂y'/∂y = 1/z, ∂y'/∂z = -y/z²
679
680 // Chain rule: ∂(u,v)/∂(x,y,z) = ∂(u,v)/∂(x_dist,y_dist) · ∂(x_dist,y_dist)/∂(x',y') · ∂(x',y')/∂(x,y,z)
681
682 let du_dx = self.pinhole.fx * (dx_dist_dx_prime * inv_z);
683 let du_dy = self.pinhole.fx * (dx_dist_dy_prime * inv_z);
684 let du_dz = self.pinhole.fx
685 * (dx_dist_dx_prime * (-x_prime * inv_z) + dx_dist_dy_prime * (-y_prime * inv_z));
686
687 let dv_dx = self.pinhole.fy * (dy_dist_dx_prime * inv_z);
688 let dv_dy = self.pinhole.fy * (dy_dist_dy_prime * inv_z);
689 let dv_dz = self.pinhole.fy
690 * (dy_dist_dx_prime * (-x_prime * inv_z) + dy_dist_dy_prime * (-y_prime * inv_z));
691
692 SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
693 }
694
695 /// Computes the Jacobian of the projection with respect to intrinsic parameters (2×9).
696 ///
697 /// # Mathematical Derivation
698 ///
699 /// The Radial-Tangential camera has 9 intrinsic parameters: [fx, fy, cx, cy, k₁, k₂, p₁, p₂, k₃]
700 ///
701 /// ## Projection Equations
702 ///
703 /// ```text
704 /// x' = x/z, y' = y/z
705 /// r² = x'² + y'²
706 /// radial = 1 + k₁·r² + k₂·r⁴ + k₃·r⁶
707 /// dx = 2·p₁·x'·y' + p₂·(r² + 2·x'²)
708 /// dy = p₁·(r² + 2·y'²) + 2·p₂·x'·y'
709 /// x'' = radial·x' + dx
710 /// y'' = radial·y' + dy
711 /// u = fx·x'' + cx
712 /// v = fy·y'' + cy
713 /// ```
714 ///
715 /// ## Jacobian Structure
716 ///
717 /// ```text
718 /// J = [ ∂u/∂fx ∂u/∂fy ∂u/∂cx ∂u/∂cy ∂u/∂k₁ ∂u/∂k₂ ∂u/∂p₁ ∂u/∂p₂ ∂u/∂k₃ ]
719 /// [ ∂v/∂fx ∂v/∂fy ∂v/∂cx ∂v/∂cy ∂v/∂k₁ ∂v/∂k₂ ∂v/∂p₁ ∂v/∂p₂ ∂v/∂k₃ ]
720 /// ```
721 ///
722 /// Linear parameters (fx, fy, cx, cy):
723 /// ```text
724 /// ∂u/∂fx = x'', ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
725 /// ∂v/∂fx = 0, ∂v/∂fy = y'', ∂v/∂cx = 0, ∂v/∂cy = 1
726 /// ```
727 ///
728 /// Radial distortion coefficients (k₁, k₂, k₃):
729 ///
730 /// Each k_i affects the radial distortion component:
731 ///
732 /// ```text
733 /// ∂radial/∂k₁ = r²
734 /// ∂radial/∂k₂ = r⁴
735 /// ∂radial/∂k₃ = r⁶
736 /// ```
737 ///
738 /// By chain rule (x'' = radial·x' + dx, y'' = radial·y' + dy):
739 ///
740 /// ```text
741 /// ∂x''/∂k₁ = x'·r²
742 /// ∂x''/∂k₂ = x'·r⁴
743 /// ∂x''/∂k₃ = x'·r⁶
744 ///
745 /// ∂y''/∂k₁ = y'·r²
746 /// ∂y''/∂k₂ = y'·r⁴
747 /// ∂y''/∂k₃ = y'·r⁶
748 /// ```
749 ///
750 /// Then:
751 ///
752 /// ```text
753 /// ∂u/∂k₁ = fx·x'·r²
754 /// ∂u/∂k₂ = fx·x'·r⁴
755 /// ∂u/∂k₃ = fx·x'·r⁶
756 ///
757 /// ∂v/∂k₁ = fy·y'·r²
758 /// ∂v/∂k₂ = fy·y'·r⁴
759 /// ∂v/∂k₃ = fy·y'·r⁶
760 /// ```
761 ///
762 /// Tangential distortion coefficients (p₁, p₂):
763 /// ```text
764 /// ∂dx/∂p₁ = 2x'y', ∂dy/∂p₁ = r² + 2y'²
765 /// ∂u/∂p₁ = fx·2x'y', ∂v/∂p₁ = fy·(r² + 2y'²)
766 /// ∂dx/∂p₂ = r² + 2x'², ∂dy/∂p₂ = 2x'y'
767 /// ∂u/∂p₂ = fx·(r² + 2x'²), ∂v/∂p₂ = fy·2x'y'
768 /// ```
769 ///
770 /// Matrix form:
771 ///
772 /// ```text
773 /// J = [ x'' 0 1 0 fx·x'·r² fx·x'·r⁴ fx·2x'y' fx·(r²+2x'²) fx·x'·r⁶ ]
774 /// [ 0 y'' 0 1 fy·y'·r² fy·y'·r⁴ fy·(r²+2y'²) fy·2x'y' fy·y'·r⁶ ]
775 /// ```
776 ///
777 /// ## References
778 ///
779 /// - Brown, "Decentering Distortion of Lenses", 1966
780 /// - OpenCV Camera Calibration Documentation
781 /// - Hartley & Zisserman, "Multiple View Geometry", Chapter 7
782 ///
783 /// ## Numerical Verification
784 ///
785 /// Verified in `test_jacobian_intrinsics_numerical()` with tolerance < 1e-5.
786 fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
787 let inv_z = 1.0 / p_cam.z;
788 let x_prime = p_cam.x * inv_z;
789 let y_prime = p_cam.y * inv_z;
790
791 let r2 = x_prime * x_prime + y_prime * y_prime;
792 let r4 = r2 * r2;
793 let r6 = r4 * r2;
794
795 let (k1, k2, p1, p2, k3) = self.distortion_params();
796
797 let radial = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
798
799 let xy = x_prime * y_prime;
800 let dx = 2.0 * p1 * xy + p2 * (r2 + 2.0 * x_prime * x_prime);
801 let dy = p1 * (r2 + 2.0 * y_prime * y_prime) + 2.0 * p2 * xy;
802
803 let x_distorted = radial * x_prime + dx;
804 let y_distorted = radial * y_prime + dy;
805
806 // ∂u/∂fx = x_distorted, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
807 // ∂v/∂fx = 0, ∂v/∂fy = y_distorted, ∂v/∂cx = 0, ∂v/∂cy = 1
808
809 // Distortion parameter derivatives
810 let du_dk1 = self.pinhole.fx * x_prime * r2;
811 let du_dk2 = self.pinhole.fx * x_prime * r4;
812 let du_dp1 = self.pinhole.fx * 2.0 * xy;
813 let du_dp2 = self.pinhole.fx * (r2 + 2.0 * x_prime * x_prime);
814 let du_dk3 = self.pinhole.fx * x_prime * r6;
815
816 let dv_dk1 = self.pinhole.fy * y_prime * r2;
817 let dv_dk2 = self.pinhole.fy * y_prime * r4;
818 let dv_dp1 = self.pinhole.fy * (r2 + 2.0 * y_prime * y_prime);
819 let dv_dp2 = self.pinhole.fy * 2.0 * xy;
820 let dv_dk3 = self.pinhole.fy * y_prime * r6;
821
822 SMatrix::<f64, 2, 9>::from_row_slice(&[
823 x_distorted,
824 0.0,
825 1.0,
826 0.0,
827 du_dk1,
828 du_dk2,
829 du_dp1,
830 du_dp2,
831 du_dk3,
832 0.0,
833 y_distorted,
834 0.0,
835 1.0,
836 dv_dk1,
837 dv_dk2,
838 dv_dp1,
839 dv_dp2,
840 dv_dk3,
841 ])
842 }
843
844 /// Validates the camera parameters.
845 ///
846 /// # Validation Rules
847 ///
848 /// - fx, fy must be positive (> 0)
849 /// - fx, fy must be finite
850 /// - cx, cy must be finite
851 /// - k₁, k₂, p₁, p₂, k₃ must be finite
852 ///
853 /// # Errors
854 ///
855 /// Returns [`CameraModelError`] if any parameter violates validation rules.
856 fn validate_params(&self) -> Result<(), CameraModelError> {
857 self.pinhole.validate()?;
858 self.get_distortion().validate()
859 }
860
861 /// Returns the pinhole parameters.
862 ///
863 /// # Returns
864 ///
865 /// A [`PinholeParams`] struct containing the focal lengths (fx, fy) and principal point (cx, cy).
866 fn get_pinhole_params(&self) -> PinholeParams {
867 self.pinhole
868 }
869
870 /// Returns the distortion model parameters.
871 ///
872 /// # Returns
873 ///
874 /// The [`DistortionModel`] associated with this camera (typically [`DistortionModel::BrownConrady`]).
875 fn get_distortion(&self) -> DistortionModel {
876 self.distortion
877 }
878
879 /// Returns the name of the camera model.
880 ///
881 /// # Returns
882 ///
883 /// The string `"rad_tan"`.
884 fn get_model_name(&self) -> &'static str {
885 "rad_tan"
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892 use nalgebra::{Matrix2xX, Matrix3xX};
893
894 type TestResult = Result<(), Box<dyn std::error::Error>>;
895
896 #[test]
897 fn test_radtan_camera_creation() -> TestResult {
898 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
899 let distortion = DistortionModel::BrownConrady {
900 k1: 0.1,
901 k2: 0.01,
902 p1: 0.001,
903 p2: 0.002,
904 k3: 0.001,
905 };
906 let camera = RadTanCamera::new(pinhole, distortion)?;
907 assert_eq!(camera.pinhole.fx, 300.0);
908 let (k1, _, p1, _, _) = camera.distortion_params();
909 assert_eq!(k1, 0.1);
910 assert_eq!(p1, 0.001);
911 Ok(())
912 }
913
914 #[test]
915 fn test_projection_at_optical_axis() -> TestResult {
916 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
917 let distortion = DistortionModel::BrownConrady {
918 k1: 0.0,
919 k2: 0.0,
920 p1: 0.0,
921 p2: 0.0,
922 k3: 0.0,
923 };
924 let camera = RadTanCamera::new(pinhole, distortion)?;
925 let p_cam = Vector3::new(0.0, 0.0, 1.0);
926 let uv = camera.project(&p_cam)?;
927
928 assert!((uv.x - 320.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
929 assert!((uv.y - 240.0).abs() < crate::PROJECTION_TEST_TOLERANCE);
930
931 Ok(())
932 }
933
934 #[test]
935 fn test_jacobian_point_numerical() -> TestResult {
936 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
937 let distortion = DistortionModel::BrownConrady {
938 k1: 0.1,
939 k2: 0.01,
940 p1: 0.001,
941 p2: 0.002,
942 k3: 0.001,
943 };
944 let camera = RadTanCamera::new(pinhole, distortion)?;
945 let p_cam = Vector3::new(0.1, 0.2, 1.0);
946
947 let jac_analytical = camera.jacobian_point(&p_cam);
948 let eps = crate::NUMERICAL_DERIVATIVE_EPS;
949
950 for i in 0..3 {
951 let mut p_plus = p_cam;
952 let mut p_minus = p_cam;
953 p_plus[i] += eps;
954 p_minus[i] -= eps;
955
956 let uv_plus = camera.project(&p_plus)?;
957 let uv_minus = camera.project(&p_minus)?;
958 let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
959
960 for r in 0..2 {
961 assert!(
962 jac_analytical[(r, i)].is_finite(),
963 "Jacobian [{r},{i}] is not finite"
964 );
965 let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
966 assert!(
967 diff < crate::JACOBIAN_TEST_TOLERANCE,
968 "Mismatch at ({}, {})",
969 r,
970 i
971 );
972 }
973 }
974 Ok(())
975 }
976
977 #[test]
978 fn test_jacobian_intrinsics_numerical() -> TestResult {
979 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
980 let distortion = DistortionModel::BrownConrady {
981 k1: 0.1,
982 k2: 0.01,
983 p1: 0.001,
984 p2: 0.002,
985 k3: 0.001,
986 };
987 let camera = RadTanCamera::new(pinhole, distortion)?;
988 let p_cam = Vector3::new(0.1, 0.2, 1.0);
989
990 let jac_analytical = camera.jacobian_intrinsics(&p_cam);
991 let params: DVector<f64> = (&camera).into();
992 let eps = crate::NUMERICAL_DERIVATIVE_EPS;
993
994 for i in 0..9 {
995 let mut params_plus = params.clone();
996 let mut params_minus = params.clone();
997 params_plus[i] += eps;
998 params_minus[i] -= eps;
999
1000 let cam_plus = RadTanCamera::from(params_plus.as_slice());
1001 let cam_minus = RadTanCamera::from(params_minus.as_slice());
1002
1003 let uv_plus = cam_plus.project(&p_cam)?;
1004 let uv_minus = cam_minus.project(&p_cam)?;
1005 let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
1006
1007 for r in 0..2 {
1008 assert!(
1009 jac_analytical[(r, i)].is_finite(),
1010 "Jacobian [{r},{i}] is not finite"
1011 );
1012 let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
1013 assert!(
1014 diff < crate::JACOBIAN_TEST_TOLERANCE,
1015 "Mismatch at ({}, {})",
1016 r,
1017 i
1018 );
1019 }
1020 }
1021 Ok(())
1022 }
1023
1024 #[test]
1025 fn test_rad_tan_from_into_traits() -> TestResult {
1026 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1027 let distortion = DistortionModel::BrownConrady {
1028 k1: 0.1,
1029 k2: 0.01,
1030 p1: 0.001,
1031 p2: 0.002,
1032 k3: 0.001,
1033 };
1034 let camera = RadTanCamera::new(pinhole, distortion)?;
1035
1036 // Test conversion to DVector
1037 let params: DVector<f64> = (&camera).into();
1038 assert_eq!(params.len(), 9);
1039 assert_eq!(params[0], 300.0);
1040 assert_eq!(params[1], 300.0);
1041 assert_eq!(params[2], 320.0);
1042 assert_eq!(params[3], 240.0);
1043 assert_eq!(params[4], 0.1);
1044 assert_eq!(params[5], 0.01);
1045 assert_eq!(params[6], 0.001);
1046 assert_eq!(params[7], 0.002);
1047 assert_eq!(params[8], 0.001);
1048
1049 // Test conversion to array
1050 let arr: [f64; 9] = (&camera).into();
1051 assert_eq!(
1052 arr,
1053 [300.0, 300.0, 320.0, 240.0, 0.1, 0.01, 0.001, 0.002, 0.001]
1054 );
1055
1056 // Test conversion from slice
1057 let params_slice = [350.0, 350.0, 330.0, 250.0, 0.2, 0.02, 0.002, 0.003, 0.002];
1058 let camera2 = RadTanCamera::from(¶ms_slice[..]);
1059 assert_eq!(camera2.pinhole.fx, 350.0);
1060 assert_eq!(camera2.pinhole.fy, 350.0);
1061 assert_eq!(camera2.pinhole.cx, 330.0);
1062 assert_eq!(camera2.pinhole.cy, 250.0);
1063 let (k1, k2, p1, p2, k3) = camera2.distortion_params();
1064 assert_eq!(k1, 0.2);
1065 assert_eq!(k2, 0.02);
1066 assert_eq!(p1, 0.002);
1067 assert_eq!(p2, 0.003);
1068 assert_eq!(k3, 0.002);
1069
1070 // Test conversion from array
1071 let camera3 =
1072 RadTanCamera::from([400.0, 400.0, 340.0, 260.0, 0.3, 0.03, 0.003, 0.004, 0.003]);
1073 assert_eq!(camera3.pinhole.fx, 400.0);
1074 assert_eq!(camera3.pinhole.fy, 400.0);
1075 let (k1, k2, p1, p2, k3) = camera3.distortion_params();
1076 assert_eq!(k1, 0.3);
1077 assert_eq!(k2, 0.03);
1078 assert_eq!(p1, 0.003);
1079 assert_eq!(p2, 0.004);
1080 assert_eq!(k3, 0.003);
1081
1082 Ok(())
1083 }
1084
1085 #[test]
1086 fn test_linear_estimation() -> TestResult {
1087 // Ground truth camera with radial distortion only (p1=p2=0)
1088 let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1089 let gt_distortion = DistortionModel::BrownConrady {
1090 k1: 0.05,
1091 k2: 0.01,
1092 p1: 0.0,
1093 p2: 0.0,
1094 k3: 0.001,
1095 };
1096 let gt_camera = RadTanCamera::new(gt_pinhole, gt_distortion)?;
1097
1098 // Generate synthetic 3D points in camera frame
1099 let n_points = 50;
1100 let mut pts_3d = Matrix3xX::zeros(n_points);
1101 let mut pts_2d = Matrix2xX::zeros(n_points);
1102 let mut valid = 0;
1103
1104 for i in 0..n_points {
1105 let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
1106 let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
1107 let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
1108
1109 if let Ok(p2d) = gt_camera.project(&p3d) {
1110 pts_3d.set_column(valid, &p3d);
1111 pts_2d.set_column(valid, &p2d);
1112 valid += 1;
1113 }
1114 }
1115 let pts_3d = pts_3d.columns(0, valid).into_owned();
1116 let pts_2d = pts_2d.columns(0, valid).into_owned();
1117
1118 // Initial camera with zero distortion
1119 let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1120 let init_distortion = DistortionModel::BrownConrady {
1121 k1: 0.0,
1122 k2: 0.0,
1123 p1: 0.0,
1124 p2: 0.0,
1125 k3: 0.0,
1126 };
1127 let mut camera = RadTanCamera::new(init_pinhole, init_distortion)?;
1128
1129 camera.linear_estimation(&pts_3d, &pts_2d)?;
1130
1131 // Verify reprojection error
1132 for i in 0..valid {
1133 let p3d = pts_3d.column(i).into_owned();
1134 let projected = camera.project(&Vector3::new(p3d.x, p3d.y, p3d.z))?;
1135 let err = ((projected.x - pts_2d[(0, i)]).powi(2)
1136 + (projected.y - pts_2d[(1, i)]).powi(2))
1137 .sqrt();
1138 assert!(err < 1.0, "Reprojection error too large: {err}");
1139 }
1140
1141 Ok(())
1142 }
1143
1144 #[test]
1145 fn test_project_unproject_round_trip() -> TestResult {
1146 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1147 let distortion = DistortionModel::BrownConrady {
1148 k1: 0.1,
1149 k2: 0.01,
1150 p1: 0.001,
1151 p2: 0.002,
1152 k3: 0.001,
1153 };
1154 let camera = RadTanCamera::new(pinhole, distortion)?;
1155
1156 let test_points = [
1157 Vector3::new(0.1, 0.2, 1.0),
1158 Vector3::new(-0.3, 0.1, 2.0),
1159 Vector3::new(0.05, -0.1, 0.5),
1160 ];
1161
1162 for p_cam in &test_points {
1163 let uv = camera.project(p_cam)?;
1164 let ray = camera.unproject(&uv)?;
1165 let dot = ray.dot(&p_cam.normalize());
1166 assert!(
1167 (dot - 1.0).abs() < 1e-5,
1168 "Round-trip failed: dot={dot}, expected ~1.0"
1169 );
1170 }
1171
1172 Ok(())
1173 }
1174
1175 #[test]
1176 fn test_project_returns_error_behind_camera() -> TestResult {
1177 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1178 let distortion = DistortionModel::BrownConrady {
1179 k1: 0.0,
1180 k2: 0.0,
1181 p1: 0.0,
1182 p2: 0.0,
1183 k3: 0.0,
1184 };
1185 let camera = RadTanCamera::new(pinhole, distortion)?;
1186 assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
1187 Ok(())
1188 }
1189
1190 #[test]
1191 fn test_project_at_min_depth_boundary() -> TestResult {
1192 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
1193 let distortion = DistortionModel::BrownConrady {
1194 k1: 0.0,
1195 k2: 0.0,
1196 p1: 0.0,
1197 p2: 0.0,
1198 k3: 0.0,
1199 };
1200 let camera = RadTanCamera::new(pinhole, distortion)?;
1201 let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
1202 if let Ok(uv) = camera.project(&p_min) {
1203 assert!(uv.x.is_finite() && uv.y.is_finite());
1204 }
1205 Ok(())
1206 }
1207}