Skip to main content

spatialrust_camera/
calibration.rs

1//! Deterministic camera calibration contracts and small dense solvers.
2
3use spatialrust_math::{solve_linear_system, LeastSquaresResult, Mat3, Vec2, Vec3};
4
5use crate::{CameraIntrinsics, KannalaBrandt4, PinholeCamera};
6
7/// Calibration input or numerical failure.
8#[derive(Clone, Debug, PartialEq, thiserror::Error)]
9pub enum CalibrationError {
10    /// The dataset is empty, non-finite, inconsistent, or underconstrained.
11    #[error("invalid calibration dataset: {0}")]
12    InvalidDataset(String),
13    /// A normal equation was singular or ill-conditioned.
14    #[error("calibration normal equation is singular")]
15    Singular,
16    /// Projection failed while evaluating residuals.
17    #[error("calibration projection failed: {0}")]
18    Projection(String),
19}
20
21/// Shared robust least-squares controls.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct CalibrationOptions {
24    /// Maximum robust/refinement iterations.
25    pub max_iterations: usize,
26    /// Huber transition in residual units; must be finite and positive.
27    pub huber_delta: f64,
28    /// Parameter-step convergence threshold.
29    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/// Common numerical receipt returned by calibration solvers.
39#[derive(Clone, Copy, Debug, Default, PartialEq)]
40pub struct CalibrationReport {
41    /// Root-mean-square residual in pixels or the solver's documented units.
42    pub rms_residual: f64,
43    /// Maximum residual magnitude.
44    pub max_residual: f64,
45    /// Number of observations evaluated.
46    pub observation_count: usize,
47    /// Iterations executed.
48    pub iterations: usize,
49    /// Whether the parameter step reached the configured tolerance.
50    pub converged: bool,
51}
52
53/// Known camera-space point and observed image pixel for mono calibration.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct PinholeObservation {
56    /// Point expressed in the camera frame.
57    pub camera_point: Vec3<f64>,
58    /// Measured image pixel.
59    pub pixel: Vec2<f64>,
60}
61
62/// Fits `fx, fy, cx, cy` from known camera-space points with robust reweighting.
63pub 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/// Fisheye angle/radius sample in normalized image coordinates.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct FisheyeObservation {
149    /// Incident ray angle in radians.
150    pub theta: f64,
151    /// Measured distorted normalized radius.
152    pub distorted_radius: f64,
153}
154
155/// Fits a Kannala–Brandt four-coefficient angle polynomial.
156pub 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/// Rigid transform represented as a rotation matrix and translation.
198#[derive(Clone, Copy, Debug, PartialEq)]
199pub struct RigidTransform3 {
200    /// Source-to-destination rotation.
201    pub rotation: Mat3<f64>,
202    /// Source-to-destination translation.
203    pub translation: Vec3<f64>,
204}
205
206impl RigidTransform3 {
207    /// Applies the transform to a point.
208    #[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/// Matched 3D point expressed in left and right camera frames.
215#[derive(Clone, Copy, Debug, PartialEq)]
216pub struct StereoPointPair {
217    /// Point in the left camera frame.
218    pub left: Vec3<f64>,
219    /// Same point in the right camera frame.
220    pub right: Vec3<f64>,
221}
222
223/// Stereo calibration result with explicit right-from-left transform.
224#[derive(Clone, Copy, Debug, PartialEq)]
225pub struct StereoCalibration {
226    /// Left calibrated camera.
227    pub left: PinholeCamera,
228    /// Right calibrated camera.
229    pub right: PinholeCamera,
230    /// Transform mapping left-camera points into the right camera.
231    pub right_from_left: RigidTransform3,
232    /// 3D alignment residual receipt.
233    pub report: CalibrationReport,
234}
235
236/// Fits stereo translation for a supplied relative rotation.
237pub 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/// Robot/camera relative motions for `A X = X B` hand-eye calibration.
275#[derive(Clone, Copy, Debug, PartialEq)]
276pub struct HandEyeMotionPair {
277    /// Robot/end-effector motion `A`.
278    pub robot_motion: RigidTransform3,
279    /// Camera motion `B`.
280    pub camera_motion: RigidTransform3,
281}
282
283/// Solves hand-eye translation for a supplied hand-eye rotation.
284pub 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/// Calibrated camera and world-to-camera pose used by bundle adjustment.
339#[derive(Clone, Copy, Debug, PartialEq)]
340pub struct BundleView {
341    /// Camera intrinsics/distortion.
342    pub camera: PinholeCamera,
343    /// World-to-camera transform.
344    pub camera_from_world: RigidTransform3,
345}
346
347/// One 2D observation of a bundle point.
348#[derive(Clone, Copy, Debug, PartialEq)]
349pub struct BundleObservation {
350    /// Index into [`BundleProblem::views`].
351    pub view_index: usize,
352    /// Index into [`BundleProblem::points`].
353    pub point_index: usize,
354    /// Measured image pixel.
355    pub pixel: Vec2<f64>,
356}
357
358/// Sparse fixed-camera bundle problem.
359#[derive(Clone, Debug, PartialEq)]
360pub struct BundleProblem {
361    /// Fixed calibrated camera views.
362    pub views: Vec<BundleView>,
363    /// Mutable world-space points.
364    pub points: Vec<Vec3<f64>>,
365    /// Sparse image observations.
366    pub observations: Vec<BundleObservation>,
367}
368
369/// Refines world-space points while keeping calibrated camera poses fixed.
370pub 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}