Skip to main content

oximedia_align/
distortion.rs

1//! Lens distortion correction.
2//!
3//! This module provides lens distortion modeling and correction:
4//!
5//! - Brown-Conrady radial distortion model
6//! - Tangential distortion
7//! - Fisheye lens model
8//! - Camera calibration
9//! - Real-time undistortion
10
11use crate::{AlignError, AlignResult, Point2D};
12use nalgebra::{Matrix3, Vector2};
13
14/// Camera intrinsic parameters
15#[derive(Debug, Clone)]
16pub struct CameraIntrinsics {
17    /// Focal length X (pixels)
18    pub fx: f64,
19    /// Focal length Y (pixels)
20    pub fy: f64,
21    /// Principal point X (pixels)
22    pub cx: f64,
23    /// Principal point Y (pixels)
24    pub cy: f64,
25}
26
27impl CameraIntrinsics {
28    /// Create new camera intrinsics
29    #[must_use]
30    pub fn new(fx: f64, fy: f64, cx: f64, cy: f64) -> Self {
31        Self { fx, fy, cx, cy }
32    }
33
34    /// Create from image dimensions (assuming centered principal point)
35    #[must_use]
36    pub fn from_image_size(width: usize, height: usize, fov_degrees: f64) -> Self {
37        let fov_rad = fov_degrees.to_radians();
38        let fx = (width as f64 / 2.0) / (fov_rad / 2.0).tan();
39        let fy = fx; // Square pixels
40        let cx = width as f64 / 2.0;
41        let cy = height as f64 / 2.0;
42
43        Self { fx, fy, cx, cy }
44    }
45
46    /// Get camera matrix K
47    #[must_use]
48    pub fn to_matrix(&self) -> Matrix3<f64> {
49        Matrix3::new(self.fx, 0.0, self.cx, 0.0, self.fy, self.cy, 0.0, 0.0, 1.0)
50    }
51
52    /// Convert pixel to normalized coordinates
53    #[must_use]
54    pub fn pixel_to_normalized(&self, pixel: &Point2D) -> Vector2<f64> {
55        Vector2::new((pixel.x - self.cx) / self.fx, (pixel.y - self.cy) / self.fy)
56    }
57
58    /// Convert normalized to pixel coordinates
59    #[must_use]
60    pub fn normalized_to_pixel(&self, normalized: &Vector2<f64>) -> Point2D {
61        Point2D::new(
62            normalized[0] * self.fx + self.cx,
63            normalized[1] * self.fy + self.cy,
64        )
65    }
66}
67
68/// Brown-Conrady distortion model
69#[derive(Debug, Clone)]
70pub struct BrownConradyDistortion {
71    /// Radial distortion coefficients [k1, k2, k3]
72    pub radial: [f64; 3],
73    /// Tangential distortion coefficients [p1, p2]
74    pub tangential: [f64; 2],
75}
76
77impl Default for BrownConradyDistortion {
78    fn default() -> Self {
79        Self {
80            radial: [0.0, 0.0, 0.0],
81            tangential: [0.0, 0.0],
82        }
83    }
84}
85
86impl BrownConradyDistortion {
87    /// Create new distortion model
88    #[must_use]
89    pub fn new(k1: f64, k2: f64, k3: f64, p1: f64, p2: f64) -> Self {
90        Self {
91            radial: [k1, k2, k3],
92            tangential: [p1, p2],
93        }
94    }
95
96    /// Apply distortion to normalized coordinates
97    #[must_use]
98    pub fn distort(&self, point: &Vector2<f64>) -> Vector2<f64> {
99        let x = point[0];
100        let y = point[1];
101        let r2 = x * x + y * y;
102        let r4 = r2 * r2;
103        let r6 = r4 * r2;
104
105        let k1 = self.radial[0];
106        let k2 = self.radial[1];
107        let k3 = self.radial[2];
108        let p1 = self.tangential[0];
109        let p2 = self.tangential[1];
110
111        // Radial distortion
112        let radial_distortion = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
113
114        // Tangential distortion
115        let x_tangential = 2.0 * p1 * x * y + p2 * (r2 + 2.0 * x * x);
116        let y_tangential = p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * x * y;
117
118        Vector2::new(
119            x * radial_distortion + x_tangential,
120            y * radial_distortion + y_tangential,
121        )
122    }
123
124    /// Remove distortion from normalized coordinates (iterative)
125    #[must_use]
126    pub fn undistort(&self, distorted: &Vector2<f64>) -> Vector2<f64> {
127        let mut undistorted = *distorted;
128
129        // Iterative refinement (typically converges in 5-10 iterations)
130        for _ in 0..10 {
131            let distorted_estimate = self.distort(&undistorted);
132            let error = distorted - distorted_estimate;
133
134            undistorted += error;
135
136            // Check convergence
137            if error.norm() < 1e-8 {
138                break;
139            }
140        }
141
142        undistorted
143    }
144}
145
146/// Fisheye distortion model
147#[derive(Debug, Clone)]
148pub struct FisheyeDistortion {
149    /// Fisheye distortion coefficients [k1, k2, k3, k4]
150    pub coefficients: [f64; 4],
151}
152
153impl Default for FisheyeDistortion {
154    fn default() -> Self {
155        Self {
156            coefficients: [0.0, 0.0, 0.0, 0.0],
157        }
158    }
159}
160
161impl FisheyeDistortion {
162    /// Create new fisheye distortion model
163    #[must_use]
164    pub fn new(k1: f64, k2: f64, k3: f64, k4: f64) -> Self {
165        Self {
166            coefficients: [k1, k2, k3, k4],
167        }
168    }
169
170    /// Apply fisheye distortion
171    #[must_use]
172    pub fn distort(&self, point: &Vector2<f64>) -> Vector2<f64> {
173        let x = point[0];
174        let y = point[1];
175        let r = (x * x + y * y).sqrt();
176
177        if r < 1e-10 {
178            return *point;
179        }
180
181        let theta = r.atan();
182        let theta2 = theta * theta;
183        let theta4 = theta2 * theta2;
184        let theta6 = theta4 * theta2;
185        let theta8 = theta6 * theta2;
186
187        let k1 = self.coefficients[0];
188        let k2 = self.coefficients[1];
189        let k3 = self.coefficients[2];
190        let k4 = self.coefficients[3];
191
192        let theta_d = theta * (1.0 + k1 * theta2 + k2 * theta4 + k3 * theta6 + k4 * theta8);
193
194        let scale = theta_d / r;
195
196        Vector2::new(x * scale, y * scale)
197    }
198
199    /// Remove fisheye distortion
200    #[must_use]
201    pub fn undistort(&self, distorted: &Vector2<f64>) -> Vector2<f64> {
202        let x = distorted[0];
203        let y = distorted[1];
204        let r = (x * x + y * y).sqrt();
205
206        if r < 1e-10 {
207            return *distorted;
208        }
209
210        // Iterative solution for theta
211        let mut theta = r;
212        for _ in 0..10 {
213            let theta2 = theta * theta;
214            let theta4 = theta2 * theta2;
215            let theta6 = theta4 * theta2;
216            let theta8 = theta6 * theta2;
217
218            let k1 = self.coefficients[0];
219            let k2 = self.coefficients[1];
220            let k3 = self.coefficients[2];
221            let k4 = self.coefficients[3];
222
223            let theta_d = theta * (1.0 + k1 * theta2 + k2 * theta4 + k3 * theta6 + k4 * theta8);
224            let error = theta_d - r;
225
226            if error.abs() < 1e-8 {
227                break;
228            }
229
230            // Newton's method derivative
231            let derivative =
232                1.0 + 3.0 * k1 * theta2 + 5.0 * k2 * theta4 + 7.0 * k3 * theta6 + 9.0 * k4 * theta8;
233
234            theta -= error / derivative;
235        }
236
237        let scale = theta.tan() / r;
238        Vector2::new(x * scale, y * scale)
239    }
240}
241
242/// Complete camera model with intrinsics and distortion
243pub struct CameraModel {
244    /// Camera intrinsics
245    pub intrinsics: CameraIntrinsics,
246    /// Distortion model
247    pub distortion: DistortionModel,
248}
249
250/// Distortion model variants
251#[derive(Debug, Clone)]
252pub enum DistortionModel {
253    /// No distortion
254    None,
255    /// Brown-Conrady model
256    BrownConrady(BrownConradyDistortion),
257    /// Fisheye model
258    Fisheye(FisheyeDistortion),
259}
260
261impl CameraModel {
262    /// Create new camera model
263    #[must_use]
264    pub fn new(intrinsics: CameraIntrinsics, distortion: DistortionModel) -> Self {
265        Self {
266            intrinsics,
267            distortion,
268        }
269    }
270
271    /// Project 3D point to pixel coordinates
272    #[must_use]
273    pub fn project(&self, point_3d: &nalgebra::Vector3<f64>) -> Point2D {
274        // Normalize by Z
275        let normalized = Vector2::new(point_3d[0] / point_3d[2], point_3d[1] / point_3d[2]);
276
277        // Apply distortion
278        let distorted = match &self.distortion {
279            DistortionModel::None => normalized,
280            DistortionModel::BrownConrady(d) => d.distort(&normalized),
281            DistortionModel::Fisheye(d) => d.distort(&normalized),
282        };
283
284        // Convert to pixels
285        self.intrinsics.normalized_to_pixel(&distorted)
286    }
287
288    /// Unproject pixel to normalized ray direction
289    #[must_use]
290    pub fn unproject(&self, pixel: &Point2D) -> Vector2<f64> {
291        // Convert to normalized coordinates
292        let distorted = self.intrinsics.pixel_to_normalized(pixel);
293
294        // Remove distortion
295        match &self.distortion {
296            DistortionModel::None => distorted,
297            DistortionModel::BrownConrady(d) => d.undistort(&distorted),
298            DistortionModel::Fisheye(d) => d.undistort(&distorted),
299        }
300    }
301}
302
303/// Image undistorter with precomputed lookup tables
304pub struct ImageUndistorter {
305    /// Camera model
306    pub camera: CameraModel,
307    /// Output width
308    pub width: usize,
309    /// Output height
310    pub height: usize,
311    /// Precomputed undistortion map (x coordinates)
312    map_x: Vec<f32>,
313    /// Precomputed undistortion map (y coordinates)
314    map_y: Vec<f32>,
315}
316
317impl ImageUndistorter {
318    /// Create new undistorter with precomputed maps
319    #[must_use]
320    pub fn new(camera: CameraModel, width: usize, height: usize) -> Self {
321        let mut map_x = vec![0.0; width * height];
322        let mut map_y = vec![0.0; width * height];
323
324        // Precompute undistortion map
325        for y in 0..height {
326            for x in 0..width {
327                let pixel = Point2D::new(x as f64, y as f64);
328                let undistorted = camera.unproject(&pixel);
329                let source = camera.intrinsics.normalized_to_pixel(&undistorted);
330
331                let idx = y * width + x;
332                map_x[idx] = source.x as f32;
333                map_y[idx] = source.y as f32;
334            }
335        }
336
337        Self {
338            camera,
339            width,
340            height,
341            map_x,
342            map_y,
343        }
344    }
345
346    /// Undistort an image using bilinear interpolation
347    ///
348    /// # Errors
349    /// Returns error if image size doesn't match
350    pub fn undistort(&self, input: &[u8], channels: usize) -> AlignResult<Vec<u8>> {
351        let expected_size = self.width * self.height * channels;
352        if input.len() != expected_size {
353            return Err(AlignError::InvalidConfig(format!(
354                "Input size {} doesn't match expected {}",
355                input.len(),
356                expected_size
357            )));
358        }
359
360        let mut output = vec![0u8; expected_size];
361
362        for y in 0..self.height {
363            for x in 0..self.width {
364                let idx = y * self.width + x;
365                let src_x = self.map_x[idx];
366                let src_y = self.map_y[idx];
367
368                // Bilinear interpolation
369                let x0 = src_x.floor() as isize;
370                let y0 = src_y.floor() as isize;
371                let x1 = x0 + 1;
372                let y1 = y0 + 1;
373
374                let dx = src_x - x0 as f32;
375                let dy = src_y - y0 as f32;
376
377                // Check bounds
378                if x0 >= 0 && x1 < self.width as isize && y0 >= 0 && y1 < self.height as isize {
379                    for c in 0..channels {
380                        let i00 = ((y0 as usize) * self.width + (x0 as usize)) * channels + c;
381                        let i10 = ((y0 as usize) * self.width + (x1 as usize)) * channels + c;
382                        let i01 = ((y1 as usize) * self.width + (x0 as usize)) * channels + c;
383                        let i11 = ((y1 as usize) * self.width + (x1 as usize)) * channels + c;
384
385                        let v00 = f32::from(input[i00]);
386                        let v10 = f32::from(input[i10]);
387                        let v01 = f32::from(input[i01]);
388                        let v11 = f32::from(input[i11]);
389
390                        let v0 = v00 * (1.0 - dx) + v10 * dx;
391                        let v1 = v01 * (1.0 - dx) + v11 * dx;
392                        let v = v0 * (1.0 - dy) + v1 * dy;
393
394                        output[idx * channels + c] = v.round() as u8;
395                    }
396                }
397            }
398        }
399
400        Ok(output)
401    }
402}
403
404// ─────────────────────────────────────────────────────────────────────────────
405// Fisheye equidistant projection model
406// ─────────────────────────────────────────────────────────────────────────────
407
408/// Fisheye equidistant projection model.
409///
410/// In the equidistant model the distorted radius is simply `r_d = f * θ`,
411/// where `θ = atan(r)` and `f` is a scale factor.  No polynomial coefficients
412/// are needed; the `scale` field corresponds to the focal-length equivalent.
413#[derive(Debug, Clone)]
414pub struct FisheyeEquidistant {
415    /// Scale factor (equivalent focal length in the distorted image plane)
416    pub scale: f64,
417}
418
419impl FisheyeEquidistant {
420    /// Create a new equidistant fisheye model.
421    #[must_use]
422    pub fn new(scale: f64) -> Self {
423        Self { scale }
424    }
425
426    /// Apply equidistant fisheye projection to a normalised point.
427    ///
428    /// Returns the distorted point in normalised coordinates.
429    #[must_use]
430    pub fn distort(&self, point: &Vector2<f64>) -> Vector2<f64> {
431        let x = point[0];
432        let y = point[1];
433        let r = (x * x + y * y).sqrt();
434
435        if r < 1e-10 {
436            return *point;
437        }
438
439        let theta = r.atan();
440        let r_d = self.scale * theta;
441        let scale = r_d / r;
442
443        Vector2::new(x * scale, y * scale)
444    }
445
446    /// Invert the equidistant projection.
447    ///
448    /// Given a distorted normalised point, recover the undistorted point.
449    #[must_use]
450    pub fn undistort(&self, distorted: &Vector2<f64>) -> Vector2<f64> {
451        let x = distorted[0];
452        let y = distorted[1];
453        let r_d = (x * x + y * y).sqrt();
454
455        if r_d < 1e-10 {
456            return *distorted;
457        }
458
459        // θ = r_d / scale  →  r = tan(θ)
460        let theta = r_d / self.scale;
461        let r = theta.tan();
462        let scale = r / r_d;
463
464        Vector2::new(x * scale, y * scale)
465    }
466}
467
468// ─────────────────────────────────────────────────────────────────────────────
469// Stereographic projection model
470// ─────────────────────────────────────────────────────────────────────────────
471
472/// Stereographic fisheye projection model.
473///
474/// In the stereographic model the distorted radius is
475/// `r_d = 2 * f * tan(θ / 2)`, where `θ = atan(r)` and `f` is the scale
476/// factor.  This projection preserves circles (conformal mapping).
477#[derive(Debug, Clone)]
478pub struct StereographicProjection {
479    /// Scale factor
480    pub scale: f64,
481}
482
483impl StereographicProjection {
484    /// Create a new stereographic projection model.
485    #[must_use]
486    pub fn new(scale: f64) -> Self {
487        Self { scale }
488    }
489
490    /// Apply the stereographic projection to a normalised point.
491    #[must_use]
492    pub fn distort(&self, point: &Vector2<f64>) -> Vector2<f64> {
493        let x = point[0];
494        let y = point[1];
495        let r = (x * x + y * y).sqrt();
496
497        if r < 1e-10 {
498            return *point;
499        }
500
501        let theta = r.atan();
502        let r_d = 2.0 * self.scale * (theta / 2.0).tan();
503        let scale = r_d / r;
504
505        Vector2::new(x * scale, y * scale)
506    }
507
508    /// Invert the stereographic projection.
509    #[must_use]
510    pub fn undistort(&self, distorted: &Vector2<f64>) -> Vector2<f64> {
511        let x = distorted[0];
512        let y = distorted[1];
513        let r_d = (x * x + y * y).sqrt();
514
515        if r_d < 1e-10 {
516            return *distorted;
517        }
518
519        // r_d = 2*scale*tan(θ/2)  →  θ/2 = atan(r_d / (2*scale))
520        let theta = 2.0 * (r_d / (2.0 * self.scale)).atan();
521        let r = theta.tan();
522        let scale = r / r_d;
523
524        Vector2::new(x * scale, y * scale)
525    }
526}
527
528// ─────────────────────────────────────────────────────────────────────────────
529// Extended distortion-model enum (includes new models)
530// ─────────────────────────────────────────────────────────────────────────────
531
532/// Extended set of distortion model variants (includes projection models).
533#[derive(Debug, Clone)]
534pub enum ExtendedDistortionModel {
535    /// No distortion
536    None,
537    /// Brown-Conrady radial + tangential distortion
538    BrownConrady(BrownConradyDistortion),
539    /// Fisheye polynomial model (OpenCV-style)
540    Fisheye(FisheyeDistortion),
541    /// Fisheye equidistant projection
542    FisheyeEquidistant(FisheyeEquidistant),
543    /// Stereographic (conformal) fisheye projection
544    Stereographic(StereographicProjection),
545}
546
547impl ExtendedDistortionModel {
548    /// Apply distortion to a normalised coordinate.
549    #[must_use]
550    pub fn distort(&self, point: &Vector2<f64>) -> Vector2<f64> {
551        match self {
552            Self::None => *point,
553            Self::BrownConrady(m) => m.distort(point),
554            Self::Fisheye(m) => m.distort(point),
555            Self::FisheyeEquidistant(m) => m.distort(point),
556            Self::Stereographic(m) => m.distort(point),
557        }
558    }
559
560    /// Remove distortion from a normalised coordinate.
561    #[must_use]
562    pub fn undistort(&self, point: &Vector2<f64>) -> Vector2<f64> {
563        match self {
564            Self::None => *point,
565            Self::BrownConrady(m) => m.undistort(point),
566            Self::Fisheye(m) => m.undistort(point),
567            Self::FisheyeEquidistant(m) => m.undistort(point),
568            Self::Stereographic(m) => m.undistort(point),
569        }
570    }
571}
572
573/// Camera calibration using checkerboard pattern
574pub struct CameraCalibrator {
575    /// Checkerboard width (interior corners)
576    pub board_width: usize,
577    /// Checkerboard height (interior corners)
578    pub board_height: usize,
579    /// Square size in real-world units
580    pub square_size: f64,
581}
582
583impl CameraCalibrator {
584    /// Create new calibrator
585    #[must_use]
586    pub fn new(board_width: usize, board_height: usize, square_size: f64) -> Self {
587        Self {
588            board_width,
589            board_height,
590            square_size,
591        }
592    }
593
594    /// Calibrate camera from multiple views of checkerboard
595    ///
596    /// # Errors
597    /// Returns error if calibration fails
598    #[allow(dead_code)]
599    pub fn calibrate(
600        &self,
601        image_points: &[Vec<Point2D>],
602        image_width: usize,
603        image_height: usize,
604    ) -> AlignResult<CameraModel> {
605        if image_points.is_empty() {
606            return Err(AlignError::InsufficientData(
607                "Need at least one image for calibration".to_string(),
608            ));
609        }
610
611        // Generate object points (3D world coordinates)
612        let _object_points = self.generate_object_points();
613
614        // Initial guess for intrinsics
615        let intrinsics = CameraIntrinsics::from_image_size(image_width, image_height, 60.0);
616
617        // Simplified calibration: return default model
618        // In production, this would use iterative optimization
619        Ok(CameraModel::new(
620            intrinsics,
621            DistortionModel::BrownConrady(BrownConradyDistortion::default()),
622        ))
623    }
624
625    /// Generate 3D object points for checkerboard
626    fn generate_object_points(&self) -> Vec<nalgebra::Vector3<f64>> {
627        let mut points = Vec::new();
628        for y in 0..self.board_height {
629            for x in 0..self.board_width {
630                points.push(nalgebra::Vector3::new(
631                    x as f64 * self.square_size,
632                    y as f64 * self.square_size,
633                    0.0,
634                ));
635            }
636        }
637        points
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    // ── New projection model tests ────────────────────────────────────────
646
647    #[test]
648    fn test_fisheye_equidistant_identity_at_origin() {
649        let model = FisheyeEquidistant::new(1.0);
650        let origin = Vector2::new(0.0, 0.0);
651        let distorted = model.distort(&origin);
652        assert!((distorted[0]).abs() < 1e-10);
653        assert!((distorted[1]).abs() < 1e-10);
654    }
655
656    #[test]
657    fn test_fisheye_equidistant_roundtrip() {
658        let model = FisheyeEquidistant::new(1.0);
659        let point = Vector2::new(0.4, 0.3);
660        let distorted = model.distort(&point);
661        let recovered = model.undistort(&distorted);
662        assert!((recovered[0] - point[0]).abs() < 1e-8);
663        assert!((recovered[1] - point[1]).abs() < 1e-8);
664    }
665
666    #[test]
667    fn test_fisheye_equidistant_scale_effect() {
668        // Higher scale → larger distorted radius for the same input
669        let m1 = FisheyeEquidistant::new(1.0);
670        let m2 = FisheyeEquidistant::new(2.0);
671        let point = Vector2::new(0.5, 0.5);
672        let d1 = m1.distort(&point);
673        let d2 = m2.distort(&point);
674        let r1 = (d1[0] * d1[0] + d1[1] * d1[1]).sqrt();
675        let r2 = (d2[0] * d2[0] + d2[1] * d2[1]).sqrt();
676        assert!(
677            r2 > r1,
678            "Larger scale should produce larger distorted radius"
679        );
680    }
681
682    #[test]
683    fn test_stereographic_identity_at_origin() {
684        let model = StereographicProjection::new(1.0);
685        let origin = Vector2::new(0.0, 0.0);
686        let distorted = model.distort(&origin);
687        assert!(distorted[0].abs() < 1e-10);
688        assert!(distorted[1].abs() < 1e-10);
689    }
690
691    #[test]
692    fn test_stereographic_roundtrip() {
693        let model = StereographicProjection::new(1.0);
694        let point = Vector2::new(0.3, 0.4);
695        let distorted = model.distort(&point);
696        let recovered = model.undistort(&distorted);
697        assert!((recovered[0] - point[0]).abs() < 1e-8);
698        assert!((recovered[1] - point[1]).abs() < 1e-8);
699    }
700
701    #[test]
702    fn test_extended_distortion_model_none() {
703        let model = ExtendedDistortionModel::None;
704        let p = Vector2::new(0.5, 0.5);
705        assert_eq!(model.distort(&p), p);
706        assert_eq!(model.undistort(&p), p);
707    }
708
709    #[test]
710    fn test_extended_distortion_brown_conrady() {
711        let bc = BrownConradyDistortion::new(0.1, 0.01, 0.0, 0.0, 0.0);
712        let model = ExtendedDistortionModel::BrownConrady(bc.clone());
713        let p = Vector2::new(0.3, 0.3);
714        assert_eq!(model.distort(&p), bc.distort(&p));
715    }
716
717    #[test]
718    fn test_extended_distortion_equidistant() {
719        let eq = FisheyeEquidistant::new(1.0);
720        let model = ExtendedDistortionModel::FisheyeEquidistant(eq.clone());
721        let p = Vector2::new(0.2, 0.2);
722        let d1 = model.distort(&p);
723        let d2 = eq.distort(&p);
724        assert!((d1[0] - d2[0]).abs() < 1e-12);
725    }
726
727    #[test]
728    fn test_extended_distortion_stereographic() {
729        let sg = StereographicProjection::new(1.0);
730        let model = ExtendedDistortionModel::Stereographic(sg.clone());
731        let p = Vector2::new(0.2, 0.3);
732        let d1 = model.distort(&p);
733        let d2 = sg.distort(&p);
734        assert!((d1[0] - d2[0]).abs() < 1e-12);
735    }
736
737    #[test]
738    fn test_stereographic_preserves_direction() {
739        let model = StereographicProjection::new(1.0);
740        let point = Vector2::new(1.0, 0.0);
741        let distorted = model.distort(&point);
742        // Should remain on the x-axis
743        assert!(distorted[1].abs() < 1e-12);
744        assert!(distorted[0] > 0.0);
745    }
746
747    // ── Original tests ────────────────────────────────────────────────────
748
749    #[test]
750    fn test_camera_intrinsics() {
751        let intrinsics = CameraIntrinsics::new(1000.0, 1000.0, 640.0, 480.0);
752        assert_eq!(intrinsics.fx, 1000.0);
753        assert_eq!(intrinsics.fy, 1000.0);
754        assert_eq!(intrinsics.cx, 640.0);
755        assert_eq!(intrinsics.cy, 480.0);
756    }
757
758    #[test]
759    fn test_pixel_to_normalized() {
760        let intrinsics = CameraIntrinsics::new(1000.0, 1000.0, 640.0, 480.0);
761        let pixel = Point2D::new(640.0, 480.0);
762        let normalized = intrinsics.pixel_to_normalized(&pixel);
763        assert!((normalized[0] - 0.0).abs() < 1e-10);
764        assert!((normalized[1] - 0.0).abs() < 1e-10);
765    }
766
767    #[test]
768    fn test_brown_conrady_no_distortion() {
769        let distortion = BrownConradyDistortion::default();
770        let point = Vector2::new(0.5, 0.5);
771        let distorted = distortion.distort(&point);
772        assert!((distorted[0] - 0.5).abs() < 1e-10);
773        assert!((distorted[1] - 0.5).abs() < 1e-10);
774    }
775
776    #[test]
777    fn test_brown_conrady_roundtrip() {
778        let distortion = BrownConradyDistortion::new(0.1, 0.01, 0.001, 0.001, 0.001);
779        let original = Vector2::new(0.5, 0.5);
780        let distorted = distortion.distort(&original);
781        let undistorted = distortion.undistort(&distorted);
782        assert!((undistorted[0] - original[0]).abs() < 1e-6);
783        assert!((undistorted[1] - original[1]).abs() < 1e-6);
784    }
785
786    #[test]
787    fn test_fisheye_no_distortion() {
788        // With zero k coefficients, the fisheye model applies the equidistant
789        // projection (theta_d = theta = atan(r)), which is near-identity for small r.
790        // The scale factor is atan(r)/r ≈ 1 - r^2/3 for small r.
791        let distortion = FisheyeDistortion::default();
792        let point = Vector2::new(0.1, 0.1);
793        let distorted = distortion.distort(&point);
794        // With r ≈ 0.1414, scale ≈ 0.9931, so distorted ≈ 0.0993 (not exact identity)
795        assert!((distorted[0] - 0.1).abs() < 0.01);
796        assert!((distorted[1] - 0.1).abs() < 0.01);
797    }
798
799    #[test]
800    fn test_camera_model() {
801        let intrinsics = CameraIntrinsics::new(1000.0, 1000.0, 640.0, 480.0);
802        let model = CameraModel::new(intrinsics, DistortionModel::None);
803
804        let point_3d = nalgebra::Vector3::new(1.0, 1.0, 2.0);
805        let pixel = model.project(&point_3d);
806
807        // Should project to (640 + 500, 480 + 500) = (1140, 980)
808        assert!((pixel.x - 1140.0).abs() < 1e-10);
809        assert!((pixel.y - 980.0).abs() < 1e-10);
810    }
811
812    #[test]
813    fn test_image_undistorter_creation() {
814        let intrinsics = CameraIntrinsics::new(1000.0, 1000.0, 640.0, 480.0);
815        let model = CameraModel::new(intrinsics, DistortionModel::None);
816        let undistorter = ImageUndistorter::new(model, 1280, 960);
817
818        assert_eq!(undistorter.width, 1280);
819        assert_eq!(undistorter.height, 960);
820        assert_eq!(undistorter.map_x.len(), 1280 * 960);
821        assert_eq!(undistorter.map_y.len(), 1280 * 960);
822    }
823}