Skip to main content

apex_camera_models/
ftheta.rs

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