Skip to main content

apex_camera_models/
fov.rs

1//! Field-of-View (FOV) camera model.
2//!
3//! Fisheye model parameterised by a single FOV coefficient `w` rather than a polynomial
4//! series, suitable for wide-FOV SLAM lenses. Has 5 intrinsic parameters. See the
5//! [fov cookbook chapter](../doc/cookbook/src/fov.html) for the full projection,
6//! unprojection, and Jacobian derivations.
7
8use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
9use nalgebra::{DVector, SMatrix, Vector2, Vector3};
10
11/// FOV camera model with 5 parameters.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct FovCamera {
14    pub pinhole: PinholeParams,
15    pub distortion: DistortionModel,
16}
17
18impl FovCamera {
19    /// Creates a new FOV camera.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`CameraModelError::InvalidParams`] if `distortion` is not
24    /// [`DistortionModel::FOV`].
25    ///
26    /// # Example
27    ///
28    /// ```
29    /// use apex_camera_models::{CameraModel, DistortionModel, FovCamera, PinholeParams};
30    ///
31    /// let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
32    /// let distortion = DistortionModel::FOV { w: 1.5 };
33    /// let camera = FovCamera::new(pinhole, distortion)?;
34    /// assert_eq!(camera.get_model_name(), "fov");
35    /// # Ok::<(), apex_camera_models::CameraModelError>(())
36    /// ```
37    pub fn new(
38        pinhole: PinholeParams,
39        distortion: DistortionModel,
40    ) -> Result<Self, CameraModelError> {
41        let camera = Self {
42            pinhole,
43            distortion,
44        };
45        camera.validate_params()?;
46        Ok(camera)
47    }
48
49    /// Returns the FOV parameter `w`. Returns `0.0` if the model is not FOV.
50    fn distortion_params(&self) -> f64 {
51        match self.distortion {
52            DistortionModel::FOV { w } => w,
53            _ => 0.0,
54        }
55    }
56
57    /// Estimates the `w` parameter by 1-D grid search over candidate values.
58    /// Requires the intrinsics `[fx, fy, cx, cy]` to already be set; needs at least
59    /// 2 correspondences. Note: this is a search, not a closed-form LS solve.
60    pub fn linear_estimation(
61        &mut self,
62        points_3d: &nalgebra::Matrix3xX<f64>,
63        points_2d: &nalgebra::Matrix2xX<f64>,
64    ) -> Result<(), CameraModelError> {
65        if points_2d.ncols() != points_3d.ncols() {
66            return Err(CameraModelError::InvalidParams(
67                "Number of 2D and 3D points must match".to_string(),
68            ));
69        }
70
71        let num_points = points_2d.ncols();
72
73        if num_points < 2 {
74            return Err(CameraModelError::InvalidParams(
75                "Need at least 2 point correspondences for linear estimation".to_string(),
76            ));
77        }
78
79        let mut best_w = 1.0;
80        let mut best_error = f64::INFINITY;
81
82        for w_test in (10..300).map(|i| i as f64 / 100.0) {
83            let mut error_sum = 0.0;
84            let mut valid_count = 0;
85
86            for i in 0..num_points {
87                let x = points_3d[(0, i)];
88                let y = points_3d[(1, i)];
89                let z = points_3d[(2, i)];
90                let u_observed = points_2d[(0, i)];
91                let v_observed = points_2d[(1, i)];
92
93                let r2 = x * x + y * y;
94                let r = r2.sqrt();
95
96                let tan_w_half = (w_test / 2.0).tan();
97                let atan_wrd = (2.0 * tan_w_half * r).atan2(z);
98
99                let eps_sqrt = f64::EPSILON.sqrt();
100                let rd = if r2 < eps_sqrt {
101                    2.0 * tan_w_half / w_test
102                } else {
103                    atan_wrd / (r * w_test)
104                };
105
106                let mx = x * rd;
107                let my = y * rd;
108
109                let u_predicted = self.pinhole.fx * mx + self.pinhole.cx;
110                let v_predicted = self.pinhole.fy * my + self.pinhole.cy;
111
112                let error = ((u_predicted - u_observed).powi(2)
113                    + (v_predicted - v_observed).powi(2))
114                .sqrt();
115
116                if error.is_finite() {
117                    error_sum += error;
118                    valid_count += 1;
119                }
120            }
121
122            if valid_count > 0 {
123                let avg_error = error_sum / valid_count as f64;
124                if avg_error < best_error {
125                    best_error = avg_error;
126                    best_w = w_test;
127                }
128            }
129        }
130
131        self.distortion = DistortionModel::FOV { w: best_w };
132
133        self.validate_params()?;
134
135        Ok(())
136    }
137}
138
139/// Converts the camera to a dynamic vector with layout `[fx, fy, cx, cy, w]`.
140impl From<&FovCamera> for DVector<f64> {
141    fn from(camera: &FovCamera) -> Self {
142        let w = camera.distortion_params();
143        DVector::from_vec(vec![
144            camera.pinhole.fx,
145            camera.pinhole.fy,
146            camera.pinhole.cx,
147            camera.pinhole.cy,
148            w,
149        ])
150    }
151}
152
153/// Converts the camera to a fixed-size array with layout `[fx, fy, cx, cy, w]`.
154impl From<&FovCamera> for [f64; 5] {
155    fn from(camera: &FovCamera) -> Self {
156        let w = camera.distortion_params();
157        [
158            camera.pinhole.fx,
159            camera.pinhole.fy,
160            camera.pinhole.cx,
161            camera.pinhole.cy,
162            w,
163        ]
164    }
165}
166
167/// Creates a camera from a slice with layout `[fx, fy, cx, cy, w]`.
168/// Returns an error if the slice has fewer than 5 elements.
169impl TryFrom<&[f64]> for FovCamera {
170    type Error = CameraModelError;
171
172    fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
173        if params.len() < 5 {
174            return Err(CameraModelError::InvalidParams(format!(
175                "FovCamera requires at least 5 parameters, got {}",
176                params.len()
177            )));
178        }
179        Ok(Self {
180            pinhole: PinholeParams {
181                fx: params[0],
182                fy: params[1],
183                cx: params[2],
184                cy: params[3],
185            },
186            distortion: DistortionModel::FOV { w: params[4] },
187        })
188    }
189}
190
191/// Creates a camera from a fixed-size array with layout `[fx, fy, cx, cy, w]`.
192impl From<[f64; 5]> for FovCamera {
193    fn from(params: [f64; 5]) -> Self {
194        Self {
195            pinhole: PinholeParams {
196                fx: params[0],
197                fy: params[1],
198                cx: params[2],
199                cy: params[3],
200            },
201            distortion: DistortionModel::FOV { w: params[4] },
202        }
203    }
204}
205
206/// Creates an `FovCamera` from a parameter slice with full validation.
207/// Unlike [`<FovCamera as TryFrom<&[f64]>>::try_from`], this also calls
208/// [`CameraModel::validate_params`] and returns any validation errors.
209pub fn try_from_params(params: &[f64]) -> Result<FovCamera, CameraModelError> {
210    let camera = FovCamera::try_from(params)?;
211    camera.validate_params()?;
212    Ok(camera)
213}
214
215impl CameraModel for FovCamera {
216    const INTRINSIC_DIM: usize = 5;
217    type IntrinsicJacobian = SMatrix<f64, 2, 5>;
218    type PointJacobian = SMatrix<f64, 2, 3>;
219
220    /// Projects a 3D point in the camera frame to 2D image coordinates.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`CameraModelError::ProjectionOutOfBounds`] if `z` is too small.
225    fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
226        let x = p_cam[0];
227        let y = p_cam[1];
228        let z = p_cam[2];
229
230        if z < crate::GEOMETRIC_PRECISION {
231            return Err(CameraModelError::ProjectionOutOfBounds);
232        }
233
234        let r = (x * x + y * y).sqrt();
235        let w = self.distortion_params();
236        let tan_w_2 = (w / 2.0).tan();
237        let mul2tanwby2 = tan_w_2 * 2.0;
238
239        let rd = if r > crate::GEOMETRIC_PRECISION {
240            let atan_wrd = (mul2tanwby2 * r / z).atan();
241            atan_wrd / (r * w)
242        } else {
243            mul2tanwby2 / w
244        };
245
246        let mx = x * rd;
247        let my = y * rd;
248
249        Ok(Vector2::new(
250            self.pinhole.fx * mx + self.pinhole.cx,
251            self.pinhole.fy * my + self.pinhole.cy,
252        ))
253    }
254
255    /// The FOV domain is exactly `z ≥ GEOMETRIC_PRECISION` (the sole
256    /// condition `project` checks), so its deficit is measured from that
257    /// plane rather than from `z = 0`. Reporting `ProjectionOutOfBounds`
258    /// for this condition means a `PointBehindCamera` arm never fires for
259    /// this model, so this override is the only way to get a deficit here:
260    /// the trait default (`-z`) would go non-positive for a point that is
261    /// invalid but still slightly in front of `z = 0`, collapsing to a
262    /// constant, gradient-less barrier. (The solver does not charge the
263    /// deficit yet — see the trait's Status note.)
264    fn projection_deficit(&self, p_cam: &Vector3<f64>) -> (f64, Vector3<f64>) {
265        (
266            crate::GEOMETRIC_PRECISION - p_cam.z,
267            Vector3::new(0.0, 0.0, -1.0),
268        )
269    }
270
271    /// Unprojects a 2D image point to a unit 3D ray. Inverts the projection via the
272    /// trigonometric relationship of the FOV model.
273    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
274        let u = point_2d.x;
275        let v = point_2d.y;
276
277        let w = self.distortion_params();
278        let tan_w_2 = (w / 2.0).tan();
279        let mul2tanwby2 = tan_w_2 * 2.0;
280
281        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
282        let my = (v - self.pinhole.cy) / self.pinhole.fy;
283
284        let r2 = mx * mx + my * my;
285        let rd = r2.sqrt();
286
287        if rd < crate::GEOMETRIC_PRECISION {
288            return Ok(Vector3::new(0.0, 0.0, 1.0));
289        }
290
291        let ru = (rd * w).tan() / mul2tanwby2;
292
293        let norm_factor = (1.0 + ru * ru).sqrt();
294        let x = mx * ru / (rd * norm_factor);
295        let y = my * ru / (rd * norm_factor);
296        let z = 1.0 / norm_factor;
297
298        Ok(Vector3::new(x, y, z))
299    }
300
301    /// 2×3 Jacobian ∂(u,v)/∂(x,y,z). See the
302    /// [cookbook](../doc/cookbook/src/fov.html#jacobians) for the full derivation.
303    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
304        let x = p_cam[0];
305        let y = p_cam[1];
306        let z = p_cam[2];
307
308        let r = (x * x + y * y).sqrt();
309        let w = self.distortion_params();
310        let tan_w_2 = (w / 2.0).tan();
311        let mul2tanwby2 = tan_w_2 * 2.0;
312
313        if r < crate::GEOMETRIC_PRECISION {
314            let rd = mul2tanwby2 / w;
315            return SMatrix::<f64, 2, 3>::new(
316                self.pinhole.fx * rd,
317                0.0,
318                0.0,
319                0.0,
320                self.pinhole.fy * rd,
321                0.0,
322            );
323        }
324
325        let atan_wrd = (mul2tanwby2 * r / z).atan();
326        let rd = atan_wrd / (r * w);
327
328        // Derivatives
329        let datan_dr = mul2tanwby2 * z / (z * z + mul2tanwby2 * mul2tanwby2 * r * r);
330        let datan_dz = -mul2tanwby2 * r / (z * z + mul2tanwby2 * mul2tanwby2 * r * r);
331
332        let drd_dr = (datan_dr * r - atan_wrd) / (r * r * w);
333        let drd_dz = datan_dz / (r * w);
334
335        let dr_dx = x / r;
336        let dr_dy = y / r;
337
338        let dmx_dx = rd + x * drd_dr * dr_dx;
339        let dmx_dy = x * drd_dr * dr_dy;
340        let dmx_dz = x * drd_dz;
341
342        let dmy_dx = y * drd_dr * dr_dx;
343        let dmy_dy = rd + y * drd_dr * dr_dy;
344        let dmy_dz = y * drd_dz;
345
346        SMatrix::<f64, 2, 3>::new(
347            self.pinhole.fx * dmx_dx,
348            self.pinhole.fx * dmx_dy,
349            self.pinhole.fx * dmx_dz,
350            self.pinhole.fy * dmy_dx,
351            self.pinhole.fy * dmy_dy,
352            self.pinhole.fy * dmy_dz,
353        )
354    }
355
356    /// 2×5 Jacobian ∂(u,v)/∂[fx, fy, cx, cy, w]. See the
357    /// [cookbook](../doc/cookbook/src/fov.html#jacobians) for the full derivation.
358    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
359        let x = p_cam[0];
360        let y = p_cam[1];
361        let z = p_cam[2];
362
363        let r = (x * x + y * y).sqrt();
364        let w = self.distortion_params();
365        let tan_w_2 = (w / 2.0).tan();
366        let mul2tanwby2 = tan_w_2 * 2.0;
367
368        let rd = if r > crate::GEOMETRIC_PRECISION {
369            let atan_wrd = (mul2tanwby2 * r / z).atan();
370            atan_wrd / (r * w)
371        } else {
372            mul2tanwby2 / w
373        };
374
375        let mx = x * rd;
376        let my = y * rd;
377
378        // ∂u/∂fx = mx, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
379        // ∂v/∂fx = 0, ∂v/∂fy = my, ∂v/∂cx = 0, ∂v/∂cy = 1
380
381        // For w derivative: ∂rd/∂w
382        let drd_dw = if r > crate::GEOMETRIC_PRECISION {
383            let tan_w_2 = (w / 2.0).tan();
384            let alpha = 2.0 * tan_w_2 * r / z;
385            let atan_alpha = alpha.atan();
386
387            // sec²(w/2) = 1 + tan²(w/2)
388            let sec2_w_2 = 1.0 + tan_w_2 * tan_w_2;
389            let dalpha_dw = sec2_w_2 * r / z;
390
391            // ∂rd/∂w = [1/(1+α²) · ∂α/∂w · r·w - atan(α) · r] / (r·w)²
392            let datan_dw = dalpha_dw / (1.0 + alpha * alpha);
393            (datan_dw * r * w - atan_alpha * r) / (r * r * w * w)
394        } else {
395            let tan_w_2 = (w / 2.0).tan();
396            let sec2_w_2 = 1.0 + tan_w_2 * tan_w_2;
397            // rd = 2·tan(w/2) / w
398            // ∂rd/∂w = [2·sec²(w/2)/2 · w - 2·tan(w/2)] / w²
399            //        = [sec²(w/2) · w - 2·tan(w/2)] / w²
400            (sec2_w_2 * w - 2.0 * tan_w_2) / (w * w)
401        };
402
403        let du_dw = self.pinhole.fx * x * drd_dw;
404        let dv_dw = self.pinhole.fy * y * drd_dw;
405
406        SMatrix::<f64, 2, 5>::new(mx, 0.0, 1.0, 0.0, du_dw, 0.0, my, 0.0, 1.0, dv_dw)
407    }
408
409    /// Validates the camera parameters.
410    ///
411    /// # Validation Rules
412    ///
413    /// - `fx`, `fy` must be positive (> 0) and finite
414    /// - `cx`, `cy` must be finite
415    /// - `w` must be in `(0, π]`
416    ///
417    /// # Errors
418    ///
419    /// Returns [`CameraModelError`] if any rule is violated.
420    fn validate_params(&self) -> Result<(), CameraModelError> {
421        self.pinhole.validate()?;
422        self.get_distortion().validate()
423    }
424
425    /// Returns the pinhole parameters.
426    fn get_pinhole_params(&self) -> PinholeParams {
427        PinholeParams {
428            fx: self.pinhole.fx,
429            fy: self.pinhole.fy,
430            cx: self.pinhole.cx,
431            cy: self.pinhole.cy,
432        }
433    }
434
435    /// Returns the distortion model (must be [`DistortionModel::FOV`]).
436    fn get_distortion(&self) -> DistortionModel {
437        self.distortion
438    }
439
440    /// Returns the model name: `"fov"`.
441    fn get_model_name(&self) -> &'static str {
442        "fov"
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use nalgebra::{Matrix2xX, Matrix3xX};
450
451    type TestResult = Result<(), Box<dyn std::error::Error>>;
452
453    #[test]
454    fn test_fov_camera_creation() -> TestResult {
455        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
456        let distortion = DistortionModel::FOV { w: 1.5 };
457        let camera = FovCamera::new(pinhole, distortion)?;
458
459        assert_eq!(camera.pinhole.fx, 300.0);
460        assert_eq!(camera.distortion_params(), 1.5);
461        Ok(())
462    }
463
464    #[test]
465    fn test_projection_at_optical_axis() -> TestResult {
466        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
467        let distortion = DistortionModel::FOV { w: 1.5 };
468        let camera = FovCamera::new(pinhole, distortion)?;
469
470        let p_cam = Vector3::new(0.0, 0.0, 1.0);
471        let uv = camera.project(&p_cam)?;
472
473        assert!((uv.x - 320.0).abs() < 1e-4);
474        assert!((uv.y - 240.0).abs() < 1e-4);
475
476        Ok(())
477    }
478
479    #[test]
480    fn test_jacobian_point_numerical() -> TestResult {
481        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
482        let distortion = DistortionModel::FOV { w: 1.5 };
483        let camera = FovCamera::new(pinhole, distortion)?;
484
485        let p_cam = Vector3::new(0.1, 0.2, 1.0);
486
487        let jac_analytical = camera.jacobian_point(&p_cam);
488        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
489
490        for i in 0..3 {
491            let mut p_plus = p_cam;
492            let mut p_minus = p_cam;
493            p_plus[i] += eps;
494            p_minus[i] -= eps;
495
496            let uv_plus = camera.project(&p_plus)?;
497            let uv_minus = camera.project(&p_minus)?;
498            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
499
500            for r in 0..2 {
501                assert!(
502                    jac_analytical[(r, i)].is_finite(),
503                    "Jacobian [{r},{i}] is not finite"
504                );
505                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
506                assert!(
507                    diff < crate::JACOBIAN_TEST_TOLERANCE,
508                    "Mismatch at ({}, {})",
509                    r,
510                    i
511                );
512            }
513        }
514        Ok(())
515    }
516
517    #[test]
518    fn test_jacobian_intrinsics_numerical() -> TestResult {
519        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
520        let distortion = DistortionModel::FOV { w: 1.5 };
521        let camera = FovCamera::new(pinhole, distortion)?;
522
523        let p_cam = Vector3::new(0.1, 0.2, 1.0);
524
525        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
526        let params: DVector<f64> = (&camera).into();
527        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
528
529        for i in 0..5 {
530            let mut params_plus = params.clone();
531            let mut params_minus = params.clone();
532            params_plus[i] += eps;
533            params_minus[i] -= eps;
534
535            let cam_plus = FovCamera::try_from(params_plus.as_slice())?;
536            let cam_minus = FovCamera::try_from(params_minus.as_slice())?;
537
538            let uv_plus = cam_plus.project(&p_cam)?;
539            let uv_minus = cam_minus.project(&p_cam)?;
540            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
541
542            for r in 0..2 {
543                assert!(
544                    jac_analytical[(r, i)].is_finite(),
545                    "Jacobian [{r},{i}] is not finite"
546                );
547                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
548                assert!(diff < 1e-4, "Mismatch at ({}, {})", r, i);
549            }
550        }
551        Ok(())
552    }
553
554    #[test]
555    fn test_fov_from_into_traits() -> TestResult {
556        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
557        let distortion = DistortionModel::FOV { w: 1.8 };
558        let camera = FovCamera::new(pinhole, distortion)?;
559
560        // Test conversion to DVector
561        let params: DVector<f64> = (&camera).into();
562        assert_eq!(params.len(), 5);
563        assert_eq!(params[0], 400.0);
564        assert_eq!(params[1], 410.0);
565        assert_eq!(params[2], 320.0);
566        assert_eq!(params[3], 240.0);
567        assert_eq!(params[4], 1.8);
568
569        // Test conversion to array
570        let arr: [f64; 5] = (&camera).into();
571        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 1.8]);
572
573        // Test conversion from slice
574        let params_slice = [450.0, 460.0, 330.0, 250.0, 2.0];
575        let camera2 = FovCamera::try_from(&params_slice[..])?;
576        assert_eq!(camera2.pinhole.fx, 450.0);
577        assert_eq!(camera2.pinhole.fy, 460.0);
578        assert_eq!(camera2.pinhole.cx, 330.0);
579        assert_eq!(camera2.pinhole.cy, 250.0);
580        assert_eq!(camera2.distortion_params(), 2.0);
581
582        // Test conversion from array
583        let camera3 = FovCamera::from([500.0, 510.0, 340.0, 260.0, 2.5]);
584        assert_eq!(camera3.pinhole.fx, 500.0);
585        assert_eq!(camera3.pinhole.fy, 510.0);
586        assert_eq!(camera3.distortion_params(), 2.5);
587
588        Ok(())
589    }
590
591    #[test]
592    fn test_linear_estimation() -> TestResult {
593        // Ground truth FOV camera
594        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
595        let gt_distortion = DistortionModel::FOV { w: 1.0 };
596        let gt_camera = FovCamera::new(gt_pinhole, gt_distortion)?;
597
598        // Generate synthetic 3D points in camera frame
599        let n_points = 50;
600        let mut pts_3d = Matrix3xX::zeros(n_points);
601        let mut pts_2d = Matrix2xX::zeros(n_points);
602        let mut valid = 0;
603
604        for i in 0..n_points {
605            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
606            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
607            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
608
609            if let Ok(p2d) = gt_camera.project(&p3d) {
610                pts_3d.set_column(valid, &p3d);
611                pts_2d.set_column(valid, &p2d);
612                valid += 1;
613            }
614        }
615        let pts_3d = pts_3d.columns(0, valid).into_owned();
616        let pts_2d = pts_2d.columns(0, valid).into_owned();
617
618        // Initial camera with default w (grid search will find best)
619        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
620        let init_distortion = DistortionModel::FOV { w: 0.5 };
621        let mut camera = FovCamera::new(init_pinhole, init_distortion)?;
622
623        camera.linear_estimation(&pts_3d, &pts_2d)?;
624
625        // FOV uses grid search so tolerance is looser
626        for i in 0..valid {
627            let col = pts_3d.column(i);
628            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
629            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
630                + (projected.y - pts_2d[(1, i)]).powi(2))
631            .sqrt();
632            assert!(err < 5.0, "Reprojection error too large: {err}");
633        }
634
635        Ok(())
636    }
637
638    #[test]
639    fn test_project_unproject_round_trip() -> TestResult {
640        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
641        let distortion = DistortionModel::FOV { w: 1.5 };
642        let camera = FovCamera::new(pinhole, distortion)?;
643
644        let test_points = [
645            Vector3::new(0.1, 0.2, 1.0),
646            Vector3::new(-0.3, 0.1, 2.0),
647            Vector3::new(0.05, -0.1, 0.5),
648        ];
649
650        for p_cam in &test_points {
651            let uv = camera.project(p_cam)?;
652            let ray = camera.unproject(&uv)?;
653            let dot = ray.dot(&p_cam.normalize());
654            assert!(
655                (dot - 1.0).abs() < 1e-6,
656                "Round-trip failed: dot={dot}, expected ~1.0"
657            );
658        }
659
660        Ok(())
661    }
662
663    #[test]
664    fn test_project_returns_error_behind_camera() -> TestResult {
665        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
666        let distortion = DistortionModel::FOV { w: 1.5 };
667        let camera = FovCamera::new(pinhole, distortion)?;
668        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
669        Ok(())
670    }
671
672    #[test]
673    fn test_project_at_min_depth_boundary() -> TestResult {
674        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
675        let distortion = DistortionModel::FOV { w: 1.5 };
676        let camera = FovCamera::new(pinhole, distortion)?;
677        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
678        if let Ok(uv) = camera.project(&p_min) {
679            assert!(uv.x.is_finite() && uv.y.is_finite());
680        }
681        Ok(())
682    }
683
684    #[test]
685    fn test_projection_off_axis() -> TestResult {
686        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
687        let distortion = DistortionModel::FOV { w: 1.5 };
688        let camera = FovCamera::new(pinhole, distortion)?;
689        let p_cam = Vector3::new(0.3, 0.0, 1.0);
690        let uv = camera.project(&p_cam)?;
691        assert!(
692            uv.x > 320.0,
693            "off-axis point should project right of principal point"
694        );
695        assert!(
696            (uv.y - 240.0).abs() < 1.0,
697            "y should be close to cy for horizontal offset"
698        );
699        Ok(())
700    }
701
702    #[test]
703    fn test_unproject_center_pixel() -> TestResult {
704        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
705        let distortion = DistortionModel::FOV { w: 1.5 };
706        let camera = FovCamera::new(pinhole, distortion)?;
707        let uv = Vector2::new(320.0, 240.0);
708        let ray = camera.unproject(&uv)?;
709        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
710        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
711        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
712        Ok(())
713    }
714
715    #[test]
716    fn test_batch_projection_matches_individual() -> TestResult {
717        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
718        let distortion = DistortionModel::FOV { w: 1.5 };
719        let camera = FovCamera::new(pinhole, distortion)?;
720        let pts = Matrix3xX::from_columns(&[
721            Vector3::new(0.0, 0.0, 1.0),
722            Vector3::new(0.3, 0.2, 1.5),
723            Vector3::new(-0.4, 0.1, 2.0),
724        ]);
725        let batch = camera.project_batch(&pts);
726        for i in 0..3 {
727            let col = pts.column(i);
728            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
729            assert!(
730                (batch[(0, i)] - p.x).abs() < 1e-10,
731                "batch u mismatch at col {i}"
732            );
733            assert!(
734                (batch[(1, i)] - p.y).abs() < 1e-10,
735                "batch v mismatch at col {i}"
736            );
737        }
738        Ok(())
739    }
740
741    #[test]
742    fn test_jacobian_dimensions() -> TestResult {
743        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
744        let distortion = DistortionModel::FOV { w: 1.5 };
745        let camera = FovCamera::new(pinhole, distortion)?;
746        let p_cam = Vector3::new(0.1, 0.2, 1.0);
747        let jac_point = camera.jacobian_point(&p_cam);
748        assert_eq!(jac_point.nrows(), 2);
749        assert_eq!(jac_point.ncols(), 3);
750        let jac_intr = camera.jacobian_intrinsics(&p_cam);
751        assert_eq!(jac_intr.nrows(), 2);
752        assert_eq!(jac_intr.ncols(), 5); // FovCamera::INTRINSIC_DIM = 5
753        Ok(())
754    }
755}