Skip to main content

locus_core/
camera.rs

1//! Zero-cost camera distortion model abstractions.
2//!
3//! This module defines the [`CameraModel`] trait and its three concrete implementations:
4//! - [`PinholeModel`]: Ideal pinhole (no distortion). `IS_RECTIFIED = true` causes the
5//!   compiler to eliminate all distortion branches in the hot path.
6//! - [`BrownConradyModel`]: Standard polynomial radial + tangential distortion (OpenCV convention).
7//! - [`KannalaBrandtModel`]: Equidistant fisheye projection model.
8//!
9//! All models operate on **normalized image coordinates** `(xn, yn)` — i.e., after
10//! dividing pixel coordinates by the focal lengths and subtracting the principal point:
11//! `xn = (px - cx) / fx`, `yn = (py - cy) / fy`.
12
13/// A compile-time abstraction over camera distortion models.
14///
15/// Monomorphizing on this trait allows the compiler to completely eliminate all
16/// distortion code when `IS_RECTIFIED = true` (e.g., for [`PinholeModel`]),
17/// leaving zero overhead for the common rectified-image case.
18pub trait CameraModel: Copy + Send + Sync + 'static {
19    /// True iff the camera produces a rectified (undistorted) image.
20    ///
21    /// When `true`, the compiler can statically prove that `distort` and `undistort`
22    /// are identity functions and will eliminate all branches guarded by `!C::IS_RECTIFIED`.
23    const IS_RECTIFIED: bool;
24
25    /// Map normalized ideal (undistorted) coordinates `(xn, yn)` to normalized
26    /// distorted coordinates `(xd, yd)`.
27    fn distort(&self, xn: f64, yn: f64) -> [f64; 2];
28
29    /// Map normalized distorted (observed) coordinates `(xd, yd)` back to normalized
30    /// ideal (undistorted) coordinates `(xu, yu)`.
31    fn undistort(&self, xd: f64, yd: f64) -> [f64; 2];
32
33    /// Compute the 2×2 Jacobian of the distortion map at `(xn, yn)`.
34    ///
35    /// Returns `[[∂xd/∂xn, ∂xd/∂yn], [∂yd/∂xn, ∂yd/∂yn]]`.
36    ///
37    /// Used in the LM Jacobian to correctly account for distortion derivatives.
38    fn distort_jacobian(&self, xn: f64, yn: f64) -> [[f64; 2]; 2];
39}
40
41// ---------------------------------------------------------------------------
42// PinholeModel — ideal rectified camera, zero runtime cost
43// ---------------------------------------------------------------------------
44
45/// Ideal pinhole camera model (no distortion).
46///
47/// All methods are `#[inline]` no-ops. The compiler eliminates every
48/// code path guarded by `!C::IS_RECTIFIED` at compile time, producing
49/// zero-overhead monomorphized code for the standard rectified-image case.
50#[derive(Clone, Copy, Debug, Default)]
51pub struct PinholeModel;
52
53impl CameraModel for PinholeModel {
54    const IS_RECTIFIED: bool = true;
55
56    #[inline]
57    fn distort(&self, xn: f64, yn: f64) -> [f64; 2] {
58        [xn, yn]
59    }
60
61    #[inline]
62    fn undistort(&self, xd: f64, yd: f64) -> [f64; 2] {
63        [xd, yd]
64    }
65
66    #[inline]
67    fn distort_jacobian(&self, _xn: f64, _yn: f64) -> [[f64; 2]; 2] {
68        [[1.0, 0.0], [0.0, 1.0]]
69    }
70}
71
72// ---------------------------------------------------------------------------
73// BrownConradyModel — standard polynomial radial + tangential distortion
74// ---------------------------------------------------------------------------
75
76#[cfg(feature = "non_rectified")]
77/// Brown-Conrady (OpenCV) lens distortion model.
78///
79/// Distortion formula (operating on normalized coordinates):
80/// ```text
81/// r² = xn² + yn²
82/// radial = 1 + k1·r² + k2·r⁴ + k3·r⁶
83/// xd = xn·radial + 2·p1·xn·yn + p2·(r² + 2·xn²)
84/// yd = yn·radial + p1·(r² + 2·yn²) + 2·p2·xn·yn
85/// ```
86///
87/// Coefficient ordering matches OpenCV's `distCoeffs` convention: `[k1, k2, p1, p2, k3]`.
88#[derive(Clone, Copy, Debug)]
89pub struct BrownConradyModel {
90    /// Radial distortion coefficient k1.
91    pub k1: f64,
92    /// Radial distortion coefficient k2.
93    pub k2: f64,
94    /// Tangential distortion coefficient p1.
95    pub p1: f64,
96    /// Tangential distortion coefficient p2.
97    pub p2: f64,
98    /// Radial distortion coefficient k3.
99    pub k3: f64,
100}
101
102#[cfg(feature = "non_rectified")]
103impl BrownConradyModel {
104    /// Construct from a flat coefficient slice `[k1, k2, p1, p2, k3]`.
105    ///
106    /// # Errors
107    /// Returns an error string if `coeffs.len() != 5`.
108    pub fn from_coeffs(coeffs: &[f64]) -> Result<Self, &'static str> {
109        if coeffs.len() != 5 {
110            return Err("BrownConrady requires exactly 5 coefficients: [k1, k2, p1, p2, k3]");
111        }
112        Ok(Self {
113            k1: coeffs[0],
114            k2: coeffs[1],
115            p1: coeffs[2],
116            p2: coeffs[3],
117            k3: coeffs[4],
118        })
119    }
120}
121
122#[cfg(feature = "non_rectified")]
123impl CameraModel for BrownConradyModel {
124    const IS_RECTIFIED: bool = false;
125
126    fn distort(&self, xn: f64, yn: f64) -> [f64; 2] {
127        let r2 = xn * xn + yn * yn;
128        let r4 = r2 * r2;
129        let r6 = r2 * r4;
130        let radial = 1.0 + self.k1 * r2 + self.k2 * r4 + self.k3 * r6;
131        let xd = xn * radial + 2.0 * self.p1 * xn * yn + self.p2 * (r2 + 2.0 * xn * xn);
132        let yd = yn * radial + self.p1 * (r2 + 2.0 * yn * yn) + 2.0 * self.p2 * xn * yn;
133        [xd, yd]
134    }
135
136    fn undistort(&self, xd: f64, yd: f64) -> [f64; 2] {
137        // Iterative Newton refinement starting from the distorted point as initial guess.
138        // 5 iterations is sufficient for sub-pixel accuracy under typical distortion magnitudes.
139        let mut xu = xd;
140        let mut yu = yd;
141        for _ in 0..5 {
142            let r2 = xu * xu + yu * yu;
143            let r4 = r2 * r2;
144            let r6 = r2 * r4;
145            let radial = 1.0 + self.k1 * r2 + self.k2 * r4 + self.k3 * r6;
146            let dx = 2.0 * self.p1 * xu * yu + self.p2 * (r2 + 2.0 * xu * xu);
147            let dy = self.p1 * (r2 + 2.0 * yu * yu) + 2.0 * self.p2 * xu * yu;
148            let xu_new = (xd - dx) / radial.max(1e-8);
149            let yu_new = (yd - dy) / radial.max(1e-8);
150            let converged = (xu_new - xu).abs() < 1e-12 && (yu_new - yu).abs() < 1e-12;
151            xu = xu_new;
152            yu = yu_new;
153            if converged {
154                break;
155            }
156        }
157        [xu, yu]
158    }
159
160    #[allow(clippy::similar_names)]
161    fn distort_jacobian(&self, xn: f64, yn: f64) -> [[f64; 2]; 2] {
162        let r2 = xn * xn + yn * yn;
163        let r4 = r2 * r2;
164        let r6 = r2 * r4;
165        let radial = 1.0 + self.k1 * r2 + self.k2 * r4 + self.k3 * r6;
166        // ∂radial/∂r² = k1 + 2·k2·r² + 3·k3·r⁴
167        let d_radial_dr2 = self.k1 + 2.0 * self.k2 * r2 + 3.0 * self.k3 * r4;
168
169        // ∂xd/∂xn:
170        //  xd = xn·radial + 2p1·xn·yn + p2·(r²+2xn²)
171        //  ∂xd/∂xn = radial + xn·(∂radial/∂xn) + 2p1·yn + p2·(2xn + 4xn)
172        //           = radial + 2xn²·d_radial_dr2 + 2p1·yn + 6p2·xn
173        let dxd_dxn =
174            radial + 2.0 * xn * xn * d_radial_dr2 + 2.0 * self.p1 * yn + 6.0 * self.p2 * xn;
175
176        // ∂xd/∂yn:
177        //  ∂xd/∂yn = xn·(∂radial/∂yn) + 2p1·xn + p2·2yn
178        //           = 2xn·yn·d_radial_dr2 + 2p1·xn + 2p2·yn
179        let dxd_dyn = 2.0 * xn * yn * d_radial_dr2 + 2.0 * self.p1 * xn + 2.0 * self.p2 * yn;
180
181        // ∂yd/∂xn:
182        //  yd = yn·radial + p1·(r²+2yn²) + 2p2·xn·yn
183        //  ∂yd/∂xn = yn·(∂radial/∂xn) + p1·2xn + 2p2·yn
184        //           = 2xn·yn·d_radial_dr2 + 2p1·xn + 2p2·yn
185        let dyd_dxn = 2.0 * xn * yn * d_radial_dr2 + 2.0 * self.p1 * xn + 2.0 * self.p2 * yn;
186
187        // ∂yd/∂yn:
188        //  ∂yd/∂yn = radial + yn·(∂radial/∂yn) + p1·(2yn+4yn) + 2p2·xn
189        //           = radial + 2yn²·d_radial_dr2 + 6p1·yn + 2p2·xn
190        let dyd_dyn =
191            radial + 2.0 * yn * yn * d_radial_dr2 + 6.0 * self.p1 * yn + 2.0 * self.p2 * xn;
192
193        [[dxd_dxn, dxd_dyn], [dyd_dxn, dyd_dyn]]
194    }
195}
196
197// ---------------------------------------------------------------------------
198// KannalaBrandtModel — equidistant fisheye projection
199// ---------------------------------------------------------------------------
200
201#[cfg(feature = "non_rectified")]
202/// Kannala-Brandt equidistant fisheye camera model.
203///
204/// Projection formula (operating on normalized coordinates):
205/// ```text
206/// r     = √(xn² + yn²)
207/// θ     = atan(r)
208/// θ_d   = θ·(1 + k1·θ² + k2·θ⁴ + k3·θ⁶ + k4·θ⁸)
209/// xd    = (θ_d / r) · xn
210/// yd    = (θ_d / r) · yn
211/// ```
212///
213/// Coefficient ordering: `[k1, k2, k3, k4]`.
214#[derive(Clone, Copy, Debug)]
215pub struct KannalaBrandtModel {
216    /// Fisheye distortion coefficient k1.
217    pub k1: f64,
218    /// Fisheye distortion coefficient k2.
219    pub k2: f64,
220    /// Fisheye distortion coefficient k3.
221    pub k3: f64,
222    /// Fisheye distortion coefficient k4.
223    pub k4: f64,
224}
225
226#[cfg(feature = "non_rectified")]
227impl KannalaBrandtModel {
228    /// Construct from a flat coefficient slice `[k1, k2, k3, k4]`.
229    ///
230    /// # Errors
231    /// Returns an error string if `coeffs.len() != 4`.
232    pub fn from_coeffs(coeffs: &[f64]) -> Result<Self, &'static str> {
233        if coeffs.len() != 4 {
234            return Err("KannalaBrandt requires exactly 4 coefficients: [k1, k2, k3, k4]");
235        }
236        Ok(Self {
237            k1: coeffs[0],
238            k2: coeffs[1],
239            k3: coeffs[2],
240            k4: coeffs[3],
241        })
242    }
243
244    /// Evaluate the angle polynomial and its derivative at θ.
245    /// Returns `(θ_d, dθ_d/dθ)`.
246    #[inline]
247    fn angle_poly(&self, theta: f64) -> (f64, f64) {
248        let t2 = theta * theta;
249        let t4 = t2 * t2;
250        let t6 = t2 * t4;
251        let t8 = t4 * t4;
252        let theta_d = theta * (1.0 + self.k1 * t2 + self.k2 * t4 + self.k3 * t6 + self.k4 * t8);
253        let dtheta_d =
254            1.0 + 3.0 * self.k1 * t2 + 5.0 * self.k2 * t4 + 7.0 * self.k3 * t6 + 9.0 * self.k4 * t8;
255        (theta_d, dtheta_d)
256    }
257}
258
259#[cfg(feature = "non_rectified")]
260impl CameraModel for KannalaBrandtModel {
261    const IS_RECTIFIED: bool = false;
262
263    fn distort(&self, xn: f64, yn: f64) -> [f64; 2] {
264        let r = (xn * xn + yn * yn).sqrt();
265        if r < 1e-8 {
266            return [xn, yn];
267        }
268        // theta = atan(r) since the point is at depth z=1 in normalized coords
269        let theta = r.atan();
270        let (theta_d, _) = self.angle_poly(theta);
271        let scale = theta_d / r;
272        [xn * scale, yn * scale]
273    }
274
275    fn undistort(&self, xd: f64, yd: f64) -> [f64; 2] {
276        let r_d = (xd * xd + yd * yd).sqrt();
277        if r_d < 1e-8 {
278            return [xd, yd];
279        }
280        // Invert θ_d = poly(θ) via Newton's method to recover θ, then r = tan(θ).
281        let mut theta = r_d; // initial guess: identity
282        for _ in 0..10 {
283            let (theta_d, d_theta_d) = self.angle_poly(theta);
284            let f = theta_d - r_d;
285            if f.abs() < 1e-12 {
286                break;
287            }
288            theta -= f / d_theta_d.max(1e-8);
289            theta = theta.max(0.0);
290        }
291        // r_undistorted = tan(θ)
292        let r_undist = theta.tan();
293        let scale = r_undist / r_d;
294        [xd * scale, yd * scale]
295    }
296
297    #[allow(clippy::similar_names)]
298    fn distort_jacobian(&self, xn: f64, yn: f64) -> [[f64; 2]; 2] {
299        let r2 = xn * xn + yn * yn;
300        let r = r2.sqrt();
301        if r < 1e-8 {
302            // Near the optical axis, the equidistant model approaches identity.
303            return [[1.0, 0.0], [0.0, 1.0]];
304        }
305
306        let theta = r.atan();
307        let (theta_d, dtheta_d_dtheta) = self.angle_poly(theta);
308
309        // scale = θ_d / r
310        // ∂scale/∂xn = (∂θ_d/∂xn · r - θ_d · ∂r/∂xn) / r²
311        //
312        // ∂r/∂xn = xn / r
313        // ∂θ/∂r  = 1 / (1 + r²)
314        // ∂θ/∂xn = (xn / r) / (1 + r²)
315        // ∂θ_d/∂xn = dθ_d_dθ · ∂θ/∂xn = dθ_d_dθ · xn / (r · (1 + r²))
316
317        let one_plus_r2 = 1.0 + r2;
318        let dthetad_dxn = dtheta_d_dtheta * xn / (r * one_plus_r2);
319        let dthetad_dyn = dtheta_d_dtheta * yn / (r * one_plus_r2);
320
321        let dscale_dxn = (dthetad_dxn * r - theta_d * (xn / r)) / r2;
322        let dscale_dyn = (dthetad_dyn * r - theta_d * (yn / r)) / r2;
323
324        // xd = scale · xn  →  ∂xd/∂xn = scale + xn · ∂scale/∂xn
325        let dxd_dxn = theta_d / r + xn * dscale_dxn;
326        let dxd_dyn = xn * dscale_dyn;
327        let dyd_dxn = yn * dscale_dxn;
328        let dyd_dyn = theta_d / r + yn * dscale_dyn;
329
330        [[dxd_dxn, dxd_dyn], [dyd_dxn, dyd_dyn]]
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::expect_used, clippy::unwrap_used)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn pinhole_is_identity() {
341        let m = PinholeModel;
342        let [xd, yd] = m.distort(0.3, -0.2);
343        assert!((xd - 0.3).abs() < f64::EPSILON);
344        assert!((yd - (-0.2)).abs() < f64::EPSILON);
345        let [xu, yu] = m.undistort(0.3, -0.2);
346        assert!((xu - 0.3).abs() < f64::EPSILON);
347        assert!((yu - (-0.2)).abs() < f64::EPSILON);
348    }
349
350    #[cfg(feature = "non_rectified")]
351    mod non_rectified {
352        use super::*;
353
354        /// Round-trip identity: distort then undistort should recover the original point.
355        fn check_roundtrip<C: CameraModel>(model: &C, xn: f64, yn: f64, tol: f64) {
356            let [xd, yd] = model.distort(xn, yn);
357            let [xu, yu] = model.undistort(xd, yd);
358            assert!((xu - xn).abs() < tol, "xn round-trip failed: {xu} vs {xn}");
359            assert!((yu - yn).abs() < tol, "yn round-trip failed: {yu} vs {yn}");
360        }
361
362        /// Numerical Jacobian check via finite differences.
363        #[allow(clippy::similar_names)]
364        fn check_jacobian<C: CameraModel>(model: &C, xn: f64, yn: f64) {
365            let eps = 1e-6;
366            let jac = model.distort_jacobian(xn, yn);
367
368            let [x0, y0] = model.distort(xn, yn);
369            let [x1, _] = model.distort(xn + eps, yn);
370            let [_, y1] = model.distort(xn, yn + eps);
371            let [x2, _] = model.distort(xn, yn + eps);
372            let [_, y2] = model.distort(xn + eps, yn);
373
374            let num_dxd_dxn = (x1 - x0) / eps;
375            let num_dxd_dyn = (x2 - x0) / eps;
376            let num_dyd_dxn = (y2 - y0) / eps;
377            let num_dyd_dyn = (y1 - y0) / eps;
378
379            assert!(
380                (jac[0][0] - num_dxd_dxn).abs() < 1e-5,
381                "∂xd/∂xn: analytic={} numeric={num_dxd_dxn}",
382                jac[0][0]
383            );
384            assert!(
385                (jac[0][1] - num_dxd_dyn).abs() < 1e-5,
386                "∂xd/∂yn: analytic={} numeric={num_dxd_dyn}",
387                jac[0][1]
388            );
389            assert!(
390                (jac[1][0] - num_dyd_dxn).abs() < 1e-5,
391                "∂yd/∂xn: analytic={} numeric={num_dyd_dxn}",
392                jac[1][0]
393            );
394            assert!(
395                (jac[1][1] - num_dyd_dyn).abs() < 1e-5,
396                "∂yd/∂yn: analytic={} numeric={num_dyd_dyn}",
397                jac[1][1]
398            );
399        }
400
401        #[test]
402        fn brown_conrady_roundtrip() {
403            let m = BrownConradyModel {
404                k1: -0.3,
405                k2: 0.1,
406                p1: 0.001,
407                p2: -0.002,
408                k3: 0.0,
409            };
410            for &(xn, yn) in &[(0.1, 0.2), (-0.3, 0.15), (0.0, 0.4)] {
411                check_roundtrip(&m, xn, yn, 1e-7);
412            }
413        }
414
415        #[test]
416        fn brown_conrady_jacobian() {
417            let m = BrownConradyModel {
418                k1: -0.3,
419                k2: 0.1,
420                p1: 0.001,
421                p2: -0.002,
422                k3: 0.0,
423            };
424            for &(xn, yn) in &[(0.1, 0.2), (-0.3, 0.15), (0.05, -0.05)] {
425                check_jacobian(&m, xn, yn);
426            }
427        }
428
429        #[test]
430        fn kannala_brandt_roundtrip() {
431            let m = KannalaBrandtModel {
432                k1: 0.1,
433                k2: -0.01,
434                k3: 0.001,
435                k4: 0.0,
436            };
437            for &(xn, yn) in &[(0.1, 0.2), (-0.3, 0.15), (0.5, 0.5)] {
438                check_roundtrip(&m, xn, yn, 1e-7);
439            }
440        }
441
442        #[test]
443        fn kannala_brandt_jacobian() {
444            let m = KannalaBrandtModel {
445                k1: 0.1,
446                k2: -0.01,
447                k3: 0.001,
448                k4: 0.0,
449            };
450            for &(xn, yn) in &[(0.1, 0.2), (-0.3, 0.15), (0.3, -0.3)] {
451                check_jacobian(&m, xn, yn);
452            }
453        }
454
455        #[test]
456        fn brown_conrady_from_coeffs_validates_length() {
457            assert!(BrownConradyModel::from_coeffs(&[0.0; 4]).is_err());
458            assert!(BrownConradyModel::from_coeffs(&[0.0; 5]).is_ok());
459            assert!(BrownConradyModel::from_coeffs(&[0.0; 6]).is_err());
460        }
461
462        #[test]
463        fn kannala_brandt_from_coeffs_validates_length() {
464            assert!(KannalaBrandtModel::from_coeffs(&[0.0; 3]).is_err());
465            assert!(KannalaBrandtModel::from_coeffs(&[0.0; 4]).is_ok());
466            assert!(KannalaBrandtModel::from_coeffs(&[0.0; 5]).is_err());
467        }
468    }
469}