1use spatialrust_math::{solve_linear_system, LeastSquaresResult, Mat3, Vec2, Vec3};
4
5use crate::{CameraIntrinsics, KannalaBrandt4, PinholeCamera};
6
7#[derive(Clone, Debug, PartialEq, thiserror::Error)]
9pub enum CalibrationError {
10 #[error("invalid calibration dataset: {0}")]
12 InvalidDataset(String),
13 #[error("calibration normal equation is singular")]
15 Singular,
16 #[error("calibration projection failed: {0}")]
18 Projection(String),
19}
20
21#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct CalibrationOptions {
24 pub max_iterations: usize,
26 pub huber_delta: f64,
28 pub convergence_tolerance: f64,
30}
31
32impl Default for CalibrationOptions {
33 fn default() -> Self {
34 Self { max_iterations: 12, huber_delta: 2.0, convergence_tolerance: 1e-10 }
35 }
36}
37
38#[derive(Clone, Copy, Debug, Default, PartialEq)]
40pub struct CalibrationReport {
41 pub rms_residual: f64,
43 pub max_residual: f64,
45 pub observation_count: usize,
47 pub iterations: usize,
49 pub converged: bool,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct PinholeObservation {
56 pub camera_point: Vec3<f64>,
58 pub pixel: Vec2<f64>,
60}
61
62pub fn calibrate_pinhole(
64 observations: &[PinholeObservation],
65 width: usize,
66 height: usize,
67 options: CalibrationOptions,
68) -> Result<(PinholeCamera, CalibrationReport), CalibrationError> {
69 validate_options(options)?;
70 if observations.len() < 4 {
71 return Err(CalibrationError::InvalidDataset(
72 "pinhole calibration needs at least four observations".to_owned(),
73 ));
74 }
75 let normalized = observations
76 .iter()
77 .map(|observation| {
78 validate_point_pixel(observation.camera_point, observation.pixel)?;
79 Ok((
80 observation.camera_point.x / observation.camera_point.z,
81 observation.camera_point.y / observation.camera_point.z,
82 ))
83 })
84 .collect::<Result<Vec<_>, CalibrationError>>()?;
85 let mut weights = vec![1.0; observations.len()];
86 let mut previous = [0.0; 4];
87 let mut parameters = [0.0; 4];
88 let mut converged = false;
89 let mut iterations = 0;
90 for iteration in 0..options.max_iterations.max(1) {
91 let x_values = normalized.iter().map(|value| value.0).collect::<Vec<_>>();
92 let y_values = normalized.iter().map(|value| value.1).collect::<Vec<_>>();
93 let u_values = observations.iter().map(|value| value.pixel.x).collect::<Vec<_>>();
94 let v_values = observations.iter().map(|value| value.pixel.y).collect::<Vec<_>>();
95 let (fx, cx) = fit_slope_intercept(&x_values, &u_values, &weights)?;
96 let (fy, cy) = fit_slope_intercept(&y_values, &v_values, &weights)?;
97 parameters = [fx, fy, cx, cy];
98 if fx <= 0.0 || fy <= 0.0 {
99 return Err(CalibrationError::InvalidDataset(
100 "calibrated focal lengths are not positive".to_owned(),
101 ));
102 }
103 iterations = iteration + 1;
104 let step = parameters
105 .iter()
106 .zip(previous)
107 .map(|(current, old)| (current - old).abs())
108 .fold(0.0, f64::max);
109 let residuals = observations
110 .iter()
111 .zip(&normalized)
112 .map(|(observation, &(x, y))| {
113 (fx.mul_add(x, cx) - observation.pixel.x)
114 .hypot(fy.mul_add(y, cy) - observation.pixel.y)
115 })
116 .collect::<Vec<_>>();
117 update_huber_weights(&residuals, options.huber_delta, &mut weights);
118 if iteration > 0 && step <= options.convergence_tolerance {
119 converged = true;
120 break;
121 }
122 previous = parameters;
123 }
124 let intrinsics = CameraIntrinsics::try_new(
125 parameters[0],
126 parameters[1],
127 parameters[2],
128 parameters[3],
129 width,
130 height,
131 )
132 .map_err(|error| CalibrationError::InvalidDataset(error.to_string()))?;
133 let camera = PinholeCamera::new(intrinsics);
134 let residuals = observations
135 .iter()
136 .map(|observation| {
137 camera
138 .project(observation.camera_point)
139 .map(|pixel| pixel_distance(pixel, observation.pixel))
140 .map_err(|error| CalibrationError::Projection(error.to_string()))
141 })
142 .collect::<Result<Vec<_>, _>>()?;
143 Ok((camera, report(&residuals, iterations, converged)))
144}
145
146#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct FisheyeObservation {
149 pub theta: f64,
151 pub distorted_radius: f64,
153}
154
155pub fn calibrate_fisheye(
157 observations: &[FisheyeObservation],
158) -> Result<(KannalaBrandt4, CalibrationReport), CalibrationError> {
159 if observations.len() < 4 {
160 return Err(CalibrationError::InvalidDataset(
161 "fisheye calibration needs at least four non-zero angles".to_owned(),
162 ));
163 }
164 let mut normal = vec![vec![0.0; 4]; 4];
165 let mut rhs = vec![0.0; 4];
166 for observation in observations {
167 if !observation.theta.is_finite()
168 || !observation.distorted_radius.is_finite()
169 || observation.theta <= 0.0
170 {
171 return Err(CalibrationError::InvalidDataset(
172 "fisheye samples must have finite positive theta".to_owned(),
173 ));
174 }
175 let theta2 = observation.theta * observation.theta;
176 let row = [theta2, theta2.powi(2), theta2.powi(3), theta2.powi(4)];
177 let target = observation.distorted_radius / observation.theta - 1.0;
178 accumulate_normal(&mut normal, &mut rhs, &row, target, 1.0);
179 }
180 let values = solved(normal, rhs)?;
181 let model = KannalaBrandt4 { k1: values[0], k2: values[1], k3: values[2], k4: values[3] };
182 let residuals = observations
183 .iter()
184 .map(|observation| {
185 let theta2 = observation.theta * observation.theta;
186 let predicted = observation.theta
187 * (1.0
188 + theta2
189 * (model.k1
190 + theta2 * (model.k2 + theta2 * (model.k3 + theta2 * model.k4))));
191 (predicted - observation.distorted_radius).abs()
192 })
193 .collect::<Vec<_>>();
194 Ok((model, report(&residuals, 1, true)))
195}
196
197#[derive(Clone, Copy, Debug, PartialEq)]
199pub struct RigidTransform3 {
200 pub rotation: Mat3<f64>,
202 pub translation: Vec3<f64>,
204}
205
206impl RigidTransform3 {
207 #[must_use]
209 pub fn transform_point(self, point: Vec3<f64>) -> Vec3<f64> {
210 self.rotation.mul_vec3(point) + self.translation
211 }
212}
213
214#[derive(Clone, Copy, Debug, PartialEq)]
216pub struct StereoPointPair {
217 pub left: Vec3<f64>,
219 pub right: Vec3<f64>,
221}
222
223#[derive(Clone, Copy, Debug, PartialEq)]
225pub struct StereoCalibration {
226 pub left: PinholeCamera,
228 pub right: PinholeCamera,
230 pub right_from_left: RigidTransform3,
232 pub report: CalibrationReport,
234}
235
236pub fn calibrate_stereo_translation(
238 left: PinholeCamera,
239 right: PinholeCamera,
240 rotation: Mat3<f64>,
241 pairs: &[StereoPointPair],
242) -> Result<StereoCalibration, CalibrationError> {
243 if pairs.len() < 3 {
244 return Err(CalibrationError::InvalidDataset(
245 "stereo calibration needs at least three point pairs".to_owned(),
246 ));
247 }
248 validate_rotation(rotation)?;
249 let mut translation = Vec3::new(0.0, 0.0, 0.0);
250 for pair in pairs {
251 validate_vec3(pair.left)?;
252 validate_vec3(pair.right)?;
253 translation = translation + pair.right - rotation.mul_vec3(pair.left);
254 }
255 let inverse_count = 1.0 / pairs.len() as f64;
256 translation = Vec3::new(
257 translation.x * inverse_count,
258 translation.y * inverse_count,
259 translation.z * inverse_count,
260 );
261 let transform = RigidTransform3 { rotation, translation };
262 let residuals = pairs
263 .iter()
264 .map(|pair| (transform.transform_point(pair.left) - pair.right).length())
265 .collect::<Vec<_>>();
266 Ok(StereoCalibration {
267 left,
268 right,
269 right_from_left: transform,
270 report: report(&residuals, 1, true),
271 })
272}
273
274#[derive(Clone, Copy, Debug, PartialEq)]
276pub struct HandEyeMotionPair {
277 pub robot_motion: RigidTransform3,
279 pub camera_motion: RigidTransform3,
281}
282
283pub fn calibrate_hand_eye_translation(
285 pairs: &[HandEyeMotionPair],
286 hand_eye_rotation: Mat3<f64>,
287) -> Result<(RigidTransform3, CalibrationReport), CalibrationError> {
288 if pairs.len() < 2 {
289 return Err(CalibrationError::InvalidDataset(
290 "hand-eye translation needs at least two motion pairs".to_owned(),
291 ));
292 }
293 validate_rotation(hand_eye_rotation)?;
294 let mut normal = vec![vec![0.0; 3]; 3];
295 let mut rhs = vec![0.0; 3];
296 for pair in pairs {
297 validate_rotation(pair.robot_motion.rotation)?;
298 validate_rotation(pair.camera_motion.rotation)?;
299 let target = hand_eye_rotation.mul_vec3(pair.camera_motion.translation)
300 - pair.robot_motion.translation;
301 for row in 0..3 {
302 let coefficients = [
303 pair.robot_motion.rotation.m[row][0] - if row == 0 { 1.0 } else { 0.0 },
304 pair.robot_motion.rotation.m[row][1] - if row == 1 { 1.0 } else { 0.0 },
305 pair.robot_motion.rotation.m[row][2] - if row == 2 { 1.0 } else { 0.0 },
306 ];
307 accumulate_normal(
308 &mut normal,
309 &mut rhs,
310 &coefficients,
311 [target.x, target.y, target.z][row],
312 1.0,
313 );
314 }
315 }
316 let values = solved(normal, rhs)?;
317 let result = RigidTransform3 {
318 rotation: hand_eye_rotation,
319 translation: Vec3::new(values[0], values[1], values[2]),
320 };
321 let residuals = pairs
322 .iter()
323 .map(|pair| {
324 let left = pair.robot_motion.rotation.mul_vec3(result.translation)
325 + pair.robot_motion.translation;
326 let right =
327 result.rotation.mul_vec3(pair.camera_motion.translation) + result.translation;
328 let rotation_error = matrix_distance(
329 pair.robot_motion.rotation.mul_mat3(result.rotation),
330 result.rotation.mul_mat3(pair.camera_motion.rotation),
331 );
332 (left - right).length().hypot(rotation_error)
333 })
334 .collect::<Vec<_>>();
335 Ok((result, report(&residuals, 1, true)))
336}
337
338#[derive(Clone, Copy, Debug, PartialEq)]
340pub struct BundleView {
341 pub camera: PinholeCamera,
343 pub camera_from_world: RigidTransform3,
345}
346
347#[derive(Clone, Copy, Debug, PartialEq)]
349pub struct BundleObservation {
350 pub view_index: usize,
352 pub point_index: usize,
354 pub pixel: Vec2<f64>,
356}
357
358#[derive(Clone, Debug, PartialEq)]
360pub struct BundleProblem {
361 pub views: Vec<BundleView>,
363 pub points: Vec<Vec3<f64>>,
365 pub observations: Vec<BundleObservation>,
367}
368
369pub fn bundle_adjust_points(
371 problem: &mut BundleProblem,
372 options: CalibrationOptions,
373) -> Result<CalibrationReport, CalibrationError> {
374 validate_options(options)?;
375 validate_bundle(problem)?;
376 let mut converged = false;
377 let mut iterations = 0;
378 for iteration in 0..options.max_iterations.max(1) {
379 let mut max_step: f64 = 0.0;
380 for point_index in 0..problem.points.len() {
381 let mut normal = vec![vec![0.0; 3]; 3];
382 let mut rhs = vec![0.0; 3];
383 let mut count = 0;
384 for observation in
385 problem.observations.iter().filter(|value| value.point_index == point_index)
386 {
387 let view = problem.views[observation.view_index];
388 let point = problem.points[point_index];
389 let predicted = project_world(view, point)?;
390 let residual =
391 [predicted.x - observation.pixel.x, predicted.y - observation.pixel.y];
392 let magnitude = residual[0].hypot(residual[1]);
393 let weight = huber_weight(magnitude, options.huber_delta);
394 const STEP: f64 = 1e-6;
395 let shifted_x = project_world(view, point + Vec3::new(STEP, 0.0, 0.0))?;
396 let shifted_y = project_world(view, point + Vec3::new(0.0, STEP, 0.0))?;
397 let shifted_z = project_world(view, point + Vec3::new(0.0, 0.0, STEP))?;
398 let jacobian = [
399 [
400 (shifted_x.x - predicted.x) / STEP,
401 (shifted_y.x - predicted.x) / STEP,
402 (shifted_z.x - predicted.x) / STEP,
403 ],
404 [
405 (shifted_x.y - predicted.y) / STEP,
406 (shifted_y.y - predicted.y) / STEP,
407 (shifted_z.y - predicted.y) / STEP,
408 ],
409 ];
410 for row in 0..2 {
411 accumulate_normal(
412 &mut normal,
413 &mut rhs,
414 &jacobian[row],
415 -residual[row],
416 weight,
417 );
418 }
419 count += 1;
420 }
421 if count >= 2 {
422 let step = solved(normal, rhs)?;
423 let delta = Vec3::new(step[0], step[1], step[2]);
424 problem.points[point_index] = problem.points[point_index] + delta;
425 max_step = max_step.max(delta.length());
426 }
427 }
428 iterations = iteration + 1;
429 if max_step <= options.convergence_tolerance {
430 converged = true;
431 break;
432 }
433 }
434 let residuals = bundle_residuals(problem)?;
435 Ok(report(&residuals, iterations, converged))
436}
437
438fn validate_bundle(problem: &BundleProblem) -> Result<(), CalibrationError> {
439 if problem.views.is_empty() || problem.points.is_empty() || problem.observations.is_empty() {
440 return Err(CalibrationError::InvalidDataset(
441 "bundle problem must be non-empty".to_owned(),
442 ));
443 }
444 for view in &problem.views {
445 validate_rotation(view.camera_from_world.rotation)?;
446 validate_vec3(view.camera_from_world.translation)?;
447 }
448 for observation in &problem.observations {
449 if observation.view_index >= problem.views.len()
450 || observation.point_index >= problem.points.len()
451 {
452 return Err(CalibrationError::InvalidDataset(
453 "bundle observation index out of bounds".to_owned(),
454 ));
455 }
456 if !observation.pixel.x.is_finite() || !observation.pixel.y.is_finite() {
457 return Err(CalibrationError::InvalidDataset("bundle pixel must be finite".to_owned()));
458 }
459 }
460 Ok(())
461}
462
463fn bundle_residuals(problem: &BundleProblem) -> Result<Vec<f64>, CalibrationError> {
464 problem
465 .observations
466 .iter()
467 .map(|observation| {
468 project_world(
469 problem.views[observation.view_index],
470 problem.points[observation.point_index],
471 )
472 .map(|pixel| pixel_distance(pixel, observation.pixel))
473 })
474 .collect()
475}
476
477fn project_world(view: BundleView, point: Vec3<f64>) -> Result<Vec2<f64>, CalibrationError> {
478 view.camera
479 .project(view.camera_from_world.transform_point(point))
480 .map_err(|error| CalibrationError::Projection(error.to_string()))
481}
482
483fn validate_options(options: CalibrationOptions) -> Result<(), CalibrationError> {
484 if options.max_iterations == 0
485 || !options.huber_delta.is_finite()
486 || options.huber_delta <= 0.0
487 || !options.convergence_tolerance.is_finite()
488 || options.convergence_tolerance <= 0.0
489 {
490 return Err(CalibrationError::InvalidDataset("invalid solver options".to_owned()));
491 }
492 Ok(())
493}
494
495fn validate_point_pixel(point: Vec3<f64>, pixel: Vec2<f64>) -> Result<(), CalibrationError> {
496 validate_vec3(point)?;
497 if point.z <= 0.0 || !pixel.x.is_finite() || !pixel.y.is_finite() {
498 return Err(CalibrationError::InvalidDataset(
499 "camera points need positive depth and finite pixels".to_owned(),
500 ));
501 }
502 Ok(())
503}
504
505fn validate_vec3(value: Vec3<f64>) -> Result<(), CalibrationError> {
506 if !value.x.is_finite() || !value.y.is_finite() || !value.z.is_finite() {
507 return Err(CalibrationError::InvalidDataset("3D values must be finite".to_owned()));
508 }
509 Ok(())
510}
511
512fn validate_rotation(rotation: Mat3<f64>) -> Result<(), CalibrationError> {
513 if rotation.m.iter().flatten().any(|value| !value.is_finite()) {
514 return Err(CalibrationError::InvalidDataset(
515 "rotation coefficients must be finite".to_owned(),
516 ));
517 }
518 let identity_error =
519 matrix_distance(rotation.transpose().mul_mat3(rotation), Mat3::<f64>::identity());
520 let determinant = rotation.m[0][0]
521 * (rotation.m[1][1] * rotation.m[2][2] - rotation.m[1][2] * rotation.m[2][1])
522 - rotation.m[0][1]
523 * (rotation.m[1][0] * rotation.m[2][2] - rotation.m[1][2] * rotation.m[2][0])
524 + rotation.m[0][2]
525 * (rotation.m[1][0] * rotation.m[2][1] - rotation.m[1][1] * rotation.m[2][0]);
526 if identity_error > 1e-6 || (determinant - 1.0).abs() > 1e-6 {
527 return Err(CalibrationError::InvalidDataset(
528 "rotation matrix must be right-handed and orthonormal".to_owned(),
529 ));
530 }
531 Ok(())
532}
533
534fn matrix_distance(left: Mat3<f64>, right: Mat3<f64>) -> f64 {
535 left.m
536 .iter()
537 .flatten()
538 .zip(right.m.iter().flatten())
539 .map(|(left, right)| (left - right).powi(2))
540 .sum::<f64>()
541 .sqrt()
542}
543
544fn fit_slope_intercept(
545 x: &[f64],
546 y: &[f64],
547 weights: &[f64],
548) -> Result<(f64, f64), CalibrationError> {
549 let mut normal = vec![vec![0.0; 2]; 2];
550 let mut rhs = vec![0.0; 2];
551 for ((&x, &y), &weight) in x.iter().zip(y).zip(weights) {
552 accumulate_normal(&mut normal, &mut rhs, &[x, 1.0], y, weight);
553 }
554 let values = solved(normal, rhs)?;
555 Ok((values[0], values[1]))
556}
557
558fn accumulate_normal(
559 normal: &mut [Vec<f64>],
560 rhs: &mut [f64],
561 row: &[f64],
562 target: f64,
563 weight: f64,
564) {
565 for i in 0..row.len() {
566 rhs[i] += weight * row[i] * target;
567 for j in 0..row.len() {
568 normal[i][j] += weight * row[i] * row[j];
569 }
570 }
571}
572
573fn solved(normal: Vec<Vec<f64>>, rhs: Vec<f64>) -> Result<Vec<f64>, CalibrationError> {
574 match solve_linear_system(normal, rhs) {
575 LeastSquaresResult::Solved(values) => Ok(values),
576 LeastSquaresResult::Singular => Err(CalibrationError::Singular),
577 }
578}
579
580fn update_huber_weights(residuals: &[f64], delta: f64, weights: &mut [f64]) {
581 for (weight, &residual) in weights.iter_mut().zip(residuals) {
582 *weight = huber_weight(residual, delta);
583 }
584}
585
586fn huber_weight(residual: f64, delta: f64) -> f64 {
587 if residual <= delta || residual <= f64::EPSILON {
588 1.0
589 } else {
590 delta / residual
591 }
592}
593
594fn pixel_distance(left: Vec2<f64>, right: Vec2<f64>) -> f64 {
595 (left.x - right.x).hypot(left.y - right.y)
596}
597
598fn report(residuals: &[f64], iterations: usize, converged: bool) -> CalibrationReport {
599 let sum_squared = residuals.iter().map(|value| value * value).sum::<f64>();
600 CalibrationReport {
601 rms_residual: (sum_squared / residuals.len().max(1) as f64).sqrt(),
602 max_residual: residuals.iter().copied().fold(0.0, f64::max),
603 observation_count: residuals.len(),
604 iterations,
605 converged,
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 fn camera() -> PinholeCamera {
614 PinholeCamera::new(CameraIntrinsics::try_new(500.0, 510.0, 320.0, 240.0, 640, 480).unwrap())
615 }
616
617 fn rotation_z(angle: f64) -> Mat3<f64> {
618 let (sin, cos) = angle.sin_cos();
619 Mat3::from_rows([cos, -sin, 0.0], [sin, cos, 0.0], [0.0, 0.0, 1.0])
620 }
621
622 fn rotation_x(angle: f64) -> Mat3<f64> {
623 let (sin, cos) = angle.sin_cos();
624 Mat3::from_rows([1.0, 0.0, 0.0], [0.0, cos, -sin], [0.0, sin, cos])
625 }
626
627 fn rotation_y(angle: f64) -> Mat3<f64> {
628 let (sin, cos) = angle.sin_cos();
629 Mat3::from_rows([cos, 0.0, sin], [0.0, 1.0, 0.0], [-sin, 0.0, cos])
630 }
631
632 #[test]
633 fn robust_mono_recovers_intrinsics_with_one_outlier() {
634 let expected = camera();
635 let mut observations = Vec::new();
636 for y in -3_i32..=3 {
637 for x in -4_i32..=4 {
638 let point =
639 Vec3::new(x as f64 * 0.08, y as f64 * 0.07, 1.5 + (x + y).abs() as f64 * 0.03);
640 observations.push(PinholeObservation {
641 camera_point: point,
642 pixel: expected.project(point).unwrap(),
643 });
644 }
645 }
646 observations[0].pixel.x += 80.0;
647 let (calibrated, report) = calibrate_pinhole(
648 &observations,
649 640,
650 480,
651 CalibrationOptions { huber_delta: 1.0, ..CalibrationOptions::default() },
652 )
653 .unwrap();
654 assert!((calibrated.intrinsics.fx - 500.0).abs() < 0.2);
655 assert!((calibrated.intrinsics.fy - 510.0).abs() < 1e-8);
656 assert!((calibrated.intrinsics.cx - 320.0).abs() < 0.1);
657 assert_eq!(report.observation_count, observations.len());
658 }
659
660 #[test]
661 fn fisheye_fit_recovers_angle_polynomial() {
662 let expected = KannalaBrandt4 { k1: 0.03, k2: -0.004, k3: 0.0005, k4: -0.00003 };
663 let observations = (1..=12)
664 .map(|index| {
665 let theta = index as f64 * 0.08;
666 let theta2 = theta * theta;
667 FisheyeObservation {
668 theta,
669 distorted_radius: theta
670 * (1.0
671 + theta2
672 * (expected.k1
673 + theta2
674 * (expected.k2
675 + theta2 * (expected.k3 + theta2 * expected.k4)))),
676 }
677 })
678 .collect::<Vec<_>>();
679 let (actual, report) = calibrate_fisheye(&observations).unwrap();
680 assert!((actual.k1 - expected.k1).abs() < 1e-9);
681 assert!((actual.k4 - expected.k4).abs() < 1e-9);
682 assert!(report.rms_residual < 1e-12);
683 }
684
685 #[test]
686 fn stereo_translation_matches_known_transform() {
687 let camera = camera();
688 let rotation = rotation_z(0.03);
689 let translation = Vec3::new(-0.2, 0.01, 0.005);
690 let pairs = (0..8)
691 .map(|index| {
692 let left = Vec3::new(index as f64 * 0.1 - 0.3, 0.05 * index as f64, 2.0);
693 StereoPointPair { left, right: rotation.mul_vec3(left) + translation }
694 })
695 .collect::<Vec<_>>();
696 let result = calibrate_stereo_translation(camera, camera, rotation, &pairs).unwrap();
697 assert!((result.right_from_left.translation - translation).length() < 1e-12);
698 assert!(result.report.rms_residual < 1e-12);
699 }
700
701 #[test]
702 fn hand_eye_translation_satisfies_ax_xb() {
703 let expected = Vec3::new(0.12, -0.04, 0.3);
704 let pairs = [rotation_x(0.4), rotation_y(-0.7), rotation_z(1.1)]
705 .into_iter()
706 .enumerate()
707 .map(|(index, rotation)| {
708 let robot_translation = Vec3::new(0.03 * index as f64, 0.02, -0.01);
709 HandEyeMotionPair {
710 robot_motion: RigidTransform3 { rotation, translation: robot_translation },
711 camera_motion: RigidTransform3 {
712 rotation,
713 translation: robot_translation + (rotation.mul_vec3(expected) - expected),
714 },
715 }
716 })
717 .collect::<Vec<_>>();
718 let (result, report) =
719 calibrate_hand_eye_translation(&pairs, Mat3::<f64>::identity()).unwrap();
720 assert!((result.translation - expected).length() < 1e-10);
721 assert!(report.rms_residual < 1e-10);
722 }
723
724 #[test]
725 fn fixed_camera_bundle_reduces_reprojection_error() {
726 let camera = camera();
727 let views = vec![
728 BundleView {
729 camera,
730 camera_from_world: RigidTransform3 {
731 rotation: Mat3::<f64>::identity(),
732 translation: Vec3::new(0.0, 0.0, 0.0),
733 },
734 },
735 BundleView {
736 camera,
737 camera_from_world: RigidTransform3 {
738 rotation: Mat3::<f64>::identity(),
739 translation: Vec3::new(-0.4, 0.0, 0.0),
740 },
741 },
742 ];
743 let truth = [Vec3::new(0.1, -0.1, 2.5), Vec3::new(-0.2, 0.15, 3.0)];
744 let mut observations = Vec::new();
745 for (point_index, &point) in truth.iter().enumerate() {
746 for (view_index, &view) in views.iter().enumerate() {
747 observations.push(BundleObservation {
748 view_index,
749 point_index,
750 pixel: project_world(view, point).unwrap(),
751 });
752 }
753 }
754 let mut problem = BundleProblem {
755 views,
756 points: truth.iter().map(|point| *point + Vec3::new(0.08, -0.04, 0.2)).collect(),
757 observations,
758 };
759 let before = report(&bundle_residuals(&problem).unwrap(), 0, false).rms_residual;
760 let after = bundle_adjust_points(&mut problem, CalibrationOptions::default()).unwrap();
761 assert!(after.rms_residual < before * 1e-4);
762 assert!(after.rms_residual < 1e-6);
763 }
764
765 #[test]
766 fn calibration_contracts_reject_invalid_rotations_and_indices() {
767 let camera = camera();
768 let reflection = Mat3::from_rows([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]);
769 let pairs =
770 [StereoPointPair { left: Vec3::new(0.0, 0.0, 1.0), right: Vec3::new(0.0, 0.0, 1.0) };
771 3];
772 assert!(calibrate_stereo_translation(camera, camera, reflection, &pairs).is_err());
773
774 let mut problem = BundleProblem {
775 views: vec![BundleView {
776 camera,
777 camera_from_world: RigidTransform3 {
778 rotation: Mat3::<f64>::identity(),
779 translation: Vec3::new(0.0, 0.0, 0.0),
780 },
781 }],
782 points: vec![Vec3::new(0.0, 0.0, 2.0)],
783 observations: vec![BundleObservation {
784 view_index: 1,
785 point_index: 0,
786 pixel: Vec2 { x: 0.0, y: 0.0 },
787 }],
788 };
789 assert!(bundle_adjust_points(&mut problem, CalibrationOptions::default()).is_err());
790 }
791}