Skip to content

Stereo Calibration ExampleΒΆ

This example demonstrates a two-camera calibration workflow in Rust, including:

  • ChArUco feature detection for both camera streams
  • Joint stereo optimization
  • Triangulation uncertainty estimation from covariance
  • Multi-camera error map generation and export to disk
use image::{GrayImage, ImageBuffer, Luma};
use libcalibgeom::{
    error_maps_multi, find_charuco_board, residual_statistics,
    subpix_saddle_point_polynomium, validate_license_with_env, ArucoDictionary, Calibration,
    Camera, CameraModelType, DetectionSettings, ImageDetection, InitializationSettings,
    OptimizationSettings, ParameterState, Point2f64, PoseRt, Target,
};
use std::fs;
use std::path::{Path, PathBuf};

fn charuco_object_points(rows: u32, columns: u32, spacing: f64) -> Vec<f64> {
    let mut xyz = Vec::with_capacity((rows * columns * 3) as usize);
    for r in 0..rows {
        for c in 0..columns {
            xyz.push(c as f64 * spacing);
            xyz.push(r as f64 * spacing);
            xyz.push(0.0);
        }
    }
    xyz
}

fn list_pngs(dir: &Path) -> Vec<PathBuf> {
    let mut paths: Vec<PathBuf> = match fs::read_dir(dir) {
        Ok(entries) => entries
            .filter_map(Result::ok)
            .map(|entry| entry.path())
            .filter(|path| {
                path.extension()
                    .and_then(|ext| ext.to_str())
                    .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
            })
            .collect(),
        Err(_) => Vec::new(),
    };
    paths.sort();
    paths
}

fn dense_points_from_ids(ids: &[u32], points: &[Point2f64], total_points: usize) -> Option<Vec<Point2f64>> {
    if ids.len() != points.len() {
        return None;
    }

    let mut dense = vec![Point2f64 { x: f64::NAN, y: f64::NAN }; total_points];
    for (id, point) in ids.iter().zip(points.iter()) {
        let index = *id as usize;
        if index >= total_points {
            return None;
        }
        dense[index] = *point;
    }
    Some(dense)
}

fn finite_point_count(points: &[Point2f64]) -> usize {
    points
        .iter()
        .filter(|p| p.x.is_finite() && p.y.is_finite())
        .count()
}

fn configure_camera(width: u32, height: u32, name: &str) -> Option<Camera> {
    let mut camera = Camera::new(CameraModelType::OpenCV, width, height, name)?;

    let _ = camera.set_parameter_state("f", ParameterState::Free)
        && camera.set_parameter_state("ar", ParameterState::Free)
        && camera.set_parameter_state("cx", ParameterState::Free)
        && camera.set_parameter_state("cy", ParameterState::Free)
        && camera.set_parameter_state("k1", ParameterState::Free)
        && camera.set_parameter_state("k2", ParameterState::Free)
        && camera.set_parameter_state("k3", ParameterState::Fixed)
        && camera.set_parameter_state("p1", ParameterState::Fixed)
        && camera.set_parameter_state("p2", ParameterState::Fixed);

    Some(camera)
}

fn normalize_luma32f_to_u8(
    image: &ImageBuffer<Luma<f32>, Vec<f32>>,
) -> ImageBuffer<Luma<u8>, Vec<u8>> {
    let mut min_value = f32::INFINITY;
    let mut max_value = f32::NEG_INFINITY;
    for pixel in image.pixels() {
        let value = pixel[0];
        if value.is_finite() {
            min_value = min_value.min(value);
            max_value = max_value.max(value);
        }
    }

    let range = if min_value.is_finite() && max_value.is_finite() {
        max_value - min_value
    } else {
        0.0
    };

    ImageBuffer::<Luma<u8>, Vec<u8>>::from_fn(image.width(), image.height(), |x, y| {
        let value = image.get_pixel(x, y)[0];
        let mapped = if !value.is_finite() || range <= f32::EPSILON {
            0u8
        } else {
            let normalized = ((value - min_value) / range).clamp(0.0, 1.0);
            (normalized * 255.0).round() as u8
        };
        Luma([mapped])
    })
}

fn main() {
    let licensed = validate_license_with_env();
    println!("license_valid: {licensed}");
    if !licensed {
        return;
    }

    let mut calibration = Calibration::new().expect("failed to create calibration object");

    let rows = 26u32;
    let columns = 38u32;
    let total_points = (rows * columns) as usize;
    let marker_size = 0.5f64;
    let feature_spacing = 0.025f64;
    let min_detected_features_per_view = 80usize;

    let data_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data/charuco_stereo");
    let cam0_dir = data_root.join("cam0");
    let cam1_dir = data_root.join("cam1");

    let cam0_paths = list_pngs(&cam0_dir);
    let cam1_paths = list_pngs(&cam1_dir);
    let pair_count = cam0_paths.len().min(cam1_paths.len());

    println!("dataset_root: {}", data_root.display());
    println!("cam0_images: {}", cam0_paths.len());
    println!("cam1_images: {}", cam1_paths.len());
    println!("paired_images: {pair_count}");

    if pair_count == 0 {
        println!("no image pairs found");
        return;
    }

    let first_image = match image::open(&cam0_paths[0]) {
        Ok(image) => image.to_luma8(),
        Err(error) => {
            println!("failed_to_open_first_cam0_image: {error}");
            return;
        }
    };
    let image_width = first_image.width();
    let image_height = first_image.height();

    let mut target = Target::new().expect("failed to create target");
    let target_ok = target.set_object_points_xyz(&charuco_object_points(rows, columns, feature_spacing));
    if !target_ok {
        println!("failed_to_set_target_object_points");
        return;
    }

    let Some(camera0) = configure_camera(image_width, image_height, "cam0") else {
        println!("failed_to_create_camera0");
        return;
    };
    let Some(camera1) = configure_camera(image_width, image_height, "cam1") else {
        println!("failed_to_create_camera1");
        return;
    };

    calibration.reset();
    if calibration.add_target(&target).is_none()
        || calibration.add_camera(&camera0).is_none()
        || calibration.add_camera(&camera1).is_none()
    {
        println!("failed_to_configure_calibration_graph");
        return;
    }

    println!(
        "configured cameras={} targets={} poses={}",
        calibration.cameras().len(),
        calibration.targets().len(),
        calibration.poses().len()
    );

    let detector_settings = DetectionSettings::new();
    let mut image_detections = Vec::new();

    for index in 0..pair_count {
        let cam0_path = &cam0_paths[index];
        let cam1_path = &cam1_paths[index];

        let image0: GrayImage = match image::open(cam0_path) {
            Ok(image) => image.to_luma8(),
            Err(error) => {
                println!("failed_to_open {}: {error}", cam0_path.display());
                continue;
            }
        };
        let image1: GrayImage = match image::open(cam1_path) {
            Ok(image) => image.to_luma8(),
            Err(error) => {
                println!("failed_to_open {}: {error}", cam1_path.display());
                continue;
            }
        };

        if image0.width() != image_width
            || image0.height() != image_height
            || image1.width() != image_width
            || image1.height() != image_height
        {
            println!("skipping_pair_size_mismatch index={index}");
            continue;
        }

        let detection0 = find_charuco_board(
            &image0,
            rows,
            columns,
            ArucoDictionary::Dict4x4,
            0,
            marker_size,
            &detector_settings,
        )
        .and_then(|(points, ids)| subpix_saddle_point_polynomium(&image0, 5, &points, 50).map(|refined| (refined, ids)))
        .and_then(|(points, ids)| dense_points_from_ids(&ids, &points, total_points));

        let detection1 = find_charuco_board(
            &image1,
            rows,
            columns,
            ArucoDictionary::Dict4x4,
            0,
            marker_size,
            &detector_settings,
        )
        .and_then(|(points, ids)| subpix_saddle_point_polynomium(&image1, 5, &points, 50).map(|refined| (refined, ids)))
        .and_then(|(points, ids)| dense_points_from_ids(&ids, &points, total_points));

        let valid0 = detection0
            .as_ref()
            .map(|points| finite_point_count(points))
            .unwrap_or(0);
        let valid1 = detection1
            .as_ref()
            .map(|points| finite_point_count(points))
            .unwrap_or(0);

        if valid0 < min_detected_features_per_view && valid1 < min_detected_features_per_view {
            println!("pair[{index}] skipped: insufficient detections (cam0={valid0}, cam1={valid1})");
            continue;
        }

        let Some(pose_id) = calibration.add_pose(PoseRt {
            rvec: [0.0, 0.0, 0.0],
            tvec: [0.0, 0.0, 0.0],
        }) else {
            println!("pair[{index}] failed_to_add_pose");
            continue;
        };

        if let Some(points) = detection0 {
            if valid0 >= min_detected_features_per_view {
                image_detections.push(ImageDetection {
                    camera_id: 0,
                    pose_id,
                    target_id: 0,
                    points,
                });
            }
        }

        if let Some(points) = detection1 {
            if valid1 >= min_detected_features_per_view {
                image_detections.push(ImageDetection {
                    camera_id: 1,
                    pose_id,
                    target_id: 0,
                    points,
                });
            }
        }

        println!("pair[{index}] accepted: cam0={valid0}, cam1={valid1}");
    }

    println!("detections_total: {}", image_detections.len());
    println!("poses_total: {}", calibration.poses().len());

    if image_detections.len() < 6 {
        println!("not_enough_detections_for_stereo_calibration");
        return;
    }

    let init_report = calibration.initialize(
        &image_detections,
        None,
        None,
        InitializationSettings::default(),
    );
    println!(
        "initialize_succeeded: {}, status: {:?}",
        init_report.succeeded, init_report.status
    );

    if !init_report.succeeded {
        return;
    }

    let optimize_report = calibration.optimize(
        &image_detections,
        None,
        None,
        OptimizationSettings {
            max_iterations: 80,
            ..OptimizationSettings::default()
        },
    );
    println!(
        "optimize_succeeded: {}, converged: {}, rpe_rms: {}, status: {:?}",
        optimize_report.succeeded,
        optimize_report.converged,
        optimize_report.rpe_rms,
        optimize_report.status
    );

    if let Some(stats) = residual_statistics(&optimize_report.reprojection_errors) {
        println!(
            "residual_stats rms={} mean={} median={}",
            stats.rms, stats.mean, stats.median
        );
    }

    let output_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../output/stereo_calibration");
    if let Err(error) = fs::create_dir_all(&output_dir) {
        println!("failed_to_create_output_dir {}: {error}", output_dir.display());
        return;
    }

    let covariance = calibration.estimate_covariance(&image_detections);
    println!(
        "covariance_succeeded: {}, status: {:?}, shape={}x{}",
        covariance.succeeded, covariance.status, covariance.rows, covariance.cols
    );

    if covariance.succeeded {
        if let Some(triangulation) = calibration.estimate_triangulation_error(&covariance, 1.0, 0.2)
        {
            println!(
                "triangulation_uncertainty rms={} max={}",
                triangulation.rms,
                triangulation.max
            );

            let summary_path = output_dir.join("triangulation_uncertainty.txt");
            let mut summary = String::new();
            summary.push_str(&format!("rms={}\n", triangulation.rms));
            summary.push_str(&format!("max={}\n", triangulation.max));
            let _ = fs::write(&summary_path, summary);
            println!("wrote: {}", summary_path.display());
        }
    }

    let image_sizes = vec![(image_width, image_height), (image_width, image_height)];
    if let Some(maps) = error_maps_multi(&optimize_report.reprojection_errors, &image_sizes)
    {
        for (camera_index, maps) in maps.into_iter().enumerate() {
            let magnitude_path = output_dir.join(format!("error_map_cam{}_magnitude.png", camera_index));
            let direction_path = output_dir.join(format!("error_map_cam{}_direction.png", camera_index));

            let magnitude_u8 = normalize_luma32f_to_u8(&maps.magnitude);
            let magnitude_ok = magnitude_u8.save(&magnitude_path).is_ok();
            let direction_ok = maps.directions_rgb.save(&direction_path).is_ok();

            println!(
                "error_maps_cam{} magnitude_saved={} direction_saved={}",
                camera_index, magnitude_ok, direction_ok
            );
        }
    } else {
        println!("failed_to_compute_error_maps_multi");
    }

    println!("output_dir: {}", output_dir.display());
    println!("{}", calibration.to_string().unwrap_or_default());
}

The example writes outputs to output/stereo_calibration/, including triangulation uncertainty summaries and per-camera error maps.