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    /// Unprojects a 2D image point to a unit 3D ray. Inverts the projection via the
256    /// trigonometric relationship of the FOV model.
257    fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
258        let u = point_2d.x;
259        let v = point_2d.y;
260
261        let w = self.distortion_params();
262        let tan_w_2 = (w / 2.0).tan();
263        let mul2tanwby2 = tan_w_2 * 2.0;
264
265        let mx = (u - self.pinhole.cx) / self.pinhole.fx;
266        let my = (v - self.pinhole.cy) / self.pinhole.fy;
267
268        let r2 = mx * mx + my * my;
269        let rd = r2.sqrt();
270
271        if rd < crate::GEOMETRIC_PRECISION {
272            return Ok(Vector3::new(0.0, 0.0, 1.0));
273        }
274
275        let ru = (rd * w).tan() / mul2tanwby2;
276
277        let norm_factor = (1.0 + ru * ru).sqrt();
278        let x = mx * ru / (rd * norm_factor);
279        let y = my * ru / (rd * norm_factor);
280        let z = 1.0 / norm_factor;
281
282        Ok(Vector3::new(x, y, z))
283    }
284
285    /// 2×3 Jacobian ∂(u,v)/∂(x,y,z). See the
286    /// [cookbook](../doc/cookbook/src/fov.html#jacobians) for the full derivation.
287    fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
288        let x = p_cam[0];
289        let y = p_cam[1];
290        let z = p_cam[2];
291
292        let r = (x * x + y * y).sqrt();
293        let w = self.distortion_params();
294        let tan_w_2 = (w / 2.0).tan();
295        let mul2tanwby2 = tan_w_2 * 2.0;
296
297        if r < crate::GEOMETRIC_PRECISION {
298            let rd = mul2tanwby2 / w;
299            return SMatrix::<f64, 2, 3>::new(
300                self.pinhole.fx * rd,
301                0.0,
302                0.0,
303                0.0,
304                self.pinhole.fy * rd,
305                0.0,
306            );
307        }
308
309        let atan_wrd = (mul2tanwby2 * r / z).atan();
310        let rd = atan_wrd / (r * w);
311
312        // Derivatives
313        let datan_dr = mul2tanwby2 * z / (z * z + mul2tanwby2 * mul2tanwby2 * r * r);
314        let datan_dz = -mul2tanwby2 * r / (z * z + mul2tanwby2 * mul2tanwby2 * r * r);
315
316        let drd_dr = (datan_dr * r - atan_wrd) / (r * r * w);
317        let drd_dz = datan_dz / (r * w);
318
319        let dr_dx = x / r;
320        let dr_dy = y / r;
321
322        let dmx_dx = rd + x * drd_dr * dr_dx;
323        let dmx_dy = x * drd_dr * dr_dy;
324        let dmx_dz = x * drd_dz;
325
326        let dmy_dx = y * drd_dr * dr_dx;
327        let dmy_dy = rd + y * drd_dr * dr_dy;
328        let dmy_dz = y * drd_dz;
329
330        SMatrix::<f64, 2, 3>::new(
331            self.pinhole.fx * dmx_dx,
332            self.pinhole.fx * dmx_dy,
333            self.pinhole.fx * dmx_dz,
334            self.pinhole.fy * dmy_dx,
335            self.pinhole.fy * dmy_dy,
336            self.pinhole.fy * dmy_dz,
337        )
338    }
339
340    /// 2×5 Jacobian ∂(u,v)/∂[fx, fy, cx, cy, w]. See the
341    /// [cookbook](../doc/cookbook/src/fov.html#jacobians) for the full derivation.
342    fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
343        let x = p_cam[0];
344        let y = p_cam[1];
345        let z = p_cam[2];
346
347        let r = (x * x + y * y).sqrt();
348        let w = self.distortion_params();
349        let tan_w_2 = (w / 2.0).tan();
350        let mul2tanwby2 = tan_w_2 * 2.0;
351
352        let rd = if r > crate::GEOMETRIC_PRECISION {
353            let atan_wrd = (mul2tanwby2 * r / z).atan();
354            atan_wrd / (r * w)
355        } else {
356            mul2tanwby2 / w
357        };
358
359        let mx = x * rd;
360        let my = y * rd;
361
362        // ∂u/∂fx = mx, ∂u/∂fy = 0, ∂u/∂cx = 1, ∂u/∂cy = 0
363        // ∂v/∂fx = 0, ∂v/∂fy = my, ∂v/∂cx = 0, ∂v/∂cy = 1
364
365        // For w derivative: ∂rd/∂w
366        let drd_dw = if r > crate::GEOMETRIC_PRECISION {
367            let tan_w_2 = (w / 2.0).tan();
368            let alpha = 2.0 * tan_w_2 * r / z;
369            let atan_alpha = alpha.atan();
370
371            // sec²(w/2) = 1 + tan²(w/2)
372            let sec2_w_2 = 1.0 + tan_w_2 * tan_w_2;
373            let dalpha_dw = sec2_w_2 * r / z;
374
375            // ∂rd/∂w = [1/(1+α²) · ∂α/∂w · r·w - atan(α) · r] / (r·w)²
376            let datan_dw = dalpha_dw / (1.0 + alpha * alpha);
377            (datan_dw * r * w - atan_alpha * r) / (r * r * w * w)
378        } else {
379            let tan_w_2 = (w / 2.0).tan();
380            let sec2_w_2 = 1.0 + tan_w_2 * tan_w_2;
381            // rd = 2·tan(w/2) / w
382            // ∂rd/∂w = [2·sec²(w/2)/2 · w - 2·tan(w/2)] / w²
383            //        = [sec²(w/2) · w - 2·tan(w/2)] / w²
384            (sec2_w_2 * w - 2.0 * tan_w_2) / (w * w)
385        };
386
387        let du_dw = self.pinhole.fx * x * drd_dw;
388        let dv_dw = self.pinhole.fy * y * drd_dw;
389
390        SMatrix::<f64, 2, 5>::new(mx, 0.0, 1.0, 0.0, du_dw, 0.0, my, 0.0, 1.0, dv_dw)
391    }
392
393    /// Validates the camera parameters.
394    ///
395    /// # Validation Rules
396    ///
397    /// - `fx`, `fy` must be positive (> 0) and finite
398    /// - `cx`, `cy` must be finite
399    /// - `w` must be in `(0, π]`
400    ///
401    /// # Errors
402    ///
403    /// Returns [`CameraModelError`] if any rule is violated.
404    fn validate_params(&self) -> Result<(), CameraModelError> {
405        self.pinhole.validate()?;
406        self.get_distortion().validate()
407    }
408
409    /// Returns the pinhole parameters.
410    fn get_pinhole_params(&self) -> PinholeParams {
411        PinholeParams {
412            fx: self.pinhole.fx,
413            fy: self.pinhole.fy,
414            cx: self.pinhole.cx,
415            cy: self.pinhole.cy,
416        }
417    }
418
419    /// Returns the distortion model (must be [`DistortionModel::FOV`]).
420    fn get_distortion(&self) -> DistortionModel {
421        self.distortion
422    }
423
424    /// Returns the model name: `"fov"`.
425    fn get_model_name(&self) -> &'static str {
426        "fov"
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use nalgebra::{Matrix2xX, Matrix3xX};
434
435    type TestResult = Result<(), Box<dyn std::error::Error>>;
436
437    #[test]
438    fn test_fov_camera_creation() -> TestResult {
439        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
440        let distortion = DistortionModel::FOV { w: 1.5 };
441        let camera = FovCamera::new(pinhole, distortion)?;
442
443        assert_eq!(camera.pinhole.fx, 300.0);
444        assert_eq!(camera.distortion_params(), 1.5);
445        Ok(())
446    }
447
448    #[test]
449    fn test_projection_at_optical_axis() -> TestResult {
450        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
451        let distortion = DistortionModel::FOV { w: 1.5 };
452        let camera = FovCamera::new(pinhole, distortion)?;
453
454        let p_cam = Vector3::new(0.0, 0.0, 1.0);
455        let uv = camera.project(&p_cam)?;
456
457        assert!((uv.x - 320.0).abs() < 1e-4);
458        assert!((uv.y - 240.0).abs() < 1e-4);
459
460        Ok(())
461    }
462
463    #[test]
464    fn test_jacobian_point_numerical() -> TestResult {
465        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
466        let distortion = DistortionModel::FOV { w: 1.5 };
467        let camera = FovCamera::new(pinhole, distortion)?;
468
469        let p_cam = Vector3::new(0.1, 0.2, 1.0);
470
471        let jac_analytical = camera.jacobian_point(&p_cam);
472        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
473
474        for i in 0..3 {
475            let mut p_plus = p_cam;
476            let mut p_minus = p_cam;
477            p_plus[i] += eps;
478            p_minus[i] -= eps;
479
480            let uv_plus = camera.project(&p_plus)?;
481            let uv_minus = camera.project(&p_minus)?;
482            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
483
484            for r in 0..2 {
485                assert!(
486                    jac_analytical[(r, i)].is_finite(),
487                    "Jacobian [{r},{i}] is not finite"
488                );
489                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
490                assert!(
491                    diff < crate::JACOBIAN_TEST_TOLERANCE,
492                    "Mismatch at ({}, {})",
493                    r,
494                    i
495                );
496            }
497        }
498        Ok(())
499    }
500
501    #[test]
502    fn test_jacobian_intrinsics_numerical() -> TestResult {
503        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
504        let distortion = DistortionModel::FOV { w: 1.5 };
505        let camera = FovCamera::new(pinhole, distortion)?;
506
507        let p_cam = Vector3::new(0.1, 0.2, 1.0);
508
509        let jac_analytical = camera.jacobian_intrinsics(&p_cam);
510        let params: DVector<f64> = (&camera).into();
511        let eps = crate::NUMERICAL_DERIVATIVE_EPS;
512
513        for i in 0..5 {
514            let mut params_plus = params.clone();
515            let mut params_minus = params.clone();
516            params_plus[i] += eps;
517            params_minus[i] -= eps;
518
519            let cam_plus = FovCamera::try_from(params_plus.as_slice())?;
520            let cam_minus = FovCamera::try_from(params_minus.as_slice())?;
521
522            let uv_plus = cam_plus.project(&p_cam)?;
523            let uv_minus = cam_minus.project(&p_cam)?;
524            let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
525
526            for r in 0..2 {
527                assert!(
528                    jac_analytical[(r, i)].is_finite(),
529                    "Jacobian [{r},{i}] is not finite"
530                );
531                let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
532                assert!(diff < 1e-4, "Mismatch at ({}, {})", r, i);
533            }
534        }
535        Ok(())
536    }
537
538    #[test]
539    fn test_fov_from_into_traits() -> TestResult {
540        let pinhole = PinholeParams::new(400.0, 410.0, 320.0, 240.0)?;
541        let distortion = DistortionModel::FOV { w: 1.8 };
542        let camera = FovCamera::new(pinhole, distortion)?;
543
544        // Test conversion to DVector
545        let params: DVector<f64> = (&camera).into();
546        assert_eq!(params.len(), 5);
547        assert_eq!(params[0], 400.0);
548        assert_eq!(params[1], 410.0);
549        assert_eq!(params[2], 320.0);
550        assert_eq!(params[3], 240.0);
551        assert_eq!(params[4], 1.8);
552
553        // Test conversion to array
554        let arr: [f64; 5] = (&camera).into();
555        assert_eq!(arr, [400.0, 410.0, 320.0, 240.0, 1.8]);
556
557        // Test conversion from slice
558        let params_slice = [450.0, 460.0, 330.0, 250.0, 2.0];
559        let camera2 = FovCamera::try_from(&params_slice[..])?;
560        assert_eq!(camera2.pinhole.fx, 450.0);
561        assert_eq!(camera2.pinhole.fy, 460.0);
562        assert_eq!(camera2.pinhole.cx, 330.0);
563        assert_eq!(camera2.pinhole.cy, 250.0);
564        assert_eq!(camera2.distortion_params(), 2.0);
565
566        // Test conversion from array
567        let camera3 = FovCamera::from([500.0, 510.0, 340.0, 260.0, 2.5]);
568        assert_eq!(camera3.pinhole.fx, 500.0);
569        assert_eq!(camera3.pinhole.fy, 510.0);
570        assert_eq!(camera3.distortion_params(), 2.5);
571
572        Ok(())
573    }
574
575    #[test]
576    fn test_linear_estimation() -> TestResult {
577        // Ground truth FOV camera
578        let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
579        let gt_distortion = DistortionModel::FOV { w: 1.0 };
580        let gt_camera = FovCamera::new(gt_pinhole, gt_distortion)?;
581
582        // Generate synthetic 3D points in camera frame
583        let n_points = 50;
584        let mut pts_3d = Matrix3xX::zeros(n_points);
585        let mut pts_2d = Matrix2xX::zeros(n_points);
586        let mut valid = 0;
587
588        for i in 0..n_points {
589            let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
590            let r = 0.1 + 0.3 * (i as f64 / n_points as f64);
591            let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
592
593            if let Ok(p2d) = gt_camera.project(&p3d) {
594                pts_3d.set_column(valid, &p3d);
595                pts_2d.set_column(valid, &p2d);
596                valid += 1;
597            }
598        }
599        let pts_3d = pts_3d.columns(0, valid).into_owned();
600        let pts_2d = pts_2d.columns(0, valid).into_owned();
601
602        // Initial camera with default w (grid search will find best)
603        let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
604        let init_distortion = DistortionModel::FOV { w: 0.5 };
605        let mut camera = FovCamera::new(init_pinhole, init_distortion)?;
606
607        camera.linear_estimation(&pts_3d, &pts_2d)?;
608
609        // FOV uses grid search so tolerance is looser
610        for i in 0..valid {
611            let col = pts_3d.column(i);
612            let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
613            let err = ((projected.x - pts_2d[(0, i)]).powi(2)
614                + (projected.y - pts_2d[(1, i)]).powi(2))
615            .sqrt();
616            assert!(err < 5.0, "Reprojection error too large: {err}");
617        }
618
619        Ok(())
620    }
621
622    #[test]
623    fn test_project_unproject_round_trip() -> TestResult {
624        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
625        let distortion = DistortionModel::FOV { w: 1.5 };
626        let camera = FovCamera::new(pinhole, distortion)?;
627
628        let test_points = [
629            Vector3::new(0.1, 0.2, 1.0),
630            Vector3::new(-0.3, 0.1, 2.0),
631            Vector3::new(0.05, -0.1, 0.5),
632        ];
633
634        for p_cam in &test_points {
635            let uv = camera.project(p_cam)?;
636            let ray = camera.unproject(&uv)?;
637            let dot = ray.dot(&p_cam.normalize());
638            assert!(
639                (dot - 1.0).abs() < 1e-6,
640                "Round-trip failed: dot={dot}, expected ~1.0"
641            );
642        }
643
644        Ok(())
645    }
646
647    #[test]
648    fn test_project_returns_error_behind_camera() -> TestResult {
649        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
650        let distortion = DistortionModel::FOV { w: 1.5 };
651        let camera = FovCamera::new(pinhole, distortion)?;
652        assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
653        Ok(())
654    }
655
656    #[test]
657    fn test_project_at_min_depth_boundary() -> TestResult {
658        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
659        let distortion = DistortionModel::FOV { w: 1.5 };
660        let camera = FovCamera::new(pinhole, distortion)?;
661        let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
662        if let Ok(uv) = camera.project(&p_min) {
663            assert!(uv.x.is_finite() && uv.y.is_finite());
664        }
665        Ok(())
666    }
667
668    #[test]
669    fn test_projection_off_axis() -> TestResult {
670        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
671        let distortion = DistortionModel::FOV { w: 1.5 };
672        let camera = FovCamera::new(pinhole, distortion)?;
673        let p_cam = Vector3::new(0.3, 0.0, 1.0);
674        let uv = camera.project(&p_cam)?;
675        assert!(
676            uv.x > 320.0,
677            "off-axis point should project right of principal point"
678        );
679        assert!(
680            (uv.y - 240.0).abs() < 1.0,
681            "y should be close to cy for horizontal offset"
682        );
683        Ok(())
684    }
685
686    #[test]
687    fn test_unproject_center_pixel() -> TestResult {
688        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
689        let distortion = DistortionModel::FOV { w: 1.5 };
690        let camera = FovCamera::new(pinhole, distortion)?;
691        let uv = Vector2::new(320.0, 240.0);
692        let ray = camera.unproject(&uv)?;
693        assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
694        assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
695        assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
696        Ok(())
697    }
698
699    #[test]
700    fn test_batch_projection_matches_individual() -> TestResult {
701        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
702        let distortion = DistortionModel::FOV { w: 1.5 };
703        let camera = FovCamera::new(pinhole, distortion)?;
704        let pts = Matrix3xX::from_columns(&[
705            Vector3::new(0.0, 0.0, 1.0),
706            Vector3::new(0.3, 0.2, 1.5),
707            Vector3::new(-0.4, 0.1, 2.0),
708        ]);
709        let batch = camera.project_batch(&pts);
710        for i in 0..3 {
711            let col = pts.column(i);
712            let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
713            assert!(
714                (batch[(0, i)] - p.x).abs() < 1e-10,
715                "batch u mismatch at col {i}"
716            );
717            assert!(
718                (batch[(1, i)] - p.y).abs() < 1e-10,
719                "batch v mismatch at col {i}"
720            );
721        }
722        Ok(())
723    }
724
725    #[test]
726    fn test_jacobian_dimensions() -> TestResult {
727        let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
728        let distortion = DistortionModel::FOV { w: 1.5 };
729        let camera = FovCamera::new(pinhole, distortion)?;
730        let p_cam = Vector3::new(0.1, 0.2, 1.0);
731        let jac_point = camera.jacobian_point(&p_cam);
732        assert_eq!(jac_point.nrows(), 2);
733        assert_eq!(jac_point.ncols(), 3);
734        let jac_intr = camera.jacobian_intrinsics(&p_cam);
735        assert_eq!(jac_intr.nrows(), 2);
736        assert_eq!(jac_intr.ncols(), 5); // FovCamera::INTRINSIC_DIM = 5
737        Ok(())
738    }
739}