Skip to main content

yscv_imgproc/ops/
calibration.rs

1/// Camera intrinsic parameters including radial and tangential distortion coefficients.
2#[derive(Debug, Clone, PartialEq)]
3pub struct CameraIntrinsics {
4    /// Focal length in x (pixels).
5    pub fx: f32,
6    /// Focal length in y (pixels).
7    pub fy: f32,
8    /// Principal point x (pixels).
9    pub cx: f32,
10    /// Principal point y (pixels).
11    pub cy: f32,
12    /// First radial distortion coefficient.
13    pub k1: f32,
14    /// Second radial distortion coefficient.
15    pub k2: f32,
16    /// First tangential distortion coefficient.
17    pub p1: f32,
18    /// Second tangential distortion coefficient.
19    pub p2: f32,
20}
21
22impl CameraIntrinsics {
23    /// Create intrinsics with zero distortion.
24    pub fn new(fx: f32, fy: f32, cx: f32, cy: f32) -> Self {
25        Self {
26            fx,
27            fy,
28            cx,
29            cy,
30            k1: 0.0,
31            k2: 0.0,
32            p1: 0.0,
33            p2: 0.0,
34        }
35    }
36}
37
38/// Remove lens distortion from a set of 2-D pixel coordinates.
39///
40/// Each point is first normalised to camera coordinates, the Brown–Conrady
41/// distortion model is applied in reverse (one Newton-style iteration is
42/// typically sufficient for small distortion), and the result is projected
43/// back to pixel coordinates.
44pub fn undistort_points(points: &[(f32, f32)], intrinsics: &CameraIntrinsics) -> Vec<(f32, f32)> {
45    let CameraIntrinsics {
46        fx,
47        fy,
48        cx,
49        cy,
50        k1,
51        k2,
52        p1,
53        p2,
54    } = *intrinsics;
55
56    points
57        .iter()
58        .map(|&(px, py)| {
59            // Normalise to camera coords.
60            let x = (px - cx) / fx;
61            let y = (py - cy) / fy;
62
63            // Iterative undistortion (5 iterations).
64            let mut xd = x;
65            let mut yd = y;
66            for _ in 0..5 {
67                let r2 = xd * xd + yd * yd;
68                let radial = 1.0 + k1 * r2 + k2 * r2 * r2;
69                let dx = 2.0 * p1 * xd * yd + p2 * (r2 + 2.0 * xd * xd);
70                let dy = p1 * (r2 + 2.0 * yd * yd) + 2.0 * p2 * xd * yd;
71                xd = (x - dx) / radial;
72                yd = (y - dy) / radial;
73            }
74
75            (xd * fx + cx, yd * fy + cy)
76        })
77        .collect()
78}
79
80/// Project 3-D world points to 2-D pixel coordinates using a pinhole camera model
81/// (no extrinsic rotation/translation — assumes camera-frame coordinates).
82pub fn project_points(
83    points_3d: &[(f32, f32, f32)],
84    intrinsics: &CameraIntrinsics,
85) -> Vec<(f32, f32)> {
86    let CameraIntrinsics {
87        fx,
88        fy,
89        cx,
90        cy,
91        k1,
92        k2,
93        p1,
94        p2,
95    } = *intrinsics;
96
97    points_3d
98        .iter()
99        .map(|&(x, y, z)| {
100            let xn = x / z;
101            let yn = y / z;
102
103            let r2 = xn * xn + yn * yn;
104            let radial = 1.0 + k1 * r2 + k2 * r2 * r2;
105            let xd = xn * radial + 2.0 * p1 * xn * yn + p2 * (r2 + 2.0 * xn * xn);
106            let yd = yn * radial + p1 * (r2 + 2.0 * yn * yn) + 2.0 * p2 * xn * yn;
107
108            (fx * xd + cx, fy * yd + cy)
109        })
110        .collect()
111}
112
113/// Triangulate 3-D points from corresponding 2-D observations in a rectified
114/// stereo pair.
115///
116/// Uses the disparity `d = pts_a.x - pts_b.x` together with the known
117/// `baseline` (distance between cameras) and shared `focal` length to recover
118/// depth:
119///
120/// ```text
121/// Z = baseline * focal / disparity
122/// X = (u - cx) * Z / focal   (cx assumed 0 here)
123/// Y = (v - cy) * Z / focal   (cy assumed 0 here)
124/// ```
125///
126/// Points with zero or negative disparity are placed at the origin `(0, 0, 0)`.
127pub fn triangulate_points(
128    pts_a: &[(f32, f32)],
129    pts_b: &[(f32, f32)],
130    baseline: f32,
131    focal: f32,
132) -> Vec<(f32, f32, f32)> {
133    pts_a
134        .iter()
135        .zip(pts_b.iter())
136        .map(|(&(ax, ay), &(bx, _by))| {
137            let d = ax - bx;
138            if d <= 0.0 {
139                return (0.0, 0.0, 0.0);
140            }
141            let z = baseline * focal / d;
142            let x = ax * z / focal;
143            let y = ay * z / focal;
144            (x, y, z)
145        })
146        .collect()
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
154        (a - b).abs() < eps
155    }
156
157    #[test]
158    fn test_project_points_no_distortion() {
159        let intr = CameraIntrinsics::new(500.0, 500.0, 320.0, 240.0);
160        let pts = vec![(0.0f32, 0.0, 1.0)];
161        let proj = project_points(&pts, &intr);
162        assert!(approx_eq(proj[0].0, 320.0, 1e-5));
163        assert!(approx_eq(proj[0].1, 240.0, 1e-5));
164    }
165
166    #[test]
167    fn test_project_and_undistort_roundtrip_no_distortion() {
168        let intr = CameraIntrinsics::new(600.0, 600.0, 300.0, 200.0);
169        let pts_3d = vec![(1.0f32, -0.5, 3.0), (-2.0, 1.0, 5.0)];
170        let projected = project_points(&pts_3d, &intr);
171        // With zero distortion, undistort should be identity.
172        let undistorted = undistort_points(&projected, &intr);
173        for (p, u) in projected.iter().zip(undistorted.iter()) {
174            assert!(approx_eq(p.0, u.0, 1e-4));
175            assert!(approx_eq(p.1, u.1, 1e-4));
176        }
177    }
178
179    #[test]
180    fn test_undistort_with_distortion() {
181        let intr = CameraIntrinsics {
182            fx: 500.0,
183            fy: 500.0,
184            cx: 320.0,
185            cy: 240.0,
186            k1: 0.1,
187            k2: 0.01,
188            p1: 0.0,
189            p2: 0.0,
190        };
191        // A point at the principal point should be unchanged regardless of distortion.
192        let pts = vec![(320.0f32, 240.0)];
193        let result = undistort_points(&pts, &intr);
194        assert!(approx_eq(result[0].0, 320.0, 1e-4));
195        assert!(approx_eq(result[0].1, 240.0, 1e-4));
196    }
197
198    #[test]
199    fn test_triangulate_known_depth() {
200        // baseline=0.1m, focal=500px, disparity=50px → Z = 0.1*500/50 = 1.0
201        let pts_a = vec![(250.0f32, 100.0)];
202        let pts_b = vec![(200.0f32, 100.0)];
203        let result = triangulate_points(&pts_a, &pts_b, 0.1, 500.0);
204        assert!(approx_eq(result[0].2, 1.0, 1e-5));
205        // X = 250 * 1.0 / 500 = 0.5
206        assert!(approx_eq(result[0].0, 0.5, 1e-5));
207    }
208
209    #[test]
210    fn test_triangulate_zero_disparity() {
211        let pts_a = vec![(100.0f32, 100.0)];
212        let pts_b = vec![(100.0f32, 100.0)];
213        let result = triangulate_points(&pts_a, &pts_b, 0.1, 500.0);
214        assert!(approx_eq(result[0].0, 0.0, 1e-5));
215        assert!(approx_eq(result[0].1, 0.0, 1e-5));
216        assert!(approx_eq(result[0].2, 0.0, 1e-5));
217    }
218}