Skip to main content

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(&params_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}