Skip to main content

apex_camera_models/
kannala_brandt.rs

1//! Kannala-Brandt fisheye camera model.
2//!
3//! Polynomial radial distortion parameterized in the incidence angle θ, suitable for fisheye
4//! lenses up to ~180° FOV. Has 8 intrinsic parameters. See the
5//! [kannala-brandt cookbook chapter](../doc/cookbook/src/kannala-brandt.html) for the full
6//! projection, unprojection, and Jacobian derivations.
7
8use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
9use nalgebra::{DVector, SMatrix, Vector2, Vector3};
10
11/// Kannala-Brandt fisheye camera model with 8 parameters.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct KannalaBrandtCamera {
14    pub pinhole: PinholeParams,
15    pub distortion: DistortionModel,
16}
17
18impl KannalaBrandtCamera {
19    /// Creates a new Kannala-Brandt fisheye camera.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not
24    /// [`DistortionModel::KannalaBrandt`].
25    ///
26    /// # Example
27    ///
28    /// ```
29    /// use apex_camera_models::{CameraModel, DistortionModel, KannalaBrandtCamera, PinholeParams};
30    ///
31    /// let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
32    /// let distortion = DistortionModel::KannalaBrandt { k1: 0.1, k2: 0.01, k3: 0.001, k4: 0.0001 };
33    /// let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
34    /// assert_eq!(camera.get_model_name(), "kannala_brandt");
35    /// # Ok::<(), apex_camera_models::CameraModelError>(())
36    /// ```
37    pub fn new(
38        pinhole: PinholeParams,
39        distortion: DistortionModel,
40    ) -> Result<Self, CameraModelError> {
41        let model = Self {
42            pinhole,
43            distortion,
44        };
45        model.validate_params()?;
46        Ok(model)
47    }
48
49    /// Returns the Kannala-Brandt distortion parameters as `(k1, k2, k3, k4)`.
50    /// Returns zeros if the model is not Kannala-Brandt.
51    fn distortion_params(&self) -> (f64, f64, f64, f64) {
52        match self.distortion {
53            DistortionModel::KannalaBrandt { k1, k2, k3, k4 } => (k1, k2, k3, k4),
54            _ => (0.0, 0.0, 0.0, 0.0),
55        }
56    }
57
58    /// Returns `true` if the point's z-coordinate is at or above [`crate::GEOMETRIC_PRECISION`].
59    fn check_projection_condition(&self, z: f64) -> bool {
60        z >= crate::GEOMETRIC_PRECISION
61    }
62
63    /// Initializes `[k1, k2, k3, k4]` via linear least-squares given 3D–2D correspondences.
64    /// Requires the intrinsics `[fx, fy, cx, cy]` to already be set; needs at least 4 correspondences.
65    pub fn linear_estimation(
66        &mut self,
67        points_3d: &nalgebra::Matrix3xX<f64>,
68        points_2d: &nalgebra::Matrix2xX<f64>,
69    ) -> Result<(), CameraModelError> {
70        if points_3d.ncols() != points_2d.ncols() {
71            return Err(CameraModelError::InvalidParams(
72                "Number of 2D and 3D points must match".to_string(),
73            ));
74        }
75        if points_3d.ncols() < 4 {
76            return Err(CameraModelError::InvalidParams(
77                "Not enough points for linear estimation (need at least 4)".to_string(),
78            ));
79        }
80
81        let num_points = points_3d.ncols();
82        let mut a_mat = nalgebra::DMatrix::zeros(num_points * 2, 4);
83        let mut b_vec = nalgebra::DVector::zeros(num_points * 2);
84
85        for i in 0..num_points {
86            let p3d = points_3d.column(i);
87            let p2d = points_2d.column(i);
88
89            let x_world = p3d.x;
90            let y_world = p3d.y;
91            let z_world = p3d.z;
92
93            let u_img = p2d.x;
94            let v_img = p2d.y;
95
96            if z_world <= f64::EPSILON {
97                continue;
98            }
99
100            let r_world = (x_world * x_world + y_world * y_world).sqrt();
101            let theta = r_world.atan2(z_world);
102
103            let theta2 = theta * theta;
104            let theta3 = theta2 * theta;
105            let theta5 = theta3 * theta2;
106            let theta7 = theta5 * theta2;
107            let theta9 = theta7 * theta2;
108
109            a_mat[(i * 2, 0)] = theta3;
110            a_mat[(i * 2, 1)] = theta5;
111            a_mat[(i * 2, 2)] = theta7;
112            a_mat[(i * 2, 3)] = theta9;
113
114            a_mat[(i * 2 + 1, 0)] = theta3;
115            a_mat[(i * 2 + 1, 1)] = theta5;
116            a_mat[(i * 2 + 1, 2)] = theta7;
117            a_mat[(i * 2 + 1, 3)] = theta9;
118
119            let x_r = if r_world < f64::EPSILON {
120                0.0
121            } else {
122                x_world / r_world
123            };
124            let y_r = if r_world < f64::EPSILON {
125                0.0
126            } else {
127                y_world / r_world
128            };
129
130            if (self.pinhole.fx * x_r).abs() < f64::EPSILON && x_r.abs() > f64::EPSILON {
131                return Err(CameraModelError::NumericalError {
132                    operation: "linear_estimation".to_string(),
133                    details: "fx * x_r is zero in linear estimation".to_string(),
134                });
135            }
136            if (self.pinhole.fy * y_r).abs() < f64::EPSILON && y_r.abs() > f64::EPSILON {
137                return Err(CameraModelError::NumericalError {
138                    operation: "linear_estimation".to_string(),
139                    details: "fy * y_r is zero in linear estimation".to_string(),
140                });
141            }
142
143            if x_r.abs() > f64::EPSILON {
144                b_vec[i * 2] = (u_img - self.pinhole.cx) / (self.pinhole.fx * x_r) - theta;
145            } else {
146                b_vec[i * 2] = if (u_img - self.pinhole.cx).abs() < f64::EPSILON {
147                    -theta
148                } else {
149                    0.0
150                };
151            }
152
153            if y_r.abs() > f64::EPSILON {
154                b_vec[i * 2 + 1] = (v_img - self.pinhole.cy) / (self.pinhole.fy * y_r) - theta;
155            } else {
156                b_vec[i * 2 + 1] = if (v_img - self.pinhole.cy).abs() < f64::EPSILON {
157                    -theta
158                } else {
159                    0.0
160                };
161            }
162        }
163
164        let svd = a_mat.svd(true, true);
165        let x_coeffs =
166            svd.solve(&b_vec, f64::EPSILON)
167                .map_err(|e_str| CameraModelError::NumericalError {
168                    operation: "svd_solve".to_string(),
169                    details: format!("SVD solve failed in linear estimation: {e_str}"),
170                })?;
171
172        self.distortion = DistortionModel::KannalaBrandt {
173            k1: x_coeffs[0],
174            k2: x_coeffs[1],
175            k3: x_coeffs[2],
176            k4: x_coeffs[3],
177        };
178
179        self.validate_params()?;
180        Ok(())
181    }
182}
183
184/// Converts the camera to a dynamic vector with layout `[fx, fy, cx, cy, k1, k2, k3, k4]`.
185impl From<&KannalaBrandtCamera> for DVector<f64> {
186    fn from(camera: &KannalaBrandtCamera) -> Self {
187        let (k1, k2, k3, k4) = camera.distortion_params();
188        DVector::from_vec(vec![
189            camera.pinhole.fx,
190            camera.pinhole.fy,
191            camera.pinhole.cx,
192            camera.pinhole.cy,
193            k1,
194            k2,
195            k3,
196            k4,
197        ])
198    }
199}
200
201/// Converts the camera to a fixed-size array with layout `[fx, fy, cx, cy, k1, k2, k3, k4]`.
202impl From<&KannalaBrandtCamera> for [f64; 8] {
203    fn from(camera: &KannalaBrandtCamera) -> Self {
204        let (k1, k2, k3, k4) = camera.distortion_params();
205        [
206            camera.pinhole.fx,
207            camera.pinhole.fy,
208            camera.pinhole.cx,
209            camera.pinhole.cy,
210            k1,
211            k2,
212            k3,
213            k4,
214        ]
215    }
216}
217
218/// Creates a camera from a slice with layout `[fx, fy, cx, cy, k1, k2, k3, k4]`.
219/// Returns an error if the slice has fewer than 8 elements.
220impl TryFrom<&[f64]> for KannalaBrandtCamera {
221    type Error = CameraModelError;
222
223    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
224        if params.len() < 8 {
225            return Err(CameraModelError::InvalidParams(format!(
226                "KannalaBrandtCamera requires at least 8 parameters, got {}",
227                params.len()
228            )));
229        }
230        Ok(Self {
231            pinhole: PinholeParams {
232                fx: params[0],
233                fy: params[1],
234                cx: params[2],
235                cy: params[3],
236            },
237            distortion: DistortionModel::KannalaBrandt {
238                k1: params[4],
239                k2: params[5],
240                k3: params[6],
241                k4: params[7],
242            },
243        })
244    }
245}
246
247/// Creates a camera from a fixed-size array with layout `[fx, fy, cx, cy, k1, k2, k3, k4]`.
248impl From<[f64; 8]> for KannalaBrandtCamera {
249    fn from(params: [f64; 8]) -> Self {
250        Self {
251            pinhole: PinholeParams {
252                fx: params[0],
253                fy: params[1],
254                cx: params[2],
255                cy: params[3],
256            },
257            distortion: DistortionModel::KannalaBrandt {
258                k1: params[4],
259                k2: params[5],
260                k3: params[6],
261                k4: params[7],
262            },
263        }
264    }
265}
266
267/// Creates a `KannalaBrandtCamera` from a parameter slice with validation.
268///
269/// Unlike `From<&[f64]>`, this constructor validates all parameters
270/// Creates a `KannalaBrandtCamera` from a parameter slice with full validation.
271/// Unlike [`<KannalaBrandtCamera as TryFrom<&[f64]>>::try_from`], this also calls
272/// [`CameraModel::validate_params`] and returns any validation errors.
273pub fn try_from_params(params: &[f64]) -> Result<KannalaBrandtCamera, CameraModelError> {
274    let camera = KannalaBrandtCamera::try_from(params)?;
275    camera.validate_params()?;
276    Ok(camera)
277}
278
279impl CameraModel for KannalaBrandtCamera {
280    const INTRINSIC_DIM: usize = 8;
281    type IntrinsicJacobian = SMatrix<f64, 2, 8>;
282    type PointJacobian = SMatrix<f64, 2, 3>;
283
284    /// Projects a 3D point in the camera frame to 2D image coordinates.
285    /// Returns [`CameraModelError::PointBehindCamera`] if the point lies at or behind
286    /// the camera.
287    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
288        let x = p_cam[0];
289        let y = p_cam[1];
290        let z = p_cam[2];
291
292        if !self.check_projection_condition(z) {
293            return Err(CameraModelError::PointBehindCamera {
294                z,
295                min_z: crate::GEOMETRIC_PRECISION,
296            });
297        }
298
299        let (k1, k2, k3, k4) = self.distortion_params();
300        let r2 = x * x + y * y;
301        let r = r2.sqrt();
302        let theta = r.atan2(z);
303
304        // Polynomial distortion: d(θ) = θ + k₁·θ³ + k₂·θ⁵ + k₃·θ⁷ + k₄·θ⁹
305        let theta2 = theta * theta;
306        let theta3 = theta2 * theta;
307        let theta5 = theta3 * theta2;
308        let theta7 = theta5 * theta2;
309        let theta9 = theta7 * theta2;
310
311        let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
312
313        if r < crate::GEOMETRIC_PRECISION {
314            let inv_z = 1.0 / z;
315            return Ok(Vector2::new(
316                self.pinhole.fx * x * inv_z + self.pinhole.cx,
317                self.pinhole.fy * y * inv_z + self.pinhole.cy,
318            ));
319        }
320
321        let inv_r = 1.0 / r;
322        Ok(Vector2::new(
323            self.pinhole.fx * theta_d * x * inv_r + self.pinhole.cx,
324            self.pinhole.fy * theta_d * y * inv_r + self.pinhole.cy,
325        ))
326    }
327
328    /// Unprojects a 2D image point to a unit 3D ray. Solves for the incidence angle
329    /// θ with Newton-Raphson, then converts to a 3D direction. Returns
330    /// [`CameraModelError::NumericalError`] if the derivative becomes too small.
331    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
332        let u = point_2d.x;
333        let v = point_2d.y;
334
335        let (k1, k2, k3, k4) = self.distortion_params();
336        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
337        let my = (v - self.pinhole.cy) / self.pinhole.fy;
338
339        let mut ru = (mx * mx + my * my).sqrt();
340
341        ru = ru.min(std::f64::consts::PI / 2.0);
342
343        if ru < crate::GEOMETRIC_PRECISION {
344            return Ok(Vector3::new(0.0, 0.0, 1.0));
345        }
346
347        let mut theta = ru;
348        const MAX_ITER: usize = 10;
349        const CONVERGENCE_THRESHOLD: f64 = crate::CONVERGENCE_THRESHOLD;
350
351        for _ in 0..MAX_ITER {
352            let theta2 = theta * theta;
353            let theta4 = theta2 * theta2;
354            let theta6 = theta4 * theta2;
355            let theta8 = theta4 * theta4;
356
357            let k1_theta2 = k1 * theta2;
358            let k2_theta4 = k2 * theta4;
359            let k3_theta6 = k3 * theta6;
360            let k4_theta8 = k4 * theta8;
361
362            let f = theta * (1.0 + k1_theta2 + k2_theta4 + k3_theta6 + k4_theta8) - ru;
363            let f_prime =
364                1.0 + 3.0 * k1_theta2 + 5.0 * k2_theta4 + 7.0 * k3_theta6 + 9.0 * k4_theta8;
365
366            if f_prime.abs() < f64::EPSILON {
367                return Err(CameraModelError::NumericalError {
368                    operation: "unprojection".to_string(),
369                    details: "Derivative too small in KB unprojection".to_string(),
370                });
371            }
372
373            let delta = f / f_prime;
374            theta -= delta;
375
376            if delta.abs() < CONVERGENCE_THRESHOLD {
377                break;
378            }
379        }
380
381        let sin_theta = theta.sin();
382        let cos_theta = theta.cos();
383
384        let scale = sin_theta / ru;
385        let x = mx * scale;
386        let y = my * scale;
387        let z = cos_theta;
388
389        Ok(Vector3::new(x, y, z).normalize())
390    }
391
392    /// 2×3 Jacobian ∂(u,v)/∂(x,y,z). See the
393    /// [cookbook](../doc/cookbook/src/kannala-brandt.html#jacobians) for the full derivation.
394    ///
395    /// # Implementation Note
396    ///
397    /// The implementation handles the optical axis singularity (r → 0) using a threshold check
398    /// and falls back to a simplified Jacobian for numerical stability.
399    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
400        let x = p_cam[0];
401        let y = p_cam[1];
402        let z = p_cam[2];
403
404        let (k1, k2, k3, k4) = self.distortion_params();
405        let r = (x * x + y * y).sqrt();
406        let theta = r.atan2(z);
407
408        let theta2 = theta * theta;
409        let theta3 = theta2 * theta;
410        let theta5 = theta3 * theta2;
411        let theta7 = theta5 * theta2;
412        let theta9 = theta7 * theta2;
413
414        let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
415
416        // ∂θ_d/∂θ = 1 + 3k₁·θ² + 5k₂·θ⁴ + 7k₃·θ⁶ + 9k₄·θ⁸
417        let dtheta_d_dtheta = 1.0
418            + 3.0 * k1 * theta2
419            + 5.0 * k2 * theta2 * theta2
420            + 7.0 * k3 * theta2 * theta2 * theta2
421            + 9.0 * k4 * theta2 * theta2 * theta2 * theta2;
422
423        if r < crate::GEOMETRIC_PRECISION {
424            // Near optical axis, use simplified Jacobian
425            return SMatrix::<f64, 2, 3>::new(
426                self.pinhole.fx * dtheta_d_dtheta / z,
427                0.0,
428                0.0,
429                0.0,
430                self.pinhole.fy * dtheta_d_dtheta / z,
431                0.0,
432            );
433        }
434
435        let inv_r = 1.0 / r;
436        let r2 = r * r;
437        let r_z2 = r2 + z * z;
438
439        // ∂θ/∂x = z·x / (r·(r² + z²))
440        // ∂θ/∂y = z·y / (r·(r² + z²))
441        // ∂θ/∂z = -r / (r² + z²)
442        let dtheta_dx = z * x / (r * r_z2);
443        let dtheta_dy = z * y / (r * r_z2);
444        let dtheta_dz = -r / r_z2;
445
446        // ∂r/∂x = x/r, ∂r/∂y = y/r, ∂r/∂z = 0
447
448        // Chain rule for u = fx · θ_d · (x/r) + cx
449        let inv_r2 = inv_r * inv_r;
450
451        let du_dx = self.pinhole.fx
452            * (dtheta_d_dtheta * dtheta_dx * x * inv_r
453                + theta_d * (inv_r - x * x * inv_r2 * inv_r));
454        let du_dy = self.pinhole.fx
455            * (dtheta_d_dtheta * dtheta_dy * x * inv_r - theta_d * x * y * inv_r2 * inv_r);
456        let du_dz = self.pinhole.fx * dtheta_d_dtheta * dtheta_dz * x * inv_r;
457
458        let dv_dx = self.pinhole.fy
459            * (dtheta_d_dtheta * dtheta_dx * y * inv_r - theta_d * x * y * inv_r2 * inv_r);
460        let dv_dy = self.pinhole.fy
461            * (dtheta_d_dtheta * dtheta_dy * y * inv_r
462                + theta_d * (inv_r - y * y * inv_r2 * inv_r));
463        let dv_dz = self.pinhole.fy * dtheta_d_dtheta * dtheta_dz * y * inv_r;
464
465        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
466    }
467
468    /// 2×8 Jacobian ∂(u,v)/∂[fx, fy, cx, cy, k1, k2, k3, k4]. See the
469    /// [cookbook](../doc/cookbook/src/kannala-brandt.html#jacobians) for the full derivation.
470    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
471        let x = p_cam[0];
472        let y = p_cam[1];
473        let z = p_cam[2];
474
475        let (k1, k2, k3, k4) = self.distortion_params();
476        let r = (x * x + y * y).sqrt();
477        let theta = r.atan2(z);
478
479        let theta2 = theta * theta;
480        let theta3 = theta2 * theta;
481        let theta5 = theta3 * theta2;
482        let theta7 = theta5 * theta2;
483        let theta9 = theta7 * theta2;
484
485        let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
486
487        if r < crate::GEOMETRIC_PRECISION {
488            return SMatrix::<f64, 2, 8>::zeros();
489        }
490
491        let inv_r = 1.0 / r;
492        let x_theta_d_r = x * theta_d * inv_r;
493        let y_theta_d_r = y * theta_d * inv_r;
494
495        // ∂u/∂fx = θ_d·x/r, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
496        // ∂v/∂fx = 0, ∂v/∂fy = θ_d·y/r, ∂v/∂cx = 0, ∂v/∂cy = 1
497
498        // ∂u/∂k₁ = fx·θ³·x/r, ∂u/∂k₂ = fx·θ⁵·x/r, etc.
499        let du_dk1 = self.pinhole.fx * theta3 * x * inv_r;
500        let du_dk2 = self.pinhole.fx * theta5 * x * inv_r;
501        let du_dk3 = self.pinhole.fx * theta7 * x * inv_r;
502        let du_dk4 = self.pinhole.fx * theta9 * x * inv_r;
503
504        let dv_dk1 = self.pinhole.fy * theta3 * y * inv_r;
505        let dv_dk2 = self.pinhole.fy * theta5 * y * inv_r;
506        let dv_dk3 = self.pinhole.fy * theta7 * y * inv_r;
507        let dv_dk4 = self.pinhole.fy * theta9 * y * inv_r;
508
509        SMatrix::<f64, 2, 8>::from_row_slice(&[
510            x_theta_d_r,
511            0.0,
512            1.0,
513            0.0,
514            du_dk1,
515            du_dk2,
516            du_dk3,
517            du_dk4,
518            0.0,
519            y_theta_d_r,
520            0.0,
521            1.0,
522            dv_dk1,
523            dv_dk2,
524            dv_dk3,
525            dv_dk4,
526        ])
527    }
528
529    /// Validates the camera parameters.
530    ///
531    /// # Validation Rules
532    ///
533    /// - `fx`, `fy` must be positive (> 0) and finite
534    /// - `cx`, `cy` must be finite
535    /// - `k1`..`k4` must be finite
536    ///
537    /// # Errors
538    ///
539    /// Returns [`CameraModelError`] if any rule is violated.
540    fn validate_params(&self) -> Result<(), CameraModelError> {
541        self.pinhole.validate()?;
542        self.get_distortion().validate()
543    }
544
545    /// Returns the pinhole parameters.
546    fn get_pinhole_params(&self) -> PinholeParams {
547        PinholeParams {
548            fx: self.pinhole.fx,
549            fy: self.pinhole.fy,
550            cx: self.pinhole.cx,
551            cy: self.pinhole.cy,
552        }
553    }
554
555    /// Returns the distortion model (must be [`DistortionModel::KannalaBrandt`]).
556    fn get_distortion(&self) -> DistortionModel {
557        self.distortion
558    }
559
560    /// Returns the model name: `"kannala_brandt"`.
561    fn get_model_name(&self) -> &'static str {
562        "kannala_brandt"
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use nalgebra::{Matrix2xX, Matrix3xX};
570
571    type TestResult = Result<(), Box<dyn std::error::Error>>;
572
573    #[test]
574    fn test_kb_camera_creation() -> TestResult {
575        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
576        let distortion = DistortionModel::KannalaBrandt {
577            k1: 0.1,
578            k2: 0.01,
579            k3: 0.001,
580            k4: 0.0001,
581        };
582        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
583        assert_eq!(camera.pinhole.fx, 300.0);
584        let (k1, _, _, _) = camera.distortion_params();
585        assert_eq!(k1, 0.1);
586        Ok(())
587    }
588
589    #[test]
590    fn test_projection_at_optical_axis() -> TestResult {
591        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
592        let distortion = DistortionModel::KannalaBrandt {
593            k1: 0.1,
594            k2: 0.01,
595            k3: 0.001,
596            k4: 0.0001,
597        };
598        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
599        let p_cam = Vector3::new(0.0, 0.0, 1.0);
600        let uv = camera.project(&p_cam)?;
601
602        assert!((uv.x - 320.0).abs() < 1e-6);
603        assert!((uv.y - 240.0).abs() < 1e-6);
604
605        Ok(())
606    }
607
608    #[test]
609    fn test_jacobian_point_numerical() -> TestResult {
610        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
611        let distortion = DistortionModel::KannalaBrandt {
612            k1: 0.1,
613            k2: 0.01,
614            k3: 0.001,
615            k4: 0.0001,
616        };
617        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
618        let p_cam = Vector3::new(0.1, 0.2, 1.0);
619
620        let jac_analytical = camera.jacobian_point(&p_cam);
621        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
622
623        for i in 0..3 {
624            let mut p_plus = p_cam;
625            let mut p_minus = p_cam;
626            p_plus[i] += eps;
627            p_minus[i] -= eps;
628
629            let uv_plus = camera.project(&p_plus)?;
630            let uv_minus = camera.project(&p_minus)?;
631            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
632
633            for r in 0..2 {
634                assert!(
635                    jac_analytical[(r, i)].is_finite(),
636                    "Jacobian [{r},{i}] is not finite"
637                );
638                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
639                assert!(
640                    diff < crate::JACOBIAN_TEST_TOLERANCE,
641                    "Mismatch at ({}, {})",
642                    r,
643                    i
644                );
645            }
646        }
647        Ok(())
648    }
649
650    #[test]
651    fn test_jacobian_intrinsics_numerical() -> TestResult {
652        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
653        let distortion = DistortionModel::KannalaBrandt {
654            k1: 0.1,
655            k2: 0.01,
656            k3: 0.001,
657            k4: 0.0001,
658        };
659        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
660        let p_cam = Vector3::new(0.1, 0.2, 1.0);
661
662        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
663        let params: DVector<f64> = (&camera).into();
664        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
665
666        for i in 0..8 {
667            let mut params_plus = params.clone();
668            let mut params_minus = params.clone();
669            params_plus[i] += eps;
670            params_minus[i] -= eps;
671
672            let cam_plus = KannalaBrandtCamera::try_from(params_plus.as_slice())?;
673            let cam_minus = KannalaBrandtCamera::try_from(params_minus.as_slice())?;
674
675            let uv_plus = cam_plus.project(&p_cam)?;
676            let uv_minus = cam_minus.project(&p_cam)?;
677            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
678
679            for r in 0..2 {
680                assert!(
681                    jac_analytical[(r, i)].is_finite(),
682                    "Jacobian [{r},{i}] is not finite"
683                );
684                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
685                assert!(
686                    diff < crate::JACOBIAN_TEST_TOLERANCE,
687                    "Mismatch at ({}, {})",
688                    r,
689                    i
690                );
691            }
692        }
693        Ok(())
694    }
695
696    #[test]
697    fn test_kb_from_into_traits() -> TestResult {
698        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
699        let distortion = DistortionModel::KannalaBrandt {
700            k1: 0.1,
701            k2: 0.01,
702            k3: 0.001,
703            k4: 0.0001,
704        };
705        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
706
707        // Test conversion to DVector
708        let params: DVector<f64> = (&camera).into();
709        assert_eq!(params.len(), 8);
710        assert_eq!(params[0], 300.0);
711        assert_eq!(params[1], 300.0);
712        assert_eq!(params[2], 320.0);
713        assert_eq!(params[3], 240.0);
714        assert_eq!(params[4], 0.1);
715        assert_eq!(params[5], 0.01);
716        assert_eq!(params[6], 0.001);
717        assert_eq!(params[7], 0.0001);
718
719        // Test conversion to array
720        let arr: [f64; 8] = (&camera).into();
721        assert_eq!(arr, [300.0, 300.0, 320.0, 240.0, 0.1, 0.01, 0.001, 0.0001]);
722
723        // Test conversion from slice
724        let params_slice = [350.0, 350.0, 330.0, 250.0, 0.2, 0.02, 0.002, 0.0002];
725        let camera2 = KannalaBrandtCamera::try_from(&params_slice[..])?;
726        assert_eq!(camera2.pinhole.fx, 350.0);
727        assert_eq!(camera2.pinhole.fy, 350.0);
728        assert_eq!(camera2.pinhole.cx, 330.0);
729        assert_eq!(camera2.pinhole.cy, 250.0);
730        let (k1, k2, k3, k4) = camera2.distortion_params();
731        assert_eq!(k1, 0.2);
732        assert_eq!(k2, 0.02);
733        assert_eq!(k3, 0.002);
734        assert_eq!(k4, 0.0002);
735
736        // Test conversion from array
737        let camera3 =
738            KannalaBrandtCamera::from([400.0, 400.0, 340.0, 260.0, 0.3, 0.03, 0.003, 0.0003]);
739        assert_eq!(camera3.pinhole.fx, 400.0);
740        assert_eq!(camera3.pinhole.fy, 400.0);
741        let (k1, k2, k3, k4) = camera3.distortion_params();
742        assert_eq!(k1, 0.3);
743        assert_eq!(k2, 0.03);
744        assert_eq!(k3, 0.003);
745        assert_eq!(k4, 0.0003);
746
747        Ok(())
748    }
749
750    #[test]
751    fn test_linear_estimation() -> TestResult {
752        // Ground truth fisheye camera
753        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
754        let gt_distortion = DistortionModel::KannalaBrandt {
755            k1: 0.1,
756            k2: 0.01,
757            k3: 0.001,
758            k4: 0.0001,
759        };
760        let gt_camera = KannalaBrandtCamera::new(gt_pinhole, gt_distortion)?;
761
762        // Generate synthetic 3D points in camera frame
763        let n_points = 50;
764        let mut pts_3d = Matrix3xX::zeros(n_points);
765        let mut pts_2d = Matrix2xX::zeros(n_points);
766        let mut valid = 0;
767
768        for i in 0..n_points {
769            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
770            let r = 0.1 + 0.4 * (i as f64 / n_points as f64);
771            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
772
773            if let Ok(p2d) = gt_camera.project(&p3d) {
774                pts_3d.set_column(valid, &p3d);
775                pts_2d.set_column(valid, &p2d);
776                valid += 1;
777            }
778        }
779        let pts_3d = pts_3d.columns(0, valid).into_owned();
780        let pts_2d = pts_2d.columns(0, valid).into_owned();
781
782        // Initial camera with zero distortion
783        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
784        let init_distortion = DistortionModel::KannalaBrandt {
785            k1: 0.0,
786            k2: 0.0,
787            k3: 0.0,
788            k4: 0.0,
789        };
790        let mut camera = KannalaBrandtCamera::new(init_pinhole, init_distortion)?;
791
792        camera.linear_estimation(&pts_3d, &pts_2d)?;
793
794        // Verify reprojection error
795        for i in 0..valid {
796            let col = pts_3d.column(i);
797            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
798            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
799                + (projected.y - pts_2d[(1, i)]).powi(2))
800            .sqrt();
801            assert!(err < 3.0, "Reprojection error too large: {err}");
802        }
803
804        Ok(())
805    }
806
807    #[test]
808    fn test_project_unproject_round_trip() -> TestResult {
809        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
810        let distortion = DistortionModel::KannalaBrandt {
811            k1: 0.1,
812            k2: 0.01,
813            k3: 0.001,
814            k4: 0.0001,
815        };
816        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
817
818        let test_points = [
819            Vector3::new(0.1, 0.2, 1.0),
820            Vector3::new(-0.3, 0.1, 2.0),
821            Vector3::new(0.05, -0.1, 0.5),
822        ];
823
824        for p_cam in &test_points {
825            let uv = camera.project(p_cam)?;
826            let ray = camera.unproject(&uv)?;
827            let dot = ray.dot(&p_cam.normalize());
828            assert!(
829                (dot - 1.0).abs() < 1e-6,
830                "Round-trip failed: dot={dot}, expected ~1.0"
831            );
832        }
833
834        Ok(())
835    }
836
837    #[test]
838    fn test_project_returns_error_behind_camera() -> TestResult {
839        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
840        let distortion = DistortionModel::KannalaBrandt {
841            k1: 0.0,
842            k2: 0.0,
843            k3: 0.0,
844            k4: 0.0,
845        };
846        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
847        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
848        Ok(())
849    }
850
851    #[test]
852    fn test_project_at_min_depth_boundary() -> TestResult {
853        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
854        let distortion = DistortionModel::KannalaBrandt {
855            k1: 0.0,
856            k2: 0.0,
857            k3: 0.0,
858            k4: 0.0,
859        };
860        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
861        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
862        if let Ok(uv) = camera.project(&p_min) {
863            assert!(uv.x.is_finite() && uv.y.is_finite());
864        }
865        Ok(())
866    }
867
868    #[test]
869    fn test_projection_off_axis() -> TestResult {
870        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
871        let distortion = DistortionModel::KannalaBrandt {
872            k1: 0.1,
873            k2: 0.01,
874            k3: 0.001,
875            k4: 0.0001,
876        };
877        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
878        let p_cam = Vector3::new(0.3, 0.0, 1.0);
879        let uv = camera.project(&p_cam)?;
880        assert!(
881            uv.x > 320.0,
882            "off-axis point should project right of principal point"
883        );
884        assert!(
885            (uv.y - 240.0).abs() < 1.0,
886            "y should be close to cy for horizontal offset"
887        );
888        Ok(())
889    }
890
891    #[test]
892    fn test_unproject_center_pixel() -> TestResult {
893        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
894        let distortion = DistortionModel::KannalaBrandt {
895            k1: 0.1,
896            k2: 0.01,
897            k3: 0.001,
898            k4: 0.0001,
899        };
900        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
901        let uv = Vector2::new(320.0, 240.0);
902        let ray = camera.unproject(&uv)?;
903        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
904        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
905        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
906        Ok(())
907    }
908
909    #[test]
910    fn test_batch_projection_matches_individual() -> TestResult {
911        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
912        let distortion = DistortionModel::KannalaBrandt {
913            k1: 0.1,
914            k2: 0.01,
915            k3: 0.001,
916            k4: 0.0001,
917        };
918        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
919        let pts = Matrix3xX::from_columns(&[
920            Vector3::new(0.0, 0.0, 1.0),
921            Vector3::new(0.3, 0.2, 1.5),
922            Vector3::new(-0.4, 0.1, 2.0),
923        ]);
924        let batch = camera.project_batch(&pts);
925        for i in 0..3 {
926            let col = pts.column(i);
927            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
928            assert!(
929                (batch[(0, i)] - p.x).abs() < 1e-10,
930                "batch u mismatch at col {i}"
931            );
932            assert!(
933                (batch[(1, i)] - p.y).abs() < 1e-10,
934                "batch v mismatch at col {i}"
935            );
936        }
937        Ok(())
938    }
939
940    #[test]
941    fn test_jacobian_dimensions() -> TestResult {
942        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
943        let distortion = DistortionModel::KannalaBrandt {
944            k1: 0.1,
945            k2: 0.01,
946            k3: 0.001,
947            k4: 0.0001,
948        };
949        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
950        let p_cam = Vector3::new(0.1, 0.2, 1.0);
951        let jac_point = camera.jacobian_point(&p_cam);
952        assert_eq!(jac_point.nrows(), 2);
953        assert_eq!(jac_point.ncols(), 3);
954        let jac_intr = camera.jacobian_intrinsics(&p_cam);
955        assert_eq!(jac_intr.nrows(), 2);
956        assert_eq!(jac_intr.ncols(), 8); // KannalaBrandtCamera::INTRINSIC_DIM = 8
957        Ok(())
958    }
959}