Skip to main content

vision_calibration_pipeline/rig_handeye/
problem.rs

1//! [`ProblemType`] implementation for multi-camera rig hand-eye calibration.
2//!
3//! This module provides the `RigHandeyeProblem` type that implements
4//! the v2 session API's `ProblemType` trait.
5
6use crate::Error;
7use serde::{Deserialize, Serialize};
8use vision_calibration_core::{Iso3, PinholeCamera};
9use vision_calibration_optim::{
10    HandEyeEstimate, HandEyeMode, RigDataset, RobotPoseMeta, RobustLoss,
11};
12#[cfg(test)]
13use vision_calibration_optim::{HandEyeParams, SolveReport};
14
15use crate::session::{InvalidationPolicy, ProblemType};
16
17use super::state::RigHandeyeState;
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Input Type
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Input for rig hand-eye calibration.
24///
25/// Reuses `RigDataset<RobotPoseMeta>` from vision_calibration_optim.
26/// Each view contains robot pose metadata and per-camera observations.
27pub type RigHandeyeInput = RigDataset<RobotPoseMeta>;
28
29// ─────────────────────────────────────────────────────────────────────────────
30// Config
31// ─────────────────────────────────────────────────────────────────────────────
32
33/// Configuration for multi-camera rig hand-eye calibration.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35#[non_exhaustive]
36pub struct RigHandeyeConfig {
37    /// Per-camera intrinsics initialization options.
38    pub intrinsics: RigHandeyeIntrinsicsConfig,
39    /// Rig and gauge options.
40    pub rig: RigHandeyeRigConfig,
41    /// Hand-eye linear initialization options.
42    pub handeye_init: RigHandeyeInitConfig,
43    /// Shared solver settings for optimization stages.
44    pub solver: RigHandeyeSolverConfig,
45    /// Final hand-eye bundle-adjustment options.
46    pub handeye_ba: RigHandeyeBaConfig,
47}
48
49/// Per-camera intrinsics initialization options for rig hand-eye calibration.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[non_exhaustive]
52pub struct RigHandeyeIntrinsicsConfig {
53    /// Number of iterations for iterative intrinsics estimation.
54    pub init_iterations: usize,
55    /// Fix k3 during intrinsics calibration.
56    pub fix_k3: bool,
57    /// Fix tangential distortion (p1, p2).
58    pub fix_tangential: bool,
59    /// Enforce zero skew.
60    pub zero_skew: bool,
61}
62
63impl Default for RigHandeyeIntrinsicsConfig {
64    fn default() -> Self {
65        Self {
66            init_iterations: 2,
67            fix_k3: true,
68            fix_tangential: false,
69            zero_skew: true,
70        }
71    }
72}
73
74/// Rig-specific options for rig hand-eye calibration.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct RigHandeyeRigConfig {
78    /// Reference camera index for rig frame (identity extrinsics).
79    pub reference_camera_idx: usize,
80    /// Re-refine intrinsics in rig BA (default: false).
81    pub refine_intrinsics_in_rig_ba: bool,
82    /// Fix first rig pose for gauge freedom (default: true, fixes view 0).
83    pub fix_first_rig_pose: bool,
84}
85
86impl Default for RigHandeyeRigConfig {
87    fn default() -> Self {
88        Self {
89            reference_camera_idx: 0,
90            refine_intrinsics_in_rig_ba: false,
91            fix_first_rig_pose: true,
92        }
93    }
94}
95
96/// Hand-eye linear initialization options for rig hand-eye calibration.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[non_exhaustive]
99pub struct RigHandeyeInitConfig {
100    /// Hand-eye mode: EyeInHand or EyeToHand.
101    pub handeye_mode: HandEyeMode,
102    /// Minimum motion angle (degrees) for linear hand-eye initialization.
103    pub min_motion_angle_deg: f64,
104}
105
106impl Default for RigHandeyeInitConfig {
107    fn default() -> Self {
108        Self {
109            handeye_mode: HandEyeMode::EyeInHand,
110            min_motion_angle_deg: 5.0,
111        }
112    }
113}
114
115/// Solver options shared across rig and hand-eye optimization stages.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[non_exhaustive]
118pub struct RigHandeyeSolverConfig {
119    /// Maximum iterations for optimization.
120    pub max_iters: usize,
121    /// Verbosity level (0 = silent, 1 = summary, 2+ = detailed).
122    pub verbosity: usize,
123    /// Robust loss function for outlier handling.
124    pub robust_loss: RobustLoss,
125}
126
127impl Default for RigHandeyeSolverConfig {
128    fn default() -> Self {
129        Self {
130            max_iters: 50,
131            verbosity: 0,
132            robust_loss: RobustLoss::None,
133        }
134    }
135}
136
137/// Hand-eye bundle-adjustment options.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[non_exhaustive]
140pub struct RigHandeyeBaConfig {
141    /// Refine robot poses in hand-eye BA (default: true).
142    pub refine_robot_poses: bool,
143    /// Robot rotation sigma for prior (radians). Default: 0.5° ≈ 0.0087 rad.
144    pub robot_rot_sigma: f64,
145    /// Robot translation sigma for prior (meters). Default: 1mm = 0.001.
146    pub robot_trans_sigma: f64,
147    /// Refine cam_se3_rig in hand-eye BA (default: false).
148    /// When true, rig extrinsics are further refined during hand-eye optimization.
149    pub refine_cam_se3_rig_in_handeye_ba: bool,
150}
151
152impl Default for RigHandeyeBaConfig {
153    fn default() -> Self {
154        Self {
155            refine_robot_poses: true,
156            robot_rot_sigma: 0.5_f64.to_radians(), // 0.5 degrees
157            robot_trans_sigma: 0.001,              // 1 mm
158            refine_cam_se3_rig_in_handeye_ba: false,
159        }
160    }
161}
162
163// ─────────────────────────────────────────────────────────────────────────────
164// Export
165// ─────────────────────────────────────────────────────────────────────────────
166
167/// Export format for rig hand-eye calibration.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[non_exhaustive]
170pub struct RigHandeyeExport {
171    /// Per-camera calibrated intrinsics + distortion.
172    pub cameras: Vec<PinholeCamera>,
173
174    /// Per-camera extrinsics: `cam_se3_rig` (T_C_R).
175    /// Transform from rig frame to camera frame.
176    pub cam_se3_rig: Vec<Iso3>,
177
178    /// Hand-eye mode used to interpret mode-dependent transforms.
179    pub handeye_mode: HandEyeMode,
180
181    /// Eye-in-hand: gripper-to-rig transform `gripper_se3_rig` (T_G_R).
182    ///
183    /// `None` for EyeToHand mode.
184    pub gripper_se3_rig: Option<Iso3>,
185
186    /// Eye-to-hand: rig-to-base transform `rig_se3_base` (T_R_B).
187    ///
188    /// `None` for EyeInHand mode.
189    pub rig_se3_base: Option<Iso3>,
190
191    /// Eye-in-hand: base-to-target transform `base_se3_target` (T_B_T).
192    ///
193    /// `None` for EyeToHand mode.
194    pub base_se3_target: Option<Iso3>,
195
196    /// Eye-to-hand: gripper-to-target transform `gripper_se3_target` (T_G_T).
197    ///
198    /// `None` for EyeInHand mode.
199    pub gripper_se3_target: Option<Iso3>,
200
201    /// Per-view robot pose corrections (if refinement enabled).
202    /// Each element is [rx, ry, rz, tx, ty, tz] in se(3).
203    pub robot_deltas: Option<Vec<[f64; 6]>>,
204
205    /// Mean reprojection error (pixels).
206    pub mean_reproj_error: f64,
207
208    /// Per-camera reprojection errors (pixels).
209    pub per_cam_reproj_errors: Vec<f64>,
210}
211
212// ─────────────────────────────────────────────────────────────────────────────
213// ProblemType Implementation
214// ─────────────────────────────────────────────────────────────────────────────
215
216/// Multi-camera rig hand-eye calibration problem.
217///
218/// Calibrates a multi-camera rig mounted on a robot arm, including:
219/// - Per-camera intrinsics and distortion
220/// - Per-camera extrinsics (camera-to-rig transforms)
221/// - Rig hand-eye transform (gripper-to-rig)
222/// - Static target pose in robot base frame
223///
224/// # Conventions
225///
226/// - `cam_se3_rig` = T_C_R (transform from rig to camera frame)
227/// - EyeInHand export: `gripper_se3_rig` (T_G_R), `base_se3_target` (T_B_T)
228/// - EyeToHand export: `rig_se3_base` (T_R_B), `gripper_se3_target` (T_G_T)
229/// - Reference camera has identity extrinsics (defines rig frame)
230///
231/// # Example
232///
233/// ```no_run
234/// use vision_calibration_pipeline::session::CalibrationSession;
235/// use vision_calibration_pipeline::rig_handeye::{
236///     RigHandeyeProblem, step_intrinsics_init_all, step_intrinsics_optimize_all,
237///     step_rig_init, step_rig_optimize, step_handeye_init, step_handeye_optimize,
238/// };
239/// # fn main() -> anyhow::Result<()> {
240/// # let rig_dataset = unimplemented!();
241///
242/// let mut session = CalibrationSession::<RigHandeyeProblem>::new();
243/// session.set_input(rig_dataset)?;
244///
245/// step_intrinsics_init_all(&mut session, None)?;
246/// step_intrinsics_optimize_all(&mut session, None)?;
247/// step_rig_init(&mut session)?;
248/// step_rig_optimize(&mut session, None)?;
249/// step_handeye_init(&mut session, None)?;
250/// step_handeye_optimize(&mut session, None)?;
251///
252/// let export = session.export()?;
253/// # Ok(())
254/// # }
255/// ```
256#[derive(Debug)]
257pub struct RigHandeyeProblem;
258
259impl ProblemType for RigHandeyeProblem {
260    type Config = RigHandeyeConfig;
261    type Input = RigHandeyeInput;
262    type State = RigHandeyeState;
263    type Output = HandEyeEstimate;
264    type Export = RigHandeyeExport;
265
266    fn name() -> &'static str {
267        "rig_handeye_v2"
268    }
269
270    fn schema_version() -> u32 {
271        1
272    }
273
274    fn validate_input(input: &Self::Input) -> Result<(), Error> {
275        if input.num_views() < 3 {
276            return Err(Error::InsufficientData {
277                need: 3,
278                got: input.num_views(),
279            });
280        }
281
282        if input.num_cameras < 2 {
283            return Err(Error::InsufficientData {
284                need: 2,
285                got: input.num_cameras,
286            });
287        }
288
289        // Check each view has correct number of cameras and robot pose
290        for (i, view) in input.views.iter().enumerate() {
291            if view.obs.cameras.len() != input.num_cameras {
292                return Err(Error::invalid_input(format!(
293                    "view {} has {} cameras, expected {}",
294                    i,
295                    view.obs.cameras.len(),
296                    input.num_cameras
297                )));
298            }
299
300            // Check at least one camera has observations in this view
301            let has_obs = view.obs.cameras.iter().any(|c| c.is_some());
302            if !has_obs {
303                return Err(Error::invalid_input(format!(
304                    "view {} has no observations from any camera",
305                    i
306                )));
307            }
308        }
309
310        Ok(())
311    }
312
313    fn validate_config(config: &Self::Config) -> Result<(), Error> {
314        if config.solver.max_iters == 0 {
315            return Err(Error::invalid_input("max_iters must be positive"));
316        }
317        if config.intrinsics.init_iterations == 0 {
318            return Err(Error::invalid_input(
319                "intrinsics_init_iterations must be positive",
320            ));
321        }
322        if config.handeye_init.min_motion_angle_deg <= 0.0 {
323            return Err(Error::invalid_input(
324                "min_motion_angle_deg must be positive",
325            ));
326        }
327        if config.handeye_ba.robot_rot_sigma <= 0.0 {
328            return Err(Error::invalid_input("robot_rot_sigma must be positive"));
329        }
330        if config.handeye_ba.robot_trans_sigma <= 0.0 {
331            return Err(Error::invalid_input("robot_trans_sigma must be positive"));
332        }
333        Ok(())
334    }
335
336    fn validate_input_config(input: &Self::Input, config: &Self::Config) -> Result<(), Error> {
337        if config.rig.reference_camera_idx >= input.num_cameras {
338            return Err(Error::invalid_input(format!(
339                "reference_camera_idx {} is out of range (num_cameras = {})",
340                config.rig.reference_camera_idx, input.num_cameras
341            )));
342        }
343        Ok(())
344    }
345
346    fn on_input_change() -> InvalidationPolicy {
347        InvalidationPolicy::CLEAR_COMPUTED
348    }
349
350    fn on_config_change() -> InvalidationPolicy {
351        InvalidationPolicy::KEEP_ALL
352    }
353
354    fn export(output: &Self::Output, config: &Self::Config) -> Result<Self::Export, Error> {
355        let cam_se3_rig: Vec<Iso3> = output
356            .params
357            .cam_to_rig
358            .iter()
359            .map(|t| t.inverse())
360            .collect();
361
362        let target_pose = output
363            .params
364            .target_poses
365            .first()
366            .copied()
367            .ok_or_else(|| Error::invalid_input("no target pose in output"))?;
368
369        let (gripper_se3_rig, rig_se3_base, base_se3_target, gripper_se3_target) = match config
370            .handeye_init
371            .handeye_mode
372        {
373            HandEyeMode::EyeInHand => (Some(output.params.handeye), None, Some(target_pose), None),
374            HandEyeMode::EyeToHand => (None, Some(output.params.handeye), None, Some(target_pose)),
375        };
376
377        Ok(RigHandeyeExport {
378            cameras: output.params.cameras.clone(),
379            cam_se3_rig,
380            handeye_mode: config.handeye_init.handeye_mode,
381            gripper_se3_rig,
382            rig_se3_base,
383            base_se3_target,
384            gripper_se3_target,
385            robot_deltas: output.robot_deltas.clone(),
386            mean_reproj_error: output.mean_reproj_error,
387            per_cam_reproj_errors: output.per_cam_reproj_errors.clone(),
388        })
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use vision_calibration_core::{CorrespondenceView, Pt2, Pt3, RigDataset, RigView, RigViewObs};
396
397    fn make_minimal_obs() -> CorrespondenceView {
398        CorrespondenceView::new(
399            vec![
400                Pt3::new(0.0, 0.0, 0.0),
401                Pt3::new(0.05, 0.0, 0.0),
402                Pt3::new(0.05, 0.05, 0.0),
403                Pt3::new(0.0, 0.05, 0.0),
404            ],
405            vec![
406                Pt2::new(100.0, 100.0),
407                Pt2::new(200.0, 100.0),
408                Pt2::new(200.0, 200.0),
409                Pt2::new(100.0, 200.0),
410            ],
411        )
412        .unwrap()
413    }
414
415    fn make_minimal_input() -> RigHandeyeInput {
416        let views = (0..3)
417            .map(|_| RigView {
418                meta: RobotPoseMeta {
419                    base_se3_gripper: Iso3::identity(),
420                },
421                obs: RigViewObs {
422                    cameras: vec![Some(make_minimal_obs()), Some(make_minimal_obs())],
423                },
424            })
425            .collect();
426
427        RigDataset::new(views, 2).unwrap()
428    }
429
430    #[test]
431    fn validate_input_requires_3_views() {
432        let input = make_minimal_input();
433        let result = RigHandeyeProblem::validate_input(&input);
434        assert!(result.is_ok());
435    }
436
437    #[test]
438    fn validate_input_requires_2_cameras() {
439        // Create single-camera input (should fail)
440        let views = (0..3)
441            .map(|_| RigView {
442                meta: RobotPoseMeta {
443                    base_se3_gripper: Iso3::identity(),
444                },
445                obs: RigViewObs {
446                    cameras: vec![Some(make_minimal_obs())],
447                },
448            })
449            .collect();
450
451        let input = RigDataset::new(views, 1).unwrap();
452        let result = RigHandeyeProblem::validate_input(&input);
453        assert!(result.is_err());
454        assert!(result.unwrap_err().to_string().contains("need 2"));
455    }
456
457    #[test]
458    fn validate_config_accepts_valid() {
459        let config = RigHandeyeConfig::default();
460        let result = RigHandeyeProblem::validate_config(&config);
461        assert!(result.is_ok());
462    }
463
464    #[test]
465    fn validate_input_config_checks_reference_camera() {
466        let input = make_minimal_input();
467        let config = RigHandeyeConfig {
468            rig: RigHandeyeRigConfig {
469                reference_camera_idx: 5, // Out of range
470                ..RigHandeyeRigConfig::default()
471            },
472            ..RigHandeyeConfig::default()
473        };
474
475        let result = RigHandeyeProblem::validate_input_config(&input, &config);
476        assert!(result.is_err());
477        assert!(
478            result
479                .unwrap_err()
480                .to_string()
481                .contains("reference_camera_idx")
482        );
483    }
484
485    #[test]
486    fn config_json_roundtrip() {
487        let config = RigHandeyeConfig {
488            solver: RigHandeyeSolverConfig {
489                max_iters: 100,
490                robust_loss: RobustLoss::Huber { scale: 2.5 },
491                ..RigHandeyeSolverConfig::default()
492            },
493            rig: RigHandeyeRigConfig {
494                reference_camera_idx: 1,
495                refine_intrinsics_in_rig_ba: true,
496                ..RigHandeyeRigConfig::default()
497            },
498            handeye_init: RigHandeyeInitConfig {
499                handeye_mode: HandEyeMode::EyeToHand,
500                ..RigHandeyeInitConfig::default()
501            },
502            handeye_ba: RigHandeyeBaConfig {
503                refine_robot_poses: false,
504                ..RigHandeyeBaConfig::default()
505            },
506            ..Default::default()
507        };
508
509        let json = serde_json::to_string_pretty(&config).unwrap();
510        let restored: RigHandeyeConfig = serde_json::from_str(&json).unwrap();
511
512        assert_eq!(restored.solver.max_iters, 100);
513        assert_eq!(restored.rig.reference_camera_idx, 1);
514        assert!(restored.rig.refine_intrinsics_in_rig_ba);
515        assert!(!restored.handeye_ba.refine_robot_poses);
516    }
517
518    #[test]
519    fn problem_name_and_version() {
520        assert_eq!(RigHandeyeProblem::name(), "rig_handeye_v2");
521        assert_eq!(RigHandeyeProblem::schema_version(), 1);
522    }
523
524    fn make_dummy_output() -> HandEyeEstimate {
525        let camera = vision_calibration_core::make_pinhole_camera(
526            vision_calibration_core::FxFyCxCySkew {
527                fx: 800.0,
528                fy: 800.0,
529                cx: 640.0,
530                cy: 360.0,
531                skew: 0.0,
532            },
533            vision_calibration_core::BrownConrady5::default(),
534        );
535
536        HandEyeEstimate {
537            params: HandEyeParams {
538                cameras: vec![camera],
539                cam_to_rig: vec![Iso3::identity()],
540                handeye: Iso3::identity(),
541                target_poses: vec![Iso3::identity()],
542            },
543            report: SolveReport { final_cost: 0.0 },
544            robot_deltas: None,
545            mean_reproj_error: 0.0,
546            per_cam_reproj_errors: vec![0.0],
547        }
548    }
549
550    #[test]
551    fn export_eye_in_hand_is_explicit() {
552        let output = make_dummy_output();
553        let config = RigHandeyeConfig {
554            handeye_init: RigHandeyeInitConfig {
555                handeye_mode: HandEyeMode::EyeInHand,
556                ..RigHandeyeInitConfig::default()
557            },
558            ..Default::default()
559        };
560        let export = RigHandeyeProblem::export(&output, &config).unwrap();
561
562        assert!(matches!(export.handeye_mode, HandEyeMode::EyeInHand));
563        assert!(export.gripper_se3_rig.is_some());
564        assert!(export.base_se3_target.is_some());
565        assert!(export.rig_se3_base.is_none());
566        assert!(export.gripper_se3_target.is_none());
567    }
568
569    #[test]
570    fn export_eye_to_hand_is_explicit() {
571        let output = make_dummy_output();
572        let config = RigHandeyeConfig {
573            handeye_init: RigHandeyeInitConfig {
574                handeye_mode: HandEyeMode::EyeToHand,
575                ..RigHandeyeInitConfig::default()
576            },
577            ..Default::default()
578        };
579        let export = RigHandeyeProblem::export(&output, &config).unwrap();
580
581        assert!(matches!(export.handeye_mode, HandEyeMode::EyeToHand));
582        assert!(export.rig_se3_base.is_some());
583        assert!(export.gripper_se3_target.is_some());
584        assert!(export.gripper_se3_rig.is_none());
585        assert!(export.base_se3_target.is_none());
586    }
587}