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 ru = (mx * mx + my * my).sqrt();
340
341        // Out-of-domain guard: pixels whose undistorted radius exceeds the
342        // model's valid field of view are REJECTED rather than silently
343        // clamped — clamping rewrote the input ray and returned a direction
344        // that never projected back to the pixel (ftheta behaves the same).
345        if !ru.is_finite() || ru > std::f64::consts::PI / 2.0 {
346            return Err(CameraModelError::NumericalError {
347                operation: "unprojection".to_string(),
348                details: format!(
349                    "undistorted radius ru = {ru} is outside the KB model's valid domain (0, pi/2]"
350                ),
351            });
352        }
353
354        if ru < crate::GEOMETRIC_PRECISION {
355            return Ok(Vector3::new(0.0, 0.0, 1.0));
356        }
357
358        let mut theta = ru;
359        const MAX_ITER: usize = 10;
360        const CONVERGENCE_THRESHOLD: f64 = crate::CONVERGENCE_THRESHOLD;
361
362        for _ in 0..MAX_ITER {
363            let theta2 = theta * theta;
364            let theta4 = theta2 * theta2;
365            let theta6 = theta4 * theta2;
366            let theta8 = theta4 * theta4;
367
368            let k1_theta2 = k1 * theta2;
369            let k2_theta4 = k2 * theta4;
370            let k3_theta6 = k3 * theta6;
371            let k4_theta8 = k4 * theta8;
372
373            let f = theta * (1.0 + k1_theta2 + k2_theta4 + k3_theta6 + k4_theta8) - ru;
374            let f_prime =
375                1.0 + 3.0 * k1_theta2 + 5.0 * k2_theta4 + 7.0 * k3_theta6 + 9.0 * k4_theta8;
376
377            if f_prime.abs() < f64::EPSILON {
378                return Err(CameraModelError::NumericalError {
379                    operation: "unprojection".to_string(),
380                    details: "Derivative too small in KB unprojection".to_string(),
381                });
382            }
383
384            let delta = f / f_prime;
385            theta -= delta;
386
387            if delta.abs() < CONVERGENCE_THRESHOLD {
388                break;
389            }
390        }
391
392        // Post-Newton convergence gate (mirrors `ftheta::unproject`): a
393        // non-converged or diverged theta must not silently become a ray.
394        if !theta.is_finite() || theta < 0.0 {
395            return Err(CameraModelError::NumericalError {
396                operation: "unprojection".to_string(),
397                details: format!("Newton-Raphson diverged, theta={theta}"),
398            });
399        }
400        let theta2 = theta * theta;
401        let residual_f = theta
402            * (1.0
403                + k1 * theta2
404                + k2 * theta2 * theta2
405                + k3 * theta2 * theta2 * theta2
406                + k4 * theta2 * theta2 * theta2 * theta2)
407            - ru;
408        if residual_f.abs() > crate::CONVERGENCE_THRESHOLD {
409            return Err(CameraModelError::NumericalError {
410                operation: "unprojection".to_string(),
411                details: format!(
412                    "Newton-Raphson did not converge after {MAX_ITER} iterations: |f(theta)| = {}",
413                    residual_f.abs()
414                ),
415            });
416        }
417
418        let sin_theta = theta.sin();
419        let cos_theta = theta.cos();
420
421        let scale = sin_theta / ru;
422        let x = mx * scale;
423        let y = my * scale;
424        let z = cos_theta;
425
426        Ok(Vector3::new(x, y, z).normalize())
427    }
428
429    /// 2×3 Jacobian ∂(u,v)/∂(x,y,z). See the
430    /// [cookbook](../doc/cookbook/src/kannala-brandt.html#jacobians) for the full derivation.
431    ///
432    /// # Implementation Note
433    ///
434    /// The implementation handles the optical axis singularity (r → 0) using a threshold check
435    /// and falls back to a simplified Jacobian for numerical stability.
436    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
437        let x = p_cam[0];
438        let y = p_cam[1];
439        let z = p_cam[2];
440
441        let (k1, k2, k3, k4) = self.distortion_params();
442        let r = (x * x + y * y).sqrt();
443        let theta = r.atan2(z);
444
445        let theta2 = theta * theta;
446        let theta3 = theta2 * theta;
447        let theta5 = theta3 * theta2;
448        let theta7 = theta5 * theta2;
449        let theta9 = theta7 * theta2;
450
451        let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
452
453        // ∂θ_d/∂θ = 1 + 3k₁·θ² + 5k₂·θ⁴ + 7k₃·θ⁶ + 9k₄·θ⁸
454        let dtheta_d_dtheta = 1.0
455            + 3.0 * k1 * theta2
456            + 5.0 * k2 * theta2 * theta2
457            + 7.0 * k3 * theta2 * theta2 * theta2
458            + 9.0 * k4 * theta2 * theta2 * theta2 * theta2;
459
460        if r < crate::GEOMETRIC_PRECISION {
461            // Near optical axis, use simplified Jacobian
462            return SMatrix::<f64, 2, 3>::new(
463                self.pinhole.fx * dtheta_d_dtheta / z,
464                0.0,
465                0.0,
466                0.0,
467                self.pinhole.fy * dtheta_d_dtheta / z,
468                0.0,
469            );
470        }
471
472        let inv_r = 1.0 / r;
473        let r2 = r * r;
474        let r_z2 = r2 + z * z;
475
476        // ∂θ/∂x = z·x / (r·(r² + z²))
477        // ∂θ/∂y = z·y / (r·(r² + z²))
478        // ∂θ/∂z = -r / (r² + z²)
479        let dtheta_dx = z * x / (r * r_z2);
480        let dtheta_dy = z * y / (r * r_z2);
481        let dtheta_dz = -r / r_z2;
482
483        // ∂r/∂x = x/r, ∂r/∂y = y/r, ∂r/∂z = 0
484
485        // Chain rule for u = fx · θ_d · (x/r) + cx
486        let inv_r2 = inv_r * inv_r;
487
488        let du_dx = self.pinhole.fx
489            * (dtheta_d_dtheta * dtheta_dx * x * inv_r
490                + theta_d * (inv_r - x * x * inv_r2 * inv_r));
491        let du_dy = self.pinhole.fx
492            * (dtheta_d_dtheta * dtheta_dy * x * inv_r - theta_d * x * y * inv_r2 * inv_r);
493        let du_dz = self.pinhole.fx * dtheta_d_dtheta * dtheta_dz * x * inv_r;
494
495        let dv_dx = self.pinhole.fy
496            * (dtheta_d_dtheta * dtheta_dx * y * inv_r - theta_d * x * y * inv_r2 * inv_r);
497        let dv_dy = self.pinhole.fy
498            * (dtheta_d_dtheta * dtheta_dy * y * inv_r
499                + theta_d * (inv_r - y * y * inv_r2 * inv_r));
500        let dv_dz = self.pinhole.fy * dtheta_d_dtheta * dtheta_dz * y * inv_r;
501
502        SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
503    }
504
505    /// 2×8 Jacobian ∂(u,v)/∂[fx, fy, cx, cy, k1, k2, k3, k4]. See the
506    /// [cookbook](../doc/cookbook/src/kannala-brandt.html#jacobians) for the full derivation.
507    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
508        let x = p_cam[0];
509        let y = p_cam[1];
510        let z = p_cam[2];
511
512        let (k1, k2, k3, k4) = self.distortion_params();
513        let r = (x * x + y * y).sqrt();
514        let theta = r.atan2(z);
515
516        let theta2 = theta * theta;
517        let theta3 = theta2 * theta;
518        let theta5 = theta3 * theta2;
519        let theta7 = theta5 * theta2;
520        let theta9 = theta7 * theta2;
521
522        let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
523
524        if r < crate::GEOMETRIC_PRECISION {
525            return SMatrix::<f64, 2, 8>::zeros();
526        }
527
528        let inv_r = 1.0 / r;
529        let x_theta_d_r = x * theta_d * inv_r;
530        let y_theta_d_r = y * theta_d * inv_r;
531
532        // ∂u/∂fx = θ_d·x/r, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
533        // ∂v/∂fx = 0, ∂v/∂fy = θ_d·y/r, ∂v/∂cx = 0, ∂v/∂cy = 1
534
535        // ∂u/∂k₁ = fx·θ³·x/r, ∂u/∂k₂ = fx·θ⁵·x/r, etc.
536        let du_dk1 = self.pinhole.fx * theta3 * x * inv_r;
537        let du_dk2 = self.pinhole.fx * theta5 * x * inv_r;
538        let du_dk3 = self.pinhole.fx * theta7 * x * inv_r;
539        let du_dk4 = self.pinhole.fx * theta9 * x * inv_r;
540
541        let dv_dk1 = self.pinhole.fy * theta3 * y * inv_r;
542        let dv_dk2 = self.pinhole.fy * theta5 * y * inv_r;
543        let dv_dk3 = self.pinhole.fy * theta7 * y * inv_r;
544        let dv_dk4 = self.pinhole.fy * theta9 * y * inv_r;
545
546        SMatrix::<f64, 2, 8>::from_row_slice(&[
547            x_theta_d_r,
548            0.0,
549            1.0,
550            0.0,
551            du_dk1,
552            du_dk2,
553            du_dk3,
554            du_dk4,
555            0.0,
556            y_theta_d_r,
557            0.0,
558            1.0,
559            dv_dk1,
560            dv_dk2,
561            dv_dk3,
562            dv_dk4,
563        ])
564    }
565
566    /// Validates the camera parameters.
567    ///
568    /// # Validation Rules
569    ///
570    /// - `fx`, `fy` must be positive (> 0) and finite
571    /// - `cx`, `cy` must be finite
572    /// - `k1`..`k4` must be finite
573    ///
574    /// # Errors
575    ///
576    /// Returns [`CameraModelError`] if any rule is violated.
577    fn validate_params(&self) -> Result<(), CameraModelError> {
578        self.pinhole.validate()?;
579        self.get_distortion().validate()
580    }
581
582    /// Returns the pinhole parameters.
583    fn get_pinhole_params(&self) -> PinholeParams {
584        PinholeParams {
585            fx: self.pinhole.fx,
586            fy: self.pinhole.fy,
587            cx: self.pinhole.cx,
588            cy: self.pinhole.cy,
589        }
590    }
591
592    /// Returns the distortion model (must be [`DistortionModel::KannalaBrandt`]).
593    fn get_distortion(&self) -> DistortionModel {
594        self.distortion
595    }
596
597    /// Returns the model name: `"kannala_brandt"`.
598    fn get_model_name(&self) -> &'static str {
599        "kannala_brandt"
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use nalgebra::{Matrix2xX, Matrix3xX};
607
608    type TestResult = Result<(), Box<dyn std::error::Error>>;
609
610    #[test]
611    fn test_kb_camera_creation() -> TestResult {
612        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
613        let distortion = DistortionModel::KannalaBrandt {
614            k1: 0.1,
615            k2: 0.01,
616            k3: 0.001,
617            k4: 0.0001,
618        };
619        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
620        assert_eq!(camera.pinhole.fx, 300.0);
621        let (k1, _, _, _) = camera.distortion_params();
622        assert_eq!(k1, 0.1);
623        Ok(())
624    }
625
626    #[test]
627    fn test_projection_at_optical_axis() -> TestResult {
628        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
629        let distortion = DistortionModel::KannalaBrandt {
630            k1: 0.1,
631            k2: 0.01,
632            k3: 0.001,
633            k4: 0.0001,
634        };
635        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
636        let p_cam = Vector3::new(0.0, 0.0, 1.0);
637        let uv = camera.project(&p_cam)?;
638
639        assert!((uv.x - 320.0).abs() < 1e-6);
640        assert!((uv.y - 240.0).abs() < 1e-6);
641
642        Ok(())
643    }
644
645    #[test]
646    fn test_jacobian_point_numerical() -> TestResult {
647        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
648        let distortion = DistortionModel::KannalaBrandt {
649            k1: 0.1,
650            k2: 0.01,
651            k3: 0.001,
652            k4: 0.0001,
653        };
654        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
655        let p_cam = Vector3::new(0.1, 0.2, 1.0);
656
657        let jac_analytical = camera.jacobian_point(&p_cam);
658        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
659
660        for i in 0..3 {
661            let mut p_plus = p_cam;
662            let mut p_minus = p_cam;
663            p_plus[i] += eps;
664            p_minus[i] -= eps;
665
666            let uv_plus = camera.project(&p_plus)?;
667            let uv_minus = camera.project(&p_minus)?;
668            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
669
670            for r in 0..2 {
671                assert!(
672                    jac_analytical[(r, i)].is_finite(),
673                    "Jacobian [{r},{i}] is not finite"
674                );
675                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
676                assert!(
677                    diff < crate::JACOBIAN_TEST_TOLERANCE,
678                    "Mismatch at ({}, {})",
679                    r,
680                    i
681                );
682            }
683        }
684        Ok(())
685    }
686
687    #[test]
688    fn test_jacobian_intrinsics_numerical() -> TestResult {
689        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
690        let distortion = DistortionModel::KannalaBrandt {
691            k1: 0.1,
692            k2: 0.01,
693            k3: 0.001,
694            k4: 0.0001,
695        };
696        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
697        let p_cam = Vector3::new(0.1, 0.2, 1.0);
698
699        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
700        let params: DVector<f64> = (&camera).into();
701        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
702
703        for i in 0..8 {
704            let mut params_plus = params.clone();
705            let mut params_minus = params.clone();
706            params_plus[i] += eps;
707            params_minus[i] -= eps;
708
709            let cam_plus = KannalaBrandtCamera::try_from(params_plus.as_slice())?;
710            let cam_minus = KannalaBrandtCamera::try_from(params_minus.as_slice())?;
711
712            let uv_plus = cam_plus.project(&p_cam)?;
713            let uv_minus = cam_minus.project(&p_cam)?;
714            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
715
716            for r in 0..2 {
717                assert!(
718                    jac_analytical[(r, i)].is_finite(),
719                    "Jacobian [{r},{i}] is not finite"
720                );
721                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
722                assert!(
723                    diff < crate::JACOBIAN_TEST_TOLERANCE,
724                    "Mismatch at ({}, {})",
725                    r,
726                    i
727                );
728            }
729        }
730        Ok(())
731    }
732
733    #[test]
734    fn test_kb_from_into_traits() -> TestResult {
735        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
736        let distortion = DistortionModel::KannalaBrandt {
737            k1: 0.1,
738            k2: 0.01,
739            k3: 0.001,
740            k4: 0.0001,
741        };
742        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
743
744        // Test conversion to DVector
745        let params: DVector<f64> = (&camera).into();
746        assert_eq!(params.len(), 8);
747        assert_eq!(params[0], 300.0);
748        assert_eq!(params[1], 300.0);
749        assert_eq!(params[2], 320.0);
750        assert_eq!(params[3], 240.0);
751        assert_eq!(params[4], 0.1);
752        assert_eq!(params[5], 0.01);
753        assert_eq!(params[6], 0.001);
754        assert_eq!(params[7], 0.0001);
755
756        // Test conversion to array
757        let arr: [f64; 8] = (&camera).into();
758        assert_eq!(arr, [300.0, 300.0, 320.0, 240.0, 0.1, 0.01, 0.001, 0.0001]);
759
760        // Test conversion from slice
761        let params_slice = [350.0, 350.0, 330.0, 250.0, 0.2, 0.02, 0.002, 0.0002];
762        let camera2 = KannalaBrandtCamera::try_from(&params_slice[..])?;
763        assert_eq!(camera2.pinhole.fx, 350.0);
764        assert_eq!(camera2.pinhole.fy, 350.0);
765        assert_eq!(camera2.pinhole.cx, 330.0);
766        assert_eq!(camera2.pinhole.cy, 250.0);
767        let (k1, k2, k3, k4) = camera2.distortion_params();
768        assert_eq!(k1, 0.2);
769        assert_eq!(k2, 0.02);
770        assert_eq!(k3, 0.002);
771        assert_eq!(k4, 0.0002);
772
773        // Test conversion from array
774        let camera3 =
775            KannalaBrandtCamera::from([400.0, 400.0, 340.0, 260.0, 0.3, 0.03, 0.003, 0.0003]);
776        assert_eq!(camera3.pinhole.fx, 400.0);
777        assert_eq!(camera3.pinhole.fy, 400.0);
778        let (k1, k2, k3, k4) = camera3.distortion_params();
779        assert_eq!(k1, 0.3);
780        assert_eq!(k2, 0.03);
781        assert_eq!(k3, 0.003);
782        assert_eq!(k4, 0.0003);
783
784        Ok(())
785    }
786
787    #[test]
788    fn test_linear_estimation() -> TestResult {
789        // Ground truth fisheye camera
790        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
791        let gt_distortion = DistortionModel::KannalaBrandt {
792            k1: 0.1,
793            k2: 0.01,
794            k3: 0.001,
795            k4: 0.0001,
796        };
797        let gt_camera = KannalaBrandtCamera::new(gt_pinhole, gt_distortion)?;
798
799        // Generate synthetic 3D points in camera frame
800        let n_points = 50;
801        let mut pts_3d = Matrix3xX::zeros(n_points);
802        let mut pts_2d = Matrix2xX::zeros(n_points);
803        let mut valid = 0;
804
805        for i in 0..n_points {
806            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
807            let r = 0.1 + 0.4 * (i as f64 / n_points as f64);
808            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
809
810            if let Ok(p2d) = gt_camera.project(&p3d) {
811                pts_3d.set_column(valid, &p3d);
812                pts_2d.set_column(valid, &p2d);
813                valid += 1;
814            }
815        }
816        let pts_3d = pts_3d.columns(0, valid).into_owned();
817        let pts_2d = pts_2d.columns(0, valid).into_owned();
818
819        // Initial camera with zero distortion
820        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
821        let init_distortion = DistortionModel::KannalaBrandt {
822            k1: 0.0,
823            k2: 0.0,
824            k3: 0.0,
825            k4: 0.0,
826        };
827        let mut camera = KannalaBrandtCamera::new(init_pinhole, init_distortion)?;
828
829        camera.linear_estimation(&pts_3d, &pts_2d)?;
830
831        // Verify reprojection error
832        for i in 0..valid {
833            let col = pts_3d.column(i);
834            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
835            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
836                + (projected.y - pts_2d[(1, i)]).powi(2))
837            .sqrt();
838            assert!(err < 3.0, "Reprojection error too large: {err}");
839        }
840
841        Ok(())
842    }
843
844    #[test]
845    fn test_project_unproject_round_trip() -> TestResult {
846        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
847        let distortion = DistortionModel::KannalaBrandt {
848            k1: 0.1,
849            k2: 0.01,
850            k3: 0.001,
851            k4: 0.0001,
852        };
853        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
854
855        let test_points = [
856            Vector3::new(0.1, 0.2, 1.0),
857            Vector3::new(-0.3, 0.1, 2.0),
858            Vector3::new(0.05, -0.1, 0.5),
859        ];
860
861        for p_cam in &test_points {
862            let uv = camera.project(p_cam)?;
863            let ray = camera.unproject(&uv)?;
864            let dot = ray.dot(&p_cam.normalize());
865            assert!(
866                (dot - 1.0).abs() < 1e-6,
867                "Round-trip failed: dot={dot}, expected ~1.0"
868            );
869        }
870
871        Ok(())
872    }
873
874    #[test]
875    fn test_project_returns_error_behind_camera() -> TestResult {
876        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
877        let distortion = DistortionModel::KannalaBrandt {
878            k1: 0.0,
879            k2: 0.0,
880            k3: 0.0,
881            k4: 0.0,
882        };
883        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
884        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
885        Ok(())
886    }
887
888    #[test]
889    fn test_project_at_min_depth_boundary() -> TestResult {
890        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
891        let distortion = DistortionModel::KannalaBrandt {
892            k1: 0.0,
893            k2: 0.0,
894            k3: 0.0,
895            k4: 0.0,
896        };
897        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
898        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
899        if let Ok(uv) = camera.project(&p_min) {
900            assert!(uv.x.is_finite() && uv.y.is_finite());
901        }
902        Ok(())
903    }
904
905    #[test]
906    fn test_projection_off_axis() -> TestResult {
907        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
908        let distortion = DistortionModel::KannalaBrandt {
909            k1: 0.1,
910            k2: 0.01,
911            k3: 0.001,
912            k4: 0.0001,
913        };
914        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
915        let p_cam = Vector3::new(0.3, 0.0, 1.0);
916        let uv = camera.project(&p_cam)?;
917        assert!(
918            uv.x > 320.0,
919            "off-axis point should project right of principal point"
920        );
921        assert!(
922            (uv.y - 240.0).abs() < 1.0,
923            "y should be close to cy for horizontal offset"
924        );
925        Ok(())
926    }
927
928    #[test]
929    fn test_unproject_center_pixel() -> TestResult {
930        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
931        let distortion = DistortionModel::KannalaBrandt {
932            k1: 0.1,
933            k2: 0.01,
934            k3: 0.001,
935            k4: 0.0001,
936        };
937        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
938        let uv = Vector2::new(320.0, 240.0);
939        let ray = camera.unproject(&uv)?;
940        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
941        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
942        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
943        Ok(())
944    }
945
946    #[test]
947    fn test_batch_projection_matches_individual() -> TestResult {
948        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
949        let distortion = DistortionModel::KannalaBrandt {
950            k1: 0.1,
951            k2: 0.01,
952            k3: 0.001,
953            k4: 0.0001,
954        };
955        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
956        let pts = Matrix3xX::from_columns(&[
957            Vector3::new(0.0, 0.0, 1.0),
958            Vector3::new(0.3, 0.2, 1.5),
959            Vector3::new(-0.4, 0.1, 2.0),
960        ]);
961        let batch = camera.project_batch(&pts);
962        for i in 0..3 {
963            let col = pts.column(i);
964            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
965            assert!(
966                (batch[(0, i)] - p.x).abs() < 1e-10,
967                "batch u mismatch at col {i}"
968            );
969            assert!(
970                (batch[(1, i)] - p.y).abs() < 1e-10,
971                "batch v mismatch at col {i}"
972            );
973        }
974        Ok(())
975    }
976
977    #[test]
978    fn test_jacobian_dimensions() -> TestResult {
979        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
980        let distortion = DistortionModel::KannalaBrandt {
981            k1: 0.1,
982            k2: 0.01,
983            k3: 0.001,
984            k4: 0.0001,
985        };
986        let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
987        let p_cam = Vector3::new(0.1, 0.2, 1.0);
988        let jac_point = camera.jacobian_point(&p_cam);
989        assert_eq!(jac_point.nrows(), 2);
990        assert_eq!(jac_point.ncols(), 3);
991        let jac_intr = camera.jacobian_intrinsics(&p_cam);
992        assert_eq!(jac_intr.nrows(), 2);
993        assert_eq!(jac_intr.ncols(), 8); // KannalaBrandtCamera::INTRINSIC_DIM = 8
994        Ok(())
995    }
996
997    /// Out-of-domain pixels must be REJECTED, not silently clamped: the old
998    /// `ru = min(ru, pi/2)` rewrote the input ray and returned a direction
999    /// that never projected back to the pixel.
1000    #[test]
1001    fn test_unproject_rejects_out_of_domain_radius() -> TestResult {
1002        let camera = KannalaBrandtCamera::new(
1003            PinholeParams::new(400.0, 400.0, 320.0, 240.0)?,
1004            DistortionModel::KannalaBrandt {
1005                k1: 0.1,
1006                k2: 0.02,
1007                k3: -0.01,
1008                k4: 0.005,
1009            },
1010        )?;
1011        // A pixel far outside any plausible focal geometry produces a large
1012        // undistorted radius.
1013        let far_pixel = Vector2::new(320.0 + 1.0e6, 240.0);
1014        let result = camera.unproject(&far_pixel);
1015        assert!(result.is_err(), "out-of-domain ru must return Err");
1016        Ok(())
1017    }
1018
1019    #[test]
1020    fn test_unproject_rejects_non_converged_newton() -> TestResult {
1021        // Extreme distortion coefficients make the Newton map diverge for
1022        // some radii; the post-Newton gate must surface that, not return a
1023        // silently unconverged ray.
1024        let camera = KannalaBrandtCamera::new(
1025            PinholeParams::new(1.0, 1.0, 0.0, 0.0)?,
1026            DistortionModel::KannalaBrandt {
1027                k1: -50.0,
1028                k2: 1.0e3,
1029                k3: -1.0e4,
1030                k4: 1.0e5,
1031            },
1032        )?;
1033        let mut rejected = 0;
1034        for px in [5.0, 50.0, 500.0] {
1035            if camera.unproject(&Vector2::new(px, 0.0)).is_err() {
1036                rejected += 1;
1037            }
1038        }
1039        assert!(rejected > 0, "divergent Newton iterations must return Err");
1040        Ok(())
1041    }
1042}