1use crate::Error;
8use crate::backend::{BackendKind, BackendSolveOptions, SolveReport, solve_with_backend};
9use crate::factors::laserline::{
10 laser_line_dist_normalized_generic, laser_plane_pixel_residual_generic,
11};
12use crate::ir::{FactorKind, FixedMask, ManifoldKind, ProblemIR, ResidualBlock, RobustLoss};
13use crate::params::distortion::{DISTORTION_DIM, pack_distortion, unpack_distortion};
14use crate::params::intrinsics::{INTRINSICS_DIM, pack_intrinsics, unpack_intrinsics};
15use crate::params::laser_plane::LaserPlane;
16use crate::params::pose_se3::{iso3_to_se3_dvec, se3_dvec_to_iso3};
17use anyhow::{Result as AnyhowResult, anyhow, ensure};
18use nalgebra::{DVector, DVectorView};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use vision_calibration_core::{
22 BrownConrady5, Camera, FxFyCxCySkew, Iso3, Pinhole, Pt2, Real, ScheimpflugParams, View,
23};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LaserlineMeta {
28 pub laser_pixels: Vec<Pt2>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub laser_weights: Vec<f64>,
33}
34
35impl LaserlineMeta {
36 pub fn validate(&self) -> Result<(), Error> {
43 if self.laser_pixels.is_empty() {
44 return Err(Error::InsufficientData { need: 1, got: 0 });
45 }
46 if !self.laser_weights.is_empty() {
47 if self.laser_weights.len() != self.laser_pixels.len() {
48 return Err(Error::invalid_input(format!(
49 "laser weight count {} must match pixel count {}",
50 self.laser_weights.len(),
51 self.laser_pixels.len()
52 )));
53 }
54 if !self.laser_weights.iter().all(|w| *w >= 0.0) {
55 return Err(Error::invalid_input("laser weights must be non-negative"));
56 }
57 }
58 Ok(())
59 }
60
61 pub fn laser_weight(&self, idx: usize) -> f64 {
63 if self.laser_weights.is_empty() {
64 1.0
65 } else {
66 self.laser_weights[idx]
67 }
68 }
69}
70
71pub type LaserlineView = View<LaserlineMeta>;
73
74pub type LaserlineDataset = Vec<LaserlineView>;
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct LaserlineParams {
80 pub intrinsics: FxFyCxCySkew<Real>,
82 pub distortion: BrownConrady5<Real>,
84 pub sensor: ScheimpflugParams,
86 pub poses: Vec<Iso3>,
88 pub plane: LaserPlane,
90}
91
92impl LaserlineParams {
93 pub fn new(
99 intrinsics: FxFyCxCySkew<Real>,
100 distortion: BrownConrady5<Real>,
101 sensor: ScheimpflugParams,
102 poses: Vec<Iso3>,
103 plane: LaserPlane,
104 ) -> Result<Self, Error> {
105 if poses.is_empty() {
106 return Err(Error::InsufficientData { need: 1, got: 0 });
107 }
108 Ok(Self {
109 intrinsics,
110 distortion,
111 sensor,
112 poses,
113 plane,
114 })
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct LaserlineEstimate {
121 pub params: LaserlineParams,
123 pub report: SolveReport,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct LaserlineStats {
130 pub mean_reproj_error: f64,
132 pub mean_laser_error: f64,
134 pub per_view_reproj_errors: Vec<f64>,
136 pub per_view_laser_errors: Vec<f64>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
142pub enum LaserlineResidualType {
143 PointToPlane,
149 #[default]
157 LineDistNormalized,
158}
159
160#[derive(Debug, Clone)]
162pub struct LaserlineSolveOptions {
163 pub calib_loss: RobustLoss,
165 pub calib_weight: f64,
167 pub laser_loss: RobustLoss,
169 pub laser_weight: f64,
171 pub fix_intrinsics: bool,
173 pub fix_distortion: bool,
175 pub fix_k3: bool,
177 pub fix_sensor: bool,
179 pub fix_poses: Vec<usize>,
181 pub fix_plane: bool,
183 pub laser_residual_type: LaserlineResidualType,
185}
186
187impl Default for LaserlineSolveOptions {
188 fn default() -> Self {
189 Self {
190 calib_loss: RobustLoss::Huber { scale: 1.0 },
191 calib_weight: 1.0,
192 laser_loss: RobustLoss::Huber { scale: 0.01 }, laser_weight: 1.0,
194 fix_intrinsics: false,
195 fix_distortion: false,
196 fix_k3: true,
197 fix_sensor: true,
198 fix_poses: vec![0], fix_plane: false,
200 laser_residual_type: LaserlineResidualType::LineDistNormalized, }
202 }
203}
204
205fn pack_scheimpflug(sensor: &ScheimpflugParams) -> DVector<f64> {
206 DVector::from_vec(vec![sensor.tilt_x, sensor.tilt_y])
207}
208
209fn unpack_scheimpflug(sensor: DVectorView<'_, f64>) -> AnyhowResult<ScheimpflugParams> {
210 ensure!(
211 sensor.len() == 2,
212 "Scheimpflug sensor params require 2D vector, got {}",
213 sensor.len()
214 );
215 Ok(ScheimpflugParams {
216 tilt_x: sensor[0],
217 tilt_y: sensor[1],
218 })
219}
220
221pub fn compute_laserline_stats(
233 dataset: &LaserlineDataset,
234 params: &LaserlineParams,
235 residual_type: LaserlineResidualType,
236) -> Result<LaserlineStats, Error> {
237 if dataset.len() != params.poses.len() {
238 return Err(Error::invalid_input(format!(
239 "dataset has {} views but {} poses",
240 dataset.len(),
241 params.poses.len()
242 )));
243 }
244
245 let camera = Camera::new(
246 Pinhole,
247 params.distortion,
248 params.sensor.compile(),
249 params.intrinsics,
250 );
251
252 let intr_vec = pack_intrinsics(¶ms.intrinsics)?;
253 let dist_vec = pack_distortion(¶ms.distortion);
254 let sensor_vec = pack_scheimpflug(¶ms.sensor);
255 let plane_normal = params.plane.normal_to_dvec();
256 let plane_distance = params.plane.distance_to_dvec();
257
258 let mut per_view_reproj_errors = Vec::with_capacity(dataset.len());
259 let mut per_view_laser_errors = Vec::with_capacity(dataset.len());
260
261 let mut total_reproj_sum = 0.0;
262 let mut total_reproj_count = 0usize;
263 let mut total_laser_sum = 0.0;
264 let mut total_laser_count = 0usize;
265
266 for (view_idx, view) in dataset.iter().enumerate() {
267 let pose = ¶ms.poses[view_idx];
268 let pose_vec = iso3_to_se3_dvec(pose);
269
270 let mut view_reproj_sum = 0.0;
271 let mut view_reproj_count = 0usize;
272 for (p3d, p2d) in view.obs.points_3d.iter().zip(view.obs.points_2d.iter()) {
273 let p_cam = pose.transform_point(p3d);
274 if let Some(projected) = camera.project_point(&p_cam) {
275 let err = (projected - *p2d).norm();
276 view_reproj_sum += err;
277 view_reproj_count += 1;
278 }
279 }
280
281 let view_reproj_mean = if view_reproj_count > 0 {
282 view_reproj_sum / view_reproj_count as f64
283 } else {
284 0.0
285 };
286 per_view_reproj_errors.push(view_reproj_mean);
287 total_reproj_sum += view_reproj_sum;
288 total_reproj_count += view_reproj_count;
289
290 let mut view_laser_sum = 0.0;
291 let mut view_laser_count = 0usize;
292 for laser_pixel in &view.meta.laser_pixels {
293 let residual = match residual_type {
294 LaserlineResidualType::PointToPlane => laser_plane_pixel_residual_generic(
295 intr_vec.as_view(),
296 dist_vec.as_view(),
297 sensor_vec.as_view(),
298 pose_vec.as_view(),
299 plane_normal.as_view(),
300 plane_distance.as_view(),
301 [laser_pixel.x, laser_pixel.y],
302 1.0,
303 )[0],
304 LaserlineResidualType::LineDistNormalized => laser_line_dist_normalized_generic(
305 intr_vec.as_view(),
306 dist_vec.as_view(),
307 sensor_vec.as_view(),
308 pose_vec.as_view(),
309 plane_normal.as_view(),
310 plane_distance.as_view(),
311 [laser_pixel.x, laser_pixel.y],
312 1.0,
313 )[0],
314 };
315 view_laser_sum += residual.abs();
316 view_laser_count += 1;
317 }
318
319 let view_laser_mean = if view_laser_count > 0 {
320 view_laser_sum / view_laser_count as f64
321 } else {
322 0.0
323 };
324 per_view_laser_errors.push(view_laser_mean);
325 total_laser_sum += view_laser_sum;
326 total_laser_count += view_laser_count;
327 }
328
329 if total_reproj_count == 0 {
330 return Err(Error::invalid_input(
331 "no valid reprojection errors for stats",
332 ));
333 }
334 if total_laser_count == 0 {
335 return Err(Error::invalid_input("no laser pixels for stats"));
336 }
337
338 Ok(LaserlineStats {
339 mean_reproj_error: total_reproj_sum / total_reproj_count as f64,
340 mean_laser_error: total_laser_sum / total_laser_count as f64,
341 per_view_reproj_errors,
342 per_view_laser_errors,
343 })
344}
345
346fn extract_solution(
348 solution: crate::backend::BackendSolution,
349 num_poses: usize,
350) -> AnyhowResult<LaserlineEstimate> {
351 let intrinsics = unpack_intrinsics(
352 solution
353 .params
354 .get("intrinsics")
355 .ok_or_else(|| anyhow!("missing intrinsics in solution"))?
356 .as_view(),
357 )?;
358
359 let distortion = unpack_distortion(
360 solution
361 .params
362 .get("distortion")
363 .ok_or_else(|| anyhow!("missing distortion in solution"))?
364 .as_view(),
365 )?;
366
367 let mut poses = Vec::new();
368 for i in 0..num_poses {
369 let name = format!("pose_{}", i);
370 let pose_vec = solution
371 .params
372 .get(&name)
373 .ok_or_else(|| anyhow!("missing {} in solution", name))?;
374 poses.push(se3_dvec_to_iso3(pose_vec.as_view())?);
375 }
376
377 let normal_name = "plane_normal".to_owned();
378 let distance_name = "plane_distance".to_owned();
379 let plane_normal = solution
380 .params
381 .get(&normal_name)
382 .ok_or_else(|| anyhow!("missing {} in solution", normal_name))?;
383 let plane_distance = solution
384 .params
385 .get(&distance_name)
386 .ok_or_else(|| anyhow!("missing {} in solution", distance_name))?;
387
388 let plane = LaserPlane::from_split_dvec(plane_normal.as_view(), plane_distance.as_view())?;
389 let sensor = unpack_scheimpflug(
390 solution
391 .params
392 .get("sensor")
393 .ok_or_else(|| anyhow!("missing sensor in solution"))?
394 .as_view(),
395 )?;
396
397 Ok(LaserlineEstimate {
398 params: LaserlineParams::new(intrinsics, distortion, sensor, poses, plane)?,
399 report: solution.solve_report,
400 })
401}
402
403pub fn optimize_laserline(
433 dataset: &LaserlineDataset,
434 initial: &LaserlineParams,
435 opts: &LaserlineSolveOptions,
436 backend_opts: &BackendSolveOptions,
437) -> Result<LaserlineEstimate, Error> {
438 let (ir, initial_map) = build_laserline_ir(dataset, initial, opts)?;
439 let solution = solve_with_backend(BackendKind::TinySolver, &ir, &initial_map, backend_opts)?;
440 extract_solution(solution, dataset.len()).map_err(Error::from)
441}
442
443fn build_laserline_ir(
445 dataset: &LaserlineDataset,
446 initial: &LaserlineParams,
447 opts: &LaserlineSolveOptions,
448) -> AnyhowResult<(ProblemIR, HashMap<String, DVector<f64>>)> {
449 ensure!(!dataset.is_empty(), "need at least one view");
450 ensure!(
451 dataset.len() == initial.poses.len(),
452 "dataset has {} views but {} initial poses",
453 dataset.len(),
454 initial.poses.len()
455 );
456 for (idx, view) in dataset.iter().enumerate() {
457 view.meta
458 .validate()
459 .map_err(|e| anyhow!("view {}: {}", idx, e))?;
460 }
461
462 let mut ir = ProblemIR::new();
463 let mut initial_map = HashMap::new();
464
465 let intrinsics_fixed = if opts.fix_intrinsics {
467 FixedMask::all_fixed(INTRINSICS_DIM)
468 } else {
469 FixedMask::all_free()
470 };
471 let intrinsics_id = ir.add_param_block(
472 "intrinsics",
473 INTRINSICS_DIM,
474 ManifoldKind::Euclidean,
475 intrinsics_fixed,
476 None,
477 );
478 initial_map.insert(
479 "intrinsics".to_string(),
480 pack_intrinsics(&initial.intrinsics)?,
481 );
482
483 let distortion_fixed = if opts.fix_distortion {
485 FixedMask::all_fixed(DISTORTION_DIM)
486 } else if opts.fix_k3 {
487 FixedMask::fix_indices(&[2]) } else {
489 FixedMask::all_free()
490 };
491 let distortion_id = ir.add_param_block(
492 "distortion",
493 DISTORTION_DIM,
494 ManifoldKind::Euclidean,
495 distortion_fixed,
496 None,
497 );
498 initial_map.insert(
499 "distortion".to_string(),
500 pack_distortion(&initial.distortion),
501 );
502
503 let sensor_fixed = if opts.fix_sensor {
505 FixedMask::all_fixed(2)
506 } else {
507 FixedMask::all_free()
508 };
509 let sensor_id = ir.add_param_block("sensor", 2, ManifoldKind::Euclidean, sensor_fixed, None);
510 initial_map.insert("sensor".to_string(), pack_scheimpflug(&initial.sensor));
511
512 let mut pose_ids = Vec::new();
514 for (i, pose) in initial.poses.iter().enumerate() {
515 let fixed = if opts.fix_poses.contains(&i) {
516 FixedMask::all_fixed(7)
517 } else {
518 FixedMask::all_free()
519 };
520 let name = format!("pose_{}", i);
521 let id = ir.add_param_block(&name, 7, ManifoldKind::SE3, fixed, None);
522 pose_ids.push(id);
523 initial_map.insert(name, iso3_to_se3_dvec(pose));
524 }
525
526 let normal_fixed = if opts.fix_plane {
528 FixedMask::all_fixed(3)
529 } else {
530 FixedMask::all_free()
531 };
532 let distance_fixed = if opts.fix_plane {
533 FixedMask::all_fixed(1)
534 } else {
535 FixedMask::all_free()
536 };
537
538 let normal_name = "plane_normal";
539 let distance_name = "plane_distance";
540 let normal_id = ir.add_param_block(normal_name, 3, ManifoldKind::S2, normal_fixed, None);
541 let distance_id = ir.add_param_block(
542 distance_name,
543 1,
544 ManifoldKind::Euclidean,
545 distance_fixed,
546 None,
547 );
548 initial_map.insert(normal_name.to_owned(), initial.plane.normal_to_dvec());
549 initial_map.insert(distance_name.to_owned(), initial.plane.distance_to_dvec());
550
551 for (view_idx, view) in dataset.iter().enumerate() {
553 let pose_id = pose_ids[view_idx];
554
555 for (pt_idx, (pt_3d, pt_2d)) in view
557 .obs
558 .points_3d
559 .iter()
560 .zip(&view.obs.points_2d)
561 .enumerate()
562 {
563 let w = view.obs.weight(pt_idx) * opts.calib_weight;
564 ir.add_residual_block(ResidualBlock {
565 params: vec![intrinsics_id, distortion_id, sensor_id, pose_id],
566 loss: opts.calib_loss,
567 factor: FactorKind::ReprojPointPinhole4Dist5Scheimpflug2 {
568 pw: [pt_3d.x, pt_3d.y, pt_3d.z],
569 uv: [pt_2d.x, pt_2d.y],
570 w,
571 },
572 residual_dim: 2,
573 });
574 }
575
576 for (laser_idx, laser_pixel) in view.meta.laser_pixels.iter().enumerate() {
578 let w = view.meta.laser_weight(laser_idx) * opts.laser_weight;
579
580 let factor = match opts.laser_residual_type {
582 LaserlineResidualType::PointToPlane => FactorKind::LaserPlanePixel {
583 laser_pixel: [laser_pixel.x, laser_pixel.y],
584 w,
585 },
586 LaserlineResidualType::LineDistNormalized => FactorKind::LaserLineDist2D {
587 laser_pixel: [laser_pixel.x, laser_pixel.y],
588 w,
589 },
590 };
591
592 ir.add_residual_block(ResidualBlock {
593 params: vec![
594 intrinsics_id,
595 distortion_id,
596 sensor_id,
597 pose_id,
598 normal_id,
599 distance_id,
600 ],
601 loss: opts.laser_loss,
602 factor,
603 residual_dim: 1,
604 });
605 }
606 }
607
608 Ok((ir, initial_map))
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use vision_calibration_core::{
615 BrownConrady5, CorrespondenceView, FxFyCxCySkew, Pt3, ScheimpflugParams,
616 };
617
618 #[test]
619 fn ir_validation_catches_missing_param() {
620 let dataset = vec![View::new(
621 CorrespondenceView::new(
622 vec![Pt3::new(0.0, 0.0, 0.0); 4],
623 vec![Pt2::new(100.0, 100.0); 4],
624 )
625 .unwrap(),
626 LaserlineMeta {
627 laser_pixels: vec![Pt2::new(200.0, 200.0); 5],
628 laser_weights: Vec::new(),
629 },
630 )];
631
632 let intrinsics = FxFyCxCySkew {
633 fx: 800.0,
634 fy: 800.0,
635 cx: 512.0,
636 cy: 384.0,
637 skew: 0.0,
638 };
639 let distortion = BrownConrady5 {
640 k1: 0.0,
641 k2: 0.0,
642 k3: 0.0,
643 p1: 0.0,
644 p2: 0.0,
645 iters: 8,
646 };
647 let poses = vec![Iso3::identity()];
648 let plane = LaserPlane::new(nalgebra::Vector3::new(0.0, 0.0, 1.0), -0.5);
649 let initial = LaserlineParams::new(
650 intrinsics,
651 distortion,
652 ScheimpflugParams::default(),
653 poses,
654 plane,
655 )
656 .unwrap();
657
658 let opts = LaserlineSolveOptions::default();
659 let (ir, mut initial_map) = build_laserline_ir(&dataset, &initial, &opts).unwrap();
660
661 initial_map.remove("intrinsics");
663
664 use crate::backend::{OptimBackend, TinySolverBackend};
666 let backend = TinySolverBackend;
667 let backend_opts = BackendSolveOptions::default();
668 let result = backend.solve(&ir, &initial_map, &backend_opts);
669 assert!(result.is_err());
670 }
671
672 #[test]
673 #[ignore = "TODO: Fix after laserline pipeline integration"]
674 fn ir_uses_s2_for_plane_normal() {
675 let dataset = vec![View::new(
676 CorrespondenceView::new(
677 vec![Pt3::new(0.0, 0.0, 0.0); 4],
678 vec![Pt2::new(100.0, 100.0); 4],
679 )
680 .unwrap(),
681 LaserlineMeta {
682 laser_pixels: vec![Pt2::new(200.0, 200.0); 5],
683 laser_weights: Vec::new(),
684 },
685 )];
686
687 let intrinsics = FxFyCxCySkew {
688 fx: 800.0,
689 fy: 800.0,
690 cx: 512.0,
691 cy: 384.0,
692 skew: 0.0,
693 };
694 let distortion = BrownConrady5 {
695 k1: 0.0,
696 k2: 0.0,
697 k3: 0.0,
698 p1: 0.0,
699 p2: 0.0,
700 iters: 8,
701 };
702 let poses = vec![Iso3::identity()];
703 let plane = LaserPlane::new(nalgebra::Vector3::new(0.0, 0.0, 1.0), -0.5);
704 let initial = LaserlineParams::new(
705 intrinsics,
706 distortion,
707 ScheimpflugParams::default(),
708 poses,
709 plane,
710 )
711 .unwrap();
712
713 let opts = LaserlineSolveOptions::default();
714 let (ir, _) = build_laserline_ir(&dataset, &initial, &opts).unwrap();
715
716 let normal_id = ir.param_by_name("plane_normal").unwrap();
717 let distance_id = ir.param_by_name("plane_distance").unwrap();
718
719 let normal_param = &ir.params[normal_id.0];
720 let distance_param = &ir.params[distance_id.0];
721
722 assert_eq!(normal_param.dim, 3);
723 assert_eq!(normal_param.manifold, ManifoldKind::S2);
724 assert_eq!(distance_param.dim, 1);
725 assert_eq!(distance_param.manifold, ManifoldKind::Euclidean);
726 }
727}