Skip to main content

scirs2_vision/camera/
mod.rs

1//! Camera models for 3D vision
2//!
3//! Provides a [`PinholeCamera`] with radial/tangential distortion (Brown–Conrady model),
4//! 3-D ↔ 2-D projection, iterative undistortion, and a [`StereoPair`] that bundles two
5//! cameras with their relative pose.
6
7pub mod intrinsics;
8pub use intrinsics::{CameraExtrinsics, CameraIntrinsics, StereoCameraSystem};
9
10use crate::error::{Result, VisionError};
11use scirs2_core::ndarray::{Array1, Array2};
12
13// ─────────────────────────────────────────────────────────────────────────────
14// PinholeCamera
15// ─────────────────────────────────────────────────────────────────────────────
16
17/// Pinhole camera with radial and tangential (Brown–Conrady) distortion.
18///
19/// Coordinate convention: X right, Y down, Z forward (optical axis).
20/// Projection (ideal):  `u = fx * X/Z + cx`,  `v = fy * Y/Z + cy`.
21///
22/// Distortion model (normalised coordinates `xn = X/Z`, `yn = Y/Z`):
23///
24/// ```text
25/// r² = xn² + yn²
26/// xd = xn*(1 + k1*r² + k2*r⁴) + 2*p1*xn*yn + p2*(r² + 2*xn²)
27/// yd = yn*(1 + k1*r² + k2*r⁴) + p1*(r² + 2*yn²) + 2*p2*xn*yn
28/// u = fx*xd + cx,  v = fy*yd + cy
29/// ```
30#[derive(Debug, Clone, PartialEq)]
31pub struct PinholeCamera {
32    /// Focal length in pixels along X.
33    pub fx: f64,
34    /// Focal length in pixels along Y.
35    pub fy: f64,
36    /// Principal point X (pixels).
37    pub cx: f64,
38    /// Principal point Y (pixels).
39    pub cy: f64,
40    /// Radial distortion coefficient k1.
41    pub k1: f64,
42    /// Radial distortion coefficient k2.
43    pub k2: f64,
44    /// Tangential distortion coefficient p1.
45    pub p1: f64,
46    /// Tangential distortion coefficient p2.
47    pub p2: f64,
48}
49
50impl PinholeCamera {
51    /// Create a new camera with all parameters explicit.
52    pub fn new(fx: f64, fy: f64, cx: f64, cy: f64, k1: f64, k2: f64, p1: f64, p2: f64) -> Self {
53        Self {
54            fx,
55            fy,
56            cx,
57            cy,
58            k1,
59            k2,
60            p1,
61            p2,
62        }
63    }
64
65    /// Create a camera with no distortion (k1=k2=p1=p2=0).
66    pub fn ideal(fx: f64, fy: f64, cx: f64, cy: f64) -> Self {
67        Self::new(fx, fy, cx, cy, 0.0, 0.0, 0.0, 0.0)
68    }
69
70    /// Project a 3-D point to a 2-D pixel using the **ideal** pinhole model
71    /// (distortion coefficients are ignored).
72    ///
73    /// Returns `Err` if `Z ≤ 0`.
74    ///
75    /// # Example
76    ///
77    /// ```
78    /// use scirs2_vision::camera::PinholeCamera;
79    ///
80    /// let cam = PinholeCamera::ideal(800.0, 800.0, 320.0, 240.0);
81    /// let px = cam.project(&[0.0, 0.0, 1.0]).unwrap();
82    /// assert!((px[0] - 320.0).abs() < 1e-9);
83    /// assert!((px[1] - 240.0).abs() < 1e-9);
84    /// ```
85    pub fn project(&self, point_3d: &[f64; 3]) -> Result<[f64; 2]> {
86        let z = point_3d[2];
87        if z <= 0.0 {
88            return Err(VisionError::InvalidParameter(
89                "point_3d[2] (Z) must be positive".to_string(),
90            ));
91        }
92        let xn = point_3d[0] / z;
93        let yn = point_3d[1] / z;
94        Ok([self.fx * xn + self.cx, self.fy * yn + self.cy])
95    }
96
97    /// Project a 3-D point to a 2-D pixel applying radial and tangential distortion.
98    ///
99    /// Returns `Err` if `Z ≤ 0`.
100    ///
101    /// # Example
102    ///
103    /// ```
104    /// use scirs2_vision::camera::PinholeCamera;
105    ///
106    /// let cam = PinholeCamera::new(800.0, 800.0, 320.0, 240.0, 0.1, 0.0, 0.0, 0.0);
107    /// let px = cam.project_distorted(&[0.0, 0.0, 1.0]).unwrap();
108    /// // On the principal axis distortion has no effect.
109    /// assert!((px[0] - 320.0).abs() < 1e-9);
110    /// assert!((px[1] - 240.0).abs() < 1e-9);
111    /// ```
112    pub fn project_distorted(&self, point_3d: &[f64; 3]) -> Result<[f64; 2]> {
113        let z = point_3d[2];
114        if z <= 0.0 {
115            return Err(VisionError::InvalidParameter(
116                "point_3d[2] (Z) must be positive".to_string(),
117            ));
118        }
119        let xn = point_3d[0] / z;
120        let yn = point_3d[1] / z;
121        let (xd, yd) = apply_distortion(self.k1, self.k2, self.p1, self.p2, xn, yn);
122        Ok([self.fx * xd + self.cx, self.fy * yd + self.cy])
123    }
124
125    /// Back-project a 2-D pixel plus a known depth into a 3-D point (inverse of
126    /// the ideal projection, distortion not reversed here).
127    ///
128    /// # Arguments
129    ///
130    /// * `pixel` – `[u, v]` pixel coordinates.
131    /// * `depth` – depth along the optical axis (Z), must be positive.
132    ///
133    /// # Example
134    ///
135    /// ```
136    /// use scirs2_vision::camera::PinholeCamera;
137    ///
138    /// let cam = PinholeCamera::ideal(800.0, 800.0, 320.0, 240.0);
139    /// let pt = cam.backproject(&[320.0, 240.0], 5.0);
140    /// assert!((pt[2] - 5.0).abs() < 1e-9);
141    /// ```
142    pub fn backproject(&self, pixel: &[f64; 2], depth: f64) -> [f64; 3] {
143        let xn = (pixel[0] - self.cx) / self.fx;
144        let yn = (pixel[1] - self.cy) / self.fy;
145        [xn * depth, yn * depth, depth]
146    }
147
148    /// Iteratively undistort a distorted pixel coordinate.
149    ///
150    /// Uses Newton-style fixed-point iteration (max 20 steps) to invert the
151    /// Brown–Conrady distortion model.
152    ///
153    /// # Example
154    ///
155    /// ```
156    /// use scirs2_vision::camera::PinholeCamera;
157    ///
158    /// let cam = PinholeCamera::new(800.0, 800.0, 320.0, 240.0, 0.1, 0.0, 0.0, 0.0);
159    /// // A point on the principal axis is unaffected by distortion.
160    /// let undist = cam.undistort_point(&[320.0, 240.0]);
161    /// assert!((undist[0] - 320.0).abs() < 1e-9);
162    /// assert!((undist[1] - 240.0).abs() < 1e-9);
163    /// ```
164    pub fn undistort_point(&self, pixel: &[f64; 2]) -> [f64; 2] {
165        // Normalised distorted coordinates.
166        let mut xn = (pixel[0] - self.cx) / self.fx;
167        let mut yn = (pixel[1] - self.cy) / self.fy;
168
169        // Fixed-point iteration: find (xn0, yn0) such that distort(xn0, yn0) = (xn, yn).
170        for _ in 0..20 {
171            let r2 = xn * xn + yn * yn;
172            let rad = 1.0 + self.k1 * r2 + self.k2 * r2 * r2;
173            let dx = 2.0 * self.p1 * xn * yn + self.p2 * (r2 + 2.0 * xn * xn);
174            let dy = self.p1 * (r2 + 2.0 * yn * yn) + 2.0 * self.p2 * xn * yn;
175
176            // Distorted value at current estimate.
177            let xd_curr = xn * rad + dx;
178            let yd_curr = yn * rad + dy;
179
180            // Observed normalised distorted coords.
181            let xd_obs = (pixel[0] - self.cx) / self.fx;
182            let yd_obs = (pixel[1] - self.cy) / self.fy;
183
184            // Residual-based correction.
185            xn += (xd_obs - xd_curr) / rad.max(1e-8);
186            yn += (yd_obs - yd_curr) / rad.max(1e-8);
187        }
188
189        [self.fx * xn + self.cx, self.fy * yn + self.cy]
190    }
191
192    /// Return the 3×3 intrinsic (calibration) matrix K as an `Array2<f64>`.
193    ///
194    /// ```text
195    /// K = [[fx,  0, cx],
196    ///      [ 0, fy, cy],
197    ///      [ 0,  0,  1]]
198    /// ```
199    ///
200    /// # Example
201    ///
202    /// ```
203    /// use scirs2_vision::camera::PinholeCamera;
204    ///
205    /// let cam = PinholeCamera::ideal(400.0, 400.0, 200.0, 150.0);
206    /// let k = cam.intrinsic_matrix();
207    /// assert_eq!(k.shape(), &[3, 3]);
208    /// assert!((k[[0, 0]] - 400.0).abs() < 1e-9);
209    /// ```
210    pub fn intrinsic_matrix(&self) -> Array2<f64> {
211        let mut k = Array2::<f64>::zeros((3, 3));
212        k[[0, 0]] = self.fx;
213        k[[0, 2]] = self.cx;
214        k[[1, 1]] = self.fy;
215        k[[1, 2]] = self.cy;
216        k[[2, 2]] = 1.0;
217        k
218    }
219
220    /// Distortion coefficient vector `[k1, k2, p1, p2]`.
221    pub fn distortion_coeffs(&self) -> [f64; 4] {
222        [self.k1, self.k2, self.p1, self.p2]
223    }
224}
225
226// ─────────────────────────────────────────────────────────────────────────────
227// Distortion helper
228// ─────────────────────────────────────────────────────────────────────────────
229
230/// Apply Brown–Conrady radial + tangential distortion to normalised image coords.
231#[inline]
232fn apply_distortion(k1: f64, k2: f64, p1: f64, p2: f64, xn: f64, yn: f64) -> (f64, f64) {
233    let r2 = xn * xn + yn * yn;
234    let rad = 1.0 + k1 * r2 + k2 * r2 * r2;
235    let xd = xn * rad + 2.0 * p1 * xn * yn + p2 * (r2 + 2.0 * xn * xn);
236    let yd = yn * rad + p1 * (r2 + 2.0 * yn * yn) + 2.0 * p2 * xn * yn;
237    (xd, yd)
238}
239
240// ─────────────────────────────────────────────────────────────────────────────
241// StereoPair
242// ─────────────────────────────────────────────────────────────────────────────
243
244/// A rectified stereo camera pair with a known relative pose.
245///
246/// The relative pose satisfies: `P_right = R * P_left + t`, where `P` denotes
247/// a 3-D point expressed in each camera's coordinate frame.
248#[derive(Debug, Clone)]
249pub struct StereoPair {
250    /// Left camera intrinsics and distortion.
251    pub left: PinholeCamera,
252    /// Right camera intrinsics and distortion.
253    pub right: PinholeCamera,
254    /// 3×3 rotation matrix from left to right camera frame.
255    pub rotation: Array2<f64>,
256    /// 3-vector translation from left to right camera frame (metres).
257    pub translation: Array1<f64>,
258}
259
260impl StereoPair {
261    /// Create a new `StereoPair`.
262    ///
263    /// # Arguments
264    ///
265    /// * `left`        – Left camera intrinsics/distortion.
266    /// * `right`       – Right camera intrinsics/distortion.
267    /// * `rotation`    – 3×3 rotation matrix `R` (right = R * left + t).
268    /// * `translation` – 3-vector translation `t`.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`VisionError::InvalidParameter`] when `rotation` is not 3×3 or
273    /// `translation` does not have length 3.
274    pub fn new(
275        left: PinholeCamera,
276        right: PinholeCamera,
277        rotation: Array2<f64>,
278        translation: Array1<f64>,
279    ) -> Result<Self> {
280        if rotation.shape() != [3, 3] {
281            return Err(VisionError::InvalidParameter(
282                "rotation must be a 3×3 matrix".to_string(),
283            ));
284        }
285        if translation.len() != 3 {
286            return Err(VisionError::InvalidParameter(
287                "translation must have length 3".to_string(),
288            ));
289        }
290        Ok(Self {
291            left,
292            right,
293            rotation,
294            translation,
295        })
296    }
297
298    /// Approximate stereo baseline (‖t‖).
299    pub fn baseline(&self) -> f64 {
300        self.translation.iter().map(|&v| v * v).sum::<f64>().sqrt()
301    }
302
303    /// Project a 3-D point (in the **left** camera frame) to both image planes.
304    ///
305    /// Returns `Ok(([ul, vl], [ur, vr]))` on success.
306    pub fn project_both(&self, point_left: &[f64; 3]) -> Result<([f64; 2], [f64; 2])> {
307        // Left projection.
308        let px_l = self.left.project(point_left)?;
309
310        // Transform to right frame: P_r = R*P_l + t.
311        let r = &self.rotation;
312        let t = &self.translation;
313        let x = r[[0, 0]] * point_left[0]
314            + r[[0, 1]] * point_left[1]
315            + r[[0, 2]] * point_left[2]
316            + t[0];
317        let y = r[[1, 0]] * point_left[0]
318            + r[[1, 1]] * point_left[1]
319            + r[[1, 2]] * point_left[2]
320            + t[1];
321        let z = r[[2, 0]] * point_left[0]
322            + r[[2, 1]] * point_left[1]
323            + r[[2, 2]] * point_left[2]
324            + t[2];
325
326        let px_r = self.right.project(&[x, y, z])?;
327        Ok((px_l, px_r))
328    }
329}
330
331// ─────────────────────────────────────────────────────────────────────────────
332// Tests
333// ─────────────────────────────────────────────────────────────────────────────
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_project_principal_axis() {
341        let cam = PinholeCamera::ideal(800.0, 800.0, 320.0, 240.0);
342        let px = cam
343            .project(&[0.0, 0.0, 1.0])
344            .expect("project should succeed for point in front of camera");
345        assert!((px[0] - 320.0).abs() < 1e-9, "u = {}", px[0]);
346        assert!((px[1] - 240.0).abs() < 1e-9, "v = {}", px[1]);
347    }
348
349    #[test]
350    fn test_project_behind_camera() {
351        let cam = PinholeCamera::ideal(800.0, 800.0, 320.0, 240.0);
352        assert!(cam.project(&[0.0, 0.0, 0.0]).is_err());
353        assert!(cam.project(&[0.0, 0.0, -1.0]).is_err());
354    }
355
356    #[test]
357    fn test_backproject_roundtrip() {
358        let cam = PinholeCamera::ideal(800.0, 800.0, 320.0, 240.0);
359        let pt3d = [1.0, -0.5, 3.0];
360        let px = cam
361            .project(&pt3d)
362            .expect("project should succeed for valid 3D point");
363        let back = cam.backproject(&px, pt3d[2]);
364        assert!((back[0] - pt3d[0]).abs() < 1e-9, "X = {}", back[0]);
365        assert!((back[1] - pt3d[1]).abs() < 1e-9, "Y = {}", back[1]);
366        assert!((back[2] - pt3d[2]).abs() < 1e-9, "Z = {}", back[2]);
367    }
368
369    #[test]
370    fn test_distorted_on_axis() {
371        // Points on the principal axis should be unaffected by distortion.
372        let cam = PinholeCamera::new(800.0, 800.0, 320.0, 240.0, 0.2, 0.05, 0.001, 0.001);
373        let ideal = cam
374            .project(&[0.0, 0.0, 1.0])
375            .expect("project should succeed for principal axis");
376        let dist = cam
377            .project_distorted(&[0.0, 0.0, 1.0])
378            .expect("project_distorted should succeed for principal axis");
379        assert!((ideal[0] - dist[0]).abs() < 1e-12);
380        assert!((ideal[1] - dist[1]).abs() < 1e-12);
381    }
382
383    #[test]
384    fn test_distorted_off_axis() {
385        // With radial distortion the distorted pixel should differ from ideal.
386        let cam = PinholeCamera::new(800.0, 800.0, 320.0, 240.0, 0.2, 0.0, 0.0, 0.0);
387        let ideal = cam
388            .project(&[1.0, 1.0, 2.0])
389            .expect("project should succeed for off-axis point");
390        let dist = cam
391            .project_distorted(&[1.0, 1.0, 2.0])
392            .expect("project_distorted should succeed for off-axis point");
393        // k1 > 0 → barrel distortion → pixel moves outward from principal point.
394        assert!(
395            (dist[0] - 320.0).abs() > (ideal[0] - 320.0).abs(),
396            "dist.u={}, ideal.u={}",
397            dist[0],
398            ideal[0]
399        );
400    }
401
402    #[test]
403    fn test_undistort_point_principal_axis() {
404        let cam = PinholeCamera::new(800.0, 800.0, 320.0, 240.0, 0.1, 0.02, 0.0, 0.0);
405        let undist = cam.undistort_point(&[320.0, 240.0]);
406        assert!((undist[0] - 320.0).abs() < 1e-9);
407        assert!((undist[1] - 240.0).abs() < 1e-9);
408    }
409
410    #[test]
411    fn test_intrinsic_matrix() {
412        let cam = PinholeCamera::ideal(400.0, 500.0, 200.0, 150.0);
413        let k = cam.intrinsic_matrix();
414        assert_eq!(k.shape(), &[3, 3]);
415        assert!((k[[0, 0]] - 400.0).abs() < 1e-12);
416        assert!((k[[1, 1]] - 500.0).abs() < 1e-12);
417        assert!((k[[0, 2]] - 200.0).abs() < 1e-12);
418        assert!((k[[1, 2]] - 150.0).abs() < 1e-12);
419        assert!((k[[2, 2]] - 1.0).abs() < 1e-12);
420        assert!((k[[0, 1]]).abs() < 1e-12); // skew = 0
421    }
422
423    #[test]
424    fn test_stereo_pair_baseline() {
425        use scirs2_core::ndarray::{Array1, Array2};
426        let cam = PinholeCamera::ideal(800.0, 800.0, 320.0, 240.0);
427        let r = Array2::<f64>::eye(3);
428        let t = Array1::from_vec(vec![-0.1, 0.0, 0.0]);
429        let stereo = StereoPair::new(cam.clone(), cam, r, t)
430            .expect("StereoPair::new should succeed with valid inputs");
431        assert!((stereo.baseline() - 0.1).abs() < 1e-9);
432    }
433}