Laserline Device Calibration
Laserline calibration jointly estimates camera parameters and a laser plane from observations of both a calibration board and laser line projections on the board. This is used in laser triangulation systems where a camera and laser are rigidly mounted together.
Problem Formulation
Parameters
- Camera intrinsics:
- Distortion: the coefficients of the configured model, e.g. for Brown-Conrady
- Sensor tilt: (Scheimpflug cameras only)
- Per-view poses: (camera-to-target SE(3))
- Laser plane: normal , distance
Observations
Each view provides two types of observations:
- Calibration points: 2D-3D correspondences from the chessboard (same as planar intrinsics)
- Laser pixels: 2D pixel positions where the laser line appears on the target
Objective
The cost function has two terms:
where are weights, are (possibly different) robust loss functions, and is the laser residual.
Laser Residual Types
Two approaches are implemented for the laser residual, selectable via configuration.
PointToPlane (3D Distance)
Algorithm:
- Undistort laser pixel to normalized coordinates
- Back-project to a ray in camera frame
- Intersect the ray with the target plane (at , transformed by pose ) to get a 3D point
- Compute signed distance from to the laser plane:
Residual dimension: 1 (meters)
LineDistNormalized (2D Line Distance) — Default
Algorithm:
- Compute the 3D intersection line of the laser plane and the target plane (in camera frame)
- Project this 3D line onto the normalized camera plane
- Undistort the laser pixel to normalized coordinates (done once, not per-iteration)
- Measure the perpendicular distance from the undistorted pixel to the projected line
- Scale by for pixel-comparable units:
Residual dimension: 1 (effective pixels)
Comparison
| Property | PointToPlane | LineDistNormalized |
|---|---|---|
| Residual units | meters | pixels |
| Undistortion | Per-iteration | Once per pixel |
| Geometry | Ray-plane intersection | 2D line distance |
| Speed | Slower | Faster |
| Recommended | Alternative | Default |
Both approaches yield similar accuracy in practice (<6% intrinsics error, <5° plane normal error in synthetic tests).
Derivation: Line-Distance Residual
Plane-Plane Intersection Line
The laser plane and the target plane (normal , distance from camera, derived from pose ) intersect in a 3D line with:
Projection onto Normalized Plane
Project the 3D line to the plane:
Perpendicular Distance
For an undistorted pixel in normalized coordinates, the perpendicular distance to the 2D line is:
Configuration
init and solver are the shared sub-structs, camera the camera model
and its fixed parameters (as in Planar Intrinsics),
and optimize groups the laser-specific bundle-adjustment settings:
#![allow(unused)] fn main() { pub struct LaserlineDeviceConfig { // Per-camera linear-initialization stage settings. pub init: IntrinsicsInitConfig, // { iterations: usize = 2, fix_k3: bool = true }; it fits only the // distortion terms the bundle adjustment estimates // The camera model (any distortion kind, an identity or Scheimpflug // sensor) and the parameters the bundle adjustment holds fixed, named per // slot; a slot left out keeps the model's default (`k3` for Brown-Conrady; // everything but the low-order radial terms under a Scheimpflug sensor). pub camera: CameraConfig, // { model: CameraModelSpec, fix: CameraFix } // Non-linear solve stage settings. `robust_loss` is not consulted here — // laser-carrying stages use `optimize.calib_loss`/`optimize.laser_loss`. pub solver: SolverConfig, // { max_iters: usize = 50, verbosity: usize = 0, .. } // Bundle-adjustment options. pub optimize: DeviceOptimizeConfig, } pub struct DeviceOptimizeConfig { pub calib_loss: RobustLoss, // Default: Huber { scale: 1.0 } pub laser_loss: RobustLoss, // Default: Huber { scale: 1.0 }, in the residual's unit pub calib_weight: f64, // Weight for calibration residuals (default: 1.0) pub laser_weight: f64, // Weight for laser residuals (default: 1.0) pub fix_poses: Vec<usize>, // Default: empty (each pose is set by its own corners) pub fix_plane: bool, pub laser_residual_type: LaserlineResidualType, // Default: LineDistNormalized } }
The line-distance residual needs a projection that keeps lines straight (the pinhole); the configuration is checked before the solve.
Weight Balancing
Since calibration residuals (in pixels) and laser residuals may have different scales, the weights allow balancing their relative influence. A common starting point is calib_weight = 1.0 and laser_weight = 1.0, adjusting if one term dominates.
Complete Example
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::laserline_device::*; let mut session = CalibrationSession::<LaserlineDeviceProblem>::new(); session.set_input(laserline_input)?; run_calibration(&mut session)?; let export = session.export()?; println!("Plane normal: {:?}", export.params.plane.normal); println!("Plane distance: {:.4}", export.params.plane.distance); println!("Reprojection error: {:.4} px", export.mean_reproj_error); println!("Laser error: {:.4}", export.stats.mean_laser_error); // The plane's uncertainty, from corner and laser residuals together. if let Some(cov) = export.uncertainty.covariance() { println!("Plane distance σ: {:.2e} m", cov.std_dev("plane.d").unwrap()); } }
export.params.camera is the calibrated camera in its model
(CameraParams). The export's uncertainty covers the camera's free
parameters (intrinsics, distortion and, for a tilted sensor, the tilt) and the
laser plane (plane.nx, plane.ny, plane.nz, plane.d, in the camera
frame), with each view's target pose as a per-view block; see
Parameter Uncertainty. The rig laserline result labels
each camera's plane cam<i>.plane.nx … and lists, in front of them, the
upstream calibration's camera parameters and extrinsics when the upstream
carries its covariance; the joint rig hand-eye laserline result adds the
planes to the rig hand-eye labels.
Scheimpflug Support
Laser profilers usually tilt their sensor. Select it in the camera model:
#![allow(unused)] fn main() { let mut config = LaserlineDeviceConfig::default(); config.camera = CameraConfig::new(CameraModelSpec::pinhole( DistortionKind::BrownConrady5, SensorKind::Scheimpflug, )); }
A laser profiler's mount tilt is a hardware property, measured separately,
so the device holds it by default: give it in the manual-init sensor seed
(step_init_with_seed; zero when unseeded), and the camera initializes as an
untilted one with the tilt attached. To estimate the tilt instead, list the
sensor parameters to hold explicitly: camera.fix.sensor = Some(vec![])
refines both angles. Without a seed, step_init then estimates the tilt with
the linear tilt sweep and refines the camera alone through the staged tilt
multi-start before it fits the laser plane, because a cold tilt = 0 start
falls into the tilt / principal-point / distortion degeneracy. A tilt seed for
an untilted camera must be zero.