1use crate::{CameraModel, CameraModelError, DistortionModel, PinholeParams};
9use nalgebra::{DVector, SMatrix, Vector2, Vector3};
10
11#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct KannalaBrandtCamera {
14 pub pinhole: PinholeParams,
15 pub distortion: DistortionModel,
16}
17
18impl KannalaBrandtCamera {
19 pub fn new(
38 pinhole: PinholeParams,
39 distortion: DistortionModel,
40 ) -> Result<Self, CameraModelError> {
41 let model = Self {
42 pinhole,
43 distortion,
44 };
45 model.validate_params()?;
46 Ok(model)
47 }
48
49 fn distortion_params(&self) -> (f64, f64, f64, f64) {
52 match self.distortion {
53 DistortionModel::KannalaBrandt { k1, k2, k3, k4 } => (k1, k2, k3, k4),
54 _ => (0.0, 0.0, 0.0, 0.0),
55 }
56 }
57
58 fn check_projection_condition(&self, z: f64) -> bool {
60 z >= crate::GEOMETRIC_PRECISION
61 }
62
63 pub fn linear_estimation(
66 &mut self,
67 points_3d: &nalgebra::Matrix3xX<f64>,
68 points_2d: &nalgebra::Matrix2xX<f64>,
69 ) -> Result<(), CameraModelError> {
70 if points_3d.ncols() != points_2d.ncols() {
71 return Err(CameraModelError::InvalidParams(
72 "Number of 2D and 3D points must match".to_string(),
73 ));
74 }
75 if points_3d.ncols() < 4 {
76 return Err(CameraModelError::InvalidParams(
77 "Not enough points for linear estimation (need at least 4)".to_string(),
78 ));
79 }
80
81 let num_points = points_3d.ncols();
82 let mut a_mat = nalgebra::DMatrix::zeros(num_points * 2, 4);
83 let mut b_vec = nalgebra::DVector::zeros(num_points * 2);
84
85 for i in 0..num_points {
86 let p3d = points_3d.column(i);
87 let p2d = points_2d.column(i);
88
89 let x_world = p3d.x;
90 let y_world = p3d.y;
91 let z_world = p3d.z;
92
93 let u_img = p2d.x;
94 let v_img = p2d.y;
95
96 if z_world <= f64::EPSILON {
97 continue;
98 }
99
100 let r_world = (x_world * x_world + y_world * y_world).sqrt();
101 let theta = r_world.atan2(z_world);
102
103 let theta2 = theta * theta;
104 let theta3 = theta2 * theta;
105 let theta5 = theta3 * theta2;
106 let theta7 = theta5 * theta2;
107 let theta9 = theta7 * theta2;
108
109 a_mat[(i * 2, 0)] = theta3;
110 a_mat[(i * 2, 1)] = theta5;
111 a_mat[(i * 2, 2)] = theta7;
112 a_mat[(i * 2, 3)] = theta9;
113
114 a_mat[(i * 2 + 1, 0)] = theta3;
115 a_mat[(i * 2 + 1, 1)] = theta5;
116 a_mat[(i * 2 + 1, 2)] = theta7;
117 a_mat[(i * 2 + 1, 3)] = theta9;
118
119 let x_r = if r_world < f64::EPSILON {
120 0.0
121 } else {
122 x_world / r_world
123 };
124 let y_r = if r_world < f64::EPSILON {
125 0.0
126 } else {
127 y_world / r_world
128 };
129
130 if (self.pinhole.fx * x_r).abs() < f64::EPSILON && x_r.abs() > f64::EPSILON {
131 return Err(CameraModelError::NumericalError {
132 operation: "linear_estimation".to_string(),
133 details: "fx * x_r is zero in linear estimation".to_string(),
134 });
135 }
136 if (self.pinhole.fy * y_r).abs() < f64::EPSILON && y_r.abs() > f64::EPSILON {
137 return Err(CameraModelError::NumericalError {
138 operation: "linear_estimation".to_string(),
139 details: "fy * y_r is zero in linear estimation".to_string(),
140 });
141 }
142
143 if x_r.abs() > f64::EPSILON {
144 b_vec[i * 2] = (u_img - self.pinhole.cx) / (self.pinhole.fx * x_r) - theta;
145 } else {
146 b_vec[i * 2] = if (u_img - self.pinhole.cx).abs() < f64::EPSILON {
147 -theta
148 } else {
149 0.0
150 };
151 }
152
153 if y_r.abs() > f64::EPSILON {
154 b_vec[i * 2 + 1] = (v_img - self.pinhole.cy) / (self.pinhole.fy * y_r) - theta;
155 } else {
156 b_vec[i * 2 + 1] = if (v_img - self.pinhole.cy).abs() < f64::EPSILON {
157 -theta
158 } else {
159 0.0
160 };
161 }
162 }
163
164 let svd = a_mat.svd(true, true);
165 let x_coeffs =
166 svd.solve(&b_vec, f64::EPSILON)
167 .map_err(|e_str| CameraModelError::NumericalError {
168 operation: "svd_solve".to_string(),
169 details: format!("SVD solve failed in linear estimation: {e_str}"),
170 })?;
171
172 self.distortion = DistortionModel::KannalaBrandt {
173 k1: x_coeffs[0],
174 k2: x_coeffs[1],
175 k3: x_coeffs[2],
176 k4: x_coeffs[3],
177 };
178
179 self.validate_params()?;
180 Ok(())
181 }
182}
183
184impl From<&KannalaBrandtCamera> for DVector<f64> {
186 fn from(camera: &KannalaBrandtCamera) -> Self {
187 let (k1, k2, k3, k4) = camera.distortion_params();
188 DVector::from_vec(vec![
189 camera.pinhole.fx,
190 camera.pinhole.fy,
191 camera.pinhole.cx,
192 camera.pinhole.cy,
193 k1,
194 k2,
195 k3,
196 k4,
197 ])
198 }
199}
200
201impl From<&KannalaBrandtCamera> for [f64; 8] {
203 fn from(camera: &KannalaBrandtCamera) -> Self {
204 let (k1, k2, k3, k4) = camera.distortion_params();
205 [
206 camera.pinhole.fx,
207 camera.pinhole.fy,
208 camera.pinhole.cx,
209 camera.pinhole.cy,
210 k1,
211 k2,
212 k3,
213 k4,
214 ]
215 }
216}
217
218impl TryFrom<&[f64]> for KannalaBrandtCamera {
221 type Error = CameraModelError;
222
223 fn try_from(params: &[f64]) -> Result<Self, Self::Error> {
224 if params.len() < 8 {
225 return Err(CameraModelError::InvalidParams(format!(
226 "KannalaBrandtCamera requires at least 8 parameters, got {}",
227 params.len()
228 )));
229 }
230 Ok(Self {
231 pinhole: PinholeParams {
232 fx: params[0],
233 fy: params[1],
234 cx: params[2],
235 cy: params[3],
236 },
237 distortion: DistortionModel::KannalaBrandt {
238 k1: params[4],
239 k2: params[5],
240 k3: params[6],
241 k4: params[7],
242 },
243 })
244 }
245}
246
247impl From<[f64; 8]> for KannalaBrandtCamera {
249 fn from(params: [f64; 8]) -> Self {
250 Self {
251 pinhole: PinholeParams {
252 fx: params[0],
253 fy: params[1],
254 cx: params[2],
255 cy: params[3],
256 },
257 distortion: DistortionModel::KannalaBrandt {
258 k1: params[4],
259 k2: params[5],
260 k3: params[6],
261 k4: params[7],
262 },
263 }
264 }
265}
266
267pub fn try_from_params(params: &[f64]) -> Result<KannalaBrandtCamera, CameraModelError> {
274 let camera = KannalaBrandtCamera::try_from(params)?;
275 camera.validate_params()?;
276 Ok(camera)
277}
278
279impl CameraModel for KannalaBrandtCamera {
280 const INTRINSIC_DIM: usize = 8;
281 type IntrinsicJacobian = SMatrix<f64, 2, 8>;
282 type PointJacobian = SMatrix<f64, 2, 3>;
283
284 fn project(&self, p_cam: &Vector3<f64>) -> Result<Vector2<f64>, CameraModelError> {
288 let x = p_cam[0];
289 let y = p_cam[1];
290 let z = p_cam[2];
291
292 if !self.check_projection_condition(z) {
293 return Err(CameraModelError::PointBehindCamera {
294 z,
295 min_z: crate::GEOMETRIC_PRECISION,
296 });
297 }
298
299 let (k1, k2, k3, k4) = self.distortion_params();
300 let r2 = x * x + y * y;
301 let r = r2.sqrt();
302 let theta = r.atan2(z);
303
304 let theta2 = theta * theta;
306 let theta3 = theta2 * theta;
307 let theta5 = theta3 * theta2;
308 let theta7 = theta5 * theta2;
309 let theta9 = theta7 * theta2;
310
311 let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
312
313 if r < crate::GEOMETRIC_PRECISION {
314 let inv_z = 1.0 / z;
315 return Ok(Vector2::new(
316 self.pinhole.fx * x * inv_z + self.pinhole.cx,
317 self.pinhole.fy * y * inv_z + self.pinhole.cy,
318 ));
319 }
320
321 let inv_r = 1.0 / r;
322 Ok(Vector2::new(
323 self.pinhole.fx * theta_d * x * inv_r + self.pinhole.cx,
324 self.pinhole.fy * theta_d * y * inv_r + self.pinhole.cy,
325 ))
326 }
327
328 fn unproject(&self, point_2d: &Vector2<f64>) -> Result<Vector3<f64>, CameraModelError> {
332 let u = point_2d.x;
333 let v = point_2d.y;
334
335 let (k1, k2, k3, k4) = self.distortion_params();
336 let mx = (u - self.pinhole.cx) / self.pinhole.fx;
337 let my = (v - self.pinhole.cy) / self.pinhole.fy;
338
339 let ru = (mx * mx + my * my).sqrt();
340
341 if !ru.is_finite() || ru > std::f64::consts::PI / 2.0 {
346 return Err(CameraModelError::NumericalError {
347 operation: "unprojection".to_string(),
348 details: format!(
349 "undistorted radius ru = {ru} is outside the KB model's valid domain (0, pi/2]"
350 ),
351 });
352 }
353
354 if ru < crate::GEOMETRIC_PRECISION {
355 return Ok(Vector3::new(0.0, 0.0, 1.0));
356 }
357
358 let mut theta = ru;
359 const MAX_ITER: usize = 10;
360 const CONVERGENCE_THRESHOLD: f64 = crate::CONVERGENCE_THRESHOLD;
361
362 for _ in 0..MAX_ITER {
363 let theta2 = theta * theta;
364 let theta4 = theta2 * theta2;
365 let theta6 = theta4 * theta2;
366 let theta8 = theta4 * theta4;
367
368 let k1_theta2 = k1 * theta2;
369 let k2_theta4 = k2 * theta4;
370 let k3_theta6 = k3 * theta6;
371 let k4_theta8 = k4 * theta8;
372
373 let f = theta * (1.0 + k1_theta2 + k2_theta4 + k3_theta6 + k4_theta8) - ru;
374 let f_prime =
375 1.0 + 3.0 * k1_theta2 + 5.0 * k2_theta4 + 7.0 * k3_theta6 + 9.0 * k4_theta8;
376
377 if f_prime.abs() < f64::EPSILON {
378 return Err(CameraModelError::NumericalError {
379 operation: "unprojection".to_string(),
380 details: "Derivative too small in KB unprojection".to_string(),
381 });
382 }
383
384 let delta = f / f_prime;
385 theta -= delta;
386
387 if delta.abs() < CONVERGENCE_THRESHOLD {
388 break;
389 }
390 }
391
392 if !theta.is_finite() || theta < 0.0 {
395 return Err(CameraModelError::NumericalError {
396 operation: "unprojection".to_string(),
397 details: format!("Newton-Raphson diverged, theta={theta}"),
398 });
399 }
400 let theta2 = theta * theta;
401 let residual_f = theta
402 * (1.0
403 + k1 * theta2
404 + k2 * theta2 * theta2
405 + k3 * theta2 * theta2 * theta2
406 + k4 * theta2 * theta2 * theta2 * theta2)
407 - ru;
408 if residual_f.abs() > crate::CONVERGENCE_THRESHOLD {
409 return Err(CameraModelError::NumericalError {
410 operation: "unprojection".to_string(),
411 details: format!(
412 "Newton-Raphson did not converge after {MAX_ITER} iterations: |f(theta)| = {}",
413 residual_f.abs()
414 ),
415 });
416 }
417
418 let sin_theta = theta.sin();
419 let cos_theta = theta.cos();
420
421 let scale = sin_theta / ru;
422 let x = mx * scale;
423 let y = my * scale;
424 let z = cos_theta;
425
426 Ok(Vector3::new(x, y, z).normalize())
427 }
428
429 fn jacobian_point(&self, p_cam: &Vector3<f64>) -> Self::PointJacobian {
437 let x = p_cam[0];
438 let y = p_cam[1];
439 let z = p_cam[2];
440
441 let (k1, k2, k3, k4) = self.distortion_params();
442 let r = (x * x + y * y).sqrt();
443 let theta = r.atan2(z);
444
445 let theta2 = theta * theta;
446 let theta3 = theta2 * theta;
447 let theta5 = theta3 * theta2;
448 let theta7 = theta5 * theta2;
449 let theta9 = theta7 * theta2;
450
451 let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
452
453 let dtheta_d_dtheta = 1.0
455 + 3.0 * k1 * theta2
456 + 5.0 * k2 * theta2 * theta2
457 + 7.0 * k3 * theta2 * theta2 * theta2
458 + 9.0 * k4 * theta2 * theta2 * theta2 * theta2;
459
460 if r < crate::GEOMETRIC_PRECISION {
461 return SMatrix::<f64, 2, 3>::new(
463 self.pinhole.fx * dtheta_d_dtheta / z,
464 0.0,
465 0.0,
466 0.0,
467 self.pinhole.fy * dtheta_d_dtheta / z,
468 0.0,
469 );
470 }
471
472 let inv_r = 1.0 / r;
473 let r2 = r * r;
474 let r_z2 = r2 + z * z;
475
476 let dtheta_dx = z * x / (r * r_z2);
480 let dtheta_dy = z * y / (r * r_z2);
481 let dtheta_dz = -r / r_z2;
482
483 let inv_r2 = inv_r * inv_r;
487
488 let du_dx = self.pinhole.fx
489 * (dtheta_d_dtheta * dtheta_dx * x * inv_r
490 + theta_d * (inv_r - x * x * inv_r2 * inv_r));
491 let du_dy = self.pinhole.fx
492 * (dtheta_d_dtheta * dtheta_dy * x * inv_r - theta_d * x * y * inv_r2 * inv_r);
493 let du_dz = self.pinhole.fx * dtheta_d_dtheta * dtheta_dz * x * inv_r;
494
495 let dv_dx = self.pinhole.fy
496 * (dtheta_d_dtheta * dtheta_dx * y * inv_r - theta_d * x * y * inv_r2 * inv_r);
497 let dv_dy = self.pinhole.fy
498 * (dtheta_d_dtheta * dtheta_dy * y * inv_r
499 + theta_d * (inv_r - y * y * inv_r2 * inv_r));
500 let dv_dz = self.pinhole.fy * dtheta_d_dtheta * dtheta_dz * y * inv_r;
501
502 SMatrix::<f64, 2, 3>::new(du_dx, du_dy, du_dz, dv_dx, dv_dy, dv_dz)
503 }
504
505 fn jacobian_intrinsics(&self, p_cam: &Vector3<f64>) -> Self::IntrinsicJacobian {
508 let x = p_cam[0];
509 let y = p_cam[1];
510 let z = p_cam[2];
511
512 let (k1, k2, k3, k4) = self.distortion_params();
513 let r = (x * x + y * y).sqrt();
514 let theta = r.atan2(z);
515
516 let theta2 = theta * theta;
517 let theta3 = theta2 * theta;
518 let theta5 = theta3 * theta2;
519 let theta7 = theta5 * theta2;
520 let theta9 = theta7 * theta2;
521
522 let theta_d = theta + k1 * theta3 + k2 * theta5 + k3 * theta7 + k4 * theta9;
523
524 if r < crate::GEOMETRIC_PRECISION {
525 return SMatrix::<f64, 2, 8>::zeros();
526 }
527
528 let inv_r = 1.0 / r;
529 let x_theta_d_r = x * theta_d * inv_r;
530 let y_theta_d_r = y * theta_d * inv_r;
531
532 let du_dk1 = self.pinhole.fx * theta3 * x * inv_r;
537 let du_dk2 = self.pinhole.fx * theta5 * x * inv_r;
538 let du_dk3 = self.pinhole.fx * theta7 * x * inv_r;
539 let du_dk4 = self.pinhole.fx * theta9 * x * inv_r;
540
541 let dv_dk1 = self.pinhole.fy * theta3 * y * inv_r;
542 let dv_dk2 = self.pinhole.fy * theta5 * y * inv_r;
543 let dv_dk3 = self.pinhole.fy * theta7 * y * inv_r;
544 let dv_dk4 = self.pinhole.fy * theta9 * y * inv_r;
545
546 SMatrix::<f64, 2, 8>::from_row_slice(&[
547 x_theta_d_r,
548 0.0,
549 1.0,
550 0.0,
551 du_dk1,
552 du_dk2,
553 du_dk3,
554 du_dk4,
555 0.0,
556 y_theta_d_r,
557 0.0,
558 1.0,
559 dv_dk1,
560 dv_dk2,
561 dv_dk3,
562 dv_dk4,
563 ])
564 }
565
566 fn validate_params(&self) -> Result<(), CameraModelError> {
578 self.pinhole.validate()?;
579 self.get_distortion().validate()
580 }
581
582 fn get_pinhole_params(&self) -> PinholeParams {
584 PinholeParams {
585 fx: self.pinhole.fx,
586 fy: self.pinhole.fy,
587 cx: self.pinhole.cx,
588 cy: self.pinhole.cy,
589 }
590 }
591
592 fn get_distortion(&self) -> DistortionModel {
594 self.distortion
595 }
596
597 fn get_model_name(&self) -> &'static str {
599 "kannala_brandt"
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use nalgebra::{Matrix2xX, Matrix3xX};
607
608 type TestResult = Result<(), Box<dyn std::error::Error>>;
609
610 #[test]
611 fn test_kb_camera_creation() -> TestResult {
612 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
613 let distortion = DistortionModel::KannalaBrandt {
614 k1: 0.1,
615 k2: 0.01,
616 k3: 0.001,
617 k4: 0.0001,
618 };
619 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
620 assert_eq!(camera.pinhole.fx, 300.0);
621 let (k1, _, _, _) = camera.distortion_params();
622 assert_eq!(k1, 0.1);
623 Ok(())
624 }
625
626 #[test]
627 fn test_projection_at_optical_axis() -> TestResult {
628 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
629 let distortion = DistortionModel::KannalaBrandt {
630 k1: 0.1,
631 k2: 0.01,
632 k3: 0.001,
633 k4: 0.0001,
634 };
635 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
636 let p_cam = Vector3::new(0.0, 0.0, 1.0);
637 let uv = camera.project(&p_cam)?;
638
639 assert!((uv.x - 320.0).abs() < 1e-6);
640 assert!((uv.y - 240.0).abs() < 1e-6);
641
642 Ok(())
643 }
644
645 #[test]
646 fn test_jacobian_point_numerical() -> TestResult {
647 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
648 let distortion = DistortionModel::KannalaBrandt {
649 k1: 0.1,
650 k2: 0.01,
651 k3: 0.001,
652 k4: 0.0001,
653 };
654 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
655 let p_cam = Vector3::new(0.1, 0.2, 1.0);
656
657 let jac_analytical = camera.jacobian_point(&p_cam);
658 let eps = crate::NUMERICAL_DERIVATIVE_EPS;
659
660 for i in 0..3 {
661 let mut p_plus = p_cam;
662 let mut p_minus = p_cam;
663 p_plus[i] += eps;
664 p_minus[i] -= eps;
665
666 let uv_plus = camera.project(&p_plus)?;
667 let uv_minus = camera.project(&p_minus)?;
668 let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
669
670 for r in 0..2 {
671 assert!(
672 jac_analytical[(r, i)].is_finite(),
673 "Jacobian [{r},{i}] is not finite"
674 );
675 let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
676 assert!(
677 diff < crate::JACOBIAN_TEST_TOLERANCE,
678 "Mismatch at ({}, {})",
679 r,
680 i
681 );
682 }
683 }
684 Ok(())
685 }
686
687 #[test]
688 fn test_jacobian_intrinsics_numerical() -> TestResult {
689 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
690 let distortion = DistortionModel::KannalaBrandt {
691 k1: 0.1,
692 k2: 0.01,
693 k3: 0.001,
694 k4: 0.0001,
695 };
696 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
697 let p_cam = Vector3::new(0.1, 0.2, 1.0);
698
699 let jac_analytical = camera.jacobian_intrinsics(&p_cam);
700 let params: DVector<f64> = (&camera).into();
701 let eps = crate::NUMERICAL_DERIVATIVE_EPS;
702
703 for i in 0..8 {
704 let mut params_plus = params.clone();
705 let mut params_minus = params.clone();
706 params_plus[i] += eps;
707 params_minus[i] -= eps;
708
709 let cam_plus = KannalaBrandtCamera::try_from(params_plus.as_slice())?;
710 let cam_minus = KannalaBrandtCamera::try_from(params_minus.as_slice())?;
711
712 let uv_plus = cam_plus.project(&p_cam)?;
713 let uv_minus = cam_minus.project(&p_cam)?;
714 let num_jac = (uv_plus - uv_minus) / (2.0 * eps);
715
716 for r in 0..2 {
717 assert!(
718 jac_analytical[(r, i)].is_finite(),
719 "Jacobian [{r},{i}] is not finite"
720 );
721 let diff = (jac_analytical[(r, i)] - num_jac[r]).abs();
722 assert!(
723 diff < crate::JACOBIAN_TEST_TOLERANCE,
724 "Mismatch at ({}, {})",
725 r,
726 i
727 );
728 }
729 }
730 Ok(())
731 }
732
733 #[test]
734 fn test_kb_from_into_traits() -> TestResult {
735 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
736 let distortion = DistortionModel::KannalaBrandt {
737 k1: 0.1,
738 k2: 0.01,
739 k3: 0.001,
740 k4: 0.0001,
741 };
742 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
743
744 let params: DVector<f64> = (&camera).into();
746 assert_eq!(params.len(), 8);
747 assert_eq!(params[0], 300.0);
748 assert_eq!(params[1], 300.0);
749 assert_eq!(params[2], 320.0);
750 assert_eq!(params[3], 240.0);
751 assert_eq!(params[4], 0.1);
752 assert_eq!(params[5], 0.01);
753 assert_eq!(params[6], 0.001);
754 assert_eq!(params[7], 0.0001);
755
756 let arr: [f64; 8] = (&camera).into();
758 assert_eq!(arr, [300.0, 300.0, 320.0, 240.0, 0.1, 0.01, 0.001, 0.0001]);
759
760 let params_slice = [350.0, 350.0, 330.0, 250.0, 0.2, 0.02, 0.002, 0.0002];
762 let camera2 = KannalaBrandtCamera::try_from(¶ms_slice[..])?;
763 assert_eq!(camera2.pinhole.fx, 350.0);
764 assert_eq!(camera2.pinhole.fy, 350.0);
765 assert_eq!(camera2.pinhole.cx, 330.0);
766 assert_eq!(camera2.pinhole.cy, 250.0);
767 let (k1, k2, k3, k4) = camera2.distortion_params();
768 assert_eq!(k1, 0.2);
769 assert_eq!(k2, 0.02);
770 assert_eq!(k3, 0.002);
771 assert_eq!(k4, 0.0002);
772
773 let camera3 =
775 KannalaBrandtCamera::from([400.0, 400.0, 340.0, 260.0, 0.3, 0.03, 0.003, 0.0003]);
776 assert_eq!(camera3.pinhole.fx, 400.0);
777 assert_eq!(camera3.pinhole.fy, 400.0);
778 let (k1, k2, k3, k4) = camera3.distortion_params();
779 assert_eq!(k1, 0.3);
780 assert_eq!(k2, 0.03);
781 assert_eq!(k3, 0.003);
782 assert_eq!(k4, 0.0003);
783
784 Ok(())
785 }
786
787 #[test]
788 fn test_linear_estimation() -> TestResult {
789 let gt_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
791 let gt_distortion = DistortionModel::KannalaBrandt {
792 k1: 0.1,
793 k2: 0.01,
794 k3: 0.001,
795 k4: 0.0001,
796 };
797 let gt_camera = KannalaBrandtCamera::new(gt_pinhole, gt_distortion)?;
798
799 let n_points = 50;
801 let mut pts_3d = Matrix3xX::zeros(n_points);
802 let mut pts_2d = Matrix2xX::zeros(n_points);
803 let mut valid = 0;
804
805 for i in 0..n_points {
806 let angle = i as f64 * 2.0 * std::f64::consts::PI / n_points as f64;
807 let r = 0.1 + 0.4 * (i as f64 / n_points as f64);
808 let p3d = Vector3::new(r * angle.cos(), r * angle.sin(), 1.0);
809
810 if let Ok(p2d) = gt_camera.project(&p3d) {
811 pts_3d.set_column(valid, &p3d);
812 pts_2d.set_column(valid, &p2d);
813 valid += 1;
814 }
815 }
816 let pts_3d = pts_3d.columns(0, valid).into_owned();
817 let pts_2d = pts_2d.columns(0, valid).into_owned();
818
819 let init_pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
821 let init_distortion = DistortionModel::KannalaBrandt {
822 k1: 0.0,
823 k2: 0.0,
824 k3: 0.0,
825 k4: 0.0,
826 };
827 let mut camera = KannalaBrandtCamera::new(init_pinhole, init_distortion)?;
828
829 camera.linear_estimation(&pts_3d, &pts_2d)?;
830
831 for i in 0..valid {
833 let col = pts_3d.column(i);
834 let projected = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
835 let err = ((projected.x - pts_2d[(0, i)]).powi(2)
836 + (projected.y - pts_2d[(1, i)]).powi(2))
837 .sqrt();
838 assert!(err < 3.0, "Reprojection error too large: {err}");
839 }
840
841 Ok(())
842 }
843
844 #[test]
845 fn test_project_unproject_round_trip() -> TestResult {
846 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
847 let distortion = DistortionModel::KannalaBrandt {
848 k1: 0.1,
849 k2: 0.01,
850 k3: 0.001,
851 k4: 0.0001,
852 };
853 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
854
855 let test_points = [
856 Vector3::new(0.1, 0.2, 1.0),
857 Vector3::new(-0.3, 0.1, 2.0),
858 Vector3::new(0.05, -0.1, 0.5),
859 ];
860
861 for p_cam in &test_points {
862 let uv = camera.project(p_cam)?;
863 let ray = camera.unproject(&uv)?;
864 let dot = ray.dot(&p_cam.normalize());
865 assert!(
866 (dot - 1.0).abs() < 1e-6,
867 "Round-trip failed: dot={dot}, expected ~1.0"
868 );
869 }
870
871 Ok(())
872 }
873
874 #[test]
875 fn test_project_returns_error_behind_camera() -> TestResult {
876 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
877 let distortion = DistortionModel::KannalaBrandt {
878 k1: 0.0,
879 k2: 0.0,
880 k3: 0.0,
881 k4: 0.0,
882 };
883 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
884 assert!(camera.project(&Vector3::new(0.0, 0.0, -1.0)).is_err());
885 Ok(())
886 }
887
888 #[test]
889 fn test_project_at_min_depth_boundary() -> TestResult {
890 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
891 let distortion = DistortionModel::KannalaBrandt {
892 k1: 0.0,
893 k2: 0.0,
894 k3: 0.0,
895 k4: 0.0,
896 };
897 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
898 let p_min = Vector3::new(0.0, 0.0, crate::MIN_DEPTH);
899 if let Ok(uv) = camera.project(&p_min) {
900 assert!(uv.x.is_finite() && uv.y.is_finite());
901 }
902 Ok(())
903 }
904
905 #[test]
906 fn test_projection_off_axis() -> TestResult {
907 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
908 let distortion = DistortionModel::KannalaBrandt {
909 k1: 0.1,
910 k2: 0.01,
911 k3: 0.001,
912 k4: 0.0001,
913 };
914 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
915 let p_cam = Vector3::new(0.3, 0.0, 1.0);
916 let uv = camera.project(&p_cam)?;
917 assert!(
918 uv.x > 320.0,
919 "off-axis point should project right of principal point"
920 );
921 assert!(
922 (uv.y - 240.0).abs() < 1.0,
923 "y should be close to cy for horizontal offset"
924 );
925 Ok(())
926 }
927
928 #[test]
929 fn test_unproject_center_pixel() -> TestResult {
930 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
931 let distortion = DistortionModel::KannalaBrandt {
932 k1: 0.1,
933 k2: 0.01,
934 k3: 0.001,
935 k4: 0.0001,
936 };
937 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
938 let uv = Vector2::new(320.0, 240.0);
939 let ray = camera.unproject(&uv)?;
940 assert!(ray.x.abs() < 1e-6, "x should be ~0, got {}", ray.x);
941 assert!(ray.y.abs() < 1e-6, "y should be ~0, got {}", ray.y);
942 assert!((ray.z - 1.0).abs() < 1e-6, "z should be ~1, got {}", ray.z);
943 Ok(())
944 }
945
946 #[test]
947 fn test_batch_projection_matches_individual() -> TestResult {
948 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
949 let distortion = DistortionModel::KannalaBrandt {
950 k1: 0.1,
951 k2: 0.01,
952 k3: 0.001,
953 k4: 0.0001,
954 };
955 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
956 let pts = Matrix3xX::from_columns(&[
957 Vector3::new(0.0, 0.0, 1.0),
958 Vector3::new(0.3, 0.2, 1.5),
959 Vector3::new(-0.4, 0.1, 2.0),
960 ]);
961 let batch = camera.project_batch(&pts);
962 for i in 0..3 {
963 let col = pts.column(i);
964 let p = camera.project(&Vector3::new(col[0], col[1], col[2]))?;
965 assert!(
966 (batch[(0, i)] - p.x).abs() < 1e-10,
967 "batch u mismatch at col {i}"
968 );
969 assert!(
970 (batch[(1, i)] - p.y).abs() < 1e-10,
971 "batch v mismatch at col {i}"
972 );
973 }
974 Ok(())
975 }
976
977 #[test]
978 fn test_jacobian_dimensions() -> TestResult {
979 let pinhole = PinholeParams::new(300.0, 300.0, 320.0, 240.0)?;
980 let distortion = DistortionModel::KannalaBrandt {
981 k1: 0.1,
982 k2: 0.01,
983 k3: 0.001,
984 k4: 0.0001,
985 };
986 let camera = KannalaBrandtCamera::new(pinhole, distortion)?;
987 let p_cam = Vector3::new(0.1, 0.2, 1.0);
988 let jac_point = camera.jacobian_point(&p_cam);
989 assert_eq!(jac_point.nrows(), 2);
990 assert_eq!(jac_point.ncols(), 3);
991 let jac_intr = camera.jacobian_intrinsics(&p_cam);
992 assert_eq!(jac_intr.nrows(), 2);
993 assert_eq!(jac_intr.ncols(), 8); Ok(())
995 }
996
997 #[test]
1001 fn test_unproject_rejects_out_of_domain_radius() -> TestResult {
1002 let camera = KannalaBrandtCamera::new(
1003 PinholeParams::new(400.0, 400.0, 320.0, 240.0)?,
1004 DistortionModel::KannalaBrandt {
1005 k1: 0.1,
1006 k2: 0.02,
1007 k3: -0.01,
1008 k4: 0.005,
1009 },
1010 )?;
1011 let far_pixel = Vector2::new(320.0 + 1.0e6, 240.0);
1014 let result = camera.unproject(&far_pixel);
1015 assert!(result.is_err(), "out-of-domain ru must return Err");
1016 Ok(())
1017 }
1018
1019 #[test]
1020 fn test_unproject_rejects_non_converged_newton() -> TestResult {
1021 let camera = KannalaBrandtCamera::new(
1025 PinholeParams::new(1.0, 1.0, 0.0, 0.0)?,
1026 DistortionModel::KannalaBrandt {
1027 k1: -50.0,
1028 k2: 1.0e3,
1029 k3: -1.0e4,
1030 k4: 1.0e5,
1031 },
1032 )?;
1033 let mut rejected = 0;
1034 for px in [5.0, 50.0, 500.0] {
1035 if camera.unproject(&Vector2::new(px, 0.0)).is_err() {
1036 rejected += 1;
1037 }
1038 }
1039 assert!(rejected > 0, "divergent Newton iterations must return Err");
1040 Ok(())
1041 }
1042}