Skip to main content

apex_camera_models/
ftheta.rs

1//! NVIDIA f-theta fisheye camera model.
2//!
3//! Polynomial-based fisheye model used in NVIDIA's autonomous-vehicle cameras. The
4//! polynomial `k₁θ + k₂θ² + k₃θ³ + k₄θ⁴` maps the incidence angle θ to an image-plane
5//! radius; the model is isotropic (no separate `fx`/`fy`). Has 6 intrinsic parameters.
6//! See the [f-theta cookbook chapter](../doc/cookbook/src/f-theta.html) for the full
7//! projection, unprojection, and Jacobian derivations.
8
9use crate::{CONVERGENCE_THRESHOLD, CameraModel, CameraModelError, GEOMETRIC_PRECISION, MIN_DEPTH};
10use crate::{DistortionModel, PinholeParams};
11use nalgebra::{DVector, Matrix3xX, SMatrix, Vector2, Vector3};
12
13/// NVIDIA f-theta fisheye camera with 6 intrinsic parameters.
14///
15/// Stores the principal point (cx, cy) and the four forward-polynomial
16/// coefficients k₁…k₄.  The model is isotropic (no separate fx/fy).
17#[derive(Clone, Copy, PartialEq)]
18pub struct FThetaCamera {
19    /// Principal point x (u₀ in the paper), pixels.
20    pub cx: f64,
21    /// Principal point y (v₀ in the paper), pixels.
22    pub cy: f64,
23    /// Forward-polynomial distortion — must be [`DistortionModel::FTheta`].
24    pub distortion: DistortionModel,
25}
26
27impl std::fmt::Debug for FThetaCamera {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        let (k1, k2, k3, k4) = self.distortion_params();
30        f.debug_struct("FThetaCamera")
31            .field("cx", &self.cx)
32            .field("cy", &self.cy)
33            .field("k1", &k1)
34            .field("k2", &k2)
35            .field("k3", &k3)
36            .field("k4", &k4)
37            .finish()
38    }
39}
40
41impl FThetaCamera {
42    /// Creates a new f-theta camera.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not
47    /// [`DistortionModel::FTheta`], or if any parameter fails validation.
48    ///
49    /// # Example
50    ///
51    /// ```
52    /// use apex_camera_models::{CameraModel, DistortionModel, FThetaCamera};
53    ///
54    /// let distortion = DistortionModel::FTheta { k1: 500.0, k2: 0.0, k3: 0.0, k4: 0.0 };
55    /// let camera = FThetaCamera::new(320.0, 240.0, distortion)?;
56    /// assert_eq!(camera.get_model_name(), "ftheta");
57    /// # Ok::<(), apex_camera_models::CameraModelError>(())
58    /// ```
59    pub fn new(cx: f64, cy: f64, distortion: DistortionModel) -> Result<Self, CameraModelError> {
60        let cam = Self { cx, cy, distortion };
61        cam.validate_params()?;
62        Ok(cam)
63    }
64
65    /// Build directly from the six scalar parameters `[cx, cy, k1, k2, k3, k4]`.
66    pub fn try_from_params(params: &[f64]) -> Result<Self, CameraModelError> {
67        Self::try_from(params)
68    }
69
70    /// Extract `(k1, k2, k3, k4)` from the stored distortion.
71    ///
72    /// # Panics
73    ///
74    /// Panics if `self.distortion` is not [`DistortionModel::FTheta`] — this
75    /// cannot happen after successful construction.
76    #[inline]
77    pub fn distortion_params(&self) -> (f64, f64, f64, f64) {
78        match self.distortion {
79            DistortionModel::FTheta { k1, k2, k3, k4 } => (k1, k2, k3, k4),
80            _ => unreachable!("FThetaCamera always has FTheta distortion"),
81        }
82    }
83
84    /// Evaluate the forward polynomial f(θ) = k₁θ + k₂θ² + k₃θ³ + k₄θ⁴.
85    #[inline]
86    fn poly_forward(&self, theta: f64) -> f64 {
87        let (k1, k2, k3, k4) = self.distortion_params();
88        theta * (k1 + theta * (k2 + theta * (k3 + theta * k4)))
89    }
90
91    /// Evaluate the derivative f′(θ) = k₁ + 2k₂θ + 3k₃θ² + 4k₄θ³.
92    #[inline]
93    fn poly_forward_deriv(&self, theta: f64) -> f64 {
94        let (k1, k2, k3, k4) = self.distortion_params();
95        k1 + theta * (2.0 * k2 + theta * (3.0 * k3 + theta * 4.0 * k4))
96    }
97
98    /// Least-squares estimation of k₁..k₄ given 3D-2D correspondences,
99    /// assuming cx and cy are already known.
100    ///
101    /// Constructs a Vandermonde system `[θ θ² θ³ θ⁴] · [k₁ k₂ k₃ k₄]ᵀ = r`
102    /// and solves it with SVD (nalgebra full-pivoting).
103    ///
104    /// Returns an updated `FThetaCamera` with the estimated polynomial
105    /// coefficients.  The principal point is unchanged.
106    ///
107    /// # Arguments
108    ///
109    /// * `points_3d` — 3×N matrix of 3D points in camera frame
110    /// * `points_2d` — 2×N matrix of observed pixel coordinates
111    pub fn linear_estimation(
112        &self,
113        points_3d: &Matrix3xX<f64>,
114        points_2d: &nalgebra::Matrix2xX<f64>,
115    ) -> Result<Self, CameraModelError> {
116        let n = points_3d.ncols();
117        if n < 4 {
118            return Err(CameraModelError::InvalidParams(
119                "linear_estimation requires at least 4 correspondences".to_string(),
120            ));
121        }
122
123        let mut a = nalgebra::DMatrix::<f64>::zeros(n, 4);
124        let mut b = nalgebra::DVector::<f64>::zeros(n);
125
126        for i in 0..n {
127            let x = points_3d[(0, i)];
128            let y = points_3d[(1, i)];
129            let z = points_3d[(2, i)];
130            let d = (x * x + y * y + z * z).sqrt();
131            if d < GEOMETRIC_PRECISION {
132                continue;
133            }
134            let theta = (z / d).clamp(-1.0, 1.0).acos();
135
136            let u = points_2d[(0, i)];
137            let v = points_2d[(1, i)];
138            let dx = u - self.cx;
139            let dy = v - self.cy;
140            let r = (dx * dx + dy * dy).sqrt();
141
142            let t2 = theta * theta;
143            let t3 = t2 * theta;
144            let t4 = t3 * theta;
145            a[(i, 0)] = theta;
146            a[(i, 1)] = t2;
147            a[(i, 2)] = t3;
148            a[(i, 3)] = t4;
149            b[i] = r;
150        }
151
152        let svd = a.svd(true, true);
153        let coeffs = svd
154            .solve(&b, GEOMETRIC_PRECISION)
155            .map_err(|e| CameraModelError::InvalidParams(format!("SVD solve failed: {e}")))?;
156
157        let distortion = DistortionModel::FTheta {
158            k1: coeffs[0],
159            k2: coeffs[1],
160            k3: coeffs[2],
161            k4: coeffs[3],
162        };
163        FThetaCamera::new(self.cx, self.cy, distortion)
164    }
165}
166
167// ── CameraModel trait ─────────────────────────────────────────────────────────
168
169impl CameraModel for FThetaCamera {
170    const INTRINSIC_DIM: usize = 6;
171
172    type IntrinsicJacobian = SMatrix<f64, 2, 6>;
173    type PointJacobian = SMatrix<f64, 2, 3>;
174
175    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
176        let (x, y, z) = (p_cam.x, p_cam.y, p_cam.z);
177
178        if z < MIN_DEPTH {
179            return Err(CameraModelError::PointBehindCamera {
180                z,
181                min_z: MIN_DEPTH,
182            });
183        }
184
185        let d = (x * x + y * y + z * z).sqrt();
186        let theta = (z / d).clamp(-1.0, 1.0).acos();
187        let f_theta = self.poly_forward(theta);
188        let r_p = (x * x + y * y).sqrt();
189
190        if r_p < GEOMETRIC_PRECISION {
191            return Ok(Vector2::new(self.cx, self.cy));
192        }
193
194        let inv_rp = 1.0 / r_p;
195        Ok(Vector2::new(
196            self.cx + f_theta * x * inv_rp,
197            self.cy + f_theta * y * inv_rp,
198        ))
199    }
200
201    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
202        let dx = point_2d.x - self.cx;
203        let dy = point_2d.y - self.cy;
204        let r_d = (dx * dx + dy * dy).sqrt();
205
206        if r_d < GEOMETRIC_PRECISION {
207            return Ok(Vector3::new(0.0, 0.0, 1.0));
208        }
209
210        let (k1, ..) = self.distortion_params();
211
212        // Newton-Raphson: solve f(theta) = r_d
213        let mut theta = r_d / k1;
214        for _ in 0..100 {
215            let f_val = self.poly_forward(theta);
216            let f_deriv = self.poly_forward_deriv(theta);
217            if f_deriv.abs() < 1e-12 {
218                break;
219            }
220            let delta = (f_val - r_d) / f_deriv;
221            theta -= delta;
222            if delta.abs() < CONVERGENCE_THRESHOLD {
223                break;
224            }
225        }
226
227        if !theta.is_finite() || theta < 0.0 {
228            return Err(CameraModelError::NumericalError {
229                operation: "ftheta_unproject".to_string(),
230                details: format!("Newton-Raphson diverged, theta={theta}"),
231            });
232        }
233
234        let sin_theta = theta.sin();
235        let cos_theta = theta.cos();
236        let inv_rd = 1.0 / r_d;
237        let ray = Vector3::new(sin_theta * dx * inv_rd, sin_theta * dy * inv_rd, cos_theta);
238        Ok(ray.normalize())
239    }
240
241    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
242        let (x, y, z) = (p_cam.x, p_cam.y, p_cam.z);
243        let r_p2 = x * x + y * y;
244        let d2 = r_p2 + z * z;
245        let d = d2.sqrt();
246        let r_p = r_p2.sqrt();
247
248        let mut j = SMatrix::<f64, 2, 3>::zeros();
249
250        if r_p < GEOMETRIC_PRECISION {
251            // Limit at optical axis: ∂u/∂x = k₁/z, ∂v/∂y = k₁/z
252            let k1 = self.distortion_params().0;
253            j[(0, 0)] = k1 / z;
254            j[(1, 1)] = k1 / z;
255            return j;
256        }
257
258        let theta = (z / d).clamp(-1.0, 1.0).acos();
259        let f_val = self.poly_forward(theta);
260        let f_prime = self.poly_forward_deriv(theta);
261
262        // A = f′·z / (r_p²·d²),   B = f / r_p³
263        let a = f_prime * z / (r_p2 * d2);
264        let b = f_val / (r_p2 * r_p);
265
266        j[(0, 0)] = a * x * x + b * y * y;
267        j[(0, 1)] = (a - b) * x * y;
268        j[(0, 2)] = -f_prime * x / d2;
269        j[(1, 0)] = j[(0, 1)];
270        j[(1, 1)] = a * y * y + b * x * x;
271        j[(1, 2)] = -f_prime * y / d2;
272        j
273    }
274
275    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
276        let (x, y, z) = (p_cam.x, p_cam.y, p_cam.z);
277        let r_p2 = x * x + y * y;
278        let r_p = r_p2.sqrt();
279        let d = (r_p2 + z * z).sqrt();
280
281        let mut j = SMatrix::<f64, 2, 6>::zeros();
282        // ∂u/∂cx = 1, ∂v/∂cy = 1
283        j[(0, 0)] = 1.0;
284        j[(1, 1)] = 1.0;
285
286        if r_p < GEOMETRIC_PRECISION {
287            // All kᵢ columns are 0 at the optical axis.
288            return j;
289        }
290
291        let theta = (z / d).clamp(-1.0, 1.0).acos();
292        let cos_phi = x / r_p;
293        let sin_phi = y / r_p;
294
295        let mut theta_pow = theta;
296        for col in 2..6 {
297            j[(0, col)] = theta_pow * cos_phi;
298            j[(1, col)] = theta_pow * sin_phi;
299            theta_pow *= theta;
300        }
301        j
302    }
303
304    fn validate_params(&self) -> Result<(), CameraModelError> {
305        if !self.cx.is_finite() || !self.cy.is_finite() {
306            return Err(CameraModelError::PrincipalPointNotFinite {
307                cx: self.cx,
308                cy: self.cy,
309            });
310        }
311        match self.distortion {
312            DistortionModel::FTheta { .. } => self.distortion.validate(),
313            _ => Err(CameraModelError::InvalidParams(
314                "FThetaCamera requires DistortionModel::FTheta".to_string(),
315            )),
316        }
317    }
318
319    fn get_pinhole_params(&self) -> PinholeParams {
320        let (k1, ..) = self.distortion_params();
321        PinholeParams {
322            fx: k1,
323            fy: k1,
324            cx: self.cx,
325            cy: self.cy,
326        }
327    }
328
329    fn get_distortion(&self) -> DistortionModel {
330        self.distortion
331    }
332
333    fn get_model_name(&self) -> &'static str {
334        "ftheta"
335    }
336}
337
338// ── Conversion traits ─────────────────────────────────────────────────────────
339
340/// Parameter order: `[cx, cy, k1, k2, k3, k4]`.
341impl From<&FThetaCamera> for [f64; 6] {
342    fn from(cam: &FThetaCamera) -> Self {
343        let (k1, k2, k3, k4) = cam.distortion_params();
344        [cam.cx, cam.cy, k1, k2, k3, k4]
345    }
346}
347
348impl From<[f64; 6]> for FThetaCamera {
349    fn from(p: [f64; 6]) -> Self {
350        Self {
351            cx: p[0],
352            cy: p[1],
353            distortion: DistortionModel::FTheta {
354                k1: p[2],
355                k2: p[3],
356                k3: p[4],
357                k4: p[5],
358            },
359        }
360    }
361}
362
363impl From<&FThetaCamera> for DVector<f64> {
364    fn from(cam: &FThetaCamera) -> Self {
365        let arr: [f64; 6] = cam.into();
366        DVector::from_row_slice(&arr)
367    }
368}
369
370impl TryFrom<&[f64]> for FThetaCamera {
371    type Error = CameraModelError;
372
373    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
374        if params.len() != 6 {
375            return Err(CameraModelError::InvalidParams(format!(
376                "FThetaCamera requires 6 parameters, got {}",
377                params.len()
378            )));
379        }
380        let cam = FThetaCamera::from([
381            params[0], params[1], params[2], params[3], params[4], params[5],
382        ]);
383        cam.validate_params()?;
384        Ok(cam)
385    }
386}
387
388// ── Tests ─────────────────────────────────────────────────────────────────────
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::{JACOBIAN_TEST_TOLERANCE, NUMERICAL_DERIVATIVE_EPS, PROJECTION_TEST_TOLERANCE};
394    use nalgebra::{Matrix2xX, Matrix3xX};
395
396    type TestResult = Result<(), Box<dyn std::error::Error>>;
397
398    /// Camera representative of a real wide-angle fisheye (k1≈focal length).
399    fn make_camera() -> FThetaCamera {
400        FThetaCamera::from([320.0, 240.0, 500.0, -10.0, 2.0, -0.1])
401    }
402
403    /// Pure-linear camera (no higher-order distortion) for analytic checks.
404    fn make_linear_camera() -> FThetaCamera {
405        FThetaCamera::from([320.0, 240.0, 500.0, 0.0, 0.0, 0.0])
406    }
407
408    // ── construction ─────────────────────────────────────────────────────────
409
410    #[test]
411    fn test_creation_and_validate() -> TestResult {
412        let cam = FThetaCamera::new(
413            320.0,
414            240.0,
415            DistortionModel::FTheta {
416                k1: 500.0,
417                k2: -10.0,
418                k3: 2.0,
419                k4: -0.1,
420            },
421        )?;
422        assert_eq!(cam.cx, 320.0);
423        assert_eq!(cam.cy, 240.0);
424        assert!(cam.validate_params().is_ok());
425        Ok(())
426    }
427
428    #[test]
429    fn test_validate_invalid_k1_zero() {
430        let cam = FThetaCamera {
431            cx: 320.0,
432            cy: 240.0,
433            distortion: DistortionModel::FTheta {
434                k1: 0.0,
435                k2: 0.0,
436                k3: 0.0,
437                k4: 0.0,
438            },
439        };
440        assert!(cam.validate_params().is_err());
441    }
442
443    #[test]
444    fn test_validate_invalid_k1_negative() {
445        let cam = FThetaCamera {
446            cx: 320.0,
447            cy: 240.0,
448            distortion: DistortionModel::FTheta {
449                k1: -1.0,
450                k2: 0.0,
451                k3: 0.0,
452                k4: 0.0,
453            },
454        };
455        assert!(cam.validate_params().is_err());
456    }
457
458    #[test]
459    fn test_validate_nan_coefficient() {
460        let cam = FThetaCamera {
461            cx: 320.0,
462            cy: 240.0,
463            distortion: DistortionModel::FTheta {
464                k1: 500.0,
465                k2: f64::NAN,
466                k3: 0.0,
467                k4: 0.0,
468            },
469        };
470        assert!(cam.validate_params().is_err());
471    }
472
473    #[test]
474    fn test_validate_wrong_distortion_type() {
475        let cam = FThetaCamera {
476            cx: 320.0,
477            cy: 240.0,
478            distortion: DistortionModel::None,
479        };
480        assert!(cam.validate_params().is_err());
481    }
482
483    #[test]
484    fn test_get_model_name() {
485        assert_eq!(make_camera().get_model_name(), "ftheta");
486    }
487
488    #[test]
489    fn test_debug_format() {
490        let cam = make_camera();
491        let s = format!("{cam:?}");
492        assert!(s.contains("FThetaCamera"));
493        assert!(s.contains("cx"));
494        assert!(s.contains("k1"));
495    }
496
497    // ── projection ───────────────────────────────────────────────────────────
498
499    #[test]
500    fn test_project_optical_axis() -> TestResult {
501        let cam = make_camera();
502        let p = Vector3::new(0.0, 0.0, 1.0);
503        let px = cam.project(&p)?;
504        assert!((px.x - cam.cx).abs() < PROJECTION_TEST_TOLERANCE);
505        assert!((px.y - cam.cy).abs() < PROJECTION_TEST_TOLERANCE);
506        Ok(())
507    }
508
509    #[test]
510    fn test_project_off_axis_linear_model() -> TestResult {
511        // With k2=k3=k4=0: f(θ) = k₁θ, so u = cx + k₁·θ·(x/r_p)
512        let cam = make_linear_camera();
513        let p = Vector3::new(1.0, 0.0, 1.0);
514        let theta = (1.0_f64 / 2.0_f64.sqrt()).acos(); // arccos(1/√2) = π/4
515        let expected_u = 320.0 + 500.0 * theta;
516        let px = cam.project(&p)?;
517        assert!(
518            (px.x - expected_u).abs() < 1e-8,
519            "u={} expected={expected_u}",
520            px.x
521        );
522        assert!((px.y - 240.0).abs() < 1e-8);
523        Ok(())
524    }
525
526    #[test]
527    fn test_project_with_higher_order_terms() -> TestResult {
528        let linear = make_linear_camera();
529        let distorted = make_camera();
530        let p = Vector3::new(0.5, 0.3, 1.0);
531        let px_lin = linear.project(&p)?;
532        let px_dist = distorted.project(&p)?;
533        // With negative k2, the distorted radius is smaller for moderate angles
534        let r_lin = ((px_lin.x - 320.0).powi(2) + (px_lin.y - 240.0).powi(2)).sqrt();
535        let r_dist = ((px_dist.x - 320.0).powi(2) + (px_dist.y - 240.0).powi(2)).sqrt();
536        assert!(r_lin > 0.0 && r_dist > 0.0);
537        Ok(())
538    }
539
540    #[test]
541    fn test_project_behind_camera_error() {
542        let cam = make_camera();
543        let p = Vector3::new(0.0, 0.0, -1.0);
544        assert!(matches!(
545            cam.project(&p),
546            Err(CameraModelError::PointBehindCamera { .. })
547        ));
548    }
549
550    // ── unprojection ─────────────────────────────────────────────────────────
551
552    #[test]
553    fn test_unproject_center_pixel() -> TestResult {
554        let cam = make_camera();
555        let px = Vector2::new(cam.cx, cam.cy);
556        let ray = cam.unproject(&px)?;
557        assert!((ray.x).abs() < PROJECTION_TEST_TOLERANCE);
558        assert!((ray.y).abs() < PROJECTION_TEST_TOLERANCE);
559        assert!((ray.z - 1.0).abs() < PROJECTION_TEST_TOLERANCE);
560        Ok(())
561    }
562
563    #[test]
564    fn test_unproject_off_axis_linear() -> TestResult {
565        // For linear camera: pixel r = k₁·θ → θ = r/k₁
566        let cam = make_linear_camera();
567        let r = 100.0_f64;
568        let px = Vector2::new(cam.cx + r, cam.cy);
569        let ray = cam.unproject(&px)?;
570        let theta_expected = r / 500.0;
571        assert!((ray.x - theta_expected.sin()).abs() < 1e-6);
572        assert!((ray.z - theta_expected.cos()).abs() < 1e-6);
573        Ok(())
574    }
575
576    // ── round-trip ───────────────────────────────────────────────────────────
577
578    #[test]
579    fn test_round_trip_optical_axis() -> TestResult {
580        let cam = make_camera();
581        let p = Vector3::new(0.0, 0.0, 2.0);
582        let px = cam.project(&p)?;
583        let ray = cam.unproject(&px)?;
584        let p_norm = p.normalize();
585        assert!((ray.x - p_norm.x).abs() < 1e-10);
586        assert!((ray.y - p_norm.y).abs() < 1e-10);
587        assert!((ray.z - p_norm.z).abs() < 1e-10);
588        Ok(())
589    }
590
591    #[test]
592    fn test_round_trip_project_unproject() -> TestResult {
593        let cam = make_camera();
594        let points = [
595            Vector3::new(0.3, 0.2, 1.0),
596            Vector3::new(-0.5, 0.1, 2.0),
597            Vector3::new(0.1, -0.4, 1.5),
598            Vector3::new(0.0, 0.6, 1.0),
599        ];
600        for p in &points {
601            let px = cam.project(p)?;
602            let ray = cam.unproject(&px)?;
603            let p_norm = p.normalize();
604            let dot = ray.dot(&p_norm);
605            assert!(
606                (dot - 1.0).abs() < 1e-8,
607                "round-trip failed for {p:?}: dot={dot}"
608            );
609        }
610        Ok(())
611    }
612
613    #[test]
614    fn test_round_trip_with_distortion() -> TestResult {
615        let cam = make_camera(); // k2..k4 nonzero
616        let p = Vector3::new(0.4, -0.3, 1.2);
617        let px = cam.project(&p)?;
618        let ray = cam.unproject(&px)?;
619        let dot = ray.dot(&p.normalize());
620        assert!((dot - 1.0).abs() < 1e-8);
621        Ok(())
622    }
623
624    // ── Jacobian point ────────────────────────────────────────────────────────
625
626    #[test]
627    fn test_jacobian_point_dimensions() {
628        let cam = make_camera();
629        let p = Vector3::new(0.3, 0.2, 1.0);
630        let j = cam.jacobian_point(&p);
631        assert_eq!(j.nrows(), 2);
632        assert_eq!(j.ncols(), 3);
633    }
634
635    #[test]
636    fn test_jacobian_point_optical_axis() {
637        let cam = make_camera();
638        let z = 2.0_f64;
639        let p = Vector3::new(0.0, 0.0, z);
640        let j = cam.jacobian_point(&p);
641        let k1 = cam.distortion_params().0;
642        let expected = k1 / z;
643        assert!((j[(0, 0)] - expected).abs() < 1e-10, "J[0,0]={}", j[(0, 0)]);
644        assert!((j[(1, 1)] - expected).abs() < 1e-10, "J[1,1]={}", j[(1, 1)]);
645        assert!(j[(0, 1)].abs() < 1e-10);
646        assert!(j[(0, 2)].abs() < 1e-10);
647        assert!(j[(1, 0)].abs() < 1e-10);
648        assert!(j[(1, 2)].abs() < 1e-10);
649    }
650
651    #[test]
652    fn test_jacobian_point_numerical() -> TestResult {
653        let cam = make_camera();
654        let p = Vector3::new(0.3, 0.2, 1.5);
655        let j_analytical = cam.jacobian_point(&p);
656        let px0 = cam.project(&p)?;
657
658        for col in 0..3 {
659            let mut p_plus = p;
660            p_plus[col] += NUMERICAL_DERIVATIVE_EPS;
661            let px_plus = cam.project(&p_plus)?;
662            let num_du = (px_plus.x - px0.x) / NUMERICAL_DERIVATIVE_EPS;
663            let num_dv = (px_plus.y - px0.y) / NUMERICAL_DERIVATIVE_EPS;
664
665            assert!(
666                (j_analytical[(0, col)] - num_du).abs() < JACOBIAN_TEST_TOLERANCE,
667                "J[0,{col}]: analytical={} numerical={num_du}",
668                j_analytical[(0, col)]
669            );
670            assert!(
671                (j_analytical[(1, col)] - num_dv).abs() < JACOBIAN_TEST_TOLERANCE,
672                "J[1,{col}]: analytical={} numerical={num_dv}",
673                j_analytical[(1, col)]
674            );
675        }
676        Ok(())
677    }
678
679    // ── Jacobian intrinsics ───────────────────────────────────────────────────
680
681    #[test]
682    fn test_jacobian_intrinsics_dimensions() {
683        let cam = make_camera();
684        let p = Vector3::new(0.3, 0.2, 1.0);
685        let j = cam.jacobian_intrinsics(&p);
686        assert_eq!(j.nrows(), 2);
687        assert_eq!(j.ncols(), 6);
688    }
689
690    #[test]
691    fn test_jacobian_intrinsics_optical_axis() {
692        let cam = make_camera();
693        let p = Vector3::new(0.0, 0.0, 1.0);
694        let j = cam.jacobian_intrinsics(&p);
695        assert!((j[(0, 0)] - 1.0).abs() < 1e-12); // ∂u/∂cx
696        assert!((j[(1, 1)] - 1.0).abs() < 1e-12); // ∂v/∂cy
697        // All kᵢ columns must be zero on axis
698        for col in 2..6 {
699            assert!(j[(0, col)].abs() < 1e-12, "J[0,{col}]={}", j[(0, col)]);
700            assert!(j[(1, col)].abs() < 1e-12, "J[1,{col}]={}", j[(1, col)]);
701        }
702    }
703
704    #[test]
705    fn test_jacobian_intrinsics_numerical() -> TestResult {
706        let cam = make_camera();
707        let p = Vector3::new(0.3, 0.2, 1.5);
708        let j_analytical = cam.jacobian_intrinsics(&p);
709        let px0 = cam.project(&p)?;
710
711        // Perturb each of the 6 intrinsics in order: cx, cy, k1, k2, k3, k4
712        let params0: [f64; 6] = (&cam).into();
713        for col in 0..6 {
714            let mut params_plus = params0;
715            params_plus[col] += NUMERICAL_DERIVATIVE_EPS;
716            let cam_plus = FThetaCamera::from(params_plus);
717            let px_plus = cam_plus.project(&p)?;
718            let num_du = (px_plus.x - px0.x) / NUMERICAL_DERIVATIVE_EPS;
719            let num_dv = (px_plus.y - px0.y) / NUMERICAL_DERIVATIVE_EPS;
720
721            assert!(
722                (j_analytical[(0, col)] - num_du).abs() < JACOBIAN_TEST_TOLERANCE,
723                "J_intr[0,{col}]: analytical={} numerical={num_du}",
724                j_analytical[(0, col)]
725            );
726            assert!(
727                (j_analytical[(1, col)] - num_dv).abs() < JACOBIAN_TEST_TOLERANCE,
728                "J_intr[1,{col}]: analytical={} numerical={num_dv}",
729                j_analytical[(1, col)]
730            );
731        }
732        Ok(())
733    }
734
735    // ── conversions ───────────────────────────────────────────────────────────
736
737    #[test]
738    fn test_from_array_roundtrip() {
739        let arr = [320.0_f64, 240.0, 500.0, -10.0, 2.0, -0.1];
740        let cam = FThetaCamera::from(arr);
741        let arr2: [f64; 6] = (&cam).into();
742        for (a, b) in arr.iter().zip(arr2.iter()) {
743            assert!((a - b).abs() < 1e-15);
744        }
745    }
746
747    #[test]
748    fn test_try_from_slice_correct_length() -> TestResult {
749        let params = [320.0_f64, 240.0, 500.0, -10.0, 2.0, -0.1];
750        let cam = FThetaCamera::try_from(params.as_slice())?;
751        assert_eq!(cam.cx, 320.0);
752        Ok(())
753    }
754
755    #[test]
756    fn test_try_from_slice_wrong_length() {
757        let params = [320.0_f64, 240.0, 500.0];
758        assert!(FThetaCamera::try_from(params.as_slice()).is_err());
759    }
760
761    #[test]
762    fn test_dvector_conversion() {
763        let cam = make_camera();
764        let v: DVector<f64> = (&cam).into();
765        assert_eq!(v.len(), 6);
766        assert!((v[0] - cam.cx).abs() < 1e-15);
767        let (k1, k2, k3, k4) = cam.distortion_params();
768        assert!((v[2] - k1).abs() < 1e-15);
769        assert!((v[3] - k2).abs() < 1e-15);
770        assert!((v[4] - k3).abs() < 1e-15);
771        assert!((v[5] - k4).abs() < 1e-15);
772    }
773
774    #[test]
775    fn test_get_pinhole_params() {
776        let cam = make_camera();
777        let pp = cam.get_pinhole_params();
778        let (k1, ..) = cam.distortion_params();
779        assert!((pp.fx - k1).abs() < 1e-15);
780        assert!((pp.fy - k1).abs() < 1e-15);
781        assert!((pp.cx - cam.cx).abs() < 1e-15);
782        assert!((pp.cy - cam.cy).abs() < 1e-15);
783    }
784
785    // ── linear estimation ─────────────────────────────────────────────────────
786
787    #[test]
788    fn test_linear_estimation() -> TestResult {
789        let gt_cam = make_camera();
790        let n = 50_usize;
791        let mut pts3d = Matrix3xX::zeros(n);
792        let mut pts2d = Matrix2xX::zeros(n);
793
794        // Sample points uniformly across a half-sphere (θ ∈ [0, 60°])
795        for i in 0..n {
796            let theta = std::f64::consts::FRAC_PI_3 * (i as f64) / (n as f64);
797            let phi = 2.0 * std::f64::consts::PI * (i as f64 * 0.618_033_988); // golden angle
798            let x = theta.sin() * phi.cos();
799            let y = theta.sin() * phi.sin();
800            let z = theta.cos();
801            pts3d[(0, i)] = x;
802            pts3d[(1, i)] = y;
803            pts3d[(2, i)] = z;
804            let px = gt_cam.project(&Vector3::new(x, y, z))?;
805            pts2d[(0, i)] = px.x;
806            pts2d[(1, i)] = px.y;
807        }
808
809        let seed = FThetaCamera::from([gt_cam.cx, gt_cam.cy, 500.0, 0.0, 0.0, 0.0]);
810        let estimated = seed.linear_estimation(&pts3d, &pts2d)?;
811
812        // Verify reprojection error < 1 pixel on training points
813        let mut max_err = 0.0_f64;
814        for i in 0..n {
815            let p = Vector3::new(pts3d[(0, i)], pts3d[(1, i)], pts3d[(2, i)]);
816            let px_est = estimated.project(&p)?;
817            let err =
818                ((px_est.x - pts2d[(0, i)]).powi(2) + (px_est.y - pts2d[(1, i)]).powi(2)).sqrt();
819            max_err = max_err.max(err);
820        }
821        assert!(max_err < 1.0, "max reprojection error={max_err:.3} px");
822        Ok(())
823    }
824
825    // ── batch projection ──────────────────────────────────────────────────────
826
827    #[test]
828    fn test_project_batch_sentinel() {
829        let cam = make_camera();
830        let pts = Matrix3xX::from_columns(&[
831            Vector3::new(0.1, 0.2, 1.0),
832            Vector3::new(0.0, 0.0, -1.0), // behind camera
833        ]);
834        let result = cam.project_batch(&pts);
835        assert!(result[(0, 0)].is_finite());
836        assert!((result[(0, 1)] - 1e6).abs() < 1.0);
837        assert!((result[(1, 1)] - 1e6).abs() < 1.0);
838    }
839}