Multi-Camera Rig Hand-Eye

This is the most complex calibration workflow: a multi-camera rig mounted on a robot arm. It combines per-camera intrinsics calibration, rig extrinsics estimation, and hand-eye calibration in a 6-step pipeline.

Problem Formulation

Transformation Chain

Frames follow the frame_se3_frame convention: T_{A,B} maps coordinates of frame into frame . For camera in view with robot pose (eye-in-hand):

where:

  • (cam_se3_rig): rig to camera (rig extrinsics)
  • (gripper_se3_rig): rig to gripper (the hand-eye transform)
  • (base_se3_gripper): known robot pose for view
  • (base_se3_target): target in the robot base frame (calibrated)

In eye-to-hand mode the export instead carries rig_se3_base and gripper_se3_target.

Parameters

  • Per-camera intrinsics: ( scalar parameters)
  • Per-camera distortion: ( scalar parameters)
  • Per-camera extrinsics: ( DOF, reference camera = identity)
  • Hand-eye: (6 DOF)
  • Target pose: (6 DOF)
  • Optionally: per-view robot corrections ( DOF, regularized)

6-Step Pipeline

Steps 1-2: Per-Camera Intrinsics

Same as Rig Extrinsics: initialize and optimize each camera's intrinsics independently.

Steps 3-4: Rig Extrinsics

Same as Rig Extrinsics: initialize camera-to-rig transforms via SE(3) averaging, then jointly optimize the rig geometry.

Step 5: Hand-Eye Initialization (step_handeye_init)

Uses the closed-form method of handeye_init.method (Tsai-Lenz by default; see Hand-Eye Linear) with the rig's poses and the robot poses:

  1. Extract relative camera motions from rig-to-target poses
  2. Extract relative robot motions from base-to-gripper poses
  3. Solve for (gripper_se3_rig)
  4. Estimate the target in the base frame,

Step 6: Hand-Eye Optimization (step_handeye_optimize)

Joint optimization of all parameters:

Parameters: per-camera intrinsics and distortion, per-camera camera-to-rig extrinsics (SE3), the hand-eye transform (SE3), and the target pose (SE3).

Factor: one ReprojPoint with a rig hand-eye chain per observation, which composes the full transform chain.

Configuration

RigHandeyeConfig groups the settings by stage:

#![allow(unused)]
fn main() {
pub struct RigHandeyeConfig {
    pub intrinsics: IntrinsicsInitConfig,   // per-camera linear init
    pub manual_init: Option<IntrinsicsManualInit>, // optional per-camera seeds
    pub camera: CameraConfig,               // camera model + fix lists shared by every camera
    pub camera_overrides: Vec<CameraOverride>, // per-camera replacement of `camera`
    pub rig: RigConfig,                     // reference_camera_idx, refine_intrinsics, refine_sensor
    pub handeye_init: HandeyeInitConfig,    // handeye_mode, method, min_motion_angle_deg
    pub solver: SolverConfig,               // max_iters, verbosity, robust_loss
    pub handeye_optimize: HandeyeOptimizeConfig, // robot_poses, refine_cam_se3_rig, refine_sensor
}
}
#![allow(unused)]
fn main() {
session.update_config(|c| {
    c.handeye_init.handeye_mode = HandeyeMode::EyeInHand;
    c.solver.robust_loss = RobustLoss::Huber { scale: 2.0 };
    c.handeye_optimize.robot_poses.refine = true;
})?;
}

Set camera.model.sensor to SensorKind::Scheimpflug for a rig of tilted-sensor cameras, or name single cameras in camera_overrides to mix lenses (see Scheimpflug and Laser Rigs). The export's cameras holds one CameraParams per camera.

Complete Example

#![allow(unused)]
fn main() {
use vision_calibration::prelude::*;
use vision_calibration::rig_handeye::*;
use vision_calibration::common::HandeyeSolution;
use vision_calibration::optim::{HandeyeMode, RobustLoss};

let mut session = CalibrationSession::<RigHandeyeProblem>::new();
session.set_input(rig_dataset_with_robot_poses)?;

// Per-camera calibration
step_intrinsics_init_all(&mut session, None)?;
step_intrinsics_optimize_all(&mut session, None)?;

// Rig geometry
step_rig_init(&mut session, None)?;
step_rig_optimize(&mut session, None)?;

// Hand-eye
step_handeye_init(&mut session, None)?;
step_handeye_optimize(&mut session, None)?;

let export = session.export()?;
match export.handeye {
    HandeyeSolution::EyeInHand { gripper_se3_rig, base_se3_target } => {
        println!("gripper_se3_rig: {gripper_se3_rig:?}, base_se3_target: {base_se3_target:?}");
    }
    HandeyeSolution::EyeToHand { rig_se3_base, gripper_se3_target } => {
        println!("rig_se3_base: {rig_se3_base:?}, gripper_se3_target: {gripper_se3_target:?}");
    }
}
println!("Baseline: {:.1} mm",
    export.cam_se3_rig[1].translation.vector.norm() * 1000.0);
println!("Per-camera errors: {:?}", export.per_cam_reproj_errors);
}

Gauge Freedom

The system has a gauge freedom: the rig frame origin and the hand-eye transform are coupled. Fixing the reference camera's extrinsics at identity resolves this by defining the rig frame to coincide with camera 0.

Practical Considerations

All the advice from Single-Camera Hand-Eye applies, plus:

  • All cameras must observe the target in at least some views for the rig extrinsics to be well-constrained
  • Views where only some cameras see the target are handled (missing observations are skipped)
  • The hand-eye transform (gripper_se3_rig) relates the gripper and the rig frame, not the gripper and an individual camera. The per-camera offset comes from the rig extrinsics.