Skip to main content

ringgrid/pixelmap/
cameramodel.rs

1use super::{PixelMapper, RadialTangentialDistortion, UndistortConfig};
2use serde::{Deserialize, Serialize};
3
4/// Pinhole camera intrinsics.
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
6pub struct CameraIntrinsics {
7    /// Focal length in x (pixels).
8    pub fx: f64,
9    /// Focal length in y (pixels).
10    pub fy: f64,
11    /// Principal point x (pixels).
12    pub cx: f64,
13    /// Principal point y (pixels).
14    pub cy: f64,
15}
16
17impl CameraIntrinsics {
18    /// Returns `true` when focal lengths are finite and non-zero.
19    pub fn is_valid(self) -> bool {
20        self.fx.is_finite()
21            && self.fy.is_finite()
22            && self.cx.is_finite()
23            && self.cy.is_finite()
24            && self.fx.abs() > 1e-12
25            && self.fy.abs() > 1e-12
26    }
27
28    /// Convert pixel coordinates to normalized pinhole coordinates.
29    pub fn pixel_to_normalized(self, pixel_xy: [f64; 2]) -> Option<[f64; 2]> {
30        if !self.is_valid() {
31            return None;
32        }
33        let x = (pixel_xy[0] - self.cx) / self.fx;
34        let y = (pixel_xy[1] - self.cy) / self.fy;
35        if x.is_finite() && y.is_finite() {
36            Some([x, y])
37        } else {
38            None
39        }
40    }
41
42    /// Convert normalized pinhole coordinates to pixel coordinates.
43    pub fn normalized_to_pixel(self, normalized_xy: [f64; 2]) -> [f64; 2] {
44        [
45            self.fx * normalized_xy[0] + self.cx,
46            self.fy * normalized_xy[1] + self.cy,
47        ]
48    }
49}
50
51/// Complete camera model (intrinsics + radial-tangential distortion).
52///
53/// Implements [`PixelMapper`], making it usable directly with
54/// [`Detector::detect_with_mapper`](crate::Detector::detect_with_mapper)
55/// for distortion-aware detection.
56///
57/// # Example
58///
59/// ```no_run
60/// use ringgrid::{BoardLayout, CameraIntrinsics, CameraModel,
61///                Detector, RadialTangentialDistortion};
62/// use std::path::Path;
63///
64/// let camera = CameraModel {
65///     intrinsics: CameraIntrinsics {
66///         fx: 900.0, fy: 900.0, cx: 640.0, cy: 480.0,
67///     },
68///     distortion: RadialTangentialDistortion {
69///         k1: -0.15, k2: 0.05, p1: 0.0, p2: 0.0, k3: 0.0,
70///     },
71/// };
72///
73/// let board = BoardLayout::from_json_file(Path::new("target.json")).unwrap();
74/// let detector = Detector::new(board);
75/// let image = image::open("photo.png").unwrap().to_luma8();
76/// let result = detector.detect_with_mapper(&image, &camera);
77/// ```
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
79pub struct CameraModel {
80    /// Camera intrinsics.
81    pub intrinsics: CameraIntrinsics,
82    /// Distortion coefficients.
83    pub distortion: RadialTangentialDistortion,
84}
85
86impl CameraModel {
87    /// Distort an undistorted pixel point into image pixel coordinates.
88    pub fn distort_pixel(self, undistorted_pixel_xy: [f64; 2]) -> Option<[f64; 2]> {
89        let xn = self.intrinsics.pixel_to_normalized(undistorted_pixel_xy)?;
90        let xd = self.distortion.distort_normalized(xn);
91        let pix = self.intrinsics.normalized_to_pixel(xd);
92        if pix[0].is_finite() && pix[1].is_finite() {
93            Some(pix)
94        } else {
95            None
96        }
97    }
98
99    /// Undistort a pixel point with default iterative settings.
100    pub fn undistort_pixel(self, distorted_pixel_xy: [f64; 2]) -> Option<[f64; 2]> {
101        self.undistort_pixel_with(distorted_pixel_xy, UndistortConfig::default())
102    }
103
104    /// Undistort a pixel point with custom iterative settings.
105    pub fn undistort_pixel_with(
106        self,
107        distorted_pixel_xy: [f64; 2],
108        cfg: UndistortConfig,
109    ) -> Option<[f64; 2]> {
110        let xd = self.intrinsics.pixel_to_normalized(distorted_pixel_xy)?;
111        let mut x = xd[0];
112        let mut y = xd[1];
113
114        for _ in 0..cfg.max_iters.max(1) {
115            let r2 = x * x + y * y;
116            let r4 = r2 * r2;
117            let r6 = r4 * r2;
118            let radial =
119                1.0 + self.distortion.k1 * r2 + self.distortion.k2 * r4 + self.distortion.k3 * r6;
120            if !radial.is_finite() || radial.abs() < 1e-12 {
121                return None;
122            }
123
124            let dx_tan = 2.0 * self.distortion.p1 * x * y + self.distortion.p2 * (r2 + 2.0 * x * x);
125            let dy_tan = self.distortion.p1 * (r2 + 2.0 * y * y) + 2.0 * self.distortion.p2 * x * y;
126            let x_next = (xd[0] - dx_tan) / radial;
127            let y_next = (xd[1] - dy_tan) / radial;
128
129            if !x_next.is_finite() || !y_next.is_finite() {
130                return None;
131            }
132
133            let dx = x_next - x;
134            let dy = y_next - y;
135            x = x_next;
136            y = y_next;
137
138            if (dx * dx + dy * dy).sqrt() <= cfg.eps.max(0.0) {
139                break;
140            }
141        }
142
143        let out = self.intrinsics.normalized_to_pixel([x, y]);
144        if out[0].is_finite() && out[1].is_finite() {
145            Some(out)
146        } else {
147            None
148        }
149    }
150}
151
152impl PixelMapper for CameraModel {
153    fn image_to_working_pixel(&self, image_xy: [f64; 2]) -> Option<[f64; 2]> {
154        self.undistort_pixel(image_xy)
155    }
156
157    fn working_to_image_pixel(&self, working_xy: [f64; 2]) -> Option<[f64; 2]> {
158        self.distort_pixel(working_xy)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn sample_camera() -> CameraModel {
167        CameraModel {
168            intrinsics: CameraIntrinsics {
169                fx: 900.0,
170                fy: 920.0,
171                cx: 640.0,
172                cy: 480.0,
173            },
174            distortion: RadialTangentialDistortion {
175                k1: -0.12,
176                k2: 0.03,
177                p1: 0.001,
178                p2: -0.0008,
179                k3: 0.0,
180            },
181        }
182    }
183
184    #[test]
185    fn intrinsics_validation_rejects_zero_focal() {
186        let k = CameraIntrinsics {
187            fx: 0.0,
188            fy: 500.0,
189            cx: 0.0,
190            cy: 0.0,
191        };
192        assert!(!k.is_valid());
193        assert!(k.pixel_to_normalized([100.0, 100.0]).is_none());
194    }
195
196    #[test]
197    fn zero_distortion_roundtrip_is_exact() {
198        let cam = CameraModel {
199            intrinsics: CameraIntrinsics {
200                fx: 800.0,
201                fy: 820.0,
202                cx: 640.0,
203                cy: 480.0,
204            },
205            distortion: RadialTangentialDistortion::default(),
206        };
207        let p = [300.25, 210.75];
208        let d = cam.distort_pixel(p).unwrap();
209        let u = cam.undistort_pixel(d).unwrap();
210        assert!((u[0] - p[0]).abs() < 1e-12);
211        assert!((u[1] - p[1]).abs() < 1e-12);
212    }
213
214    #[test]
215    fn roundtrip_with_distortion_is_stable() {
216        let cam = sample_camera();
217        let p = [250.0, 180.0];
218        let d = cam.distort_pixel(p).unwrap();
219        let u = cam.undistort_pixel(d).unwrap();
220        assert!((u[0] - p[0]).abs() < 1e-5, "x={}, p={}", u[0], p[0]);
221        assert!((u[1] - p[1]).abs() < 1e-5, "y={}, p={}", u[1], p[1]);
222    }
223}