use anyhow::Result;
use nalgebra::{Point3, UnitVector3, Vector3};
use vision_calibration_core::{
BrownConrady5, Camera, FxFyCxCySkew, Iso3, Pinhole, Pt2, Pt3, Real, SensorModel,
};
#[derive(Debug, Clone)]
pub struct LaserlineView {
pub laser_pixels: Vec<Pt2>,
pub camera_se3_target: Iso3,
}
#[derive(Debug, Clone)]
pub struct LinearPlaneEstimate {
pub normal: UnitVector3<f64>,
pub distance: f64,
pub inliers: Vec<usize>,
pub rmse: f64,
}
pub struct LaserlinePlaneSolver;
impl LaserlinePlaneSolver {
pub fn from_points_3d(points_camera: &[Pt3]) -> Result<LinearPlaneEstimate> {
if points_camera.len() < 3 {
anyhow::bail!(
"insufficient points: got {}, need at least {}",
points_camera.len(),
3
);
}
let n = points_camera.len();
let mut centroid = Vector3::zeros();
for pt in points_camera {
centroid += pt.coords;
}
centroid /= n as f64;
let mut cov = nalgebra::Matrix3::zeros();
for pt in points_camera {
let centered = pt.coords - centroid;
cov += centered * centered.transpose();
}
let eigen = cov.symmetric_eigen();
let mut indexed_eigenvalues: Vec<(usize, f64)> =
eigen.eigenvalues.iter().copied().enumerate().collect();
indexed_eigenvalues.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let (min_idx, min_eigenvalue) = indexed_eigenvalues[0];
let (_second_idx, second_eigenvalue) = indexed_eigenvalues[1];
let (_max_idx, max_eigenvalue) = indexed_eigenvalues[2];
const RANK_THRESHOLD: f64 = 1e-8;
if max_eigenvalue > RANK_THRESHOLD {
let rank1_ratio = second_eigenvalue / max_eigenvalue;
if rank1_ratio < RANK_THRESHOLD {
anyhow::bail!("collinear points: cannot fit plane to points along a single line");
}
} else if min_eigenvalue.abs() < RANK_THRESHOLD && second_eigenvalue < RANK_THRESHOLD {
anyhow::bail!("collinear points: cannot fit plane to points along a single line");
}
let normal_vec = eigen.eigenvectors.column(min_idx);
let normal = UnitVector3::new_normalize(normal_vec.into());
let distance = -normal.dot(¢roid);
let mut sum_sq_dist = 0.0;
for pt in points_camera {
let dist = normal.dot(&pt.coords) + distance;
sum_sq_dist += dist * dist;
}
let rmse = (sum_sq_dist / n as f64).sqrt();
Ok(LinearPlaneEstimate {
normal,
distance,
inliers: (0..n).collect(),
rmse,
})
}
pub fn from_views<Sm>(
views: &[LaserlineView],
camera: &Camera<Real, Pinhole, BrownConrady5<Real>, Sm, FxFyCxCySkew<Real>>,
) -> Result<LinearPlaneEstimate>
where
Sm: SensorModel<Real>,
{
if views.len() < 2 {
anyhow::bail!(
"insufficient views: got {}, need at least {}",
views.len(),
2
);
}
let mut all_points = Vec::new();
for view in views {
let points =
Self::compute_3d_points(&view.laser_pixels, camera, &view.camera_se3_target)?;
all_points.extend(points);
}
if all_points.len() < 3 {
anyhow::bail!(
"insufficient points: got {}, need at least {}",
all_points.len(),
3
);
}
Self::from_points_3d(&all_points)
}
#[deprecated(
since = "0.2.0",
note = "Single view produces collinear points. Use from_views() with multiple views."
)]
pub fn from_view<Sm>(
view: &LaserlineView,
camera: &Camera<Real, Pinhole, BrownConrady5<Real>, Sm, FxFyCxCySkew<Real>>,
) -> Result<LinearPlaneEstimate>
where
Sm: SensorModel<Real>,
{
if view.laser_pixels.is_empty() {
anyhow::bail!("insufficient points: got 0, need at least 1");
}
let points_camera =
Self::compute_3d_points(&view.laser_pixels, camera, &view.camera_se3_target)?;
Self::from_points_3d(&points_camera)
}
fn compute_3d_points<Sm>(
laser_pixels: &[Pt2],
camera: &Camera<Real, Pinhole, BrownConrady5<Real>, Sm, FxFyCxCySkew<Real>>,
camera_se3_target: &Iso3,
) -> Result<Vec<Pt3>>
where
Sm: SensorModel<Real>,
{
let mut points_camera = Vec::with_capacity(laser_pixels.len());
for pixel in laser_pixels {
let ray = camera.backproject_pixel(&Pt2::new(pixel.x, pixel.y));
let ray_dir_camera = ray.point.normalize();
let ray_origin_camera = Point3::origin();
let ray_origin_target = camera_se3_target.inverse_transform_point(&ray_origin_camera);
let ray_dir_target = camera_se3_target.inverse_transform_vector(&ray_dir_camera);
if ray_dir_target.z.abs() < 1e-12 {
anyhow::bail!("ray parallel to target plane (degenerate geometry)");
}
let t = -ray_origin_target.z / ray_dir_target.z;
let pt_target = ray_origin_target + ray_dir_target * t;
let pt_camera = camera_se3_target.transform_point(&pt_target);
points_camera.push(pt_camera);
}
Ok(points_camera)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::UnitQuaternion;
use vision_calibration_core::{PinholeCamera, make_pinhole_camera};
fn make_test_camera() -> PinholeCamera {
let intrinsics = FxFyCxCySkew {
fx: 800.0,
fy: 800.0,
cx: 640.0,
cy: 480.0,
skew: 0.0,
};
let distortion = BrownConrady5 {
k1: 0.0,
k2: 0.0,
k3: 0.0,
p1: 0.0,
p2: 0.0,
iters: 8,
};
make_pinhole_camera(intrinsics, distortion)
}
#[test]
fn plane_from_perfect_points() {
let points = vec![
Pt3::new(0.0, 0.0, 0.5),
Pt3::new(1.0, 0.0, 0.5),
Pt3::new(0.0, 1.0, 0.5),
Pt3::new(1.0, 1.0, 0.5),
Pt3::new(0.5, 0.5, 0.5),
];
let result = LaserlinePlaneSolver::from_points_3d(&points).unwrap();
assert!((result.normal.z.abs() - 1.0).abs() < 1e-6);
assert!(result.normal.x.abs() < 1e-6);
assert!(result.normal.y.abs() < 1e-6);
assert!((result.distance + 0.5).abs() < 1e-6);
assert!(result.rmse < 1e-10);
}
#[test]
fn plane_from_tilted_points() {
let points = vec![
Pt3::new(0.0, 0.0, 0.3),
Pt3::new(1.0, 0.0, 0.8),
Pt3::new(0.0, 1.0, 0.3),
Pt3::new(1.0, 1.0, 0.8),
Pt3::new(0.5, 0.5, 0.55),
];
let result = LaserlinePlaneSolver::from_points_3d(&points).unwrap();
let expected_normal = Vector3::new(-0.5, 0.0, 1.0).normalize();
let dot = result.normal.dot(&expected_normal);
assert!(dot.abs() > 0.99, "normal mismatch: dot={}", dot);
assert!(result.rmse < 1e-6);
}
#[test]
fn plane_insufficient_points() {
let points = vec![Pt3::new(0.0, 0.0, 0.5), Pt3::new(1.0, 0.0, 0.5)];
assert!(LaserlinePlaneSolver::from_points_3d(&points).is_err());
}
#[test]
fn plane_detects_collinear_points() {
let points = vec![
Pt3::new(0.0, 0.0, 0.5),
Pt3::new(1.0, 0.0, 0.5),
Pt3::new(2.0, 0.0, 0.5),
Pt3::new(3.0, 0.0, 0.5),
];
let result = LaserlinePlaneSolver::from_points_3d(&points);
let err = result.unwrap_err().to_string();
assert!(
err.contains("collinear points"),
"unexpected error: {}",
err
);
}
#[test]
fn from_views_insufficient_views() {
let camera = make_test_camera();
let view = LaserlineView {
laser_pixels: vec![Pt2::new(100.0, 100.0), Pt2::new(200.0, 100.0)],
camera_se3_target: Iso3::identity(),
};
let result = LaserlinePlaneSolver::from_views(&[view], &camera);
let err = result.unwrap_err().to_string();
assert!(
err.contains("insufficient views"),
"unexpected error: {}",
err
);
}
#[test]
fn from_views_with_two_poses() {
let camera = make_test_camera();
let pose1 = Iso3::from_parts(
nalgebra::Translation3::new(0.0, 0.0, 1.0),
UnitQuaternion::identity(),
);
let pose2 = Iso3::from_parts(
nalgebra::Translation3::new(0.0, 0.1, 1.0),
UnitQuaternion::from_axis_angle(&Vector3::x_axis(), 0.1),
);
let view1 = LaserlineView {
laser_pixels: vec![
Pt2::new(400.0, 480.0),
Pt2::new(640.0, 480.0),
Pt2::new(880.0, 480.0),
],
camera_se3_target: pose1,
};
let view2 = LaserlineView {
laser_pixels: vec![
Pt2::new(400.0, 520.0),
Pt2::new(640.0, 520.0),
Pt2::new(880.0, 520.0),
],
camera_se3_target: pose2,
};
let result = LaserlinePlaneSolver::from_views(&[view1, view2], &camera);
assert!(result.is_ok(), "from_views should succeed: {:?}", result);
}
}