1use 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
19pub type RigHandeyeInput = RigDataset<RobotPoseMeta>;
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35#[non_exhaustive]
36pub struct RigHandeyeConfig {
37 pub intrinsics: RigHandeyeIntrinsicsConfig,
39 pub rig: RigHandeyeRigConfig,
41 pub handeye_init: RigHandeyeInitConfig,
43 pub solver: RigHandeyeSolverConfig,
45 pub handeye_ba: RigHandeyeBaConfig,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[non_exhaustive]
52pub struct RigHandeyeIntrinsicsConfig {
53 pub init_iterations: usize,
55 pub fix_k3: bool,
57 pub fix_tangential: bool,
59 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#[derive(Debug, Clone, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct RigHandeyeRigConfig {
78 pub reference_camera_idx: usize,
80 pub refine_intrinsics_in_rig_ba: bool,
82 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#[derive(Debug, Clone, Serialize, Deserialize)]
98#[non_exhaustive]
99pub struct RigHandeyeInitConfig {
100 pub handeye_mode: HandEyeMode,
102 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#[derive(Debug, Clone, Serialize, Deserialize)]
117#[non_exhaustive]
118pub struct RigHandeyeSolverConfig {
119 pub max_iters: usize,
121 pub verbosity: usize,
123 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#[derive(Debug, Clone, Serialize, Deserialize)]
139#[non_exhaustive]
140pub struct RigHandeyeBaConfig {
141 pub refine_robot_poses: bool,
143 pub robot_rot_sigma: f64,
145 pub robot_trans_sigma: f64,
147 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(), robot_trans_sigma: 0.001, refine_cam_se3_rig_in_handeye_ba: false,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
169#[non_exhaustive]
170pub struct RigHandeyeExport {
171 pub cameras: Vec<PinholeCamera>,
173
174 pub cam_se3_rig: Vec<Iso3>,
177
178 pub handeye_mode: HandEyeMode,
180
181 pub gripper_se3_rig: Option<Iso3>,
185
186 pub rig_se3_base: Option<Iso3>,
190
191 pub base_se3_target: Option<Iso3>,
195
196 pub gripper_se3_target: Option<Iso3>,
200
201 pub robot_deltas: Option<Vec<[f64; 6]>>,
204
205 pub mean_reproj_error: f64,
207
208 pub per_cam_reproj_errors: Vec<f64>,
210}
211
212#[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 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 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 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, ..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}