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}