Introduction
calibration-rs is a Rust library for camera calibration — the process of estimating the internal parameters (intrinsics, lens distortion) and external parameters (pose, rig geometry, hand-eye transforms) of camera systems from observed correspondences between known 3D points and their 2D projections.
Three Ways In
- The desktop app runs a calibration from a folder of images and shows the result, its residuals and its uncertainty, without code (The Desktop App).
- Python:
pip install vision-calibration, one call per workflow (Getting Started). - Rust: the
vision-calibrationcrate, with the same workflows plus step-by-step sessions (Getting Started).
Not sure which workflow fits your hardware? Choose Your Workflow maps your setup to a workflow and a camera model and says what to capture.
Who This Book Is For
Engineers and researchers in machine vision, robotics and 3D reconstruction who want to:
- calibrate single cameras, stereo rigs or multi-camera systems;
- perform hand-eye calibration for cameras mounted on robot arms;
- calibrate laser triangulation devices (camera + laser plane);
- understand the mathematics behind calibration algorithms.
Running a calibration needs no mathematical background. The theory chapters assume familiarity with linear algebra (SVD, eigendecomposition, least squares) and basic projective geometry (homogeneous coordinates, projection matrices), and introduce Lie group theory (SO(3), SE(3)) when needed.
What calibration-rs Provides
The library covers the full calibration pipeline:
- Linear initialization — closed-form solvers (Zhang's method, DLT, PnP, Tsai-Lenz hand-eye, epipolar geometry) that produce approximate parameter estimates
- Non-linear refinement — Levenberg-Marquardt bundle adjustment that minimizes reprojection error to sub-pixel accuracy
- Session framework — a high-level API with step functions, configuration, and JSON checkpointing
- Data handling and diagnostics — target detectors with a detection cache, a dataset manifest, per-feature residuals and parameter uncertainty for every result
Workspace Structure
calibration-rs is organized as a layered Rust workspace with a layered architecture:
vision-calibration (facade)
→ vision-calibration-pipeline (sessions, workflows, dataset runner)
→ vision-calibration-optim (non-linear refinement)
→ vision-calibration-linear (initialization)
→ vision-calibration-dataset / -detect (manifest, detectors)
→ vision-mvg (N-view geometry, rectification, dense stereo)
→ vision-geometry (two-view solvers)
→ vision-calibration-core (primitives, camera models, RANSAC)
The key dependency rule: the linear and optimization crates are peers — they both depend on vision-calibration-core but not on each other. This separation keeps initialization algorithms independent of the optimization backend.
Relation to OpenCV
Readers familiar with OpenCV's calibration module will find analogous functionality throughout:
| OpenCV | calibration-rs |
|---|---|
cv::calibrateCamera | Planar intrinsics pipeline (Zhang init + bundle adjustment) |
cv::solvePnP | pnp::p3p(), pnp::dlt() |
cv::findHomography | dlt_homography(), dlt_homography_ransac() |
cv::findFundamentalMat | fundamental_8point() |
cv::findEssentialMat | essential_5point() |
cv::stereoCalibrate | Rig extrinsics pipeline |
calibration-rs differs from OpenCV in several ways: it is written in pure Rust, uses a composable camera model with generic type parameters, provides a backend-agnostic optimization IR, and offers a session framework with JSON checkpointing for production workflows.
Book Organization
The book is the one user guide for the library. It is organized by what you want to do first and by the mathematics behind it last:
- Choose Your Workflow: from your hardware to a workflow, a camera model and a capture plan.
- Getting Started: install the library and run a first calibration in Rust and Python, then a stereo rig on real data.
- Calibration Workflows: one chapter per problem type (planar intrinsics, hand-eye, rigs, laser devices), plus choosing a distortion model, seeding the initialization with prior knowledge and reading per-feature residuals.
- The Session Framework: the
CalibrationSessionAPI behind every workflow. - Data and Detection: calibration targets and their detectors, the dataset manifest that describes a folder of images, laser datasets and the generated manifest field reference.
- Uncertainty: the covariance every result carries, how to read it, and the projection uncertainty of a camera.
- Multiple-View Geometry: pose recovery, triangulation, bundle adjustment, rectification and dense stereo for calibrated cameras.
- The Desktop App: running calibrations and inspecting the results without writing code.
- Theory: the camera model, geometric primitives and robust estimation, the linear initialization algorithms and the non-linear optimization machinery, each with a formal problem statement and derivation.
- Reference and Development: the regression suite, extending the library, synthetic data and a type reference.
Each algorithm chapter in the theory parts includes a formal problem statement, objective function, assumptions, and a full derivation leading to the implementation.
The chapters show Rust unless a section says otherwise; Getting Started puts Rust and Python side by side.
Choose Your Workflow
Three questions settle how to calibrate a system: how you want to run it, which workflow matches your hardware, and which camera model matches your lenses. This chapter answers them in that order and ends with what to capture.
1. How you run it
| Route | When | Start at |
|---|---|---|
| Desktop app | You have a folder of images and want a result and its diagnostics without writing code | The Desktop App |
| Python | You script your lab work or analysis in Python | Getting Started, the Python half |
| Rust | You embed calibration in a Rust program or need the session steps | Getting Started, the Rust half |
All three run the same solvers and produce the same JSON export, so a result from one opens in the others.
2. Which workflow
The workflow follows from the hardware: how many cameras there are, whether a robot moves the camera or the target, and whether a laser projects a line for triangulation.
| Your system | Workflow | Manifest topology | Python runner |
|---|---|---|---|
| One camera; you want its intrinsics and distortion | Planar intrinsics | planar_intrinsics | run_planar_intrinsics |
| One camera on a robot, or a fixed camera watching a target the robot holds | Single-camera hand-eye | single_cam_handeye | run_single_cam_handeye |
| Two or more rigidly mounted cameras: a stereo pair or a multi-camera rig | Rig extrinsics | rig_extrinsics | run_rig_extrinsics |
| A camera rig on a robot, or a fixed rig watching the robot's target | Rig hand-eye | rig_handeye | run_rig_handeye |
| One camera and a laser line projector mounted together | Laserline device | laserline_device | run_laserline_device |
| A rig on a robot with one laser per camera, rig already calibrated | Rig laserline device | rig_laserline_device | run_rig_laserline_device |
| The same rig with the rig, hand-eye and laser planes in one solve | Rig hand-eye laserline | rig_handeye_laserline | run_rig_handeye_laserline |
| One camera and its laser on a robot, with the camera, plane and hand-eye in one solve | Rig hand-eye laserline (a rig of one) | rig_handeye_laserline | run_rig_handeye_laserline |
Each workflow also calibrates the intrinsics of every camera it uses: a rig does not need a planar-intrinsics run per camera first. The hand-eye workflows cover both mountings, the camera on the gripper (eye-in-hand, the default) and the camera fixed in the scene (eye-to-hand); the configuration says which.
The two rig laser workflows differ in staging. rig_laserline_device fits
the laser planes against a rig hand-eye result you computed earlier and
keep fixed; rig_handeye_laserline estimates everything at once.
A single laser head on a robot can be calibrated in two workflows, the
laserline device and then the single-camera hand-eye. If you do this, hold the
first workflow's camera fixed in the second. The laser plane is in that
camera's frame, and a hand-eye found with a re-estimated camera does not
compose with it. rig_handeye_laserline with one camera avoids the question.
3. Which camera model
The camera model is a setting of every workflow, not a workflow of its own.
A fisheye stereo pair is a rig_extrinsics calibration with a fisheye
camera model; a tilted-sensor laser profiler is a laserline_device
calibration with a Scheimpflug sensor. The model has three parts:
| Part | Choices | Pick |
|---|---|---|
| Projection | pinhole; equidistant (fisheye) | pinhole for ordinary lenses; equidistant for fisheye lenses, and always for rays 90° or more off the optical axis, which a pinhole cannot represent |
| Distortion | Brown-Conrady (5 terms, the default), rational (8), thin prism (9), division (1), Kannala-Brandt (4, for fisheye) | the default for ordinary lenses; Kannala-Brandt with the equidistant projection; the richer kinds only when the residuals show a pattern the default leaves |
| Sensor | straight (default); Scheimpflug (tilted) | Scheimpflug only when the sensor is deliberately tilted, as in laser profilers |
The details are in Choosing a Distortion Model, Fisheye Projection and Sensor Models and Scheimpflug Tilt. A rig may mix models: each camera can override the shared one.
4. What to capture
Every workflow observes a planar calibration target: a chessboard, a ChArUco board, a PuzzleBoard or a ring grid (Calibration Targets and Detection). Measure the square or ring pitch of the printed board; the calibration's scale comes from it.
- Views. At least 3 per camera for any workflow to run; 15 to 30 for a result worth keeping. Tilt the board 15–30° about both of its axes, make it a third to a half of the image wide, and move it into the corners of the image, where distortion is largest. Capturing Calibration Images measures what each of these buys.
- Rigs. Every camera needs its own 3 or more views of the board, and the rig's cameras need views in which they see the board at the same time, so the board links them.
- Robots. One robot pose per view, from the controller, with the frame convention and units written down: the manifest asks for them and does not guess. Rotate the gripper about at least two axes between views; a hand-eye calibration from translations alone is undetermined.
- Lasers. Two frames per view: one with the board lit and the laser off, one with the laser on (Laser Datasets).
Every result carries its parameter uncertainty, which shows how well the views constrained it (Parameter Uncertainty). For a single camera, the library also ranks candidate board poses by how much each would help as the next view (Planning the Next View).
Next
Describe your image folder in a dataset manifest. The folder sniffer writes a first draft from the file and folder names and leaves what only you know, such as the target and the robot's pose convention, marked for you to fill in; the desktop app edits the manifest in a form. Then follow the chapter for your workflow from the table above, and judge the result with Judging a Result.
Architecture Overview
calibration-rs is organized as a layered workspace of Rust crates plus Python bindings. This chapter explains the dependency structure, data flow, and key design patterns.
Crate Dependency Graph
vision-calibration (facade: re-exports everything)
│
├── vision-calibration-pipeline (sessions, problem types, dataset runner)
│ ├── vision-calibration-optim (non-linear optimization)
│ ├── vision-calibration-linear (closed-form initialization)
│ ├── vision-calibration-dataset (dataset manifest)
│ ├── vision-calibration-detect (target detectors, detection cache)
│ └── vision-geometry
│
└── vision-mvg (N-view geometry, rectification, dense stereo)
└── vision-geometry (two-view solvers)
vision-calibration-core is the base layer: linear, optim, vision-geometry
and vision-mvg all depend on it.
Key rule: vision-calibration-linear and vision-calibration-optim are peers. They both depend on vision-calibration-core but never on each other. This keeps initialization algorithms free of optimization dependencies and vice versa. vision-calibration-linear may also use vision-geometry; vision-calibration-pipeline does not depend on vision-mvg (the facade re-exports it directly).
Crate Responsibilities
vision-calibration-core
The foundation layer providing:
- Math types — nalgebra-based aliases:
Pt2,Pt3,Vec3,Mat3,Iso3,Real(=f64) - Camera models — composable trait-based pipeline:
ProjectionModel,DistortionModel,SensorModel,IntrinsicsModel - Observation types —
CorrespondenceView(2D-3D point pairs),View<Meta>,PlanarDataset,RigDataset - RANSAC engine — generic
Estimatortrait with configurable options - Synthetic data utilities — grid generation, pose sampling, projection
- Reprojection error computation — single-camera and multi-camera rig
vision-geometry
Deterministic two-view and pose solvers, all free functions in per-topic modules:
| Module | Functions |
|---|---|
homography | dlt_homography, dlt_homography_ransac |
epipolar | fundamental_8point, essential_5point |
triangulation | triangulate_point_linear |
camera_matrix | dlt_camera_matrix |
pnp | dlt, p3p, epnp, dlt_ransac |
The facade re-exports the main solvers at the root of its mvg module (vision_calibration::mvg::dlt_homography, …) and the PnP solvers as init::pnp; the full set is in the vision-geometry crate.
vision-mvg
N-view geometry on top of vision-geometry: calibrated relative-pose recovery, N-view triangulation, bundle adjustment from a calibration, stereo rectification from a calibration, and dense stereo matching. Facade path: vision_calibration::mvg.
vision-calibration-dataset and vision-calibration-detect
vision-calibration-dataset defines DatasetSpec, the on-disk manifest describing images, robot poses, and target metadata. vision-calibration-detect provides the target detectors (chessboard, ChArUco, PuzzleBoard, ring grid) and a filesystem detection cache. The pipeline's dataset_runner turns a manifest into calibration inputs.
vision-calibration-linear
Closed-form initialization solvers (the two-view solvers live in vision-geometry). Each produces an approximate estimate suitable for seeding non-linear optimization:
| Solver | Input | Output |
|---|---|---|
| Zhang's method | Homographies | Intrinsics |
| Distortion fit | + homographies | Brown-Conrady coefficients |
| Iterative intrinsics | Observations | Joint + distortion |
| Planar pose | + homography | SE(3) pose |
| Tsai-Lenz hand-eye | Robot + camera motions | Hand-eye SE(3) |
| Rig extrinsics | Per-camera poses | Camera-to-rig SE(3) |
| Laser plane | Laser pixels + target poses | Plane (normal + distance) |
vision-calibration-optim
Non-linear refinement with a backend-agnostic architecture:
- IR (Intermediate Representation) —
ProblemIRwithParamBlockandResidualBlocktypes that describe optimization problems independently of any solver - Factors — generic residual functions parameterized over
RealFieldfor automatic differentiation - Backends — tiny-solver and factrs: two linearization engines driven by one Levenberg–Marquardt loop with sparse linear solvers
- Problem builders — domain-specific functions that construct IR from calibration data
vision-calibration-pipeline
The session framework providing production-ready workflows:
CalibrationSession<P: ProblemType>— generic state container with config, input, state, output, exports- Step functions — free functions operating on
&mut CalibrationSession<P>(e.g.,step_init,step_optimize) - Pipeline functions — convenience wrappers chaining all steps
- JSON checkpointing — full serialization for session persistence
- Seven problem types:
PlanarIntrinsicsProblem,SingleCamHandeyeProblem,LaserlineDeviceProblem,RigExtrinsicsProblem,RigHandeyeProblem,RigLaserlineDeviceProblem,RigHandeyeLaserlineProblem. Planar intrinsics takes the camera model as config (camera.model), so a tilted sensor is a value of its sensor slot; the rig problems takecameraplus per-cameracamera_overrides, so each rig camera can have its own distortion and sensor.
vision-calibration
Unified facade crate that re-exports everything through a clean module hierarchy:
#![allow(unused)] fn main() { use vision_calibration::prelude::*; // Minimal planar hello-world imports use vision_calibration::planar_intrinsics::*; // Planar workflow use vision_calibration::core::*; // Math types use vision_calibration::mvg::dlt_homography; // Two-view solvers use vision_calibration::mvg::rectification; // N-view geometry use vision_calibration::optim::RobustLoss; // Optimization vocabulary (hand-picked) }
vision-calibration-py
Python bindings crate (maturin/PyO3) exposing high-level session workflows; published to PyPI.
Data Flow
Every calibration workflow follows the same pattern:
Observations (2D-3D correspondences)
│
▼
Linear Initialization (vision-calibration-linear)
│ Closed-form solvers: ~5-40% accuracy
▼
Non-Linear Refinement (vision-calibration-optim)
│ Levenberg-Marquardt: <2% accuracy, <1 px reprojection
▼
Calibrated Parameters (K, distortion, poses, ...)
The session framework wraps this flow with configuration, state tracking, and checkpointing:
CalibrationSession::new()
│
▼
session.set_input(dataset)
│
▼
step_init(&mut session, None) ← linear initialization
│
▼
step_optimize(&mut session, None) ← non-linear refinement
│
▼
session.export() ← calibrated parameters
Design Patterns
Composable Camera Model
The camera projection pipeline is built from four independent traits composed via generics:
pixel = K(sensor(distortion(projection(direction))))
Each stage can be mixed and matched. For example, a standard camera uses Pinhole + BrownConrady5 + IdentitySensor + FxFyCxCy, while a laser profiler might use Pinhole + BrownConrady5 + ScheimpflugParams + FxFyCxCy.
Backend-Agnostic Optimization
Problems are defined as an intermediate representation (IR) that is independent of any specific solver. The IR is then compiled to a solver-specific form:
Problem Builder → ProblemIR → OptimBackend::solve()
(generic) (solver-specific)
This allows swapping the optimization backend without changing problem definitions.
Step Functions
Calibration workflows are decomposed into discrete steps implemented as free functions. This allows:
- Inspection of intermediate state between steps
- Resumption from any point (via JSON checkpointing)
- Customization of per-step options
- Composition of steps from different problem types
Getting Started
This chapter calibrates a camera twice, each time in Rust and in Python. The first run uses synthetic data: a planar board seen from several poses, the two-step calibration (linear initialization, then non-linear refinement), and the intrinsics and reprojection error it recovers. The second run, Calibrate Your Own Images, starts from a folder of board images and ends with the camera, the uncertainty of each parameter and a quality verdict.
Both interfaces drive the same solvers and expose the same workflow: a session per problem type with its step functions, and a one-call runner per workflow (run_calibration in Rust; run_planar_intrinsics, run_rig_extrinsics, ... in Python). The Rust program below steps through a session; the Python program uses the runner, and Step by Step in Python shows the session.
Install
Rust
[dependencies]
vision-calibration = { version = "0.16", features = ["synthetic"] }
anyhow = "1"
vision-calibration is the facade crate: it re-exports the calibration workflows, the dataset manifest, the target detectors' cache and the multiple-view geometry tools. The synthetic feature adds the synthetic-data generators the example below calibrates; a calibration from real images does not need it.
Python
pip install vision-calibration
Python 3.10 or newer. To build the extension from a checkout instead, run maturin develop -m crates/vision-calibration-py/Cargo.toml. The package is vision_calibration; the examples below import it as vc.
Command line
cargo install vision-calibration-cli
The Python package installs the same vcal program. It calibrates a folder of images without code: The Command Line.
A Minimal Calibration
Each program builds a synthetic camera with known intrinsics and Brown-Conrady distortion, projects an 8x6 board (40 mm squares) from several poses, and recovers the camera from those pixels alone.
Rust
use anyhow::Result; use vision_calibration::core::make_pinhole_camera; use vision_calibration::planar_intrinsics::{step_init, step_optimize}; use vision_calibration::prelude::*; use vision_calibration::synthetic::planar; fn main() -> Result<()> { // 1. Define the ground-truth camera let k_gt = FxFyCxCy { fx: 800.0, fy: 780.0, cx: 640.0, cy: 360.0 }; let dist_gt = BrownConrady5 { k1: 0.05, k2: -0.02, k3: 0.0, p1: 0.001, p2: -0.001, iters: 8, }; let camera = make_pinhole_camera(k_gt, dist_gt); // 2. Generate synthetic observations let board = planar::grid_points(8, 6, 0.04); // 8x6 grid, 40 mm squares let poses = planar::poses_yaw_y_z(6, -0.2, 0.08, 0.5, 0.05); let views = planar::project_views_all(&camera, &board, &poses)?; // 3. Create a session and set the input let dataset = PlanarDataset::new( views.into_iter().map(View::without_meta).collect() )?; let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_input(dataset)?; // 4. Run the calibration step_init(&mut session, None)?; // linear initialization step_optimize(&mut session, None)?; // non-linear refinement // 5. Inspect the result let export = session.export()?; let k = export.params.camera.intrinsics; println!("fx={:.1}, fy={:.1}, cx={:.1}, cy={:.1}", k.fx, k.fy, k.cx, k.cy); println!("Mean reprojection error: {:.4} px", export.mean_reproj_error); Ok(()) }
The library ships this program as an example:
cargo run -p vision-calibration --example planar_synthetic
Python
import math
import vision_calibration as vc
# 1. The ground-truth camera and a planar board (8x6 grid, 40 mm squares)
fx, fy, cx, cy = 800.0, 780.0, 640.0, 360.0
k1, k2, p1, p2 = 0.05, -0.02, 0.001, -0.001
board = [(i * 0.04, j * 0.04, 0.0) for i in range(8) for j in range(6)]
def project(pt, yaw, tilt, z):
"""Pixel of a board point for a board turned by (yaw, tilt) at depth z."""
x, y, _ = pt
cyw, syw, ct, st = math.cos(yaw), math.sin(yaw), math.cos(tilt), math.sin(tilt)
xc = cyw * x + syw * st * y # R = Ry(yaw) * Rx(tilt)
yc = ct * y
zc = -syw * x + cyw * st * y + z
xn, yn = xc / zc, yc / zc
r2 = xn * xn + yn * yn
radial = 1.0 + k1 * r2 + k2 * r2 * r2
xd = xn * radial + 2 * p1 * xn * yn + p2 * (r2 + 2 * xn * xn)
yd = yn * radial + p1 * (r2 + 2 * yn * yn) + 2 * p2 * xn * yn
return (fx * xd + cx, fy * yd + cy)
# 2. Synthetic observations: varied yaw, tilt and distance
poses = [(-0.3 + 0.1 * v, 0.25 * math.sin(v), 0.45 + 0.04 * v) for v in range(8)]
views = [
vc.PlanarView(
observation=vc.Observation(
points_3d=board,
points_2d=[project(p, yaw, tilt, z) for p in board],
)
)
for yaw, tilt, z in poses
]
# 3. Build the input and run the calibration (initialization + refinement)
dataset = vc.PlanarIntrinsicsInput(views=views)
result = vc.run_planar_intrinsics(dataset)
# 4. Inspect the result
k = result.camera.intrinsics
print(f"fx={k.fx:.1f}, fy={k.fy:.1f}, cx={k.cx:.1f}, cy={k.cy:.1f}")
print(f"Mean reprojection error: {result.mean_reproj_error:.4f} px")
Real data replaces step 2 in both languages: detect the board corners in each image and pair every corner with its position on the board. Calibrate Your Own Images below does that for a folder of images; Planar Intrinsics with Real Data shows the detection step by hand.
Step by Step in Python
The runner above is a session run in one call. The session gives the steps, their results and the init log:
session = vc.PlanarIntrinsicsSession()
session.set_input(dataset)
init = session.step_init() # linear initialization
print(f"init fx={init.camera.intrinsics.fx:.1f}")
session.step_optimize() # non-linear refinement
result = session.export() # the same PlanarIntrinsicsExport
cam = vc.CameraModel(result.camera) # project with the calibrated camera
print(cam.project([0.0, 0.0, 1.0])) # the principal point
Calibrate Your Own Images
A folder of photographs of a calibration board is enough to start. Without code, vcal init <folder> and vcal run <folder>/dataset.toml do what this section does (The Command Line). The program below takes three steps. It drafts a dataset manifest that describes the folder, waits for you to fill in what only you know, and then detects the board, calibrates and judges the result. It works for any workflow: the manifest's topology picks it.
1. Draft the manifest
The folder sniffer reads file and folder names, never image content. It groups images into cameras, finds a robot-pose file if there is one and picks the topology. What it cannot know, the board above all, it leaves as a placeholder and lists under _unresolved. For a folder of board images from one camera, the draft is:
{
"version": 1,
"topology": "planar_intrinsics",
"cameras": [{ "id": "cam0", "images": { "kind": "glob", "pattern": "*.png" } }],
"target": { "kind": "chessboard", "rows": 0, "cols": 0, "square_size_m": 0.0 },
"_unresolved": ["target"]
}
2. Fill in what only you know
Describe the board and delete _unresolved. For a chessboard, rows and cols count the inner corners, where four squares meet, and square_size_m is the measured side of a printed square in metres:
"target": { "kind": "chessboard", "rows": 7, "cols": 11, "square_size_m": 0.03 }
The calibration refuses a manifest whose _unresolved list is not empty, so a placeholder never reaches a result. ChArUco boards, PuzzleBoards and ring grids are described in Calibration Targets and Detection, and robot poses and the other fields in The Dataset Manifest.
3. Calibrate and judge
Rust
[dependencies]
vision-calibration = "0.16"
anyhow = "1"
serde_json = "1"
The real-image route needs no feature flags: detection, the manifest and the dataset runner are part of the default build.
use std::path::PathBuf; use anyhow::{Result, bail}; use vision_calibration::analysis::{CheckStatus, QualityThresholds, assess}; use vision_calibration::common::CalibratedCameras; use vision_calibration::core::Uncertainty; use vision_calibration::dataset::{DatasetSpec, sniff_folder}; use vision_calibration::control::RunControl; use vision_calibration::dataset_runner::{Detection, calibrate_dataset}; use vision_calibration::detect::FsDetectionCache; fn main() -> Result<()> { let dir = PathBuf::from("my_board_images"); let image_size = [1024, 576]; // width, height in pixels // 1. Draft the manifest and stop until it is filled in. let manifest = dir.join("dataset.json"); if !manifest.exists() { let draft = sniff_folder(&dir)?; std::fs::write(&manifest, serde_json::to_string_pretty(&draft)?)?; bail!("fill in {:?} in {}, delete `_unresolved`", draft.unresolved, manifest.display()); } // 2. Detect (cached for the next run) and calibrate. let spec = DatasetSpec::from_path(&manifest)?; let cache = FsDetectionCache::new(dir.join(".detections")); let run = calibrate_dataset(&spec, &dir, None, Detection::Cached(&cache), None, &RunControl::default())?; println!("{} of {} views used", run.usable_views, run.total_views); // 3. The camera and the uncertainty of its parameters. let export = &run.export; let k = &export.camera_params()[0].intrinsics; println!("fx={:.2} fy={:.2} cx={:.2} cy={:.2}", k.fx, k.fy, k.cx, k.cy); if let Uncertainty::Available(cov) = export.parameter_uncertainty() { for label in ["fx", "fy", "cx", "cy"] { println!(" σ({label}) = {:.3} px", cov.std_dev(label).unwrap_or(f64::NAN)); } } // 4. The verdict, and the checks that did not pass. let report = assess(export, &[image_size], &QualityThresholds::default())?; println!("verdict: {:?}", report.verdict); for check in report.checks.iter().filter(|c| c.status != CheckStatus::Pass) { println!(" {:?} {:?}: {}", check.kind, check.status, check.message); } // 5. Keep the export: JSON with a published schema. std::fs::write(dir.join("calibration.json"), serde_json::to_string_pretty(export)?)?; Ok(()) }
calibrate_dataset reads the manifest's topology and runs that workflow with its default configuration; pass Some(CalibrationConfig::...) to change it (see Configuring a Run). Its result holds the export, which every analysis accepts whichever workflow produced it, and the detections it left out with the reason for each. The library ships this program, generalized to several cameras, as the calibrate_folder example:
cargo run --release -p vision-calibration --example calibrate_folder -- my_board_images 1024 576
Python
import json
import sys
from pathlib import Path
import vision_calibration as vc
folder = Path("my_board_images")
manifest = folder / "dataset.json"
# 1. Draft the manifest and stop until it is filled in.
if not manifest.exists():
draft = vc.sniff_folder(folder)
manifest.write_text(json.dumps(draft, indent=2))
sys.exit(f"fill in {draft['_unresolved']} in {manifest}, delete _unresolved")
# 2. Detect (cached for the next run) and calibrate.
cache = vc.FsDetectionCache(folder / ".detections")
result = vc.calibrate_dataset(manifest, cache=cache)
# 3. The camera and the uncertainty of its parameters.
k, u = result.camera.intrinsics, result.uncertainty
print(f"fx={k.fx:.2f} ± {u.std_dev('fx'):.3f} fy={k.fy:.2f} ± {u.std_dev('fy'):.3f}")
print(f"cx={k.cx:.2f} ± {u.std_dev('cx'):.3f} cy={k.cy:.2f} ± {u.std_dev('cy'):.3f}")
# 4. The verdict, and the checks that did not pass.
report = vc.assess(result, image_sizes=[(1024, 576)])
print("verdict:", report.verdict)
for check in report.checks:
if check.status != "pass":
print(" ", check.kind, check.status, check.message)
Reading the output
On the 20 left-camera images of the stereo set in Stereo Rig with Real Data (a 7×11 chessboard with 30 mm squares, 1024×576 pixels), both programs print the same numbers; the Python program's output is:
fx=713.85 ± 7.672 fy=724.67 ± 7.821
cx=523.41 ± 1.688 cy=285.95 ± 1.433
verdict: warn
variance_factor not_applicable no expected feature noise was stated (expected_feature_noise_px)
projection_sigma warn projection σ up to 7.065 px over the 1024×576 image (centre 1.665 px, corners 7.065 px)
Read it from the bottom up. The verdict is warn, not pass: where a ray lands in the image is uncertain by up to 7 px in the corners, against 1.7 px in the centre. The views did not cover the image corners well enough to pin the focal length down to better than about 1 % (σ(fx) ≈ 7.7 px). More views with the board near the corners and tilted would shrink it; Planning the Next View ranks candidate poses by how much each would help. The variance check is not applicable until you state the detector's noise (expected_feature_noise_px). Judging a Result explains every check, and Reading the Covariance the σ.
No images at hand: a sample dataset
The library downloads real calibration datasets with a manifest already filled in. Each one comes from its publisher at a pinned revision, every file is checked against its SHA-256, and a second fetch reuses the cache:
| Sample | Workflow | Data | Licence |
|---|---|---|---|
opencv_left | planar intrinsics | 13 views of a 6×9 chessboard, 640×480, OpenCV's stereo sample | Apache-2.0 |
opencv_stereo | rig extrinsics | both cameras of the same stereo sample, the board turned by 90° in most views | Apache-2.0 |
opencv_fisheye_stereo | rig extrinsics, fisheye | 34 views of a 6×8 chessboard, 1280×800, OpenCV's fisheye test set | none stated |
kuka_1 | single-camera hand-eye | 30 views of a 17×28 chessboard and the robot poses | none stated |
ds8 | rig hand-eye | three cameras on a robot arm, 42 poses of a 10×14 chessboard | US public domain |
Each sample carries the citation its publisher asks for (citation in the catalogue). In Rust, fetching needs the fetch feature, which adds an HTTPS client:
vision-calibration = { version = "0.16", features = ["fetch"] }
use vision_calibration::dataset::{DatasetSpec, samples}; use vision_calibration::control::RunControl; use vision_calibration::dataset_runner::{Detection, calibrate_dataset}; fn main() -> anyhow::Result<()> { let manifest = samples::fetch("opencv_left", &samples::default_cache_dir()?)?; let spec = DatasetSpec::from_path(&manifest)?; let dir = manifest.parent().expect("the manifest sits in the sample's folder"); let run = calibrate_dataset(&spec, dir, None, Detection::Uncached, None, &RunControl::default())?; println!("{} of {} views used", run.usable_views, run.total_views); Ok(()) }
manifest = vc.samples.fetch("opencv_left")
result = vc.calibrate_dataset(manifest)
The cache is vision-calibration in the platform's cache directory; the VISION_CALIBRATION_DATA environment variable, or a directory passed to fetch, puts it elsewhere. The example program above runs on a fetched sample's folder as on your own. The fisheye sample needs a fisheye camera model in its configuration (Fisheye Projection).
Configuring a Run
Every workflow has a *Config type with the same grouped shape (solver, camera, init, ...). Rust updates it on the session or hands it to calibrate_dataset wrapped in CalibrationConfig; Python passes it to the runner, to calibrate_dataset or to the session's set_config.
Rust
#![allow(unused)] fn main() { use vision_calibration::optim::RobustLoss; use vision_calibration::planar_intrinsics::run_calibration; session.update_config(|c| { c.solver.max_iters = 80; c.solver.robust_loss = RobustLoss::Huber { scale: 1.0 }; })?; run_calibration(&mut session)?; // the same two steps in one call }
From a manifest, the configuration names its workflow, and a configuration for a different topology than the manifest's is an error raised before any image is read:
#![allow(unused)] fn main() { use vision_calibration::control::RunControl; use vision_calibration::dataset_runner::{CalibrationConfig, Detection, calibrate_dataset}; use vision_calibration::planar_intrinsics::PlanarIntrinsicsConfig; let mut config = PlanarIntrinsicsConfig::default(); config.solver.max_iters = 80; let config = CalibrationConfig::PlanarIntrinsics(config); let run = calibrate_dataset(&spec, &dir, Some(config), Detection::Cached(&cache), None, &RunControl::default())?; }
Python
config = vc.PlanarIntrinsicsConfig(
solver=vc.SolverConfig(max_iters=80, robust_loss=vc.robust_huber(1.0)),
)
result = vc.run_planar_intrinsics(dataset, config)
result = vc.calibrate_dataset("my_board_images/dataset.json", config) # from a manifest
What Happens in Each Step
Step 1: Linear Initialization
step_init (session.step_init() in Python; run_planar_intrinsics runs it first):
- Computes a homography from each view's 2D-3D correspondences using DLT (direct linear transform)
- Estimates intrinsics from the homographies using Zhang's method
- Estimates lens distortion coefficients from homography residuals
- Iterates steps 2-3 to refine the joint + distortion estimate
- Recovers each view's camera pose from its homography and
After this step, intrinsics are typically within 10-40% of the true values: good enough to start the non-linear optimization.
Step 2: Bundle Adjustment
step_optimize runs Levenberg-Marquardt, minimizing the total reprojection error:
where is the camera projection function, is the pose of view , are distortion coefficients, are known 3D board points, and are observed pixel coordinates.
After optimization, expect under 2% error on intrinsics and under 1 px mean reprojection error on real data. The synthetic data above is noise-free, so both programs recover the ground truth almost exactly.
Where to Go Next
- Stereo Rig with Real Data: a two-camera rig from detected corners, in under a minute.
- Planar Intrinsics Calibration: the full mathematical treatment of this workflow.
- Parameter Uncertainty: every result carries the covariance of its parameters.
- Architecture Overview: how the crates fit together.
- The Dataset Manifest: every field of the manifest, robot poses and image pairing included.
The Command Line
vcal calibrates a folder of images without a line of code: it drafts the
dataset manifest, checks it, detects the target, calibrates, and writes the
result and its quality report as JSON. It runs the same library calls as
Calibrate Your Own Images, so
the numbers are the same.
cargo install vision-calibration-cli # or: pip install vision-calibration
The Rust crate and the Python package install the same program.
| Command | Does |
|---|---|
vcal init <folder> | drafts <folder>/dataset.toml from the file names |
vcal check <manifest> | checks the manifest and its images, without detecting |
vcal run <manifest> | detects, calibrates, writes calibration.json and quality.json (--report adds report.html) |
vcal sample list | lists the sample datasets |
vcal sample fetch <id> | downloads a sample dataset and prints its manifest's path |
vcal <command> --help lists each command's options.
A folder of images
vcal init data/my_camera
wrote data/my_camera/dataset.toml
fill in, then delete `_unresolved`:
target
The draft lists what the file names cannot tell, the board above all, under
_unresolved. Describe the board (The Dataset Manifest) and
delete the list:
[target]
kind = "chessboard"
rows = 6 # inner corners down
cols = 9 # inner corners across
square_size_m = 0.025
vcal check then reads the manifest and each image's header. It reports a
manifest that is incomplete or invalid, a camera whose images it cannot find,
and a camera whose images differ in size, all before any detection:
$ vcal check data/my_camera/dataset.toml
data/my_camera/dataset.toml: planar_intrinsics
camera cam0: 640 × 480 px
ok
vcal run calibrates. On the 13 views of the opencv_left sample, fetched
to ~/.cache/vision-calibration, it prints:
$ vcal run ~/.cache/vision-calibration/opencv_left/dataset.toml
planar_intrinsics: 13 of 13 views used
camera 0 (left), 640 × 480 px:
fx = 534.108 ± 0.930 px
fy = 534.155 ± 0.966 px
cx = 341.029 ± 1.075 px
cy = 233.586 ± 1.144 px
verdict: warn
not_applicable variance_factor: no expected feature noise was stated (expected_feature_noise_px)
warn projection_sigma camera 0: projection σ up to 5.490 px over the 640×480 image (centre 1.146 px, corners 5.490 px)
wrote ~/.cache/vision-calibration/opencv_left/calibration.json and ~/.cache/vision-calibration/opencv_left/quality.json
Each parameter comes with its σ, and the verdict with the checks that did not pass (Judging a Result). Here the 13 views leave the image corners uncertain by 5 px; Capturing Calibration Images says which views would fix that. A view left out of the calibration is listed with its reason, such as a chessboard whose corners do not reach every edge of the board.
calibration.json is the workflow's export, the same JSON the library and
the desktop app write, and quality.json is the quality report. --report
adds report.html, all of it as one page with the residual and
projection-σ figures (The Calibration Report). -o <dir>
writes them elsewhere. Detections are cached in .detections next to the
manifest, so a second run skips detection; --redetect detects again.
Configuration
Without a configuration file the workflow runs with its defaults: a pinhole
camera with Brown-Conrady distortion. -c <file> changes the defaults the
file names and keeps the rest. The file is TOML or JSON and holds the
workflow's *Config fields. A fisheye camera needs a fisheye model:
# fisheye.toml
camera.model = { projection = "equidistant", distortion = "kannala_brandt4", sensor = "identity" }
vcal run "$(vcal sample fetch opencv_fisheye_stereo)" -c fisheye.toml
On that sample the default model leaves each focal length uncertain by 14 px; the fisheye model brings σ(fx) to 1.1 px. A field the workflow does not have is an error, so a misspelt name never passes silently. The fields are those of Configuring a Run and of each workflow's chapter.
Sample datasets
vcal sample list
vcal run "$(vcal sample fetch kuka_1)" -o out/kuka
fetch downloads a sample from its publisher, verifies every file's SHA-256
and prints the path of its manifest, so it composes with run. A second
fetch reuses the cache: VISION_CALIBRATION_DATA, else vision-calibration
in the platform's cache directory, or --cache <dir>.
Exit status
| Status | Meaning |
|---|---|
| 0 | done; for run, the verdict is pass or warn |
| 1 | an error, printed on standard error |
| 2 | a wrong command line |
| 3 | run wrote its files, but the verdict is fail |
| 130 | interrupted with Ctrl-C: the run stopped and wrote nothing |
A script can stop on a failed calibration with vcal run ... || exit.
Progress and Ctrl-C
On a terminal, vcal run keeps one line of standard error up to date: the
images detected (detecting 12/40), then each step and its solver
iterations (optimize: iteration 7/50, cost 1.2345e2). Piped or redirected,
it prints no progress. Ctrl-C stops the run at the next image or iteration,
prints interrupted and exits with status 130; a second Ctrl-C exits at
once.
Limits
The laser workflows (laserline_device, rig_laserline_device,
rig_handeye_laserline) need a laser-line extractor, which the library takes
from code (Laser Datasets); vcal run
refuses them before any detection. Calibrate them from Rust or Python.
Capturing Calibration Images
A calibration is only as good as the views it sees. The solver does not tell a good capture from a poor one by the residuals, which can be small either way; the views decide how well the focal length, the principal point and the distortion are determined. This chapter measures what matters, so a capture can be planned before the first image is taken.
The short version, for one camera and a planar board:
- 15 to 30 views, each with the whole board in the image.
- Tilt the board 15–30° out of the fronto-parallel pose, and turn the tilt direction from view to view: about the board's horizontal axis, then its vertical axis, both ways.
- Make the board large in the image: a third to a half of the image width.
- Move the board over the whole image, into every corner, not only the centre.
A rig needs this for every camera, plus views that two cameras see at once. A robot moves the camera instead of the board, so the same rules apply to the gripper's poses, rotated about at least two axes (Choose Your Workflow).
What each rule buys
The numbers below come from the capture_planning example, which you can
run and change:
cargo run --release -p vision-calibration --example capture_planning
It simulates one camera (1280 × 960 pixels, fx = 1000 px, barrel distortion) and a chessboard of 30 mm squares with 0.2 px corner noise, and calibrates each capture plan. The baseline is 16 views of a board with 12 × 9 interior corners, 0.65 m away, centred on each image quadrant in turn and tilted by 20°. Each table changes one thing. A view whose board leaves the image is dropped, as a chessboard detector would drop it ("used").
The columns are the stated standard deviations of the focal length, the principal point and the first radial coefficient, and the projection σ (Projection Uncertainty): how far, in pixels, the calibration's uncertainty moves where a ray lands, at the image centre and at its worst anywhere in the image. The quality check warns when the worst exceeds 1 px (Judging a Result).
Number of views
| Views | σ(fx) px | σ(cx) px | σ(k1) | σ centre px | σ max px |
|---|---|---|---|---|---|
| 4 | 2.31 | 1.44 | 0.0010 | 1.45 | 3.09 |
| 8 | 1.24 | 0.76 | 0.0007 | 0.80 | 1.49 |
| 16 | 0.86 | 0.50 | 0.0005 | 0.50 | 0.92 |
| 32 | 0.68 | 0.40 | 0.0004 | 0.40 | 0.73 |
The uncertainty falls roughly as one over the square root of the number of views: doubling the views gains about 30 %. More views are cheap insurance, but they do not make up for a poor tilt or a small board.
Tilt
| Tilt | Used | σ(fx) px | σ(cx) px | σ max px |
|---|---|---|---|---|
| 0° | 16 | fails | ||
| 3° | 16 | 36.8 | 1.10 | 29.0 |
| 6° | 16 | 8.44 | 0.77 | 6.15 |
| 11° | 16 | 2.18 | 0.56 | 1.71 |
| 20° | 16 | 0.86 | 0.50 | 0.92 |
| 29° | 12 | 0.63 | 0.62 | 0.95 |
| 40° | 4 | 12.99 | 1.19 | 12.07 |
The focal length is measured by perspective: how the board's far edge shrinks against its near edge. A board facing the camera squarely shows none, and its distance and the focal length trade off exactly, so the initialization fails (see When the views cannot work). A few degrees leave the focal length tens of pixels uncertain. From about 15° on it is well determined, and more tilt helps little. Past 30° a large board no longer fits in the image when it sits near a corner, and views are lost: here only 4 of 16 survive at 40°.
Tilt direction
| Eight views tilted | σ(fx) px | σ(cx) px | σ max px |
|---|---|---|---|
| all the same way | fails | ||
| about one axis, both ways | 95.9 | 0.38 | 282 |
| about both axes, both ways | 1.24 | 0.76 | 1.49 |
Views tilted the same way are parallel planes and tell the camera nothing more than one of them; the initialization fails. Tilting about the board's horizontal axis, both ways, gives two board orientations: they determine the vertical focal length and leave the horizontal one almost free. Turn the board about both axes.
Board size in the image
| Board | Width in the image | σ(fx) px | σ(cx) px | σ max px |
|---|---|---|---|---|
| 6 × 5 corners | 18 % | 5.64 | 3.19 | 6.39 |
| 9 × 7 corners | 29 % | 1.86 | 1.09 | 2.05 |
| 12 × 9 corners | 40 % | 0.86 | 0.50 | 0.92 |
A larger board in the image has more corners and, more importantly, more perspective across it. Going from a fifth to two fifths of the image width cuts every σ by a factor of six or seven. Bring the board closer, or use a larger board, until it fills a third to a half of the width; the limit is that it still fits whole in the image when tilted near a corner.
Placement
| Board placed | σ(fx) px | σ(k1) | σ centre px | σ max px |
|---|---|---|---|---|
| centre only | 0.79 | 0.0037 | 0.78 | 14.4 |
| each quadrant | 0.86 | 0.0005 | 0.50 | 0.92 |
Distortion grows toward the image corners. Views that keep the board in the centre determine the focal length as well as any, but leave the distortion coefficients to be extrapolated to the corners, where the projection σ reaches 14 px. Moving the board into each quadrant, its outer corners near the image corners, brings the worst σ under a pixel.
A rig needs its joint error too
The tables above judge each parameter by its own σ. A rig adds parameters that trade off with the cameras' own: one camera's rotation in the rig and the two principal points all move the image of a board that sits in the middle of both images the same way. Views that leave them to trade off still give every parameter a σ that holds, but the error along the combination the views do pin down (here camera 1's yaw against the difference of the principal points) comes from larger errors that cancel, and through the second order those still reach it. Anything that uses the whole covariance at once — a calibration prior in bundle adjustment, a test of two calibrations against each other — then trusts that combination several times too much.
The result's uncertainty predicts this (second_order, see
Reading the Covariance), and the quality report's
covariance_linearity check warns above 1.2. For a synthetic stereo pair
(eight views of an 8 × 6 board with 30 mm squares at 0.6–0.8 m, 0.3 px
noise), over 300 calibrations each:
| Views | Predicted worst scatter / σ | Measured | NEES per parameter (measured) |
|---|---|---|---|
| centred, tilted up to 0.2 rad | 2.83 | 3.00 | 1.64 |
| centred, tilted up to 0.3 rad | 1.87 | 2.05 | 1.27 |
| centred, tilted up to 0.5 rad | 1.45 | 1.66 | 1.15 |
| off centre by 12 cm and 9 cm, tilted up to 0.2 rad | 1.55 | 1.56 | 1.10 |
| off centre, tilted up to 0.5 rad | 1.06 | — | 1.02 |
Tilt and placement each help, and only both together bring the combination within its σ (the last row's measured worst is below what 300 calibrations resolve). The effect grows with the noise variance: the first row at 0.1 px predicts 1.35, at 0.03 px 1.04. So for a rig, put the board in the image corners as well as tilting it, in views both cameras see.
Check while you capture
You do not have to trust the rules: calibrate after the first ten or so views and read the result. The quality verdict flags poor coverage, a large projection σ and, for a rig, a joint error beyond the covariance, and the projection map shows where in the image the calibration is weak:
#![allow(unused)] fn main() { use vision_calibration::analysis::{QualityThresholds, assess}; use vision_calibration::common::CalibratedCameras; let export = session.export()?; let report = assess(&export, &[[1280, 960]], &QualityThresholds::default())?; println!("{:?}", report.verdict); let map = export.projection_uncertainty(0, [1280, 960])?; println!("σ centre {:.2} px, worst {:.2} px", map.center, map.max); }
report = vc.assess(result, image_sizes=[(1280, 960)])
print(report.verdict)
p = vc.projection_uncertainty(result, (1280, 960), camera=0)
print(f"σ centre {p.center:.2f} px, worst {p.max:.2f} px")
For a single camera, rank_candidates then says which board pose to take
next: it predicts, without a re-solve, how much each candidate view would
reduce the worst projection σ (Planning the Next View).
When the views cannot work
When the board planes are parallel in every view, fronto-parallel or tilted the same way throughout, no calibration can recover the focal length, and the initialization stops before any solve with an error that says so and names the fix:
the 16 views do not determine the camera intrinsics: ...; tilt the board
about both of its axes, by 15° to 30°, from view to view
The initialization measures how far the board orientations spread across the views, as seen through a camera whose focal length is the image extent the corners cover, and rejects a spread under 1°. A spread of a few degrees still initializes, with the large σ the tables above show. With strong lens distortion, parallel boards no longer look parallel, and the same error comes from Zhang's method finding no camera that fits the views.
In Rust it is a typed error to match on; in Python it is a ValueError
with the same message:
#![allow(unused)] fn main() { use vision_calibration::{Error, init}; match run_calibration(&mut session) { Err(Error::Linear(init::Error::DegenerateViews { .. })) => { println!("recapture: tilt the board about both of its axes"); } other => other?, } }
The image itself
The geometry above assumes every corner is found to a fraction of a pixel. That needs:
- Focus and sharpness: the board in focus over its whole tilted extent, with an exposure short enough that it does not blur when held by hand.
- Even lighting without glare on the board; a matte print.
- A flat, rigid board, its square size measured on the print (the calibration's scale comes from it), not taken from the file.
- Fixed camera settings: focus, zoom and resolution unchanged through the capture and afterwards. A refocus changes the intrinsics.
The detection noise scales every σ above in proportion: a detector that finds corners to 0.1 px instead of 0.2 px halves them.
Stereo Rig with Real Data
This chapter walks through the stereo_session example, which calibrates a stereo camera rig from synchronized left/right views with RigExtrinsicsProblem. It is the quickest way to see a complete multi-camera calibration on real data: the corners are already detected and committed to the repository, so no images are needed.
Run It First
cargo run -p vision-calibration --example stereo_session --release
The program loads 20 views of a 7x11 chessboard (30 mm squares) seen by both cameras and prints, among other lines:
--- Step 2: Per-Camera Intrinsics Optimization ---
Camera 0: fx=713.8, fy=724.7, cx=523.4, cy=286.0, reproj_err=0.234px
Camera 1: fx=724.8, fy=735.6, cx=513.9, cy=293.0, reproj_err=0.244px
--- Step 4: Rig Bundle Adjustment ---
Rig BA mean reprojection error: 0.2461 px
Camera 0: 0.2413 px
Camera 1: 0.2509 px
Rig baseline (after BA): |c1 - c0| = 0.1898 m (189.84 mm)
--- Uncertainty ---
Baseline: 189.84 ± 0.489 mm
Corner noise σ ≈ 0.188 px (6160 residuals)
The ± comes from the export's uncertainty: the covariance of the estimated parameters, here of camera 1's cam_se3_rig. The rig solve refines both cameras' intrinsics with the rig, so it includes their uncertainty, and it is scaled by the view scatter, about 4 here: the error the views do not share with each other (see Parameter Uncertainty).
The Session in Five Calls
A calibration is a session plus a sequence of step functions invoked in order. Each step reads what it needs from the session, writes its own output back, and returns a typed result, so you can inspect the state between any two steps:
Session::new()
→ set_input(observations)
→ step_intrinsics_init_all ← Zhang's method per camera
→ step_intrinsics_optimize_all ← per-camera refinement
→ step_rig_init ← linear cam_se3_rig + rig_se3_target
→ step_rig_optimize ← joint bundle adjustment
→ export() ← stable, JSON-serializable result
The interesting part of the example is ten lines:
#![allow(unused)] fn main() { let mut session = CalibrationSession::<RigExtrinsicsProblem>::new(); session.set_input(input)?; step_intrinsics_init_all(&mut session, None)?; step_intrinsics_optimize_all(&mut session, None)?; step_rig_init(&mut session, None)?; step_rig_optimize(&mut session, None)?; let export = session.export()?; let mean_reproj = export.mean_reproj_error; let centre = |i: usize| export.cam_se3_rig[i].inverse().translation.vector; let baseline = (centre(1) - centre(0)).norm(); }
RigExtrinsicsProblem is a marker type: through the ProblemType trait it fixes the input, configuration and export types, so the session is fully typed. Each step_* takes an Option<*Options> for per-call tweaks (max_iters, verbosity, ...); None uses the session's config. export() returns a RigExtrinsicsExport, the stable view of the result and the handoff to other tools.
The same calibration from Python is one call (crates/vision-calibration-py/examples/stereo_session.py loads the same observation file):
import vision_calibration as vc
# `views`: one vc.RigExtrinsicsView per frame, with a vc.Observation (or None)
# per camera; `dataset` wraps them.
dataset = vc.RigExtrinsicsInput(num_cameras=2, views=views)
result = vc.run_rig_extrinsics(dataset)
print(result.mean_reproj_error, result.per_cam_reproj_errors)
Other camera setups use other problem types with the same pattern: only the input shape and the step list change. Step Functions vs Pipeline Functions tabulates them, and each problem type's facade module (vision_calibration::rig_extrinsics, ...) re-exports its types and steps.
Building the Rig Dataset
A rig view holds one optional observation per camera. A camera that does not see the target in a given frame contributes None; the calibration copes with such views as long as each camera has enough usable views overall (at least 3).
#![allow(unused)] fn main() { use vision_calibration::core::{NoMeta, RigDataset, RigView, RigViewObs}; views.push(RigView { meta: NoMeta, obs: RigViewObs { cameras: vec![left, right], // each an Option<CorrespondenceView> }, }); let input = RigDataset::new(views, 2)?; // 2 cameras }
Each CorrespondenceView is built from the detected corners with CorrespondenceView::new(points_3d, points_2d)?, exactly as in Planar Intrinsics with Real Data.
Reading the Step Results
Each step returns its own result struct; keep them to report progress:
#![allow(unused)] fn main() { use vision_calibration::rig_extrinsics::{ RigExtrinsicsProblem, step_intrinsics_init_all, step_intrinsics_optimize_all, step_rig_init, step_rig_optimize, }; step_intrinsics_init_all(&mut session, None)?; let intr_opt = step_intrinsics_optimize_all(&mut session, None)?; let rig_init = step_rig_init(&mut session, None)?; let rig_opt = step_rig_optimize(&mut session, None)?; println!("Rig BA mean reprojection error: {:.4} px", rig_opt.mean_reproj_error); for (i, err) in rig_opt.per_cam_reproj_errors.iter().enumerate() { println!(" Camera {i}: {err:.4} px"); } }
run_calibration(&mut session) (vision_calibration::rig_extrinsics::run_calibration) runs the same four steps with default options in one call.
Every step also appends an entry to the session log, which records what the initialization produced and which values came from manual seeds (see Seeding with Prior Knowledge):
#![allow(unused)] fn main() { for entry in session.log() { println!("[{}] {:?} {}", entry.operation, entry.success, entry.notes.as_deref().unwrap_or("")); } }
CalibrationSession is JSON-serializable: persist it after step_rig_init (session.to_json()) and resume later with a fresh step_rig_optimize under different solver options (see CalibrationSession).
The Example Dataset
The example reads regression/observations/stereo_rig.json: the corners of a 7×11 chessboard with 30 mm squares, detected in 20 image pairs of a 1024×576 stereo camera. The file is part of the regression suite; its input field is a serialized RigExtrinsicsInput, so it loads with serde_json:
#![allow(unused)] fn main() { let text = std::fs::read_to_string("regression/observations/stereo_rig.json")?; let mut file: serde_json::Value = serde_json::from_str(&text)?; let input: RigExtrinsicsInput = serde_json::from_value(file["input"].take())?; }
With your own images, detect the board in each camera's image of a pair and build the RigView as above. Pair the images by a shared index or timestamp; a camera whose detection fails contributes None. The detection step is the same as in Planar Intrinsics with Real Data. A ChArUco board identifies its corners by their markers, so a partly visible board still yields correspondences:
#![allow(unused)] fn main() { use calib_targets::aruco::builtins; use calib_targets::charuco::{CharucoBoardSpec, CharucoParams, MarkerLayout}; use calib_targets::detect; let board = CharucoBoardSpec::new( 22, // rows 22, // columns 0.00135, // cell size, metres 0.75, // marker size relative to a cell builtins::DICT_4X4_1000, // dictionary ) .with_marker_layout(MarkerLayout::OpenCvCharuco); let charuco_params = CharucoParams::for_board(board); let detection = detect::detect_charuco(&img, &charuco_params); for corner in detection.corners { let target = corner.target_position; // board-frame position, metres points_3d.push(Pt3::new(target.x as f64, target.y as f64, 0.0)); points_2d.push(Pt2::new(corner.position.x as f64, corner.position.y as f64)); } }
Command-Line Options
cargo run -p vision-calibration --example stereo_session --release -- --max-views=10
--max-views=N uses the first N views. The Python counterpart is crates/vision-calibration-py/examples/stereo_session.py.
Interpreting Results
The example prints the number of views each camera sees, per-camera intrinsics with per-camera reprojection errors, the rig BA reprojection error (about 0.25 px on this dataset), and the baseline (about 189 mm).
- Per-camera intrinsics: cameras with the same lens should have similar focal lengths. Differences in principal point are normal.
- Baseline: the norm of the translation between camera centers should match the physical distance between the cameras. For a horizontal stereo pair, expect a translation primarily along X and a rotation primarily around Y.
- Reprojection errors should be within your application tolerance.
Verification
- Epipolar constraint: corresponding points should lie on epipolar lines. Measure the average distance from points to their epipolar lines (see Epipolar Geometry).
- Rectification: the pair should rectify cleanly, with horizontal scanlines in the rectified images corresponding.
- Triangulation: triangulate known board corners and verify the 3D distances match the board geometry (see Linear Triangulation).
Data Collection Tips
- Both cameras should see the board in most views; views seen by only one camera constrain only that camera's intrinsics.
- Vary board orientation and distance — same advice as single-camera calibration.
- Ensure synchronization — if images are not captured simultaneously, the board may have moved between left and right captures.
- Overlap region — the board should be in the overlapping field of view of both cameras.
Planar Intrinsics Calibration
This is the most common calibration workflow: estimate camera intrinsics and lens distortion from multiple views of a planar calibration board. It combines Zhang's linear initialization with Levenberg-Marquardt bundle adjustment.
Problem Formulation
Parameters:
- Intrinsics: — 4 scalars
- Distortion: — 5 scalars
- Per-view poses: — SE(3) transforms (6 DOF each)
Total: parameters.
Observations: For each view and board point :
- Known 3D position (on the board, at )
- Observed pixel
Objective: Minimize total reprojection error:
where is the full camera projection pipeline: SE(3) transform → pinhole → distortion → intrinsics.
With robust loss :
Two-Step Pipeline
Step 1: Linear Initialization (step_init)
- Homographies: Compute for each view via DLT (from board points at to observed pixels)
- Intrinsics: Estimate from homographies using Zhang's method, iteratively refined with distortion estimation (see Iterative Intrinsics)
- Distortion: Estimate from homography residuals (see Distortion Fit)
- Poses: Decompose each homography to recover (see Pose from Homography)
After initialization, intrinsics are typically within 10-40% of the true values.
Step 2: Non-Linear Refinement (step_optimize)
Constructs the optimization problem as IR:
- Parameter blocks:
"cam"(4D, Euclidean),"dist"(the distortion model's coefficients, Euclidean; absent for no distortion),"sensor"(tilt_x, tilt_y, Scheimpflug sensor only),"pose/0"..."pose/M-1"(7D, SE3) - Residual blocks: One
ReprojPoint { model: PINHOLE4_DIST5, chain: SinglePose, .. }per observation (2D residual) - Solver: the Levenberg–Marquardt loop on the backend
solver.backendselects (tiny-solver by default; see Solver Backends)
After optimization, expect <2% intrinsics error and <1 px mean reprojection error.
Configuration
init and solver are shared sub-structs reused by every intrinsics-bearing
problem type; camera names the camera model and its fixed parameters:
#![allow(unused)] fn main() { pub struct PlanarIntrinsicsConfig { // Linear-initialization stage settings. pub init: IntrinsicsInitConfig, // { iterations: usize = 2, fix_k3: bool = true }; it fits only the // distortion terms the camera's solves estimate // Non-linear solve stage settings. pub solver: SolverConfig, // { max_iters: usize = 120, verbosity: usize = 0, robust_loss: RobustLoss = None } // The camera model and the parameters held fixed. pub camera: CameraConfig, // { model: CameraModelSpec { projection, distortion, sensor }, // fix: CameraFix { intrinsics, projection, distortion, sensor } } pub fix_poses: Vec<usize>, // Fix specific view poses } }
solver.max_iters is sized for a tilted sensor's tilt valley; the solver stops
at convergence, so the cap only bounds runs that do not converge.
Camera Model
camera.model names one kind per slot. The default is a pinhole projection,
Brown-Conrady distortion and an identity (untilted) sensor.
camera.model.distortion selects the lens model that is fitted: Brown-Conrady (default), rational (Rational8), thin-prism (ThinPrism9), division (Division1) or none.
#![allow(unused)] fn main() { use vision_calibration::core::DistortionKind; session.update_config(|c| c.camera.model.distortion = DistortionKind::Rational8)?; }
camera.fix lists, per slot (intrinsics, distortion, sensor), the parameter names held fixed during the non-linear solve; a slot left None keeps the model's default (k3 for Brown-Conrady). The models, their packed layouts, the defaults and when a richer model helps are in Choosing a Distortion Model.
camera.model.sensor = SensorKind::Scheimpflug calibrates a tilted sensor
as well; see Tilted (Scheimpflug) Sensor below.
Tilted (Scheimpflug) Sensor
With camera.model.sensor = SensorKind::Scheimpflug the same workflow also
estimates the sensor tilt tilt_x, tilt_y (radians). The tilt trades off
against the principal point and radial distortion, so the sensor changes three
defaults and both steps:
- The default fix list leaves only the low-order radial terms free.
- The linear stage holds the tangential terms while the tilt is estimated: a free tangential term is ill-posed next to the tilt there.
- A Scheimpflug sensor needs a distortion model (
DistortionKind::Noneis a configuration error).
step_init_with_seed takes an optional sensor seed in IntrinsicsManualInit
(a typed error for an untilted camera). With a seeded tilt — the nominal
mount angle, plus a coarse focal — step_optimize trusts that basin: it sweeps
the leading radial coefficient over {0, -0.20, -0.40} with intrinsics and
tilt held, then runs a joint solve bounded around the seed (focals
[0.75, 1.5]×, principal point ±0.15·f, tilt ±0.35 rad: a mount's
nominal tilt can be off by more than 0.1 rad). A single camera's views
often pin the tilt poorly, as it trades with the principal point; the
result's covariance shows it, and a rig's joint solve (refine_sensor)
determines it far better. Without a tilt
seed (cold start, experimental) it runs a staged multi-start: a cheap
tilt sweep with the principal point frozen, a refine of the winning basin,
and a bounded joint refine. The init log warns when the intrinsics are not
seeded, because a from-scratch start can settle in a wrong tilt/focal basin.
#![allow(unused)] fn main() { use vision_calibration::core::{FxFyCxCy, ScheimpflugParams, SensorKind}; use vision_calibration::planar_intrinsics::{ PlanarIntrinsicsConfig, PlanarIntrinsicsProblem, IntrinsicsManualInit, step_init_with_seed, step_optimize, }; let mut config = PlanarIntrinsicsConfig::default(); config.camera.model.sensor = SensorKind::Scheimpflug; let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_config(config)?; session.set_input(dataset)?; let mut seed = IntrinsicsManualInit::default(); seed.intrinsics = Some(FxFyCxCy { fx: 1050.0, fy: 1050.0, cx: 360.0, cy: 270.0 }); seed.sensor = Some(ScheimpflugParams { tilt_x: -0.087, tilt_y: 0.0 }); // nominal mount tilt step_init_with_seed(&mut session, seed, None)?; step_optimize(&mut session, None)?; }
The export is the same PlanarIntrinsicsExport; params.camera.sensor holds
the calibrated tilt and the covariance reports tilt_x ± σ. A device spec
derives the seed with device_seed::scheimpflug_seed.
Complete Example
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::planar_intrinsics::{step_init, step_optimize}; let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_input(dataset)?; // Optional: customize configuration session.update_config(|c| { c.solver.max_iters = 80; c.solver.robust_loss = RobustLoss::Huber { scale: 2.0 }; })?; // Run pipeline // Inspect initialization let init = step_init(&mut session, None)?; println!("Init fx={:.1}, fy={:.1}", init.camera.intrinsics.fx, init.camera.intrinsics.fy); step_optimize(&mut session, None)?; // Export results let export = session.export()?; let k = export.params.camera.intrinsics; println!("Final fx={:.1}, fy={:.1}", k.fx, k.fy); println!("Reprojection error: {:.4} px", export.mean_reproj_error); }
Filtering (Optional Step)
After optimization, views or individual observations with high reprojection error can be filtered out:
#![allow(unused)] fn main() { use vision_calibration::planar_intrinsics::{step_filter, IntrinsicsFilterOptions}; let mut filter_opts = IntrinsicsFilterOptions::default(); filter_opts.max_reproj_error = 2.0; // Remove observations > 2 px filter_opts.min_points_per_view = 10; // Minimum points to keep a view filter_opts.remove_sparse_views = true; // Drop views below threshold step_filter(&mut session, Some(filter_opts))?; // Filtering edits the input, which clears the computed state: run both steps again step_init(&mut session, None)?; step_optimize(&mut session, None)?; // Or, in one call: run_calibration_with_filtering(&mut session, Some(filter_opts))? }
OpenCV equivalence:
cv::calibrateCameraperforms both initialization and optimization internally. calibration-rs separates these steps for inspection and customization.
Accuracy Expectations
| Stage | Intrinsics error | Reprojection error |
|---|---|---|
After step_init | 10-40% | Not computed |
After step_optimize | <2% | <1 px mean |
| After filtering + re-optimize | <1% | <0.5 px mean |
Input Requirements
- Minimum 3 views (for Zhang's closed form)
- Minimum 4 points per view (for homography estimation)
- View diversity: Views should include rotation around both axes and vary in distance
- Board coverage: Points should span the full image area for good distortion estimation
Planar Intrinsics with Real Data
This chapter walks through the planar_real example, which calibrates a camera from real chessboard images.
Dataset
The example uses the images of the kuka_1 sample, which it downloads on the first run (sample datasets):
- Pattern: 17×28 chessboard, 20 mm squares (the size is read from
squaresize.txt) - Images: 30 PNG files
01.png…30.png, 1928×1208 px
Workflow
1. Corner Detection
The example detects corners with the calib_targets crate (a dev-dependency of the examples; the calibration library itself takes plain point correspondences). Each image is processed independently; failed detections are skipped and the workflow continues with the views that succeed.
#![allow(unused)] fn main() { use calib_targets::chessboard::{ChessboardDetection, ChessboardParams}; use calib_targets::detect::{self, default_chess_config}; const SQUARE_SIZE_M: f64 = 0.02; // 20 mm squares let board_params = ChessboardParams::default(); let img = image::ImageReader::open(path)?.decode()?.to_luma8(); let Ok(detection) = detect::detect_chessboard(&img, &default_chess_config(), &board_params) else { return Ok(None); // no board found in this image }; }
2. Correspondence Construction
Each detected corner carries its grid index (corner.grid.u, corner.grid.v) and its pixel position. The board lies in the plane , so the 3D point follows directly from the grid index and the square size:
#![allow(unused)] fn main() { fn detection_to_view(detection: ChessboardDetection) -> Result<CorrespondenceView> { let mut points_3d = Vec::new(); let mut points_2d = Vec::new(); for corner in detection.corners { let grid = corner.grid; points_3d.push(Pt3::new( grid.u as f64 * SQUARE_SIZE_M, grid.v as f64 * SQUARE_SIZE_M, 0.0, )); points_2d.push(Pt2::new(corner.position.x as f64, corner.position.y as f64)); } Ok(CorrespondenceView::new(points_3d, points_2d)?) } }
CorrespondenceView::new validates that both point lists have the same length.
3. Dataset and Calibration
The remaining pipeline is identical to the synthetic case:
#![allow(unused)] fn main() { let dataset = PlanarDataset::new(views.into_iter().map(View::without_meta).collect())?; let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_input(dataset)?; let init = step_init(&mut session, None)?; let opt = step_optimize(&mut session, None)?; let export = session.export()?; let k = export.params.camera.intrinsics; println!("fx={:.2} fy={:.2} cx={:.2} cy={:.2}", k.fx, k.fy, k.cx, k.cy); println!("Mean reprojection error: {:.4} px", export.mean_reproj_error); }
4. Parameter Uncertainty
The export carries the covariance of the estimated parameters (see Parameter Uncertainty):
#![allow(unused)] fn main() { use vision_calibration::core::Uncertainty; if let Uncertainty::Available(cov) = &export.uncertainty { println!("fx = {:.2} ± {:.2} px", k.fx, cov.std_dev("fx").unwrap()); println!("corr(fx, cx) = {:+.3}", cov.correlation("fx", "cx").unwrap()); } }
On this dataset the example reports a mean reprojection error of about 0.13 px and fx ≈ 2058 px with a standard deviation of about 0.3 px.
5. Outlier Filtering
Real data usually contains a few poorly detected corners. run_calibration_with_filtering solves, removes observations above a reprojection-error threshold, and solves again:
#![allow(unused)] fn main() { use vision_calibration::planar_intrinsics::{IntrinsicsFilterOptions, run_calibration_with_filtering}; let mut filter_opts = IntrinsicsFilterOptions::default(); filter_opts.max_reproj_error = 1.0; filter_opts.min_points_per_view = 10; filter_opts.remove_sparse_views = true; run_calibration_with_filtering(&mut session, Some(filter_opts))?; }
Interpreting Results
Key outputs to examine:
- Focal lengths (, ): Should match the expected value based on sensor size and lens focal length.
- Principal point (, ): Should be near the image center. Large offsets may indicate a decentered lens.
- Distortion (, ): Negative indicates barrel distortion (common). suggests a wide-angle lens.
- Reprojection error: <1 px is good. >2 px suggests problems with corner detection or insufficient view diversity.
Comparison with Synthetic Data
| Aspect | Synthetic | Real |
|---|---|---|
| Corner accuracy | Exact (no detection noise) | ~0.1-0.5 px (detector dependent) |
| Distortion | Known ground truth | Unknown, estimated |
| View coverage | Controlled | May have gaps |
| Typical reprojection error | <0.01 px | 0.1-1.0 px |
Practical Tips
- Use 15-30 images with diverse viewpoints
- Cover the full image area — points only near the center poorly constrain distortion
- Include tilted views — not just frontal. Rotation around both axes constrains all intrinsic parameters
- Check corner ordering — mismatched 2D-3D correspondences cause calibration failure
- Keep
fix_k3on (the default) — only estimate if reprojection error is high and you suspect higher-order radial distortion
Running the Example
cargo run -p vision-calibration --example planar_real --release
The example prints per-step results including initialization accuracy, optimization convergence, the final calibrated parameters and their standard deviations. Its Python counterpart is crates/vision-calibration-py/examples/planar_real.py.
Choosing a Distortion Model
Every workflow that estimates intrinsics picks a lens distortion model and a per-parameter fix list. Get it wrong and you either overfit sparse data (a free k3 on 20 views chases noise) or underfit a genuinely distorted wide-angle lens. This chapter lists the available models, the default fix lists and why they are the defaults, and when reaching for a richer model actually buys anything.
The Models
DistortionKind (vision_calibration::core::DistortionKind) fills the distortion slot of the camera model (camera.model.distortion in every *Config) and selects the packed coefficient layout the factors compile against:
| Kind | dim | Packed layout | Model |
|---|---|---|---|
None | 0 | (no distortion block) | no distortion |
BrownConrady5 | 5 | [k1, k2, k3, p1, p2] | Brown-Conrady radial and tangential (default) |
Rational8 | 8 | [k1, k2, k3, k4, k5, k6, p1, p2] | rational polynomial |
ThinPrism9 | 9 | [k1, k2, k3, p1, p2, s1, s2, s3, s4] | Brown-Conrady plus thin prism |
Division1 | 1 | [lambda] | Fitzgibbon division model |
Every workflow calibrates every kind with a straight or a tilted (Scheimpflug) sensor: planar intrinsics, single-camera hand-eye and the laserline device through camera, the rigs per camera (camera plus camera_overrides). The rig laser workflows take each camera of the upstream rig in its own model.
Read the coefficients from export.params.camera.distortion, a DistortionParams with one variant per model (see Serialization and Runtime-Dynamic Types).
The Fix Lists
camera.fix (a CameraFix) names, per slot, the parameters the non-linear solve holds fixed:
intrinsics: any offx, fy, cx, cy;distortion: the model's own coefficient names;sensor:tilt_x, tilt_y.
A slot left None keeps the camera model's default; an explicit list replaces it; an unknown name is a configuration error raised before any work. In JSON: "camera": {"model": {...}, "fix": {"intrinsics": ["cx", "cy"], "distortion": ["k3", "p1", "p2"]}}.
The defaults:
BrownConrady5:k3fixed.k3often overfits sparse or noisy corner data; relax it only for wide-angle lenses or high-quality data. The same rule applies to the linear stage'sIntrinsicsInitConfig::fix_k3, which defaults totruefor the same reason.- The extended models: nothing fixed, since they are chosen for their extra terms.
- Any model under a Scheimpflug sensor: everything but the low-order radial terms (
k1,k2; the division model'slambda) fixed. See below.
Scheimpflug-Specific Defaults
A Scheimpflug sensor (camera.model.sensor = SensorKind::Scheimpflug) changes two things, both because the sensor tilt couples with distortion:
- The default distortion fix list keeps only
k1,k2(orlambda) free: the tilt can absorb tangential-distortion-like signal, so leavingp1andp2free overfits. - The linear stage holds
p1andp2at zero while the tilt is estimated, even when the fix list frees them: a free tangential term is ill-posed next to the tilt in the linear fit. The non-linear solve, which separates them, then moves them.
In every workflow the linear stage fits only the distortion terms the camera's solves estimate: a term the fix list holds stays at zero from the start, rather than at a linear fit the later solves would keep.
The tilt itself is the sensor slot of the same fix list (camera.fix.sensor = Some(vec!["tilt_y".into()]) holds tilt_y), since the tilt is a sensor parameter, not a distortion one.
Configuring a Model
Do nothing
PlanarIntrinsicsConfig::default() selects BrownConrady5 with k3 fixed and an untilted sensor. Most datasets should start here.
Choose an extended model
#![allow(unused)] fn main() { use vision_calibration::core::DistortionKind; use vision_calibration::planar_intrinsics::PlanarIntrinsicsConfig; let mut config = PlanarIntrinsicsConfig::default(); config.camera.model.distortion = DistortionKind::Rational8; // The extended models fix nothing by default: k1..k6, p1, p2 are all refined. // Name what to hold, e.g. the higher-order radial block: config.camera.fix.distortion = Some(vec!["k4".into(), "k5".into(), "k6".into()]); }
Fix intrinsics or distortion terms
#![allow(unused)] fn main() { // Fix cx, cy but optimize fx, fy session.update_config(|c| { c.camera.fix.intrinsics = Some(vec!["cx".into(), "cy".into()]); })?; // Fix k3 and the tangential terms session.update_config(|c| { c.camera.fix.distortion = Some(vec!["k3".into(), "p1".into(), "p2".into()]); })?; }
A tilted sensor, seeded
#![allow(unused)] fn main() { use vision_calibration::core::{DistortionKind, SensorKind}; use vision_calibration::planar_intrinsics::{ IntrinsicsManualInit, PlanarIntrinsicsConfig, PlanarIntrinsicsProblem, step_init_with_seed, step_optimize, }; use vision_calibration::session::CalibrationSession; let mut config = PlanarIntrinsicsConfig::default(); config.camera.model.sensor = SensorKind::Scheimpflug; // radial-only default fix list config.camera.model.distortion = DistortionKind::ThinPrism9; let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_input(dataset)?; session.set_config(config)?; let mut seed = IntrinsicsManualInit::default(); seed.intrinsics = Some(nominal_k); // datasheet focal length / pixel pitch seed.sensor = Some(mount_tilt); // mechanical mount angle step_init_with_seed(&mut session, seed, None)?; step_optimize(&mut session, None)?; }
The linear stage always produces Brown-Conrady coefficients; they are embedded into the chosen model with any extra degrees of freedom zeroed before the non-linear refinement. A distortion seed is a value in the chosen model itself (its kind must match camera.model.distortion) and replaces the fitted coefficients as given. See Seeding with Prior Knowledge for the seeding mechanism and Planar Intrinsics Calibration for why a tilted camera starts from a seed.
Does a Richer Model Help?
Under a Scheimpflug sensor, selecting a richer distortion model is not on its own a change to what is optimized, and that surprises people. Mean reprojection error was measured under each model on two six-camera Scheimpflug rigs. Under the radial-only fix list (k1, k2 free), the configuration the shipped baselines use, the model choice alone does not move the floor:
- Rig A:
BrownConrady5 = Rational8 = ThinPrism9 = 0.3316 pxmedian mean reprojection error (the extrak4-k6ands1-s4coefficients stay at their zero seed under the radial-only list and never move);Division1 = 0.3358 px(stable, but its worst camera sits alone at0.838 px). - Rig B:
0.4739 pxfor the three polynomial models,0.4745 pxforDivision1.
This is expected: the radial-only list frees only k1 and k2 (or lambda), so Rational8 and ThinPrism9 are Brown-Conrady in everything but name.
Freeing every coefficient does move the numbers, in both directions. On one rig it improved the hardest camera slightly (0.5008 -> 0.4988 px); on the other it diverged on the two cameras with the thinnest data. An all-free configuration needs a per-camera warm start to be safe, which is why it is not the default.
Practical Guidance
- Default to
BrownConrady5withk3fixed (or, under a Scheimpflug sensor, the radial-only list) unless you have concrete evidence of higher-order distortion, such as a very wide field of view or a fisheye-adjacent lens. Every committed benchmark baseline stays onBrownConrady5because, under the production mask, switching the model is a no-op. - If you reach for an extended model, free its new coefficients deliberately via
camera.fix.distortionwhen the sensor's default holds them. Under a Scheimpflug sensor, selecting the model alone does not unlock any new degrees of freedom. - Freeing every extra coefficient is experimental: it needs a per-camera warm start to avoid diverging on thin-data views. Reproduce the comparison above on your own dataset before doing it on production data.
Special Cases
- Wide-angle or fisheye-adjacent lenses: set
camera.fix.distortion = Some(vec![])andinit.fix_k3 = falseso the linear stage also fitsk3. This is the one knob the default advice tells you not to flip without a reason. DistortionKind::None: pinhole-only, for pre-rectified or synthetic inputs. It is not accepted with a Scheimpflug sensor: the tilt is estimated jointly with the radial terms, so configuration validation rejectsNonewith a typed error.- Hand-eye and laser workflows: the same
camera.model.distortionchoice as planar intrinsics. A rig sets it per camera, and the rig laser workflows inherit each camera's model from the upstream rig.
Fisheye Calibration: OpenCV's Test Set
This walkthrough calibrates the fisheye stereo pair OpenCV uses to test its
own fisheye module, and sets the result next to OpenCV's. The camera is the
Kannala-Brandt fisheye: an equidistant projection with
KannalaBrandt4 distortion, the model cv::fisheye calibrates.
The data
The set is calib-3_stereo_from_JY of OpenCV's test data repository
(opencv_extra, testdata/cv/cameracalibration/fisheye/): 34 synchronized
image pairs, 1280×800, of an 8×6 chessboard with 24.4 mm squares. It ships
the chessboard corners with the images, in cv::FileStorage files
(left.xml, right.xml and object.xml), and OpenCV's tests calibrate
those corners rather than detecting them again. The publisher states no
licence for the data.
The regression suite freezes the same corners, so calibration-rs and OpenCV
calibrate exactly the same points. Its three entries are
opencv_fisheye_left, opencv_fisheye_right (planar_intrinsics) and
opencv_fisheye_stereo (rig_extrinsics); the observations are in
regression/observations/<id>.json. suite fetch downloads the corner
files from a pinned commit of opencv_extra and checks them against their
SHA-256 (see Regression Suite).
Rust
The observation file's input is the problem's input, so it deserializes
straight into a PlanarDataset. The only setting that differs from a pinhole
calibration is the camera model:
#![allow(unused)] fn main() { use vision_calibration::core::{ CameraModelSpec, DistortionKind, PlanarDataset, ProjectionKind, SensorKind, }; use vision_calibration::planar_intrinsics::{ PlanarIntrinsicsConfig, PlanarIntrinsicsProblem, step_init, step_optimize, }; use vision_calibration::session::CalibrationSession; let text = std::fs::read_to_string("regression/observations/opencv_fisheye_left.json")?; let mut file: serde_json::Value = serde_json::from_str(&text)?; let input: PlanarDataset = serde_json::from_value(file["input"].take())?; let mut config = PlanarIntrinsicsConfig::default(); config.camera.model = CameraModelSpec::new( ProjectionKind::Equidistant, DistortionKind::KannalaBrandt4, SensorKind::Identity, ); let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_config(config)?; session.set_input(input)?; let init = step_init(&mut session, None)?; // bearing-based fisheye initializer step_optimize(&mut session, None)?; let export = session.export()?; println!("{:?}", export.params.camera.intrinsics); println!("{:?}", export.params.camera.distortion.values()); // k1..k4 }
step_init runs the bearing-based initializer, which
needs no focal-length guess or image size: here it starts at
fx = fy = 556.65, 0.3 % from the final value. The stereo pair is a
rig_extrinsics calibration with the same config.camera.model; the rig
refines both cameras' intrinsics jointly with the extrinsics, as OpenCV's
fisheye::stereoCalibrate does. The complete program, left camera and
stereo pair, is an example:
cargo run -p vision-calibration --example fisheye_opencv
Python
import json
import vision_calibration as vc
with open("regression/observations/opencv_fisheye_left.json") as f:
views = json.load(f)["input"]["views"]
dataset = vc.PlanarIntrinsicsInput(
views=[
vc.PlanarView(
observation=vc.Observation(
points_3d=v["obs"]["points_3d"], points_2d=v["obs"]["points_2d"]
)
)
for v in views
]
)
fisheye = vc.CameraModelSpec(projection="equidistant", distortion="kannala_brandt4")
config = vc.PlanarIntrinsicsConfig(camera=vc.CameraConfig(model=fisheye))
result = vc.run_planar_intrinsics(dataset, config)
k, d = result.camera.intrinsics, result.camera.distortion
print(f"fx={k.fx:.3f} fy={k.fy:.3f} cx={k.cx:.3f} cy={k.cy:.3f}")
print(f"k1..k4 = {d.k1:+.6f} {d.k2:+.6f} {d.k3:+.6f} {d.k4:+.6f}")
print(f"mean reprojection error {result.mean_reproj_error:.4f} px")
The stereo pair takes a vc.RigExtrinsicsInput of vc.RigExtrinsicsViews
(one vc.Observation per camera) and a
vc.RigExtrinsicsConfig(camera=vc.CameraConfig(model=fisheye)), run with
vc.run_rig_extrinsics.
Against OpenCV
OpenCV's fisheyeTest.Calibration calibrates the left camera from the same
corners with CALIB_RECOMPUTE_EXTRINSIC | CALIB_CHECK_COND | CALIB_FIX_SKEW and checks the result against stored values. The suite's
OpenCV harness runs cv2.fisheye.calibrate with the same flags:
| Left camera | fx | fy | cx | cy | k1 | k2 | k3 | k4 | Mean reprojection |
|---|---|---|---|---|---|---|---|---|---|
| calibration-rs | 558.479 | 560.507 | 620.458 | 381.938 | −0.00150 | −0.00317 | 0.00590 | −0.00367 | 0.2227 px |
| OpenCV 4.12 | 558.478 | 560.507 | 620.459 | 381.939 | −0.00146 | −0.00330 | 0.00606 | −0.00374 | 0.2227 px |
| OpenCV's test values | 558.478 | 560.507 | 620.459 | 381.939 | −0.00146 | −0.00330 | 0.00606 | −0.00374 | — |
| calibration-rs σ | 1.19 | 1.21 | 0.82 | 0.75 | 0.0047 | 0.016 | 0.020 | 0.0084 |
The two libraries agree to within 0.01 of our standard deviation on every
parameter, and OpenCV's result is the one its own test expects. The
distortion coefficients differ in the third digit, but they are strongly
correlated (k2, k3 and k4 each have a σ larger than its value), and
both fits reach the same mean reprojection error.
The stereo pair is compared with fisheye::stereoCalibrate, which OpenCV's
fisheyeTest.stereoCalibrate runs with the same flags. Both refine the two
cameras' intrinsics with the rig and land on the same solution: the
translation from the left camera to the right is
T = (−0.099403, 0.002708, 0.001293) m (a 99.45 mm baseline) in both, the
focal lengths and principal points agree to 0.001 px, and the distortion
coefficients within 0.01σ.
calib-bench suite compare prints these comparisons for every entry; the
table is in Regression Suite.
Single-Camera Hand-Eye Calibration
Hand-eye calibration estimates the rigid transform between a camera and a robot's gripper (or base). This workflow combines intrinsics calibration with hand-eye estimation in a 4-step pipeline.
Problem Formulation
Transformation Chain (Eye-in-Hand)
For a camera mounted on the robot gripper:
where:
- : camera-to-gripper (the calibrated hand-eye transform, inverted)
- : gripper-to-base (known from robot kinematics, inverted)
- : base-to-target (the target's pose in the robot base frame, also calibrated)
Equivalently:
Eye-to-Hand Variant
For a fixed camera observing a target on the gripper:
where is the camera-to-base transform and is the target-to-gripper transform.
Optimization Objective
Minimize reprojection error across all views, jointly over all parameters:
where depends on the known robot pose for view , the hand-eye transform , and the target pose .
4-Step Pipeline
Step 1: Intrinsics Initialization (step_intrinsics_init)
Runs the Iterative Intrinsics solver to estimate and distortion from the calibration board observations, ignoring robot poses entirely.
Step 2: Intrinsics Optimization (step_intrinsics_optimize)
Runs planar intrinsics bundle adjustment to refine , distortion, and per-view camera-to-target poses. This gives accurate per-view poses needed for hand-eye initialization.
Step 3: Hand-Eye Initialization (step_handeye_init)
Solves the hand-eye and the target pose in closed form with the method of
handeye_init.method: Tsai-Lenz by default, or Park-Martin, Daniilidis or
Shah (see Hand-Eye Linear):
- Compute relative motions from robot poses and camera poses
- Filter pairs by rotation magnitude (reject small rotations)
- Estimate the hand-eye rotation and translation
- Estimate : through the first view, or over all views with Shah's method, which estimates it together with the hand-eye
Step 4: Hand-Eye Optimization (step_handeye_optimize)
Joint non-linear optimization of all parameters:
Parameter blocks:
"cam"(4D, Euclidean): intrinsics"dist"(Euclidean): the distortion coefficients of the configured model (absent without distortion)"sensor"(2D, Euclidean): the sensor tilt, for a Scheimpflug camera"handeye"(7D, SE3): gripper-to-camera transform"target"(7D, SE3): base-to-target transform
Residual blocks: one ReprojPoint { model, chain: Handeye { .. }, .. } per observation, model being the camera's CameraModelSpec.
Without robot-pose refinement the robot poses are not optimized: they are known constants from the robot kinematics (passed as per-residual data).
Robot Pose Refinement
Robot poses carry errors of their own, usually larger than the corner noise
once projected through the lever arm to the board: 0.05° at 1 m moves the
camera's view by almost a millimetre. Taken as exact, these errors end up in
the camera parameters. robot_poses.refine (on by default) gives every view
an SE(3) correction of its measured pose:
Additional parameter blocks: "robot_delta/v" (6D, Euclidean se(3)
tangent [ω, v]) per view, applied in the gripper frame as
base_se3_gripper · exp(δ): ω is the tool's orientation error and v its
position error
Additional residuals: a zero-mean Se3TangentPrior per view, with the
robot's rotation and translation standard deviations σ_r (rot_sigma) and
σ_t (trans_sigma), weighed against the corner residuals at their pixel
noise σ_px:
The objective is then the maximum-likelihood one, in units of σ_px²: a
correction costs as much as the corner evidence it explains away. σ_px is
not known in advance, so the solve runs once at 1 px, takes σ_px from the
corner residuals and runs again from that solution. Every view's correction
is free; the priors are what tell the corrections apart from a change of the
hand-eye or target pose that would move every view alike.
The priors' standard deviations decide how far the result trusts the robot.
Too loose, and the hand-eye and target standard deviations come out larger
than the data warrants; too tight, and the robot's errors leak into the
camera parameters while the standard deviations come out too small. So by
default (robot_poses.estimate_sigma) the solve estimates them: rot_sigma
and trans_sigma (0.5°, 1 mm) are only its starting values. Each prior's
variance factor is the sum of its squared normalized corrections over its
redundancy, ŝ² = Σ (δₖ/σ)² / Σ (1 − Var(δₖ)/σ²), the share of each
correction the corner data do not already determine. σ is scaled by ŝ
and the solve repeats until both factors are 1. The export reports the
estimate as robot_pose_sigma, the robot's orientation and position error
in this calibration. On synthetic scenes it recovers the simulated error
from a prior ten times too tight or too loose, and the normalized errors of
the hand-eye and target come out with an RMS of 1.02–1.13. With
estimate_sigma off, the configured values are taken as the robot's true
accuracy.
A correction common to every view looks exactly like a change of the
transform carried by the gripper: the hand-eye in eye-in-hand, the target's
pose in the gripper in eye-to-hand. That transform is therefore known only
as well as the robot's mean error over the views: its rotation's standard
deviation is at least about σ_r / √views, its translation's σ_t / √views.
Configuration
Settings are grouped by stage; the shared sub-structs (IntrinsicsInitConfig,
CameraConfig, HandeyeInitConfig, SolverConfig, RobotPoseConfig) are
reused across every hand-eye problem type:
#![allow(unused)] fn main() { pub struct SingleCamHandeyeConfig { // Per-camera linear-initialization stage settings. pub intrinsics: IntrinsicsInitConfig, // { iterations: usize = 2, fix_k3: bool = true }; it fits only the // distortion terms the camera's solves estimate // The camera model (any distortion kind, an identity or Scheimpflug // sensor) and the parameters its solves hold fixed, named per slot. pub camera: CameraConfig, // { model: CameraModelSpec, fix: CameraFix } // Hand-eye linear-initialization stage settings (Tsai-Lenz DLT). pub handeye_init: HandeyeInitConfig, // { handeye_mode: HandeyeMode, method: HandeyeMethod = TsaiLenz, // min_motion_angle_deg: f64 = 5.0 } // Non-linear solve stage settings. pub solver: SolverConfig, // { max_iters, verbosity, robust_loss } // Robot-pose refinement settings for the hand-eye bundle adjustment. pub robot_poses: RobotPoseConfig, // { refine: bool = true, rot_sigma: f64, trans_sigma: f64 } } }
Input Format
Each view is a View<RobotPoseMeta> (aliased as SingleCamHandeyeView); RobotPoseMeta is in vision_calibration::common, shared with the rig hand-eye workflows:
#![allow(unused)] fn main() { pub struct RobotPoseMeta { pub base_se3_gripper: Iso3, // Robot pose (from kinematics) } // Type alias pub type SingleCamHandeyeView = View<RobotPoseMeta>; // Where View<Meta> has fields: obs: CorrespondenceView, meta: Meta }
Mounting Modes
Poses follow the frame_se3_frame naming: base_se3_gripper reads "gripper pose expressed in the base frame" (). The unknowns are named the same way. The mounting mode is HandeyeInitConfig::handeye_mode:
HandeyeMode::EyeInHand(the default): the camera rides on the gripper and observes a fixed target. The calibration solves forgripper_se3_camera() andbase_se3_target().HandeyeMode::EyeToHand: the camera is fixed in the scene and observes a target mounted on the gripper. The calibration solves forcamera_se3_base() andgripper_se3_target().
The export's four pose fields are mutually exclusive by mode: the active mode fills its pair and leaves the other pair None.
To switch to eye-to-hand:
#![allow(unused)] fn main() { use vision_calibration::optim::HandeyeMode; use vision_calibration::single_cam_handeye::SingleCamHandeyeConfig; let mut config = SingleCamHandeyeConfig::default(); config.handeye_init.handeye_mode = HandeyeMode::EyeToHand; session.set_config(config)?; }
In eye-to-hand mode the pixel-to-3D chain for a fixed camera observing a gripper-mounted target composes as , the same identity step_handeye_init_with_seed uses to derive camera_se3_base when only gripper_se3_target is seeded (see Seeding with Prior Knowledge).
Complete Example
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::single_cam_handeye::*; let views: Vec<SingleCamHandeyeView> = /* load data */; let input = SingleCamHandeyeInput::new(views)?; let mut session = CalibrationSession::<SingleCamHandeyeProblem>::new(); session.set_input(input)?; step_intrinsics_init(&mut session, None)?; step_intrinsics_optimize(&mut session, None)?; step_handeye_init(&mut session, None)?; step_handeye_optimize(&mut session, None)?; let export = session.export()?; println!("Camera: {:?}", export.camera); // CameraParams, in the configured model println!("Hand-eye and target: {:?}", export.handeye); println!("Reprojection error: {:.4} px", export.mean_reproj_error); }
Reading the Export
#![allow(unused)] fn main() { use vision_calibration::single_cam_handeye::SingleCamHandeyeSolution; match export.handeye { SingleCamHandeyeSolution::EyeInHand { gripper_se3_camera, base_se3_target } => { // gripper_se3_camera: T_G_C, base_se3_target: T_B_T } SingleCamHandeyeSolution::EyeToHand { camera_se3_base, gripper_se3_target } => { // camera_se3_base: T_C_B, gripper_se3_target: T_G_T } } }
export.handeye holds exactly the two poses of the mode, so an export cannot carry the wrong pose for its mode. In JSON it is the handeye object, tagged by mode:
"handeye": {
"mode": "EyeInHand",
"gripper_se3_camera": { "rotation": [0, 0, 0, 1], "translation": [0.05, 0, 0.1] },
"base_se3_target": { "rotation": [0, 0, 0, 1], "translation": [0, 0, 1] }
}
In Python, result.handeye is a CameraEyeInHand or a CameraEyeToHand, and result.handeye.mode names the mode.
export.camera is the calibrated camera (CameraParams) in the configured model, export.robot_deltas the per-view robot-pose corrections when refinement is on, and export.diagnostics.per_feature_residuals the per-corner reprojection records (see Per-Feature Residuals).
The SingleCamHandeyeInput::new constructor rejects an empty set of views and any view with fewer than 4 correspondences (the minimum for a homography). Each stage has a *_with_seed sibling and a typed step result (IntrinsicsInitResult, HandeyeOptimizeResult, ...); see Seeding with Prior Knowledge.
From a Dataset Manifest
Instead of assembling the views by hand, a manifest with topology = "single_cam_handeye" describes where the images, the target and the robot-pose file live. vision_calibration::dataset_runner::build_single_cam_handeye_input runs the (cached) corner detection and returns the SingleCamHandeyeInput; see The Dataset Manifest.
Camera Models
The camera model is the config's camera.model: any distortion kind
(Brown-Conrady by default, rational, thin-prism, division, or none) and an
identity or Scheimpflug sensor. The intrinsics stage dispatches on it like
Planar Intrinsics: Zhang's method embedded into the
model's distortion, and for a tilted sensor the linear tilt sweep followed by
the staged tilt multi-start. A known tilt goes in the intrinsics manual-init
sensor seed (step_intrinsics_init_with_seed); a tilt seed for an untilted
camera must be zero. camera.fix names the parameters both solves hold.
Practical Considerations
- Rotation diversity is critical for Tsai-Lenz initialization. Include rotations around at least 2 axes.
- 5-10 views minimum, with 10-20 recommended for robustness.
- Robot accuracy: If the robot's reported poses have errors > 1mm or > 0.1°, enable robot pose refinement.
- Initialization failure: If Tsai-Lenz produces a poor estimate (large reprojection error after optimization), the most likely cause is insufficient rotation diversity.
OpenCV equivalence:
cv::calibrateHandEyeprovides the linear initialization step. calibration-rs adds joint non-linear refinement and optional robot pose correction.
Hand-Eye with KUKA Robot
This chapter walks through the handeye_session example, which performs eye-in-hand calibration using a real KUKA robot dataset.
Dataset
The kuka_1 sample, which the example downloads on the first run (sample datasets):
- Images: 30 PNG files (
01.pngthrough30.png) - Robot poses:
RobotPosesVec.txt— one row-major homogeneous matrix per line (16 whitespace-separated values), each the pose of the gripper in the robot base frame, in metres. ImageNN.pngpairs with pose rowNN. - Board square size:
squaresize.txt(20mm) - Calibration board: 17×28 chessboard, 20 mm squares
Data Loading
The example's support module (examples/support/handeye_io.rs) reads the square size and the robot poses, detects chessboard corners in each image with the calib_targets chessboard detector, and pairs each successful detection with its robot pose. Images where detection fails are skipped.
Each pose line is converted to an Iso3:
#![allow(unused)] fn main() { let r = Matrix3::new( values[0], values[1], values[2], values[4], values[5], values[6], values[8], values[9], values[10], ); let t = Vector3::new(values[3], values[7], values[11]); let rot = Rotation3::from_matrix_unchecked(r); let pose = Iso3::from_parts(Translation3::from(t), UnitQuaternion::from_rotation_matrix(&rot)); }
The detected views become the calibration input:
#![allow(unused)] fn main() { use vision_calibration::common::RobotPoseMeta; use vision_calibration::single_cam_handeye::{ SingleCamHandeyeInput, SingleCamHandeyeProblem, SingleCamHandeyeView, }; let views: Vec<SingleCamHandeyeView> = samples .into_iter() .map(|s| SingleCamHandeyeView { obs: s.view, // CorrespondenceView meta: RobotPoseMeta { base_se3_gripper: s.robot_pose }, // T_B_G }) .collect(); let input = SingleCamHandeyeInput::new(views)?; }
Calibration
#![allow(unused)] fn main() { use vision_calibration::single_cam_handeye::{ step_handeye_init, step_handeye_optimize, step_intrinsics_init, step_intrinsics_optimize, }; let mut session = CalibrationSession::<SingleCamHandeyeProblem>::new(); session.set_input(input)?; step_intrinsics_init(&mut session, None)?; let intr_opt = step_intrinsics_optimize(&mut session, None)?; step_handeye_init(&mut session, None)?; let he_opt = step_handeye_optimize(&mut session, None)?; let export = session.export()?; }
Running the Example
cargo run -p vision-calibration --example handeye_session
The example reports:
- Dataset summary (total images, used views, skipped views)
- Per-step results (initialization, optimization)
- Final calibrated parameters (intrinsics, distortion, hand-eye transform, target pose)
Output on kuka_1 (30 chessboard views, eye-in-hand, the default mode), abridged:
--- Step 2: Intrinsics Optimization ---
Intrinsics: fx=2057.8, fy=2058.4, cx=962.1, cy=609.9
Reprojection error: 0.1346 px
--- Step 3: Hand-Eye Initialization (Tsai-Lenz) ---
Hand-eye |t|: 0.2806m
--- Step 4: Hand-Eye Optimization ---
Final reprojection error: 0.1346 px
Robot pose refinements (30 views):
Max rotation delta: 0.0022 rad (0.12°)
Max translation delta: 0.0013m (1.31mm)
Estimated robot error: 0.043° / 0.45mm per axis
The joint hand-eye error matches the intrinsics-only error: every view is explained through one shared gripper_se3_camera and one shared base_se3_target, and the small per-view robot-pose corrections (0.12° and 1.3 mm at most) absorb the kinematic noise, and the solve estimates the arm's error from them: 0.043° and 0.45 mm per axis. A correction acts in the gripper frame, so its rotation and translation are the tool's orientation and position errors. A hand-eye error far above the intrinsics-only error points at robot-pose noise, mechanical play or a wrong pose convention.
Interpreting Results
Key outputs of the export (eye-in-hand):
- Hand-eye transform (
gripper_se3_camera): translation magnitude should match the physical camera-to-gripper distance. Rotation should reflect the mounting orientation. - Target in base (
base_se3_target): the calibration board's position in the robot base frame. Verify against the known physical setup. - Reprojection error (
mean_reproj_error): under 1 px indicates a good calibration; above 3 px suggests problems.
Common Failure Modes
-
Insufficient rotation diversity: All robot poses rotate around the same axis (e.g., only wrist rotation). The Tsai-Lenz initialization will fail or produce a poor estimate.
-
Incorrect pose convention: Robot poses must be (
base_se3_gripper). If the convention is inverted, the calibration will diverge. -
Mismatched corner ordering: If the chessboard detector assigns corners in a different order than expected, the 2D-3D correspondences are wrong.
-
Robot pose timestamps: If images and robot poses are not synchronized, the calibration will fail.
Data Collection Recommendations
- Rotation diversity: Include poses with significant roll, pitch, and yaw. Avoid pure translations or rotations around a single axis.
- Target visibility: Ensure the calibration board is fully visible in all images. Partial visibility causes corner detection failure.
- Stable poses: Take images when the robot is stationary. Motion blur degrades corner detection.
- Coverage: Vary the distance to the board and the position within the image.
Multi-Camera Rig Extrinsics
A multi-camera rig is a set of cameras rigidly attached to a common frame. Rig extrinsics calibration estimates the relative pose of each camera within the rig, in addition to per-camera intrinsics and distortion.
Problem Formulation
Parameters
- Per-camera intrinsics: — one per camera
- Per-camera distortion: — one per camera
- Per-camera extrinsics: — camera-to-rig transforms (one per camera, reference camera = identity)
- Per-view rig poses: — rig-to-target transforms (one per view)
Transform Chain
For camera in view :
where .
Objective
This jointly optimizes all cameras' parameters and all rig geometry. Each observation depends on two SE(3) transforms composed together (a ReprojPoint factor with the TwoSe3 chain).
4-Step Pipeline
Step 1: Per-Camera Intrinsics Initialization (step_intrinsics_init_all)
For each camera independently:
- Compute homographies from the views where that camera has observations
- Run iterative intrinsics estimation (Zhang + distortion fit)
- Recover per-camera, per-view poses
Step 2: Per-Camera Intrinsics Optimization (step_intrinsics_optimize_all)
For each camera independently:
- Run planar intrinsics bundle adjustment
- Refine , , and per-view poses
Step 3: Rig Extrinsics Initialization (step_rig_init)
Uses the linear initialization from Multi-Camera Rig Initialization:
- Define the rig frame by the reference camera ()
- For each non-reference camera, compute by averaging across views
- Compute rig-to-target poses from the reference camera's poses
Step 4: Rig Bundle Adjustment (step_rig_optimize)
Joint optimization of all parameters:
Parameter blocks:
"cam/k"(4D): per-camera intrinsics"dist/k"(5D for Brown-Conrady): per-camera distortion"extrinsics/k"(7D, SE3): per-camera camera-to-rig transforms"rig_pose/v"(7D, SE3): per-view rig-to-target poses
Residual blocks: one ReprojPoint { model: <camera's CameraModelSpec>, chain: TwoSe3, .. } per observation. A tilted camera adds a "sensor/k" block (2D); "dist/k" has the size of the camera's distortion model.
Fixed parameters: The reference camera's extrinsics are fixed at identity (gauge freedom).
Configuration
Settings are grouped by stage; the shared sub-structs (IntrinsicsInitConfig, RigConfig,
SolverConfig) are reused across every rig-family problem type:
#![allow(unused)] fn main() { pub struct RigExtrinsicsConfig { // Per-camera linear-initialization stage settings. pub intrinsics: IntrinsicsInitConfig, // { iterations, fix_k3 } // (fits only the distortion terms each camera's solves estimate) // Camera model shared by every camera (projection, distortion, sensor) // and its named fix lists; default Brown-Conrady, identity sensor. pub camera: CameraConfig, // { model: CameraModelSpec, fix: CameraFix } // Per-camera replacements of `camera`, for rigs that mix lenses or sensors. pub camera_overrides: Vec<CameraOverride>, // { camera, model, fix } // Rig frame options. pub rig: RigConfig, // { reference_camera_idx, refine_intrinsics, refine_sensor } // Non-linear solve stage settings. pub solver: SolverConfig, // { max_iters, verbosity, robust_loss } } }
Every camera uses camera unless camera_overrides names it. An override replaces the model and the fix lists of one camera, so a rig can mix distortion kinds and sensors. A camera index out of range or listed twice is a validation error. A slot left out of fix keeps the model's default. With rig.refine_intrinsics set, each camera's fix lists are held during the rig bundle adjustment.
#![allow(unused)] fn main() { use vision_calibration::common::config::CameraOverride; use vision_calibration::core::{CameraModelSpec, DistortionKind, SensorKind}; // Camera 1 has a tilted sensor and a rational lens; the others are straight Brown-Conrady. session.update_config(|c| { c.camera_overrides = vec![CameraOverride::new( 1, CameraModelSpec::pinhole(DistortionKind::Rational8, SensorKind::Scheimpflug), )]; })?; }
A rig of tilted cameras sets config.camera = CameraConfig::new(CameraModelSpec::pinhole(DistortionKind::BrownConrady5, SensorKind::Scheimpflug)) instead. Tilt seeds go through IntrinsicsManualInit::per_cam_sensors and step_intrinsics_init_all_with_seed; zero tilts need no seed (see Scheimpflug and Laser Rigs). The export's cameras is one CameraParams per camera, stating the calibrated distortion and sensor.
The reference camera (rig.reference_camera_idx) is fixed at identity, which removes the rig's 6-DOF gauge freedom; no separate pose-gauge setting is needed.
Input Format
#![allow(unused)] fn main() { pub struct RigDataset<Meta> { pub num_cameras: usize, pub views: Vec<RigView<Meta>>, } pub struct RigView<Meta> { pub obs: RigViewObs, // Per-camera observations (Option per camera) pub meta: Meta, } }
Each view has an Option<CorrespondenceView> per camera — cameras that don't see the target in a given view have None.
Per-Camera Reprojection Error
After optimization, per-camera reprojection errors are computed independently:
#![allow(unused)] fn main() { let export = session.export()?; for (cam_idx, error) in export.per_cam_reproj_errors.iter().enumerate() { println!("Camera {}: {:.4} px", cam_idx, error); } println!("Overall: {:.4} px", export.mean_reproj_error); }
Stereo Baseline
For a 2-camera rig, the baseline (distance between camera centers) is:
since (camera 0 is the reference).
Complete Example
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::rig_extrinsics::*; let mut session = CalibrationSession::<RigExtrinsicsProblem>::new(); session.set_input(rig_dataset)?; step_intrinsics_init_all(&mut session, None)?; step_intrinsics_optimize_all(&mut session, None)?; step_rig_init(&mut session, None)?; step_rig_optimize(&mut session, None)?; let export = session.export()?; let baseline = export.cam_se3_rig[1].translation.vector.norm(); println!("Stereo baseline: {:.1} mm", baseline * 1000.0); }
OpenCV equivalence:
cv::stereoCalibratefor 2-camera rigs. calibration-rs generalizes to cameras.
Multi-Camera Rig Hand-Eye
This is the most complex calibration workflow: a multi-camera rig mounted on a robot arm. It combines per-camera intrinsics calibration, rig extrinsics estimation, and hand-eye calibration in a 6-step pipeline.
Problem Formulation
Transformation Chain
Frames follow the frame_se3_frame convention: T_{A,B} maps coordinates of frame into frame . For camera in view with robot pose (eye-in-hand):
where:
- (
cam_se3_rig): rig to camera (rig extrinsics) - (
gripper_se3_rig): rig to gripper (the hand-eye transform) - (
base_se3_gripper): known robot pose for view - (
base_se3_target): target in the robot base frame (calibrated)
In eye-to-hand mode the export instead carries rig_se3_base and gripper_se3_target.
Parameters
- Per-camera intrinsics: ( scalar parameters)
- Per-camera distortion: ( scalar parameters)
- Per-camera extrinsics: ( DOF, reference camera = identity)
- Hand-eye: (6 DOF)
- Target pose: (6 DOF)
- Optionally: per-view robot corrections ( DOF, regularized)
6-Step Pipeline
Steps 1-2: Per-Camera Intrinsics
Same as Rig Extrinsics: initialize and optimize each camera's intrinsics independently.
Steps 3-4: Rig Extrinsics
Same as Rig Extrinsics: initialize camera-to-rig transforms via SE(3) averaging, then jointly optimize the rig geometry.
Step 5: Hand-Eye Initialization (step_handeye_init)
Uses the closed-form method of handeye_init.method (Tsai-Lenz by default; see Hand-Eye Linear) with the rig's poses and the robot poses:
- Extract relative camera motions from rig-to-target poses
- Extract relative robot motions from base-to-gripper poses
- Solve for (
gripper_se3_rig) - Estimate the target in the base frame,
Step 6: Hand-Eye Optimization (step_handeye_optimize)
Joint optimization of all parameters:
Parameters: per-camera intrinsics and distortion, per-camera camera-to-rig extrinsics (SE3), the hand-eye transform (SE3), and the target pose (SE3).
Factor: one ReprojPoint with a rig hand-eye chain per observation, which composes the full transform chain.
Configuration
RigHandeyeConfig groups the settings by stage:
#![allow(unused)] fn main() { pub struct RigHandeyeConfig { pub intrinsics: IntrinsicsInitConfig, // per-camera linear init pub manual_init: Option<IntrinsicsManualInit>, // optional per-camera seeds pub camera: CameraConfig, // camera model + fix lists shared by every camera pub camera_overrides: Vec<CameraOverride>, // per-camera replacement of `camera` pub rig: RigConfig, // reference_camera_idx, refine_intrinsics, refine_sensor pub handeye_init: HandeyeInitConfig, // handeye_mode, method, min_motion_angle_deg pub solver: SolverConfig, // max_iters, verbosity, robust_loss pub handeye_optimize: HandeyeOptimizeConfig, // robot_poses, refine_cam_se3_rig, refine_sensor } }
#![allow(unused)] fn main() { session.update_config(|c| { c.handeye_init.handeye_mode = HandeyeMode::EyeInHand; c.solver.robust_loss = RobustLoss::Huber { scale: 2.0 }; c.handeye_optimize.robot_poses.refine = true; })?; }
Set camera.model.sensor to SensorKind::Scheimpflug for a rig of tilted-sensor cameras, or name single cameras in camera_overrides to mix lenses (see Scheimpflug and Laser Rigs). The export's cameras holds one CameraParams per camera.
Complete Example
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::rig_handeye::*; use vision_calibration::common::HandeyeSolution; use vision_calibration::optim::{HandeyeMode, RobustLoss}; let mut session = CalibrationSession::<RigHandeyeProblem>::new(); session.set_input(rig_dataset_with_robot_poses)?; // Per-camera calibration step_intrinsics_init_all(&mut session, None)?; step_intrinsics_optimize_all(&mut session, None)?; // Rig geometry step_rig_init(&mut session, None)?; step_rig_optimize(&mut session, None)?; // Hand-eye step_handeye_init(&mut session, None)?; step_handeye_optimize(&mut session, None)?; let export = session.export()?; match export.handeye { HandeyeSolution::EyeInHand { gripper_se3_rig, base_se3_target } => { println!("gripper_se3_rig: {gripper_se3_rig:?}, base_se3_target: {base_se3_target:?}"); } HandeyeSolution::EyeToHand { rig_se3_base, gripper_se3_target } => { println!("rig_se3_base: {rig_se3_base:?}, gripper_se3_target: {gripper_se3_target:?}"); } } println!("Baseline: {:.1} mm", export.cam_se3_rig[1].translation.vector.norm() * 1000.0); println!("Per-camera errors: {:?}", export.per_cam_reproj_errors); }
Gauge Freedom
The system has a gauge freedom: the rig frame origin and the hand-eye transform are coupled. Fixing the reference camera's extrinsics at identity resolves this by defining the rig frame to coincide with camera 0.
Practical Considerations
All the advice from Single-Camera Hand-Eye applies, plus:
- All cameras must observe the target in at least some views for the rig extrinsics to be well-constrained
- Views where only some cameras see the target are handled (missing observations are skipped)
- The hand-eye transform (
gripper_se3_rig) relates the gripper and the rig frame, not the gripper and an individual camera. The per-camera offset comes from the rig extrinsics.
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.
Scheimpflug and Laser Rigs
Four pieces extend the basic workflows to tilted-sensor cameras and laser-triangulation rigs. A tilted sensor is a value of the camera model, camera.model.sensor = Scheimpflug, in every workflow: planar intrinsics, single-camera hand-eye, the laserline device and the rigs.
| Piece | Solves |
|---|---|
PlanarIntrinsicsProblem with a Scheimpflug sensor | Single-camera intrinsics, distortion, and sensor tilt from planar views |
camera / camera_overrides | Select the lens and sensor of every rig camera, or of single cameras |
RigLaserlineDeviceProblem | One laser plane per camera of an already-calibrated rig |
RigHandeyeLaserlineProblem | Rig, hand-eye, and laser planes jointly |
See Sensor Models and Scheimpflug Tilt for the tilt model and Laserline Device Calibration for the single-camera laser problem.
Single Tilted Camera
A single tilted camera is calibrated by Planar Intrinsics
with camera.model.sensor = SensorKind::Scheimpflug: same input
(PlanarDataset), two extra parameters, the sensor tilt angles
. Tilt, focal length, and distortion are strongly coupled,
so seed a coarse prior (the nominal focal length and the nominal mount tilt)
instead of relying on the from-scratch initialization:
#![allow(unused)] fn main() { use vision_calibration::core::{FxFyCxCy, ScheimpflugParams, SensorKind}; use vision_calibration::planar_intrinsics::{ PlanarIntrinsicsConfig, PlanarIntrinsicsProblem, IntrinsicsManualInit, step_init_with_seed, step_optimize, }; let mut config = PlanarIntrinsicsConfig::default(); config.camera.model.sensor = SensorKind::Scheimpflug; let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_config(config)?; session.set_input(dataset)?; // PlanarDataset let mut seed = IntrinsicsManualInit::default(); seed.intrinsics = Some(FxFyCxCy { fx: 1050.0, fy: 1050.0, cx: 360.0, cy: 270.0 }); seed.sensor = Some(ScheimpflugParams { tilt_x: -0.087, tilt_y: 0.0 }); step_init_with_seed(&mut session, seed, None)?; step_optimize(&mut session, None)?; let export = session.export()?; }
run_calibration(&mut session) runs the unseeded step_init and step_optimize in one call. The Planar Intrinsics chapter describes the seeded and cold-start solves.
Camera Models for Rigs
RigExtrinsicsProblem and RigHandeyeProblem take a camera (the model every camera uses) and camera_overrides (per-camera replacements). Any distortion kind combines with either sensor, per camera:
#![allow(unused)] fn main() { use vision_calibration::common::config::{CameraConfig, CameraOverride}; use vision_calibration::core::{CameraModelSpec, DistortionKind, SensorKind}; session.update_config(|c| { // Every camera: tilted sensor, Brown-Conrady distortion. c.camera = CameraConfig::new(CameraModelSpec::pinhole( DistortionKind::BrownConrady5, SensorKind::Scheimpflug, )); // Camera 1 is an untilted camera with a rational lens. c.camera_overrides = vec![CameraOverride::new( 1, CameraModelSpec::pinhole(DistortionKind::Rational8, SensorKind::Identity), )]; c.rig.refine_sensor = false; // keep the tilts fixed in the rig bundle adjustment })?; }
The default is a Brown-Conrady camera with an identity sensor. camera.fix and each override's fix name the parameters held fixed, per slot (intrinsics, distortion, sensor); a slot left out keeps the model's default, which for a Scheimpflug camera holds everything but k1, k2. rig.refine_intrinsics keeps these fix lists in the rig bundle adjustment, and rig.refine_sensor (hand-eye: handeye_optimize.refine_sensor) lets the tilts move there.
Tilt seeds are device data, not config: pass per_cam_sensors in the manual-init struct (RigHandeyeConfig::manual_init, or rig_extrinsics::IntrinsicsManualInit with step_intrinsics_init_all_with_seed for rig extrinsics). A seed is used verbatim and skips the linear stage's tilt sweep; without one the linear stage sweeps the tilt grid, which includes zero. A non-zero tilt seed for an untilted camera is an error.
Each tilted camera is first calibrated from its own views, as in single-camera planar intrinsics: the linear stage holds p1, p2 at zero (a tangential term and the tilt trade off, and the default fix list holds them in every later solve), and the camera's own solve frees every target pose. On clean synthetic data this solve reaches the reprojection noise floor, with the principal point and tilt at the truth within their standard deviations.
From scratch (nothing seeded, for any camera), a tilted camera's own solve can still settle in a wrong tilt / principal-point basin, which the rig bundle adjustment cannot leave while it holds the tilt. Both rig workflows then solve each tilted camera through a wider multi-start over linear tilt seeds, and re-solve a Brown-Conrady tilted camera that still fits worse than 0.5 px from a nominal seed built from the rig's well-fitted cameras. This costs extra solves (several times the per-camera stage on a hard rig), and a seeded rig skips it.
RigLaserlineDevice
Given a frozen rig calibration, RigLaserlineDeviceProblem fits one laser plane per camera and reports each plane both in the camera frame and in the rig frame.
#![allow(unused)] fn main() { pub struct RigLaserlineDeviceConfig { pub solver: SolverConfig, // max_iters defaults to 200 pub laser_residual_type: LaserlineResidualType, } pub struct RigLaserlineDeviceInput { pub dataset: RigLaserlineDataset, // target corners + laser pixels per camera/view pub upstream: RigUpstreamCalibration, // cameras (CameraParams), cam_se3_rig, // rig_se3_target, uncertainty pub initial_planes_cam: Option<Vec<LaserPlane>>, } }
RigUpstreamCalibration holds one CameraParams per camera, and each camera's laser residuals are evaluated in its own model, so the rig laser workflows accept every distortion kind and untilted and Scheimpflug cameras alike (a general homography sensor, which no camera model describes, is a typed error). The usual source is a rig hand-eye export:
#![allow(unused)] fn main() { use vision_calibration::rig_laserline_device::{ RigLaserlineDeviceInput, RigLaserlineDeviceProblem, run_calibration, }; // The laser views are the hand-eye run's views: its own board poses. let upstream = rig_export.to_upstream_calibration(rig_export.rig_se3_target.clone())?; let mut session = CalibrationSession::<RigLaserlineDeviceProblem>::new(); session.set_input(RigLaserlineDeviceInput { dataset, upstream, initial_planes_cam: None })?; run_calibration(&mut session)?; let export = session.export()?; // laser_planes_rig, laser_planes_cam, per_camera_stats, ... }
to_upstream_calibration takes one board pose per laser view, through the hand-eye chain: the export's own rig_se3_target when the laser views are its views, or the chain at the laser views' robot poses (EyeInHand: T_R_T = T_G_R⁻¹ · T_B_G⁻¹ · T_B_T; EyeToHand: T_R_T = T_R_B · T_B_G · T_G_T).
Uncertainty. The planes are fitted with the upstream calibration held at its values, so a plane moves with the cameras, their extrinsics and the board poses it was fitted against. RigUpstreamCalibration::uncertainty is the upstream's covariance over those (cam1.fx, cam1.cam_se3_rig.rx, view3.rig_se3_target.rx, …), and to_upstream_calibration fills it from the hand-eye calibration's: the cameras and extrinsics as they are, and the hand-eye and target poses as each view's board pose, correlated across views and with the cameras. The robot poses, with any per-view corrections the hand-eye run estimated, count as exact. The export's covariance then has three parts, all in one matrix:
- each plane's own fit, from its laser pixels;
- the upstream's share,
A Σ_u Aᵀ, withAhow the fitted planes move with the upstream parameters: it correlates the planes of different cameras; - the cameras' parameters and extrinsics with their upstream covariance, and their cross terms
A Σ_uwith the planes.
So a laser point measured with this calibration (triangulate_laser on export.calibrated_rig()) carries the whole calibration, as with the joint solve below, in the rig frame. The export does not carry the hand-eye: for points in the gripper or base frame with the hand-eye's share, measure with the joint solve's export, whose rig carries the hand-eye correlated with the planes (see Points in the Robot's Frames). With the upstream's uncertainty unavailable (an upstream built by hand, the default), the upstream counts as exact: the covariance lists the planes alone, conditional on the upstream, and a laser point's uncertainty is unavailable for want of the cameras'. The rig hand-eye run's covariance holds the extrinsics only when it refines them (handeye_optimize.refine_cam_se3_rig); otherwise they count as exact here too.
In Python, vc.rig_upstream_calibration(export, export.rig_se3_target) gives the same RigUpstreamCalibration, its uncertainty the covariance's serde payload.
The steps step_init and step_optimize are available for finer control. dataset is a RigLaserlineDataset (RigLaserlineDataset::new(views, num_cameras), also exported from this module; its view type RigLaserlineView is in vision_calibration::common) whose views hold, per camera, target correspondences and the extracted laser pixels. Laser pixel extraction itself is left to the application.
RigHandeyeLaserline
RigHandeyeLaserlineProblem solves the whole chain jointly: it runs rig hand-eye, initializes the per-camera laser planes from the frozen hand-eye geometry, then refines rig, hand-eye, and laser parameters together.
One camera. A single laser head on a robot is a rig of one camera: num_cameras: 1, one target and one laser slot per view. The camera's frame is the rig's, so the hand-eye is the camera's (gripper_se3_rig is gripper_se3_camera). Its camera, laser plane and hand-eye come out of one solve, in one camera frame.
#![allow(unused)] fn main() { pub struct RigHandeyeLaserlineConfig { pub handeye: RigHandeyeConfig, // warm-start stage (incl. `camera`) pub laserline_init: RigLaserlineDeviceConfig, // plane-initialization stage pub joint_optimize: JointOptimizeConfig, // solver, calib_loss, laser_loss, // calib_weight, laser_weight, fix, // fix_handeye, ... } }
joint_optimize.fix holds one CameraFix per camera, named in that camera's model.
Left empty, every camera gets the joint stage's default: intrinsics and the
low-order radial terms (k1, k2; the division model's lambda) refined,
the higher-order distortion terms and the sensor tilt held at their
warm-start values, because they trade off against the extrinsics and the
laser planes. An entry's slot left out keeps the model's default, as in every
CameraFix.
The input is a RigHandeyeLaserlineInput { views, num_cameras }. Each RigHandeyeLaserlineView (exported from this module; its parts RigLaserlineView and RobotPoseMeta are in vision_calibration::common) carries the per-camera target observations, the laser pixels, and the robot pose (base_se3_gripper). There is a single entry point:
#![allow(unused)] fn main() { use vision_calibration::rig_handeye_laserline::{RigHandeyeLaserlineProblem, run_calibration}; let mut session = CalibrationSession::<RigHandeyeLaserlineProblem>::new(); session.set_input(input)?; run_calibration(&mut session)?; let export = session.export()?; // cam_se3_rig, handeye (gripper_se3_rig and base_se3_target), laser_planes_rig, per_camera_stats, ... }
When the rig and its hand-eye are already calibrated (a staged procedure that checks each stage before the next), or the laser planes are known from the design, run_calibration_with_seed starts the joint solve from them: its JointManualInit takes a RigHandeyeExport of the same views (rig_handeye) and one plane per camera in camera frame (planes_cam). Seeding with Prior Knowledge shows both.
As in the single-camera laser problem, calibration and laser residuals use separate robust losses (joint_optimize.calib_loss, joint_optimize.laser_loss); joint_optimize.solver.robust_loss is not consulted.
Examples
- Rust:
cargo run -p vision-calibration --example laserline_device_session(single-camera laser plane with a Scheimpflug-capable sensor) andcargo run -p vision-calibration --example rig_handeye_synthetic(the rig hand-eye stage that feeds the rig laser problems). - Rust tests showing seeded Scheimpflug calibration:
crates/vision-calibration/tests/planar_intrinsics_scheimpflug.rs(one camera) andcrates/vision-calibration/tests/rig_extrinsics_scheimpflug.rs(a rig). - Python:
crates/vision-calibration-py/examples/laserline_device_session.pyandrig_handeye_synthetic.py; a tilted single camera runs throughrun_planar_intrinsicswithcamera.model.sensor = "scheimpflug", and the package also exposesrun_rig_laserline_deviceandrun_rig_handeye_laserline.
Seeding with Prior Knowledge
By default every workflow initializes itself with the closed-form solvers described in Why Linear Initialization Matters: Zhang's method for intrinsics, a DLT for rig extrinsics, Tsai-Lenz (or another closed-form method) for hand-eye. That is the right behaviour when the data is well conditioned. In industrial settings you often have better priors:
- datasheet focal lengths or principal points;
- factory-measured Scheimpflug tilts;
- laser-plane geometry from mechanical drawings;
- a previously calibrated rig that new data should only nudge.
Manual initialization lets you seed any subset of these and leaves the rest to the automatic path and the non-linear optimizer.
The Seed Types
For every problem type and every initialization stage there are two things:
- a
step_*_with_seedfunction that takes a*ManualInitstruct before its options; - a typed
*ManualInitstruct whose fields are allOption<T>.Nonemeans "auto-initialize this group";Some(value)means "use this value verbatim, do not auto-initialize".
#![allow(unused)] fn main() { // Automatic path step_init(&mut session, None)?; step_optimize(&mut session, None)?; // Seeded path let mut seed = IntrinsicsManualInit::default(); seed.intrinsics = Some(nominal_camera_k); // distortion: None -> auto-initialized (zeros when intrinsics are seeded) // sensor: None -> tilt seed, Scheimpflug cameras only (a typed error otherwise) // poses: None -> recovered from homographies using the seeded intrinsics step_init_with_seed(&mut session, seed, None)?; step_optimize(&mut session, None)?; }
The seed structs are #[non_exhaustive]: build one with Default::default() and assign the fields you want, as above. Struct-update syntax (..Default::default()) does not compile outside the defining crate.
After step_*_with_seed returns, the session state is fully initialized, with the same postcondition as the automatic path; step_optimize does not need to know which fields came from where. The session log records the source of every stage, for example:
[intrinsics_init_all] initialized 2 cameras (manual: per_cam_intrinsics, per_cam_distortion)
[rig_init] ref_cam=0, 20 views (manual: cam_se3_rig, rig_se3_target)
Walkthrough: Replaying a Calibration on a Rig
The manual_init example seeds the rig stage of the stereo calibration from a previous run. It loads the corners committed in regression/observations/stereo_rig.json and runs the pipeline three times:
cargo run -p vision-calibration --example manual_init --release
1. Run the automatic pipeline once
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::rig_extrinsics::{ IntrinsicsManualInit, RigExtrinsicsProblem, RigManualInit, step_intrinsics_init_all_with_seed, step_intrinsics_optimize_all, step_rig_init_with_seed, step_rig_optimize, }; let mut session_a = CalibrationSession::<RigExtrinsicsProblem>::new(); session_a.set_input(load_input()?)?; step_intrinsics_init_all_with_seed(&mut session_a, IntrinsicsManualInit::default(), None)?; let intr_opt_a = step_intrinsics_optimize_all(&mut session_a, None)?; let rig_init_a = step_rig_init_with_seed(&mut session_a, RigManualInit::default(), None)?; step_rig_optimize(&mut session_a, None)?; }
IntrinsicsManualInit::default() and RigManualInit::default() have all fields None, so this is exactly the automatic path: the same as run_calibration.
2. Capture what the stages produced
The values live in the typed results the steps return:
#![allow(unused)] fn main() { use vision_calibration::core::{BrownConrady5, DistortionParams}; let cameras_a = intr_opt_a.cameras.clone(); // Vec<CameraParams> let cam_se3_rig_a = rig_init_a.initial_cam_se3_rig.clone(); // per-camera T_C_R let rig_se3_target_a = rig_init_a.initial_rig_se3_target.clone(); // per-view T_R_T let per_cam_k: Vec<_> = cameras_a.iter().map(|c| c.intrinsics).collect(); let per_cam_dist: Vec<BrownConrady5<f64>> = cameras_a .iter() .map(|c| c.distortion.clone()) .collect(); }
3. Replay them on a fresh session
#![allow(unused)] fn main() { let mut session_b = CalibrationSession::<RigExtrinsicsProblem>::new(); session_b.set_input(load_input()?)?; let mut intrinsics_seed = IntrinsicsManualInit::default(); intrinsics_seed.per_cam_intrinsics = Some(per_cam_k); intrinsics_seed.per_cam_distortion = Some(per_cam_dist); step_intrinsics_init_all_with_seed(&mut session_b, intrinsics_seed, None)?; // Skip step_intrinsics_optimize_all: the seed is already optimized. let mut rig_seed = RigManualInit::default(); rig_seed.cam_se3_rig = Some(cam_se3_rig_a); rig_seed.rig_se3_target = Some(rig_se3_target_a); step_rig_init_with_seed(&mut session_b, rig_seed, None)?; step_rig_optimize(&mut session_b, None)?; }
On the stereo dataset the replay reproduces the first run exactly (|Δ reproj| = 0 px); the example asserts this. A third run seeds the intrinsics with a 10 % focal-length error and a 50 px principal-point offset and still converges to the same 0.2496 px solution, which shows that a rough seed is enough for the optimizer.
Variations
Partial seeding
When intrinsics are seeded but distortion is not, distortion starts at zero in the camera's distortion model: the automatic distortion fit is coupled to the iterative intrinsics estimation and does not run when intrinsics are seeded.
A distortion seed is a DistortionParams of the camera's own model: its kind must equal camera.model.distortion (a Kannala-Brandt seed for a fisheye camera, a Rational seed for a rational one). It is used as given, never converted; a seed of another kind is a typed error naming the field, raised before any work. In JSON it is the tagged form {"type": "brown_conrady5", "k1": -0.1, ...}.
When intrinsics are seeded but poses are not, the poses are recovered from the per-view homographies using the seeded intrinsics, not automatically estimated ones. This keeps the geometric chain consistent with the seed.
Seeding from datasheet values
For a rig whose cameras share one optical design:
#![allow(unused)] fn main() { use vision_calibration::core::{BrownConrady5, DistortionParams, FxFyCxCy}; let datasheet_k = FxFyCxCy { fx: 1800.0, fy: 1800.0, cx: 360.0, cy: 270.0 }; let nominal_distortion = DistortionParams::BrownConrady5 { params: BrownConrady5 { k1: -0.1, ..Default::default() }, }; let mut seed = IntrinsicsManualInit::default(); seed.per_cam_intrinsics = Some(vec![datasheet_k; num_cameras]); seed.per_cam_distortion = Some(vec![nominal_distortion; num_cameras]); step_intrinsics_init_all_with_seed(&mut session, seed, None)?; }
This unblocks scenarios where Zhang's method fails on borderline data, for example strongly tilted Scheimpflug rigs. For those, also set per_cam_sensors to the nominal mount tilts: a seeded tilt is used verbatim and skips the linear stage's tilt sweep. A non-zero tilt for a camera with an identity sensor is an error; a zero tilt is accepted. RigHandeyeConfig::manual_init takes the same per_cam_sensors. The single-camera seeds (planar_intrinsics::IntrinsicsManualInit, single_cam_handeye::IntrinsicsManualInit and laserline_device::DeviceManualInit) take one tilt in sensor, with the same rule.
The device_seed module derives these seeds from a device description (focal length in millimetres, pixel pitch, mount tilt); see Device Specifications.
Coupling rule (rig stage)
cam_se3_rig and rig_se3_target are geometrically coupled. The rig stage's RigManualInit requires both or neither, and enforces it at runtime with a typed Error::InvalidInput.
Hand-eye coupling
RigHandeye has three initialization stages (intrinsics, rig, hand-eye), each with its own *ManualInit. The hand-eye stage's mode_target_pose depends on the mounting mode:
EyeInHand:handeye = T_G_R,mode_target_pose = T_B_T.EyeToHand:handeye = T_R_B,mode_target_pose = T_G_T.
If you seed handeye but not mode_target_pose, the latter is derived from the chain.
Staged rig laser calibration
RigHandeyeLaserline has no steps: run_calibration runs rig hand-eye, fits each camera's laser plane against that frozen geometry, then refines everything jointly. run_calibration_with_seed takes a JointManualInit that replaces either warm start:
rig_handeye: aRigHandeyeExportof the same views, for example from aRigHandeyeProblemsession you ran (and inspected) first. The rig hand-eye stage is skipped; its cameras, extrinsics, hand-eye transform, target poses and robot-pose corrections start the joint solve.planes_cam: one laser plane per camera, in camera frame, for example from the mechanical drawing. The plane stage refines them instead of fitting its starting planes linearly.
#![allow(unused)] fn main() { use vision_calibration::rig_handeye_laserline::{JointManualInit, run_calibration_with_seed}; // `rig_export` is the export of a rig hand-eye session on the same views, // with `config.handeye` as its config. let mut seed = JointManualInit::default(); seed.rig_handeye = Some(rig_export); seed.planes_cam = Some(design_planes); // Vec<LaserPlane>, one per camera run_calibration_with_seed(&mut joint_session, seed)?; }
With the warm start a rig hand-eye session computes from the joint session's input and config.handeye, the result is identical to run_calibration. The export must have the input's camera count, each camera in the configured model, one target pose per view, and the configured hand-eye mode with its transforms; the seeded planes must be one per camera. Anything else is an Error::InvalidInput, raised before any solve.
In Python
The seeds are dataclasses in the problem's module, with the Rust names and fields; a field left None is estimated. The datasheet seed above:
import vision_calibration as vc
from vision_calibration import rig_extrinsics
seed = rig_extrinsics.IntrinsicsManualInit(
per_cam_intrinsics=[vc.PinholeIntrinsics(fx=1800.0, fy=1800.0, cx=360.0, cy=270.0)] * 2,
per_cam_distortion=[vc.BrownConradyDistortion(k1=-0.1, k2=0.0, k3=0.0, p1=0.0, p2=0.0, iters=8)] * 2,
)
init = session.step_intrinsics_init_all_with_seed(seed)
print(session.log[-1].notes) # the init log names the manual and the estimated parts
Distortion seeds are the distortion dataclasses of the camera model (BrownConradyDistortion, KannalaBrandt4Distortion, …). A seed that does not fit the camera model (a tilt for an untilted camera, a distortion of another kind, one rig pose without the other) raises ValueError.
The staged rig laser calibration takes the rig hand-eye session's export as it is:
from vision_calibration import rig_handeye_laserline
rig_export = rig_session.export() # a RigHandeyeSession on the same views
joint = vc.RigHandeyeLaserlineSession()
joint.set_input(dataset)
joint.run_calibration_with_seed(
rig_handeye_laserline.JointManualInit(rig_handeye=rig_export, planes_cam=design_planes)
)
See Also
- Step Functions vs Pipeline Functions: the full list of
*_with_seedvariants. - Per-Feature Residuals: inspect per-corner errors once the calibration finishes.
- The example this chapter follows:
crates/vision-calibration/examples/manual_init.rs.
Per-Feature Residuals
After a calibration finishes, the only error metric most pipelines surface is the mean reprojection error in pixels. That number says something is wrong but not which corners are wrong. To debug a low-quality calibration (a board that flexed, a corner detection that drifted, a camera with a borderline field of view) you need the per-corner error broken down by view and camera.
Every *Export therefore carries per-feature residual records, so a consumer (the desktop app's Diagnose workspace, a Python notebook, a regression test) can drill in without re-running any geometry.
Structure
Every export has a diagnostics group (ExportDiagnostics) holding per_feature_residuals: PerFeatureResiduals and, when the images are known, the image_manifest. The container is always present (empty by default); empty inner vectors mean "this problem type does not produce observations of that flavour". For example, PlanarIntrinsicsExport.diagnostics.per_feature_residuals.laser is always empty.
Each export also serializes a required kind discriminator (ExportKind, snake_case: "planar_intrinsics", "rig_handeye", ...) as its first field, so a consumer that loads raw JSON can narrow on the single tag instead of probing which fields are present.
PerFeatureResiduals
├─ target: Vec<TargetFeatureResidual> // per-corner reprojection records
├─ laser: Vec<LaserFeatureResidual> // per-pixel laser residuals
├─ laser_lines: Vec<LaserLineTrace> // the projected laser line of each view
├─ target_hist_per_camera: Option<Vec<FeatureResidualHistogram>>
└─ laser_hist_per_camera: Option<Vec<FeatureResidualHistogram>>
A target record carries an explicit (pose, camera, feature) triple, the 3D target point target_xyz_m, the observed_px and projected_px pixels and the error_px between them. Records are pose-major: the outer loop is the view, the inner loop the camera (cameras without an observation in that view are skipped), the innermost the feature index in that view's and camera's points_3d.
Option fields carry two meanings:
error_px = None(withprojected_px = None): the projection diverged for that record, for instance a point behind the camera. This separates "feature absent" from "feature present but the model is bad".residual_m = Noneon a laser record: the back-projected ray missed the laser plane.
Histogram bucket edges are fixed at [1, 2, 5, 10] px, giving the buckets <=1, <=2, <=5, <=10, >10. A histogram aggregates only records that produced a finite error_px.
Walkthrough
The examples below use the stereo chessboard rig of Stereo Rig with Real Data. The manual_init example prints the same quantities.
1. Run a calibration
No configuration is needed: the records are always populated.
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::rig_extrinsics::{RigExtrinsicsProblem, run_calibration}; let mut session = CalibrationSession::<RigExtrinsicsProblem>::new(); session.set_input(my_dataset)?; run_calibration(&mut session)?; let export = session.export()?; }
2. Summarize the histograms
#![allow(unused)] fn main() { let pf = &export.diagnostics.per_feature_residuals; let total = pf.target.len(); let with_value = pf.target.iter().filter(|r| r.error_px.is_some()).count(); println!("{total} target records ({with_value} converged, {} divergent)", total - with_value); if let Some(hists) = pf.target_hist_per_camera.as_ref() { for (i, h) in hists.iter().enumerate() { println!("cam {i}: count={} mean={:.4}px max={:.4}px buckets {:?}", h.count, h.mean, h.max, h.counts); } } }
Output on the 20-view stereo chessboard rig:
3080 target records (3080 converged, 0 divergent)
cam 0: count=1540 mean=0.2448px max=1.0262px buckets [1539, 1, 0, 0, 0]
cam 1: count=1540 mean=0.2544px max=0.9146px buckets [1540, 0, 0, 0, 0]
3. Find the worst corners
Sort by error_px to triage outliers:
#![allow(unused)] fn main() { let mut worst: Vec<_> = pf .target .iter() .filter_map(|r| r.error_px.map(|e| (e, r))) .collect(); worst.sort_by(|a, b| b.0.total_cmp(&a.0)); for (err, r) in worst.iter().take(5) { println!( "pose={} cam={} feat={} target_xy_m=({:.3},{:.3}) observed=({:.1},{:.1}) err={:.3}px", r.pose, r.camera, r.feature, r.target_xyz_m[0], r.target_xyz_m[1], r.observed_px[0], r.observed_px[1], err ); } }
A diagnose view typically projects this onto the source image as a heatmap; the (observed_px, projected_px) pair lets you draw an arrow from observed to projected for every record.
4. Cross-check against the aggregate
The per-camera histogram mean must agree with the mean recomputed from the records. The manual_init example asserts this:
#![allow(unused)] fn main() { let mean_from_records: f64 = pf .target .iter() .filter(|r| r.camera == cam_idx) .filter_map(|r| r.error_px) .sum::<f64>() / hist.count as f64; assert!((mean_from_records - hist.mean).abs() < 1e-9); }
Laser Residuals
LaserlineDeviceExport and RigLaserlineDeviceExport fill laser and laser_lines in addition to target. Each LaserFeatureResidual carries:
residual_m: the point-to-plane distance in metres from the back-projected ray's intersection with the target plane to the calibrated laser plane;residual_px: the distance in pixels from the observed pixel to the projected laser line. This is the line-distance residual the laser solve minimizes, measured on the undistorted image plane.
Each LaserLineTrace in laser_lines is one view's projected laser line (pose, camera, points_px): a polyline over the extent of that view's laser pixels, for 2D overlays. Lens distortion bends the line, so draw all of its points rather than a segment between the first and last.
Which Camera Chain Produced Them
The records use exactly the camera chain the optimizer used, so they agree with the reported mean error:
- Hand-eye exports (
SingleCamHandeyeExport,RigHandeyeExport) derive each view's camera-from-target pose from the hand-eye chain; the export already carries the resulting records. - Scheimpflug cameras (a planar or rig camera whose
CameraParamscarries a tilted sensor) are projected through the full pinhole, distortion and Scheimpflug chain.
JSON and Python
Every record type derives Serialize and Deserialize, and the export round-trips through JSON:
#![allow(unused)] fn main() { let json = serde_json::to_string(&export)?; let restored: RigExtrinsicsExport = serde_json::from_str(&json)?; assert_eq!( restored.diagnostics.per_feature_residuals, export.diagnostics.per_feature_residuals, ); }
Empty vectors and None histograms are skipped on the wire ({} for an empty container).
In Python every result has per_feature_residuals, a PerFeatureResiduals whose record lists are numpy tables: target holds one row per TargetFeatureResidual and laser one per LaserFeatureResidual, a column per field (pose, camera, feature, observed_px as (N, 2), ...). A value the record leaves unset, such as the projection of a point behind the camera, is NaN. laser_lines is a list of LaserLineTrace, the histograms lists of FeatureResidualHistogram.
import numpy as np
res = result.per_feature_residuals
worst = np.argsort(-np.nan_to_num(res.target.error_px))[:5]
for i in worst:
print(res.target.pose[i], res.target.camera[i], res.target.feature[i],
f"{res.target.error_px[i]:.3f}")
print(res.target_hist_per_camera[0].counts)
Where Else They Show Up
The desktop app's Diagnose workspace draws these records on the source images, with a histogram, a per-pose table and a cameras-by-poses matrix (see Inspecting Results).
CalibrationSession
CalibrationSession<P: ProblemType> is the central state container for calibration workflows. It holds all data for a calibration run — configuration, input observations, intermediate state, final output, and an audit log — with full JSON serialization for checkpointing.
Structure
A session has two public fields and otherwise exposes its data through methods:
| Member | Access | Purpose |
|---|---|---|
config | pub field | Algorithm parameters (iterations, fix masks, loss functions) |
exports | pub field | Timestamped ExportRecords created by export() |
| input | input(), require_input(), set_input(), clear_input() | Observation data |
| output | output(), require_output() | Final calibration result, set by the last step |
metadata() | method | SessionMetadata: problem type name, schema version, timestamps, description |
log() | method | Audit trail of operations, &[LogEntry] |
Intermediate state is internal to the pipeline. Read intermediate results from the typed values returned by each step function (for example IntrinsicsInitResult), from log(), or from export().
The public methods of CalibrationSession<P>:
| Group | Methods |
|---|---|
| Construction | new(), with_description(..), with_input(..) |
| Input | set_input, input, input_mut, require_input, require_input_mut, has_input, clear_input |
| Config | set_config, update_config |
| Output | output, output_mut, require_output, set_output, has_output, clear_output |
| Export | export, export_with_notes, export_peek |
| Validation | validate |
| Log | log, log_success, log_success_with_notes, log_failure |
| Metadata | metadata |
| Reset | reset_state, reset_output, reset |
| Serialization | to_json, from_json |
Lifecycle
1. Create
#![allow(unused)] fn main() { let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); // Or with a description: let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::with_description( "Lab camera calibration" ); }
2. Set Input
#![allow(unused)] fn main() { session.set_input(dataset)?; }
Input is validated via ProblemType::validate_input(). Setting input clears computed state (per the invalidation policy).
3. Configure
#![allow(unused)] fn main() { session.update_config(|c| { c.solver.max_iters = 50; c.solver.robust_loss = RobustLoss::Huber { scale: 2.0 }; })?; }
Configuration is validated via ProblemType::validate_config().
4. Run Steps
#![allow(unused)] fn main() { step_init(&mut session, None)?; step_optimize(&mut session, None)?; }
Step functions are free functions operating on &mut CalibrationSession<P>. Each step reads input/state, performs computation, and updates state (or output).
5. Export
#![allow(unused)] fn main() { let export = session.export()?; }
Creates an ExportRecord with the current timestamp and output. Multiple exports can be created (e.g., after re-optimization with different settings).
JSON Checkpointing
The entire session can be serialized and restored:
#![allow(unused)] fn main() { // Save let json = session.to_json()?; std::fs::write("calibration.json", &json)?; // Restore let json = std::fs::read_to_string("calibration.json")?; let mut restored = CalibrationSession::<PlanarIntrinsicsProblem>::from_json(&json)?; // Resume from where we left off step_optimize(&mut restored, None)?; }
This enables:
- Interruption recovery: Save progress and resume later
- Reproducibility: Share exact calibration state
- Debugging: Inspect the session at any point
Invalidation Policies
When input or configuration changes, computed state may need to be cleared:
| Event | Default policy |
|---|---|
set_input() | Clear state and output (CLEAR_COMPUTED) |
update_config() | Keep everything (KEEP_ALL) |
clear_input() | Same as set_input() (CLEAR_COMPUTED), then the input is removed |
Each problem type may override these policies.
Audit Log
Every step function appends to the session log:
#![allow(unused)] fn main() { pub struct LogEntry { pub timestamp: u64, pub operation: String, pub success: bool, pub notes: Option<String>, } }
The log records what was done and when, useful for tracking calibration history.
Reading Results
#![allow(unused)] fn main() { // Input (required before steps) let input = session.require_input()?; // Returns error if no input // Intermediate results: the typed value returned by each step let init = step_init(&mut session, None)?; println!("Init fx={:.1}", init.camera.intrinsics.fx); // Output (available after optimize) let output = session.require_output()?; // Session bookkeeping println!("{} ({} log entries)", session.metadata().problem_type, session.log().len()); }
Progress and Cancellation
A long run reports its progress, and stops on request, through a
RunControl (in vision_calibration::control): an optional CancelToken
and observers that receive each Progress report.
use vision_calibration::control::{CancelToken, Progress, RunControl};
let token = CancelToken::new();
let control = RunControl::new()
.with_cancel(token.clone())
.with_observer(|progress| match progress {
Progress::Step { name, .. } => eprintln!("{name}"),
Progress::Iteration { iter, cost, .. } => eprintln!(" {iter}: {cost:.3e}"),
_ => {}
});
session.set_control(control);
// From another thread (a Ctrl-C handler, a Cancel button): token.cancel();
match run_calibration(&mut session) {
Err(vision_calibration::Error::Cancelled) => eprintln!("stopped"),
other => other?,
}
| Report | When |
|---|---|
Detection { done, total } | a target image was detected or read from the cache |
LaserExtraction { done, total } | a laser image was extracted |
Step { name } | run_calibration begins a step ("intrinsics_init_all", …) |
Iteration { iter, max_iters, cost } | a solver iteration finished; iter restarts at 1 for each solve |
The token is checked before every solver iteration, between the steps of
run_calibration and before every image, so a run stops within one
iteration or one image of the request. A stopped call returns
Error::Cancelled (CalibrateDatasetError::Cancelled from
calibrate_dataset, RunError::Cancelled from a build_*_input
converter), never a partial result: a stopped step stores nothing in the
session. Progress is a side channel: a run with observers returns exactly
what it returns without them, bit for bit, and nothing it reports enters a
result.
The same control goes to dataset_runner::calibrate_dataset and the
build_*_input converters as their last argument, and to
optim::problem::solve as SolveOptions::control. Observers run on the
calibrating thread at every report, so keep them cheap; one that must
redraw a screen can drop reports that arrive too fast.
In Python
Each problem type has its session class, named after it:
PlanarIntrinsicsSession, SingleCamHandeyeSession, RigExtrinsicsSession,
RigHandeyeSession, LaserlineDeviceSession, RigLaserlineDeviceSession
and RigHandeyeLaserlineSession. The step functions are its methods, with
the Rust names; each takes the step's options and returns the step's result
as a dataclass. Long steps release the GIL.
import vision_calibration as vc
session = vc.PlanarIntrinsicsSession("Lab camera calibration")
session.set_input(dataset) # a PlanarIntrinsicsInput, or its payload dict
session.set_config(vc.PlanarIntrinsicsConfig(solver=vc.SolverConfig(max_iters=50)))
init = session.step_init(vc.IntrinsicsInitOptions(iterations=3))
print(f"Init fx={init.camera.intrinsics.fx:.1f}")
session.step_optimize()
result = session.export(notes="first pass") # a PlanarIntrinsicsExport
for entry in session.log: # the audit log, as LogEntry dataclasses
print(entry.operation, entry.success, entry.notes)
# Checkpoint and resume
restored = vc.PlanarIntrinsicsSession.from_json(session.to_json())
input and output return the serde payloads (dicts), config the config
dataclass, exports the ExportRecords and metadata the
SessionMetadata. run_calibration() runs the problem's default steps. An
input or config the problem rejects, a step out of order and a seed that does
not fit raise ValueError; a numerical failure raises RuntimeError.
Ctrl-C stops a running step with KeyboardInterrupt. set_progress gives
the session a callable that receives every report as a dict
({"kind": "iteration", "iter": 3, "max_iters": 50, "cost": 12.5}); raising
from it stops the step, and the exception propagates. The run_* functions,
calibrate_dataset and the build_*_input converters take the callable as
their progress argument:
def show(report):
if report["kind"] == "detection":
print(f"detected {report['done']}/{report['total']}")
result = vc.calibrate_dataset("data/kuka_1/dataset.toml", progress=show)
ProblemType Trait
The ProblemType trait defines the interface for a calibration problem. It specifies the types for configuration, input, output, and export, along with validation and lifecycle hooks. The trait is intentionally minimal — behavior is implemented in external step functions, not trait methods.
ProblemType is sealed: it can only be implemented by the seven problem types that ship with the pipeline crate, so downstream crates use it as a bound (CalibrationSession<P: ProblemType>) but do not implement it. Contributors adding a problem type inside the pipeline crate should read Adding a New Pipeline Problem Type; a pipeline of your own is composed from the public pieces instead (see Compose Your Own Pipeline).
Definition
#![allow(unused)] fn main() { pub trait ProblemType: ProblemState + Sized + 'static { type Config: Clone + Default + Serialize + DeserializeOwned + Debug; type Input: Clone + Serialize + DeserializeOwned + Debug; type Output: Clone + Serialize + DeserializeOwned + Debug; type Export: Clone + Serialize + DeserializeOwned + Debug; fn name() -> &'static str; fn schema_version() -> u32 { 1 } fn validate_input(input: &Self::Input) -> Result<(), Error> { Ok(()) } fn validate_config(config: &Self::Config) -> Result<(), Error> { Ok(()) } fn validate_input_config( input: &Self::Input, config: &Self::Config ) -> Result<(), Error> { Ok(()) } fn on_input_change() -> InvalidationPolicy { InvalidationPolicy::CLEAR_COMPUTED } fn on_config_change() -> InvalidationPolicy { InvalidationPolicy::KEEP_ALL } fn export( input: &Self::Input, output: &Self::Output, config: &Self::Config ) -> Result<Self::Export, Error>; } }
ProblemState is a crate-private supertrait that carries the internal State associated type (the intermediate results between steps). It is what seals the trait; State is not part of the public API.
Associated Types
| Type | Purpose | Requirements |
|---|---|---|
Config | Algorithm parameters, <Name>Config | Default (sensible defaults) |
Input | Observation data, <Name>Input | Validated on set |
Output | The final solve's estimate, an *Estimate | Set by last step |
Export | User-facing result, <Name>Export | Created from Input + Output + Config |
The Serialize + DeserializeOwned bounds enable JSON checkpointing (the internal state is serialized as well). Clone enables snapshot operations.
Required Method: name()
Returns a stable string identifier for the problem type:
#![allow(unused)] fn main() { fn name() -> &'static str { "planar_intrinsics_v2" } }
This is stored in session metadata and used for deserialization. Changing the name breaks compatibility with existing checkpoints.
Required Method: export()
Converts the internal output to a user-facing export format:
#![allow(unused)] fn main() { fn export( input: &Self::Input, output: &Self::Output, config: &Self::Config, ) -> Result<Self::Export, Error>; }
The export may transform, filter, or enrich the output. The input is provided so exports can attach per-feature reprojection residuals; for example, PlanarIntrinsicsProblem::export() computes per-feature residuals and reprojection error metrics from the raw optimization output.
Validation Hooks
Three optional validation hooks run at specific points:
| Hook | When it runs |
|---|---|
validate_input(input) | On session.set_input(input) |
validate_config(config) | On session.set_config(config) or update_config() |
validate_input_config(input, config) | When both input and config are present |
Example: PlanarIntrinsicsProblem validates that input has at least 3 views with at least 4 points each.
Invalidation Policies
Control what happens when input or config changes:
#![allow(unused)] fn main() { pub struct InvalidationPolicy { pub clear_state: bool, pub clear_output: bool, pub clear_exports: bool, } impl InvalidationPolicy { pub const KEEP_ALL: Self = ...; // Nothing cleared pub const CLEAR_COMPUTED: Self = ...; // State + output cleared pub const CLEAR_ALL: Self = ...; // Everything cleared } }
Defaults:
- Input change →
CLEAR_COMPUTED(re-running steps is needed) - Config change →
KEEP_ALL(output is still valid, but may not reflect new config)
Problem Types
| Type | name() | Steps |
|---|---|---|
PlanarIntrinsicsProblem | "planar_intrinsics_v2" | init → optimize |
SingleCamHandeyeProblem | "single_cam_handeye_v2" | intrinsics init → intrinsics optimize → hand-eye init → hand-eye optimize |
LaserlineDeviceProblem | "laserline_device_v1" | init → optimize |
RigExtrinsicsProblem | "rig_extrinsics_v2" | intrinsics init (all) → intrinsics optimize (all) → rig init → rig optimize |
RigHandeyeProblem | "rig_handeye_v2" | 6 steps (intrinsics + rig + hand-eye) |
RigLaserlineDeviceProblem | "rig_laserline_device_v1" | init → optimize |
RigHandeyeLaserlineProblem | "rig_handeye_laserline_v1" | run_calibration only |
Design Philosophy
The trait is minimal by design:
- No step methods: Steps are free functions, not trait methods. This allows flexible composition and avoids trait object limitations.
- No algorithm logic: The trait defines data types and validation, not computation.
- Serialization-first: All types are serializable, enabling checkpointing from day one.
Step Functions vs Pipeline Functions
calibration-rs offers two ways to run calibration workflows: step functions for granular control and pipeline functions for convenience.
Step Functions
Step functions are free functions that operate on a mutable session reference:
#![allow(unused)] fn main() { pub fn step_init( session: &mut CalibrationSession<PlanarIntrinsicsProblem>, opts: Option<IntrinsicsInitOptions>, ) -> Result<IntrinsicsInitResult, Error> }
Each step:
- Reads the input, config, and the results of earlier steps from the session
- Performs one phase of the calibration (e.g., initialization or optimization)
- Updates the session (and possibly its output)
- Logs the operation
- Returns a typed result value summarizing what it computed
Advantages
Intermediate inspection: Examine the typed result of each step.
#![allow(unused)] fn main() { let init = step_init(&mut session, None)?; // Inspect initialization quality before committing to optimization if (init.camera.intrinsics.fx - expected_fx).abs() / expected_fx > 0.5 { eprintln!("Warning: init fx={:.0} is far from expected {:.0}", init.camera.intrinsics.fx, expected_fx); } let opt = step_optimize(&mut session, None)?; println!("{:.3} px after {} iterations", opt.mean_reproj_error, opt.iterations); }
Per-step configuration: Override options for individual steps.
#![allow(unused)] fn main() { // Use more iterations for optimization let mut opts = IntrinsicsOptimizeOptions::default(); opts.max_iters = Some(200); step_optimize(&mut session, Some(opts))?; }
Selective execution: Skip steps or re-run specific steps.
#![allow(unused)] fn main() { // Re-run optimization with different settings (without re-initializing) session.update_config(|c| c.solver.robust_loss = RobustLoss::Cauchy { scale: 3.0 })?; step_optimize(&mut session, None)?; }
Checkpointing: Save and restore between steps.
#![allow(unused)] fn main() { step_init(&mut session, None)?; let checkpoint = session.to_json()?; std::fs::write("after_init.json", &checkpoint)?; step_optimize(&mut session, None)?; }
Manual Seeds
Every problem with an initialization step also has a *_with_seed variant (for example step_init_with_seed taking an IntrinsicsManualInit) that accepts a coarse prior for some parameters and auto-estimates the rest. The plain step_init is the same call with an empty seed. Seeding with Prior Knowledge walks through the seed types with a runnable example.
Names
Every problem module uses the same names, so code written against one problem reads the same against another:
- The problem is
<Name>Problem, with<Name>Input,<Name>Configand<Name>Export(PlanarIntrinsicsInput,RigHandeyeConfig, …). An input that is a shared data type is a type alias (PlanarIntrinsicsInput = PlanarDataset). - The problem's output is the final solve's estimate,
*Estimate(PlanarIntrinsicsEstimate,HandeyeEstimate, …). The hand-eye estimate is shared by the single-camera and rig workflows and lives invision_calibration::common. - A step of stage
<Stage>and action<Action>takesOption<<Stage><Action>Options>(Nonekeeps the session config) and returns<Stage><Action>Result; its_with_seedvariant takes a<Stage>ManualInitbefore the options. The stages areIntrinsics,RigandHandeye; the single-stage laser problems useDevice(camera and laser plane,laserline_device) andPlane(the laser planes,rig_laserline_device); the joint rig laser solve is theJointstage (JointOptimizeConfig,JointManualInit). - Option types shared by several problems (
IntrinsicsInitOptions,RigOptimizeOptions,HandeyeInitOptions, …) are one type each, imported fromvision_calibration::common. Step results and manual seeds are per problem:rig_handeye::IntrinsicsInitResultandplanar_intrinsics::IntrinsicsInitResultare different types. run_calibration(session)has the same signature in every problem and runs with the session's config.
Pipeline Functions
Pipeline functions chain all steps into a single call:
#![allow(unused)] fn main() { pub fn run_calibration( session: &mut CalibrationSession<PlanarIntrinsicsProblem>, ) -> Result<(), Error> { step_init(session, None)?; step_optimize(session, None)?; Ok(()) } }
When to Use
- Quick prototyping: Get results with minimal code
- Default settings: When the defaults work and you don't need inspection
- Scripts and automation: When human inspection is not needed
#![allow(unused)] fn main() { let mut session = CalibrationSession::<PlanarIntrinsicsProblem>::new(); session.set_input(dataset)?; run_calibration(&mut session)?; let export = session.export()?; }
Available Steps and Pipeline Functions
Paths are relative to the facade module named after the problem (vision_calibration::<module>).
| Problem | Module | Steps | Pipeline function |
|---|---|---|---|
PlanarIntrinsicsProblem | planar_intrinsics | step_init, step_optimize, step_filter | run_calibration(session), run_calibration_with_filtering(session, opts) |
SingleCamHandeyeProblem | single_cam_handeye | step_intrinsics_init, step_intrinsics_optimize, step_handeye_init, step_handeye_optimize | run_calibration(session) |
LaserlineDeviceProblem | laserline_device | step_init, step_optimize | run_calibration(session) |
RigExtrinsicsProblem | rig_extrinsics | step_intrinsics_init_all, step_intrinsics_optimize_all, step_rig_init, step_rig_optimize | run_calibration(session) |
RigHandeyeProblem | rig_handeye | the four rig steps above plus step_handeye_init, step_handeye_optimize | run_calibration(session) |
RigLaserlineDeviceProblem | rig_laserline_device | step_init, step_optimize | run_calibration(session) |
RigHandeyeLaserlineProblem | rig_handeye_laserline | (none; the pipeline is a single joint solve) | run_calibration(session), run_calibration_with_seed(session, manual) |
The *_with_seed variants exist for step_init / step_intrinsics_init / step_intrinsics_init_all / step_rig_init / step_handeye_init of the problems above. RigHandeyeLaserlineProblem has no steps; run_calibration_with_seed takes a JointManualInit that replaces its rig hand-eye warm start, its starting laser planes, or both.
step_filter removes high-residual points from the session input, which invalidates the computed state; run step_init and step_optimize again afterwards. run_calibration_with_filtering does exactly that: solve, filter, solve.
In Python
The steps are methods of the problem's session class, with the same names. Options are dataclasses whose None fields keep the session config; results are dataclasses. The shared option types are importable from the package root; each problem's seeds and results live in the problem's module, which mirrors the Rust module of the same name:
import vision_calibration as vc
from vision_calibration import rig_extrinsics
session = vc.RigExtrinsicsSession()
session.set_input(dataset)
init = session.step_intrinsics_init_all() # rig_extrinsics.IntrinsicsInitResult
session.step_intrinsics_optimize_all(vc.IntrinsicsOptimizeOptions(max_iters=200))
rig = session.step_rig_init()
session.step_rig_optimize()
# Re-run the rig stage from its own estimate
session.step_rig_init_with_seed(
rig_extrinsics.RigManualInit(
cam_se3_rig=rig.initial_cam_se3_rig,
rig_se3_target=rig.initial_rig_se3_target,
)
)
The one-shot run_<problem> functions (run_planar_intrinsics, run_rig_extrinsics, ...) are the pipeline functions: a session, run_calibration and export in one call.
Recommendation
Use step functions for production calibration where you need to:
- Verify initialization quality
- Adjust parameters between steps
- Handle failures gracefully
- Log and audit the process
Use pipeline functions for:
- Examples and tutorials
- Batch processing with known-good settings
- Quick experiments
Calibration Targets and Detection
The calibration workflows consume 2D-3D correspondences: a pixel in the image and the position of the same point on the target, in metres, in the target's own frame. Turning images into those correspondences is the job of the vision-calibration-detect crate. It ships four target detectors behind one interface and a content-addressed cache, so repeated runs over the same images never repeat the detection.
You rarely call a detector yourself. The dataset runner picks the detector from the manifest's [target] table, runs it over every image through the cache and assembles the problem input. This chapter is for choosing a target, understanding what each detector returns, and using the detectors or the cache directly.
What a Detector Returns
Every detector turns one image into a list of Feature values:
#![allow(unused)] fn main() { pub struct Feature { pub image_xy: [f64; 2], // pixel (column, row) in the source image pub world_xyz: [f64; 3], // metres, in the target frame; the target plane is z = 0 } }
A frame that simply contains no target is not an error: every detector returns an empty list. Errors (DetectError) are reserved for a malformed configuration (Config, InvalidConfig) and genuine backend failures (Backend), so a misconfigured board cannot masquerade as an unlucky image.
Every detector also passes its result through reject_ambiguous_detection. The targets are planar, so the image-to-target map is a homography and therefore injective: one pixel is one board point. A detection in which two correspondences share an image point has mislabelled its grid. The whole detection is rejected (an empty result), because a duplicated point is evidence that the grid walk lost track of the labels, and the labels that happen not to collide come from the same broken walk. The caller sees a skipped view, which every workflow handles.
The Four Targets
The target is chosen by the manifest's [target] table, kind being one of the four names below. All lengths are in metres.
kind | Fields | What it detects |
|---|---|---|
chessboard | rows, cols (interior corners), square_size_m | Saddle corners of a plain checkerboard |
charuco | rows, cols (squares), square_size_m, marker_size_m, dictionary | Chessboard corners identified by embedded ArUco markers |
puzzleboard | layout ("puzzle_<R>x<C>"), cell_size_m | Saddles of a self-identifying chessboard variant |
ringgrid | pitch_m, rows, long_row_cols, marker_outer_radius_m, marker_inner_radius_m, marker_ring_width_m | Centres of coded ring markers on a hexagonal lattice |
Chessboard
rows and cols count interior corners: a 9x6 setting is a 10x7 grid of squares. cols corners run across the image and rows down; a board seen turned by 90 degrees is also accepted whole.
The underlying detector labels each view on its own: grid index (0, 0) is the detection's top-left corner in that image, +u runs along the board axis closest to the image's +x and +v along the one closest to +y, and the corner positions are lifted to (i * square_size_m, j * square_size_m, 0). A board seen whole and the same way up gets the same labels in every view. A partial board is labelled from its own top-left corner, so its target frame is shifted by the rows and columns out of view; a board seen turned gets labels turned with it.
That is harmless for single-camera intrinsics and the single-camera laserline device, which estimate one target pose per view: a shifted frame only changes that view's pose, and partial views near the image border are valuable for the distortion model. It is not harmless where views share one target frame: hand-eye (the target is fixed relative to the robot base or gripper) and every rig topology (the cameras of one view see one target pose). A shifted frame there contradicts the other views. On a three-camera hand-eye rig, keeping partial boards raises the worst camera's mean reprojection error from 0.29 px to 4.7 px.
So in the shared-frame topologies the dataset runner keeps a chessboard detection only when it spans the whole board: corners on all four edges, each edge holding at least half of its corners (and two). Interior corners the detector missed do not matter, since the edges still fix the labels; an edge held by a stray corner or two does not count, because the detector can pad a partial board that way. A partial board is dropped, and the result's rejected list records it with its reason (PartialChessboard). There is no switch to turn this off, because a view that fails the check would give a wrong result.
A whole board is the board's own grid up to a turn in its plane: two possible labellings for a rectangular board (rows != cols), four for a square one. What the runner does with the turn depends on what the labels must agree with.
- The cameras of one view (every rig topology). Each camera's labels are turned to agree with the view's first camera, so a hand-held board may be turned any way in any view. The runner picks the labelling from the board's direction in each image: two cameras of a rigid rig see the board turned by the same relative angle in every view, up to the board's symmetry (half a turn for a rectangular board, a quarter for a square one), and how the board moves through both images across the views tells that angle whole, so a camera mounted turned or upside down relative to the others is matched too. When the views are too few (fewer than three shared views) or the board moves by less than a square, the cameras are taken to be mounted the same way up. A detection whose direction does not single out one labelling, within 45 degrees for a rectangular board and 22.5 for a square one, is dropped as
AmbiguousChessboardTurn. - One target frame through every view (hand-eye and the laser rigs). The frame is the one camera 0 labels. The board must be seen as declared,
colsacross androwsdown, by camera 0, or, in a view camera 0 misses, within 45 degrees of how camera 0 would see it; a view where it is turned by 90 degrees is dropped asTurnedChessboard. A board seen upside down by camera 0, or a square board seen turned by 90 degrees, cannot be told from upright: capture the board the same way up in every view, and prefer a non-square board.
A camera left with no usable chessboard view is an error (NoWholeChessboardViews) before any solve. To use partial views in a shared frame, use a ChArUco or PuzzleBoard target. Their corners are identified by codes printed on the board, so a partial view is labelled correctly.
ChArUco
A ChArUco board interleaves a chessboard with ArUco markers, and each corner is identified by the markers around it. A partly visible board therefore still yields correctly labelled corners, and the result is sparse: occluded or unidentified cells simply contribute no feature, so the per-view feature count varies.
rows and cols count squares (not corners). marker_size_m must be positive and at most square_size_m. dictionary is an ArUco dictionary name such as "DICT_4X4_1000"; the layout is checked before any image is read, because a larger board needs more marker codes than a small dictionary holds (a 12x12 board needs 72, which DICT_4X4_50 cannot supply). validate_dictionary and validate_charuco_layout expose these checks. The marker layout follows the OpenCV ChArUco convention, so coordinates are indexed the way an OpenCV-printed board expects.
PuzzleBoard
A PuzzleBoard is a self-identifying chessboard: each saddle decodes to a unique grid index from a local code window, so a single partial view yields globally consistent correspondences. The manifest names the layout as "puzzle_<rows>x<cols>" (the prefix is optional, "130x130" also parses); the dimensions are all the detector needs. Coordinates are in the board's own frame with its origin at the top-left, like the chessboard and ChArUco detectors, and decoded saddles outside the printed board are discarded.
Two fixed parts of the recipe are not configurable: the image is upscaled 2x before decoding (saddle decoding degrades on small or blurred tiles; pixel coordinates are divided back down), and the search is restricted to the declared board.
Ring Grid
A coded ring grid is a hexagonal lattice of ring markers, each carrying a 16-sector code. A decoded marker contributes one correspondence at its centre when its code maps to a known board position. rows is the number of marker rows and long_row_cols the number of columns of the longest (even-indexed) row; the shorter rows follow from the lattice. The three radii describe one marker (outer radius, inner radius, ring-band width) and are validated against pitch_m when the board layout is built. Detection runs in adaptive-scale mode, since the marker pixel size is unknown for an arbitrary image.
The detector fits a flat shape: a hexagonal lattice of coded rings. Rectangular lattices and plain uncoded annuli are not exposed.
Corner Detector Overrides
The chessboard and ChArUco detectors both start from a ChESS-family corner detector. Four optional overrides live in the manifest's [detector.chess_corners] table (and in each detector's chess_corners field). They are hashed into the detection-cache key, so changing any of them re-runs detection.
| Field | Meaning |
|---|---|
strategy | "chess" (the default) or "radon". Radon is several times slower and less biased at a corner's sub-pixel position on sharp, finely sampled boards such as rendered ones. |
threshold_value | Floor on the corner response, gating which response peaks become corners at all. Absolute under ChESS; under Radon a fraction in (0, 1] of the frame's maximum response. |
min_corner_strength | Floor on corner strength for entering the grid builder, gating which corners are trusted as lattice nodes. Defaults to 33.0, which drops weak, defocused corners. |
covariance | Report each corner's position covariance; see below. Off by default. |
Set min_corner_strength = 0.0 on small or soft image tiles. There the floor can remove a third of the board, and the resulting sparse, unevenly covered view conditions worse than the weak corners did; the loss shows up as a diverged camera, not as a slightly worse residual.
Corner covariance from the detector
The chessboard and ChArUco detectors can report, for every corner, the covariance of its position: the image noise propagated through the corner localizer, plus the localizer's systematic-error model. Ask for it in the manifest:
[detector.chess_corners.covariance]
image_noise = { known = 2.0 }
image_noise is "estimate" (the default when the section is present) or { known = σ }, the standard deviation of the image's pixel noise in gray levels of the 8-bit image (finite and at least 0), for example the standard deviation of the difference of two frames of a static scene divided by √2.
With the section present, every observation carries the covariances in cov_px (px², [xx, xy, yy] in Feature::cov_px), the residuals are whitened by them, and the report gives the unit-weight scale factor σ̂₀ instead of an RMS in pixels; see Per-Point Covariance. Positions are identical with and without it.
- JPEG and demosaiced images. The per-image noise estimate is unreliable there, and the detector may then report no covariance. The run stops with an error naming the image and suggesting
image_noise = { known = σ }. No corner is dropped to get past this. - Supported targets.
chessboardandcharuco. The PuzzleBoard detector runs on an upscaled image, for which no covariance is reported, and ring grids have none; a manifest that setscovariancefor another target is an error, raised before any detection. - Not recommended for calibration. On real chessboard and ChArUco images, weighting a calibration by the detector's covariance did not improve its accuracy on held-out views, and on some cameras it made it slightly worse; its stated uncertainty was no closer to the real scatter either. It stays off unless you ask for it. The setting is part of the detection-cache key.
In Python, Features.cov_px is the (N, 3) array of covariances (None when not requested), and Features.to_observation() carries it into the observation.
detector.min_features_per_view (a manifest field, not a detector option) sets the minimum number of features for a view to be kept. It defaults to 4, the homography minimum. Raise it for detectors whose sparse detections are unreliable, such as ring-grid decodes with fewer than 8 markers. A plain chessboard in a shared-frame topology needs no floor: the whole-board rule above already applies.
Calling a Detector Directly
A DetectorConfig names the target and carries its typed configuration, one variant per detector; the set is closed and owned by the crate. It detects in an 8-bit grayscale GrayImageView (row-major pixels; decode and convert the image with any library):
#![allow(unused)] fn main() { use vision_calibration::detect::{ChessboardConfig, DetectorConfig, GrayImageView}; let config = DetectorConfig::Chessboard(ChessboardConfig { rows: 17, cols: 28, square_size_m: 0.02, chess_corners: None, }); let gray = image::open("data/kuka_1/01.png")?.to_luma8(); let features = config.detect(GrayImageView::new(gray.width(), gray.height(), gray.as_raw())?)?; println!("{} corners", features.len()); }
ChessboardConfig, CharucoConfig, PuzzleboardConfig and RinggridConfig reject unknown fields when deserialized, so a typo in a hand-written config fails loudly. Each detector sits behind its own cargo feature of vision-calibration-detect (chessboard, charuco, puzzleboard, ringgrid; all on by default), so a build that needs one target does not compile the others. The facade crate's vision_calibration::detect module re-exports the configs, the cache, Feature and the error types.
To get features into a calibration without a manifest, build the problem input from Feature values yourself: world_xyz and image_xy are exactly the points_3d and points_2d of a CorrespondenceView.
The facade's dataset_runner::detect_target runs a detector from a manifest's target (and optional detector) section instead of a detector config, lowered exactly as a manifest run lowers it:
#![allow(unused)] fn main() { use vision_calibration::dataset::TargetSpec; use vision_calibration::dataset_runner::detect_target; use vision_calibration::detect::GrayImageView; let target = TargetSpec::Chessboard { rows: 17, cols: 28, square_size_m: 0.02 }; let gray = image::open("data/kuka_1/01.png")?.to_luma8(); let features = detect_target(GrayImageView::new(gray.width(), gray.height(), gray.as_raw())?, &target, None)?; }
In Python
detect_target takes a (H, W) uint8 grayscale numpy image and the target as its manifest payload, and returns a Features table: image_xy (N, 2) and world_xyz (N, 3) arrays. An impossible target (an unknown ChArUco dictionary) raises RunError with kind == "invalid_target_config"; an image without a usable board gives an empty table.
import numpy as np
from PIL import Image
import vision_calibration as vc
target = {"kind": "chessboard", "rows": 17, "cols": 28, "square_size_m": 0.02}
views = []
for path in ["data/kuka_1/01.png", "data/kuka_1/02.png", "data/kuka_1/03.png"]:
gray = np.asarray(Image.open(path).convert("L"))
features = vc.detect_target(gray, target)
print(path, len(features), "corners")
views.append(vc.PlanarView(observation=features.to_observation()))
result = vc.run_planar_intrinsics(vc.PlanarIntrinsicsInput(views=views))
vc.reject_ambiguous_detection(features) returns the detection, or an empty one when two features share an image point.
The Detection Cache
Detection dominates the cost of iterating on a calibration: the solvers take milliseconds to seconds, the detectors seconds per image. The DetectionCache trait stores detections so a rerun with the same images and the same detector parameters reads files instead of detecting again.
A cache entry is keyed by CacheKey:
- the image content: a 64-bit FNV-1a digest of the raw file bytes. File modification time is deliberately not used, so moving or copying an image does not invalidate its entry;
- the detector name:
"chessboard","charuco", ... (laser extraction uses alaser:<name>namespace); - the configuration: a hash of the detector configuration serialized to JSON with sorted keys, together with the crop the image was detected in, the crate's release and the detector's output revision (
CacheKey::for_detection). Semantically equal configurations collide on purpose; changing any detector parameter, upgrading the detector stack or bumping a detector's output revision gives a new key and so recomputes, while the entries of the other detectors stay valid.
#![allow(unused)] fn main() { use vision_calibration::detect::{CacheKey, CachedFeatures, DetectionCache, FsDetectionCache, GrayImageView}; let cache = FsDetectionCache::new("cache/kuka_1"); // created on first write let bytes = std::fs::read("data/kuka_1/01.png")?; let key = CacheKey::for_detection(&bytes, &config, None); // no crop let features = match cache.get(&key)? { Some(hit) => hit.features, None => { let gray = image::load_from_memory(&bytes)?.to_luma8(); let features = config.detect(GrayImageView::new(gray.width(), gray.height(), gray.as_raw())?)?; cache.put(&key, &CachedFeatures { features: features.clone() })?; features } }; }
FsDetectionCache writes one JSON file per entry under its root, named <image>-<detector>-<config>.json with a portable file-name encoding that is valid on Linux, macOS and Windows. Entries are written atomically, so the cache is safe under concurrent readers and writers. The dataset runner takes a Detection mode: Detection::Cached(&cache) reuses and stores entries, Detection::Refresh(&cache) detects every image again and overwrites its entry, and Detection::Uncached uses no cache. The cache is any &dyn DetectionCache, so an application can supply its own store.
In Python, vc.FsDetectionCache(root) is the filesystem cache, passed as cache= to calibrate_dataset and the build_*_input functions (without one, every image is detected). Its get(key) and put(key, features) take a CacheKey; CacheKey.from_inputs(image_bytes, detector, config) keys entries you manage yourself.
Choosing a Target
The detectors differ in what a partial view gives you:
- A chessboard is labelled from its own top-left corner in each image, so only complete views of the board are safe in a rig or hand-eye run (see above).
- ChArUco, PuzzleBoard and ring-grid targets identify every corner or marker, so any visible part of the board is usable and the target frame is the same in every view. This matters when a robot occludes the board or the board is larger than the field of view of some cameras.
Whatever the target, the accuracy of the calibration is bounded by the accuracy of the detected points. The reprojection floor on real data is usually set by the detector and the print quality, not by the solver. The per-feature residuals show whether the error is spread evenly or concentrated on a few views or corners.
The Dataset Manifest
A calibration dataset is a folder of images, usually a robot-pose file, and some knowledge only you have: what the target is, how big it is, which frame convention the robot controller uses. The dataset manifest (DatasetSpec, in the vision-calibration-dataset crate) is the one on-disk format that describes all of it. The manifest is descriptive, never prescriptive: your data stays where it is, and the manifest points at it.
The same manifest drives three front ends: the Rust dataset runner, the desktop app's Run workspace (which edits it in a schema-driven form) and the folder sniffer that writes a first draft.
An Example
data/kuka_1/dataset.toml in the repository describes a single-camera hand-eye dataset: 30 chessboard images and a file with one robot pose per line.
version = 1
topology = "single_cam_handeye"
description = "kuka_1"
[[cameras]]
id = "cam0"
images = { kind = "glob", pattern = "*.png" }
[target]
kind = "chessboard"
rows = 17
cols = 28
square_size_m = 0.02
[robot_poses]
path = "RobotPosesVec.txt"
format = "rowmajor4x4"
[pose_pairing]
kind = "by_index"
[pose_convention]
transform = "t_base_tcp"
rotation_format = "matrix4x4_row_major"
translation_units = "m"
Paths are relative to the directory that holds the manifest unless they are absolute. TOML is the usual authoring format; the same structure is valid JSON, and a field reference generated from the JSON schema is in Manifest Field Reference.
Topologies
topology selects the problem type the runner dispatches to. It describes the data (how many cameras, whether there are robot poses and laser frames), not the camera model: a tilted-sensor camera is a planar_intrinsics dataset whose calibration config names the sensor.
topology | Problem type | Cameras | Robot poses | Laser images |
|---|---|---|---|---|
planar_intrinsics | PlanarIntrinsicsProblem | exactly 1 | no | no |
single_cam_handeye | SingleCamHandeyeProblem | exactly 1 | required | no |
laserline_device | LaserlineDeviceProblem | exactly 1 | no | required |
rig_extrinsics | RigExtrinsicsProblem | 2 or more | no | no |
rig_handeye | RigHandeyeProblem | 2 or more | required | no |
rig_laserline_device | RigLaserlineDeviceProblem | 2 or more | required | required |
rig_handeye_laserline | RigHandeyeLaserlineProblem | 1 or more | required | required |
A field that the topology does not use is an error, not something silently ignored: laser, upstream_calibration and per-camera laser_images are rejected on the non-laser topologies, and upstream_calibration is also rejected on laserline_device and rig_handeye_laserline, which calibrate their own upstream geometry. rig_laserline_device is a second stage that needs upstream_calibration, the path to a frozen rig hand-eye export; see Laser Datasets.
Cameras and Images
Each [[cameras]] entry has an id (unique; used in pairings and exports) and an images source:
{ kind = "glob", pattern = "cam0/*.png" }: a shell glob, expanded relative to the manifest directory and sorted in natural order (2.pngbefore10.png);{ kind = "list", paths = ["a.png", "b.png"] }: an explicit ordered list.
roi_xywh = [x, y, w, h] crops the image to a rectangle in source pixels before detection; the width and height must be non-zero. It is how one image holding several camera tiles becomes several cameras. Laser topologies add a laser_images source per camera with the same two forms.
Target
[target] is tagged by kind: chessboard, charuco, puzzleboard or ringgrid, with the fields documented in Calibration Targets and Detection. [detector] holds optional detection overrides shared by the target families (min_features_per_view, and the [detector.chess_corners] table).
Robot Poses
[robot_poses] points at the pose file; [pose_convention] says how to read it. There are no defaults for the convention: it is required whenever robot_poses is set, because a wrong guess (gripper in base or base in gripper, millimetres or metres) produces a plausible but wrong calibration.
format | File | Notes |
|---|---|---|
rowmajor4x4 | headerless text, 16 floats per line | One row-major 4x4 homogeneous matrix per pose (KUKA-style exports). Needs rotation_format = "matrix4x4_row_major" and no columns. |
counted4x4 | headerless text, a count then that many 4x4 matrices | The layout of the robot-world hand-eye datasets of Tabb and Ahmad Yousef. Same requirements as rowmajor4x4. |
csv, json, jsonl | tabular | Need a columns mapping, or matrix_field for rows that store the pose as one nested 4x4. |
The [pose_convention] table has three required fields:
transform: which transform a row encodes.t_base_tcpis the gripper pose in the robot base frame (KUKA and UR controllers),t_tcp_baseits inverse (some ABB exports),t_world_tcpandt_tcp_worldthe same two for a fixed world frame.rotation_format:quat_xyzw,quat_wxyz,euler_xyz_deg,euler_xyz_rad,euler_zyx_deg,euler_zyx_rad,axis_angle_radormatrix4x4_row_major.translation_units:mormm.
For tabular formats, [robot_poses.columns] maps your column names to tx, ty, tz, a rotation list whose length matches the rotation format, and an optional pose_id.
[pose_pairing] aligns images with each other and with pose rows:
{ kind = "by_index" }: the i-th image of every camera pairs with pose row i; counts must match.{ kind = "shared_filename_token", regex = "...", group = "..." }: a regex with a named capture group extracts a view token from every filename (and thepose_idcolumn of the pose file); the cameras and the poses must produce the same set of tokens.
The Folder Sniffer
Writing a manifest by hand for a folder of 400 images is tedious, so sniff_folder writes a first draft:
#![allow(unused)] fn main() { use std::path::Path; use vision_calibration::dataset::sniff_folder; let spec = sniff_folder(Path::new("data/my_dataset"))?; println!("{:?}", spec.unresolved); }
The same function is vcal init on the command line (The Command Line) and the Sniff folder button of the app's Run workspace:
vcal init data/my_dataset # writes data/my_dataset/dataset.toml
It looks only at file names and folder structure, never at image content, and infers only what is structurally unambiguous:
- cameras: images are grouped by parent directory; a single group is
cam0, several groups take their directory names; each gets a*.extglob for its dominant extension (png,jpg,jpeg,tif,tiff,bmp; dot-files are skipped); - a robot-pose file: a non-image file whose name contains
pose, classified by extension and content (csv,json,jsonl, a 16-float line forrowmajor4x4, a count-then-matrices file forcounted4x4); by_indexpairing, when every camera and the pose file have the same count;- the topology from the camera count and the presence of poses (one camera, no poses:
planar_intrinsics; one camera with poses:single_cam_handeye; several cameras with poses:rig_handeye; several without:rig_extrinsics).
The Unresolved-Fields Rule
Everything that needs domain knowledge is never guessed. The sniffer leaves a placeholder in the manifest (an empty chessboard, an incomplete convention) and records the dotted path of the field in the manifest's _unresolved list:
_unresolved = ["pose_convention.transform", "pose_convention.translation_units", "target"]
The placeholders exist only so that the manifest deserializes and a form has something to edit; they are never trusted numerically. The runner refuses a manifest whose _unresolved list is not empty. You resolve each field (fill in the target, confirm the convention) and delete the list.
The sniffer flags, as applicable:
| Path | Why it cannot be inferred |
|---|---|
target | board geometry is never inferred from images |
topology | several cameras without poses can be a rig or independent single-camera calibrations |
pose_pairing | camera and pose counts differ |
pose_convention.transform, pose_convention.translation_units | a matrix file does not say which frame it expresses or its units (the sniffer pre-fills t_base_tcp and m, the common defaults, but still asks you to confirm them) |
robot_poses.columns, pose_convention | tabular pose files need a column mapping and a full convention |
The same philosophy applies at run time. When the runner meets an ambiguity it cannot resolve from the manifest (for example a hand-eye manifest with no pose_pairing, or token pairing without a pose_id column) it returns RunError::AskUser { field, prompt, suggestions } instead of choosing. The app turns that into a dialog that asks you to pick one of the suggestions; library code decides what to do with it.
Validation
vision_calibration_dataset::validate(&spec) checks the structural rules without touching the filesystem (it does not expand globs), and every runner converter calls it first. It returns a typed ValidationError: unresolved fields; a topology that needs robot poses and has none, or a camera count outside the topology's range; a missing pose_convention; duplicate camera ids; empty image sources; a rotation-column count that does not match the rotation format; tabular pose formats without columns; matrix formats with a columns mapping; degenerate ROIs; laser fields on a non-laser topology and missing laser sources on a laser one; out-of-range laser or detector parameters.
Running a Manifest
The converters in vision_calibration::dataset_runner turn a DatasetSpec into the input of a problem type, running detection through a detection cache:
| Topology | Converter |
|---|---|
planar_intrinsics | build_planar_input |
single_cam_handeye | build_single_cam_handeye_input |
rig_extrinsics | build_rig_extrinsics_input |
rig_handeye | build_rig_handeye_input |
laserline_device | build_laserline_device_input |
rig_laserline_device | build_rig_laserline_device_input |
rig_handeye_laserline | build_rig_handeye_laserline_input |
#![allow(unused)] fn main() { use std::path::Path; use vision_calibration::dataset::DatasetSpec; use vision_calibration::control::RunControl; use vision_calibration::dataset_runner::{Detection, build_single_cam_handeye_input}; use vision_calibration::detect::FsDetectionCache; use vision_calibration::prelude::*; use vision_calibration::single_cam_handeye::{SingleCamHandeyeProblem, run_calibration}; let base_dir = Path::new("data/kuka_1"); let spec = DatasetSpec::from_path(base_dir.join("dataset.toml"))?; // TOML or JSON let cache = FsDetectionCache::new("cache/kuka_1"); let run = build_single_cam_handeye_input(&spec, base_dir, Detection::Cached(&cache), &RunControl::default())?; println!("{}/{} views usable", run.usable_views, run.total_views); let mut session = CalibrationSession::<SingleCamHandeyeProblem>::new(); session.set_input(run.input)?; run_calibration(&mut session)?; }
Each converter checks the topology and target, validates the manifest and resolves the pairing before it reads a single image, so a wrong manifest fails in milliseconds with a message that names the field. A view whose detection yields fewer features than detector.min_features_per_view (default 4) is dropped. With a plain chessboard, the topologies whose views share one target frame (single_cam_handeye and every rig topology) also drop a view unless its corners span the whole board: a partial chessboard is labelled from its own top-left corner and would move that frame. A rig turns each camera's labels to agree with the other cameras of the view, so a board seen turned is kept; where the target is fixed through the views (hand-eye), a board camera 0 sees turned by 90° is dropped. Calibration Targets and Detection explains the rules, and recommends ChArUco or PuzzleBoard targets when you need partial views. planar_intrinsics and laserline_device estimate one target pose per view and keep partial boards.
Each result lists the detections it dropped in rejected, each with its camera, image and RejectReason (TooFewFeatures, PartialChessboard, TurnedChessboard or AmbiguousChessboardTurn). A camera left with no usable view is an error rather than a poor calibration: NoWholeChessboardViews when the whole-board rule dropped any of its views, InsufficientUsableViews otherwise. Each result also carries the source image path of every accepted view, which is how the app shows the right image under each residual.
In Python
calibrate_dataset runs a manifest end to end: it reads the file (TOML or JSON), picks the converter and the session by the manifest's topology, calibrates with the given config and returns the result. The manifest's relative paths resolve against its directory.
import vision_calibration as vc
cache = vc.FsDetectionCache("cache/kuka_1")
result = vc.calibrate_dataset("data/kuka_1/dataset.toml", vc.SingleCamHandeyeConfig(), cache=cache)
print(result.handeye.mode, f"{result.mean_reproj_error:.3f} px")
The converters are vision_calibration.dataset_runner.build_*_input, with the Rust names, for a run you drive step by step; each returns the run result (input as a payload, view_paths, usable_views, total_views). A manifest can also be a DatasetSpec payload, with base_dir= naming the directory its paths resolve against. vc.sniff_folder(path) returns the drafted manifest as a payload.
The runner's errors keep their types. RunError carries the Rust variant as kind ("validation", "empty_image_match", "pose_count_mismatch", ...); the ask-user ambiguity is its subclass AskUserError, with the field, prompt and suggestions:
from vision_calibration.dataset_runner import build_single_cam_handeye_input
try:
run = build_single_cam_handeye_input(spec, base_dir="data/kuka_1")
except vc.AskUserError as err:
print(f"set {err.field}: {err.prompt} (e.g. {err.suggestions})")
session = vc.SingleCamHandeyeSession()
session.set_input(run.input)
Device Specifications
A second, optional file describes the hardware rather than the data: spec.json next to the manifest (DEVICE_SPEC_FILENAME), parsed as DeviceSpec. It holds datasheet values: per camera the focal length in millimetres, the pixel pitch in micrometres, the image resolution, an optional principal point and an optional Scheimpflug mount tilt in degrees; for rigs the nominal camera mounts, the hand-eye mounting and laser-plane nominals.
The vision_calibration::device_seed functions derive manual-init seeds from it (scheimpflug_seed, rig_intrinsics_seed, nominal_cam_se3_rig, handeye_seed, rig_layout_seed). The intrinsics derivation is f_px = focal_mm * 1000 / pixel_pitch_um for both focal lengths with the principal point defaulting to the image centre; all unit conversion (millimetres, micrometres and degrees to pixels, metres and radians) happens there and nowhere else. The functions work per camera id, never by position, and a mismatch is a typed DeviceSeedError. The seeds feed the rig family; the single-camera problems take a seed directly (see Seeding with Prior Knowledge).
JSON Schema
The manifest types derive a JSON Schema, and cargo xtask emit-schemas writes it to app/src/schemas/dataset_spec.json. The app generates its manifest form from that file, so every field the Rust type accepts is editable without hand-writing TOML; external tools can use it for validation and completion in an editor. Manifest Field Reference renders the schema as tables.
Laser Datasets
A laser-triangulation device captures two frames per view: a target frame (board visible, projector off) and a laser frame (projector on). The manifest describes both. The runner detects the target features, extracts the laser-line pixels (cached like detections) and assembles the input of the laser problem types: LaserlineDevice, RigLaserlineDevice and RigHandeyeLaserline. For the rig laser topology that includes the frozen upstream rig hand-eye calibration.
This chapter extends The Dataset Manifest with the laser-specific fields. The calibration workflows themselves are in Laserline Device Calibration and Scheimpflug and Laser Rigs.
How the Manifest Describes Laser Data
laser_imagesper camera mirrorsimages: a glob or an explicit list. It pairs with the target views through the samepose_pairing: withby_indexthe i-th laser image pairs with the i-th target image, withshared_filename_tokenthe same regex runs on both filename sets. A target view without a laser frame is a gap; a laser frame without a target view is an error.[laser]tunes the extraction:scan_axis(colswalks image columns, one point per column, for lines running mostly left to right;rowswalks rows, for vertical lines),sigma(Gaussian derivative scale in pixels),pos_threshandneg_thresh(edge-response thresholds) andmin_points(the smallest usable extraction, at least 2). Omit the table for the defaults: extraction tuning has safe defaults and is not an ambiguity the runner refuses to guess.RigLaserlineDeviceis a second stage:upstream_calibrationpoints at a frozenRigHandeyeExportJSON. The per-view board poses are recomputed from the laser dataset's own robot poses through the frozen hand-eye chain, so the laser views need not match the upstream run's views. The export's covariance comes with it, in the terms of the input (the cameras and extrinsics as they are, the hand-eye and target poses as each laser view's board pose), so the planes' covariance carries the upstream calibration's share.RigHandeyeLaserlineis the joint solve: it computes its own hand-eye warm start and takes noupstream_calibration.
Walkthrough: A Scheimpflug Rig, Eye-to-Hand
version = 1
topology = "rig_laserline_device"
upstream_calibration = "rig_handeye_export.json"
[target]
kind = "charuco"
rows = 12
cols = 9
square_size_m = 0.01
marker_size_m = 0.0075
dictionary = "DICT_4X4_1000"
[laser]
min_points = 120 # views below the bar become gap slots
[robot_poses]
path = "poses.json"
format = "json"
matrix_field = "tcp2base" # a nested 4x4 per row (vendor JSON)
[pose_convention]
transform = "t_base_tcp"
rotation_format = "matrix4x4_row_major"
translation_units = "mm"
[pose_pairing]
kind = "by_index"
[[cameras]]
id = "cam0"
roi_xywh = [0, 0, 720, 540] # one tile of a multi-camera strip image
[cameras.images]
kind = "glob"
pattern = "target_*.png"
[cameras.laser_images]
kind = "glob"
pattern = "laser_*.png"
# ... one [[cameras]] entry per camera
Run the hand-eye stage first (topology = "rig_handeye", the same cameras and poses, no laser fields) and save its export as rig_handeye_export.json. In the desktop app each stage is one Run click; such a setup needs non-default configuration (Scheimpflug sensors, eye-to-hand, PointToPlane laser residuals), set in the config form or in a preset.
A single-camera LaserlineDevice is the same minus robot_poses and upstream_calibration: it calibrates the intrinsics and the laser plane jointly from the target and laser frames alone.
Library Consumers: Inject an Extractor
The published crates do not ship a laser-line extractor, and extraction is the one step of the runner that you provide. Implement the open LaserPixelExtractor trait and hand it to the converter:
#![allow(unused)] fn main() { use vision_calibration::dataset::LaserExtractionSpec; use vision_calibration::control::RunControl; use vision_calibration::dataset_runner::{Detection, LaserPixelExtractor, build_rig_laserline_device_input}; use vision_calibration::detect::GrayImageView; struct MyExtractor; impl LaserPixelExtractor for MyExtractor { fn name(&self) -> &str { "my-extractor-v1" } // cache-key namespace fn extract( &self, image: GrayImageView<'_>, // 8-bit grayscale, row-major spec: &LaserExtractionSpec, ) -> Result<Vec<[f64; 2]>, Box<dyn std::error::Error + Send + Sync>> { // subpixel laser-line points in the (ROI-cropped) image todo!() } } let run = build_rig_laserline_device_input( &spec, base_dir, Detection::Cached(&cache), &MyExtractor, &RunControl::default(), )?; }
The runner owns the caching (keyed laser:<name> plus a hash of the extraction spec), the ROI cropping and the coordinate lifting; the extractor only extracts. Unlike the target detectors, a closed set, this trait is open. name() is part of the cache key, so switching implementations re-extracts instead of serving another extractor's pixels.
In Python
Any object with a name and an extract(image, spec) method is an extractor (the LaserPixelExtractor protocol of vision_calibration.dataset_runner). extract receives the cropped laser image as a (H, W) uint8 grayscale array and the manifest's [laser] section as a dict, and returns the line's points as an (N, 2) array of (x, y) pixels:
import numpy as np
import vision_calibration as vc
class ColumnPeak:
name = "column-peak-v1"
def extract(self, image, spec):
"""Per column, the row centroid of the pixels above half the peak."""
values = image.astype(float)
points = []
for col in range(image.shape[1]):
column = values[:, col]
if column.max() < 50:
continue
weights = np.where(column > column.max() / 2, column, 0.0)
points.append((col, weights @ np.arange(len(column)) / weights.sum()))
return np.array(points).reshape(-1, 2)
result = vc.calibrate_dataset("data/my_laser_rig/dataset.toml", laser_extractor=ColumnPeak())
An exception the extractor raises fails the run with a RunError of kind == "laser_extraction".
Variations
- Sparse laser coverage (rig): cameras may miss the laser in some views (
Noneslots), but every camera needs at least one usable laser view; otherwise its plane is unconstrained and the runner fails fast. - Vertical laser lines:
scan_axis = "rows". - Re-extraction: edit
[laser]and the cache key changes, so nothing stale is served. The same contract holds for detector configurations.
Manifest Field Reference
Every field of the dataset manifest, generated from its JSON Schema. The prose chapter The Dataset Manifest explains how the pieces fit together; this page is the exhaustive list. The schema itself is app/src/schemas/dataset_spec.json in the repository, and the desktop app builds its manifest form from it.
Paths in a manifest are relative to the directory that holds it unless they are absolute. A field marked required has no default and must be present; an optional field may be omitted.
DatasetSpec
Top-level dataset manifest. Authoritative description of where the user's calibration data lives and how to interpret it.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
cameras | array of CameraSource | yes | Per-camera image sources. Length must match the topology's expected camera count (a single-camera topology requires exactly one entry, a rig topology at least two, rig_handeye_laserline at least one). | |
target | TargetSpec | yes | Calibration-target specification. Tagged enum on kind. | |
topology | Topology | yes | Calibration topology — selects which problem type the runner will dispatch to. | |
_unresolved | array of string | no | Field paths the manifest generator could not determine. Validated to be empty before the runner accepts the manifest. Populated as e.g. ["pose_convention.transform"]. | |
description | string | no | Free-form human-readable description. | |
detector | DetectorSpec | no | Detector-stage overrides shared by target families. Defaults preserve the detector's built-in behavior. | |
laser | LaserExtractionSpec | no | Laser-line extraction parameters for the laser topologies. None means defaults — extraction tuning has safe defaults and is not a fail-fast ambiguity. Rejected on non-laser topologies. | |
pose_convention | PoseConvention | no | Frame conventions for robot poses. No defaults — every component is required when robot_poses is set. | |
pose_pairing | PosePairing | no | How per-camera images are paired across views (and optionally with robot poses). None is permitted only where pairing is irrelevant: a single camera without robot poses. | |
robot_poses | RobotPoseSource | no | Robot-pose source for hand-eye topologies. None for pure-intrinsics calibrations. | |
upstream_calibration | string | no | Path to a frozen upstream RigHandeyeExport JSON, relative to the manifest directory unless absolute. Required for Topology::RigLaserlineDevice, rejected elsewhere. Joint hand-eye laserline calibration computes its own hand-eye warm start and does not use this field. | |
version | integer | no | 1 | Format-version sentinel. Always 1 for now; bumped on breaking schema revisions. |
Unknown fields are rejected.
CameraSource
Per-camera image source.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable camera identifier (e.g. "cam0"). Used as a key in pose pairings and downstream exports. |
images | ImagePattern | yes | Glob pattern (e.g. "cam0/*.png") or explicit list of image paths, relative to the manifest's directory unless absolute. |
laser_images | ImagePattern | no | Laser-frame image source for the laser topologies. One laser image per view, captured by the same sensor with the line projector on; roi_xywh applies to laser frames too. Laser images pair with target views through pose_pairing. |
roi_xywh | array of 4 integer | no | Optional rectangular ROI in source-image pixels — [x, y, w, h]. When set, only the cropped region is fed to the detector and stored in the export's image manifest. |
Unknown fields are rejected.
ImagePattern
How image paths for a camera are listed.
Tagged on kind; the fields depend on the variant.
kind = "glob": Single shell-glob pattern, evaluated relative to the manifest directory. Example: "cam0/*.png".
| Field | Type | Required | Description |
|---|---|---|---|
pattern | string | yes | The glob pattern. |
kind = "list": Explicit ordered list of paths.
| Field | Type | Required | Description |
|---|---|---|---|
paths | array of string | yes | Paths relative to the manifest directory unless absolute. |
TargetSpec
Calibration-target metadata. Tagged on kind.
Tagged on kind; the fields depend on the variant.
kind = "chessboard": Standard checkerboard. Counts are interior corners (i.e. 9x6 ⇒ a 10×7 black-and-white square grid).
A plain chessboard has no markers: each view is labelled from the detection's own top-left corner. Where views share one target frame (single-camera hand-eye and every rig topology) the runner therefore keeps a view only when its corners reach every edge of the board. In a rig, each camera's labels are turned to agree with the other cameras of the view, so a board seen turned is kept. Where the target is fixed through the views (hand-eye), the board must appear the same way up in every image of camera 0 (cols across and rows down); a board seen turned by 90° is dropped, and one seen upside down, or a square board seen turned by 90°, cannot be told apart. Use a ChArUco or PuzzleBoard target to calibrate a shared frame from partial views.
| Field | Type | Required | Description |
|---|---|---|---|
cols | integer | yes | Number of interior corners across the board (the image's columns axis when the board is seen as declared). |
rows | integer | yes | Number of interior corners down the board (the image's rows axis when the board is seen as declared). |
square_size_m | number | yes | Edge length of one square in metres. |
kind = "charuco": ChArUco board. Marker IDs and dictionary live here so detection is fully deterministic from the manifest.
| Field | Type | Required | Description |
|---|---|---|---|
cols | integer | yes | Number of squares along the cols axis. |
dictionary | string | yes | ArUco dictionary identifier (e.g. "DICT_4X4_50"). The detector validates this against the supported set. |
marker_size_m | number | yes | Edge length of an embedded marker in metres. |
rows | integer | yes | Number of squares along the rows axis (full grid, not interior corners). |
square_size_m | number | yes | Edge length of one square in metres. |
kind = "puzzleboard": Puzzleboard target: a self-identifying chessboard. The layout name carries the board's dimensions, so the manifest fully determines detection.
| Field | Type | Required | Description |
|---|---|---|---|
cell_size_m | number | yes | Edge length of one cell in metres. |
layout | string | yes | Board dimensions as puzzle_<rows>x<cols> (e.g. "puzzle_12x12"; the puzzle_ prefix is optional). |
kind = "ringgrid": Coded ring-grid target (the ringgrid crate). The board is a hex-lattice of self-identifying ring markers; its geometry mirrors ringgrid::BoardLayout so the manifest fully determines detection.
| Field | Type | Required | Description |
|---|---|---|---|
long_row_cols | integer | yes | Number of columns in the longest (even-indexed) row. Shorter rows are derived by the hex-lattice layout. |
marker_inner_radius_m | number | yes | Inner ring radius in metres. |
marker_outer_radius_m | number | yes | Outer ring radius in metres. |
marker_ring_width_m | number | yes | Width of each ring band in metres. |
pitch_m | number | yes | Center-to-center spacing between adjacent markers in metres. |
rows | integer | yes | Number of marker rows. |
Topology
Calibration topology — selects the problem-type dispatch.
A topology describes the data (how many cameras, robot poses, laser images), not the camera model: a tilted (Scheimpflug) single camera is a planar_intrinsics dataset whose calibration config names the sensor.
| Value | Description |
|---|---|
planar_intrinsics | Single camera, planar target — PlanarIntrinsicsProblem (any camera model, including a tilted sensor). |
single_cam_handeye | Single camera mounted on a robot — SingleCamHandeyeProblem. |
laserline_device | Camera + laser plane — LaserlineDeviceProblem. |
rig_extrinsics | Multi-camera rig, target only — RigExtrinsicsProblem. |
rig_handeye | Multi-camera rig + robot — RigHandeyeProblem. |
rig_handeye_laserline | Multi-camera rig + robot + laser planes — joint hand-eye/laser BA. |
rig_laserline_device | Multi-camera rig + laser planes — RigLaserlineDeviceProblem. |
DetectorSpec
Target-detector overrides. These options affect feature detection only; they are hashed into the detection-cache key by the runner.
| Field | Type | Required | Description |
|---|---|---|---|
chess_corners | ChessCornersDetectorSpec | no | ChESS corner extractor options shared by chessboard-like detectors (plain chessboard and ChArUco). |
min_features_per_view | integer | no | Minimum detected features for a view (or a camera's slot in a rig view) to be kept, in every topology. None keeps the runner default (4, the homography minimum). Raise it for detectors whose sparse detections are unreliable — e.g. ring-grid decodes with < 8 markers are noisy enough to poison a solve. A plain chessboard needs no floor to stay consistent: where views share one target frame the runner already keeps only views that span the whole board. |
Unknown fields are rejected.
ChessCornersDetectorSpec
ChESS corner extractor overrides.
| Field | Type | Required | Description |
|---|---|---|---|
covariance | CornerCovarianceSpec | no | Attach the detector's per-corner position covariance to every observation. Absent (the default) = off. The covariance is the image noise propagated through the corner localizer, plus the localizer's systematic-error model. When present, a calibration weights each corner by it (whitened residuals) and its report gives the unit-weight scale factor σ̂₀ instead of an RMS in pixels. Off by default, and not recommended for calibration. On real chessboard and ChArUco images, weighting by it did not improve the accuracy on held-out views and made it slightly worse on some cameras; its stated uncertainty was no closer to the real scatter. Supported by the chessboard and charuco targets only: the PuzzleBoard detector runs on an upscaled image, for which no covariance is reported, and ring grids have none. A manifest that sets it for another target is an error. |
min_corner_strength | number | no | Minimum corner strength for a detected corner to enter the grid builder. None keeps the detector default (33.0). The floor is on the strategy's response scale; under Radon the default never binds.The default drops weakly-firing corners, which is the right trade whenever corners are plentiful. Set 0.0 to disable it on small or soft image tiles, where the floor can remove a third of the board and the resulting sparse view conditions worse than the weak corners did. |
strategy | CornerStrategySpec | no | Corner detector. None keeps ChESS. |
threshold_value | number | no | Acceptance threshold on the corner response. None keeps the strategy's default.Under ChESS it is an absolute floor on the raw response: a corner is kept when its response exceeds it. Under Radon it is a fraction in (0, 1] of the frame's maximum response.A sibling threshold_mode key is rejected by deny_unknown_fields: the corner detector has no threshold mode. |
Unknown fields are rejected.
CornerCovarianceSpec
Per-corner covariance options; see [ChessCornersDetectorSpec::covariance].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
image_noise | ImageNoiseSpec | no | "estimate" | The image noise that the propagated covariance is expressed in. Defaults to estimate: the noise is estimated from each image, which is unreliable on JPEG or demosaiced images; give known for those. |
Unknown fields are rejected.
ImageNoiseSpec
The pixel-noise level behind a corner covariance.
In JSON / TOML: "estimate" or { known = 2.0 }.
Tagged on kind; the fields depend on the variant.
kind = "": Estimate the noise from each image. Unreliable on JPEG or demosaiced images, where a corner then reports no covariance and the run fails.
kind = "": The standard deviation of the image's pixel noise, in gray levels of the 8-bit image (finite and >= 0), e.g. the standard deviation of the difference of two frames of a static scene divided by √2.
| Field | Type | Required | Description |
|---|---|---|---|
known | number | yes |
Unknown fields are rejected.
CornerStrategySpec
The corner detector a chessboard-like detector runs.
| Value | Description |
|---|---|
chess | The ChESS response. Fast; the default. |
radon | The whole-image Radon response. Several times slower than ChESS, and less biased at a corner's sub-pixel position on sharp, finely sampled boards (e.g. rendered ones). |
LaserExtractionSpec
Laser-line extraction parameters. Consumed by the runner's injected LaserPixelExtractor; hashed (canonical JSON) into the detection-cache key so edits re-extract.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
min_points | integer | no | 20 | Minimum extracted points for a usable laser view. Views below the bar are dropped (single camera) or become gap slots (rig). Must be at least 2 (a line needs two points); 0/1 would let an empty extraction pass as usable and leave the plane unconstrained. |
neg_thresh | number | no | 4.0 | Negative-edge response threshold. |
pos_thresh | number | no | 4.0 | Positive-edge response threshold. |
scan_axis | LaserScanAxis | no | "cols" | Scan direction. cols walks image columns (one point per column; for laser lines running mostly left-to-right), rows walks image rows. |
sigma | number | no | 1.2 | Gaussian derivative sigma in pixels for the edge detector. |
Unknown fields are rejected.
LaserScanAxis
Laser scan direction.
| Value | Description |
|---|---|
cols | One detection per image column. |
rows | One detection per image row. |
PoseConvention
Frame conventions for the robot pose stream.
All three components are required (no defaults). Each is a closed enum so the runtime supports a finite, validated set of conventions.
| Field | Type | Required | Description |
|---|---|---|---|
rotation_format | RotationFormat | yes | How rotation is parameterised in the file. |
transform | TransformConvention | yes | Which transform the pose row encodes. |
translation_units | TranslationUnits | yes | Translation units in the file. |
Unknown fields are rejected.
RotationFormat
Rotation parameterisation in the file.
| Value | Description |
|---|---|
quat_xyzw | Hamilton quaternion as [qx, qy, qz, qw]. |
quat_wxyz | Hamilton quaternion as [qw, qx, qy, qz]. |
euler_xyz_deg | Intrinsic XYZ Euler angles in degrees. |
euler_xyz_rad | Intrinsic XYZ Euler angles in radians. |
euler_zyx_deg | Intrinsic ZYX Euler angles in degrees. |
euler_zyx_rad | Intrinsic ZYX Euler angles in radians. |
axis_angle_rad | Axis-angle vector (length encodes magnitude, radians). |
matrix4x4_row_major | Row-major 4×4 homogeneous matrix flattened across 16 columns. |
TransformConvention
Which transform a pose row encodes.
| Value | Description |
|---|---|
t_base_tcp | T_base_tcp — gripper pose in the robot base frame (KUKA, UR default). |
t_tcp_base | T_tcp_base — base pose in the gripper frame (some ABB exports). |
t_world_tcp | T_world_tcp — gripper pose in a fixed world frame (less common; some lab-frame setups). |
t_tcp_world | T_tcp_world — world pose in the gripper frame. |
TranslationUnits
Translation units in the file.
| Value | Description |
|---|---|
m | Metres (SI, preferred). |
mm | Millimetres (some ABB / Fanuc exports). |
PosePairing
How per-camera image lists align across views and (optionally) align with robot poses.
Tagged on kind; the fields depend on the variant.
kind = "by_index": All cameras have the same number of images, paired by index (image[i] from cam0 ↔ image[i] from cam1 ↔ pose[i]).
kind = "shared_filename_token": A regex applied to filenames extracts a shared "view token" (e.g. timestamp or pose id). All cameras and the pose file must produce the same set of tokens.
| Field | Type | Required | Description |
|---|---|---|---|
group | string | yes | Name of the capture group whose value identifies the view. |
regex | string | yes | Regex with at least one named capture group. |
RobotPoseSource
Where and how robot poses are stored on disk.
| Field | Type | Required | Description |
|---|---|---|---|
format | RobotPoseFormat | yes | File-format selector. |
path | string | yes | Path to the pose file, relative to the manifest directory unless absolute. |
columns | PoseColumnMap | no | Column / field mapping from the user's file to the canonical fields the converter consumes. Required for the tabular formats (csv / json / jsonl) unless matrix_field is set; must be absent for rowmajor4x4 and counted4x4, whose matrix layout is fixed. |
matrix_field | string | no | For json / jsonl files whose rows store the whole pose as a single nested 4×4 (or flat-16) array field, names that field (e.g. "tcp2base"). Mutually exclusive with columns; requires pose_convention.rotation_format = matrix4x4_row_major. Translation comes from the matrix's fourth column, scaled by translation_units. No pose_id ⇒ by_index pairing only. |
Unknown fields are rejected.
RobotPoseFormat
Robot-pose file format.
| Value | Description |
|---|---|
csv | Comma-separated values with a header row. |
json | JSON array of objects. |
jsonl | JSONL (one JSON object per line). |
rowmajor4x4 | Headerless text: each non-empty line is 16 whitespace-separated floats forming a row-major 4×4 homogeneous transform (KUKA-style exports). Requires pose_convention.rotation_format = "matrix4x4_row_major" and no columns mapping. |
counted4x4 | Headerless text: the matrix count, then that many row-major 4×4 homogeneous transforms as whitespace-separated floats on any number of lines (the layout of the robot-world hand-eye datasets of Tabb and Ahmad Yousef). Same requirements as rowmajor4x4. |
PoseColumnMap
Mapping from user-defined column / field names to the canonical pose fields. All seven of tx, ty, tz, qx, qy, qz, qw are required when rotation_format is a quaternion; the runner rejects partial mappings as a fail-fast event.
| Field | Type | Required | Description |
|---|---|---|---|
rotation | array of string | yes | Rotation columns. Length and meaning depend on the pose_convention.rotation_format. |
tx | string | yes | Translation x column. |
ty | string | yes | Translation y column. |
tz | string | yes | Translation z column. |
pose_id | string | no | Optional view-id column (string or integer). When absent, the pose row index is used as the implicit pose id. |
Unknown fields are rejected.
Parameter Uncertainty
A calibration result says not only what the parameters are but how well the
data determines them. Every result carries an uncertainty: the covariance
of the estimated parameters at the solution, the noise level the residuals
imply, and, when no covariance can be given, the reason. The laserline
device result keeps it with its estimate, as estimate.uncertainty (in
Python also result.uncertainty).
Reprojection error alone does not answer this. A camera calibrated from a
handful of similar views can reproject its own corners to 0.1 px and still have
a focal length that is off by a percent, with the principal point
traded off against it. The covariance shows that directly: a large σ of fx,
and a strong fx–cx correlation. On the real images of data/kuka_1, the planar_real example reports fx = 2057.8 ± 0.8 px.
What a σ means
The covariance is first order at the solution: it describes how the estimate would scatter if the calibration were repeated with new noise of the same kind, for the same views. The noise level is taken from the residuals themselves, so nothing has to be known in advance; when the observations state a covariance per point, the residuals are weighed by them and the level becomes a factor on what they state (Per-Point Pixel Covariance). Two consequences follow:
σmeasures the noise-driven error, and includes errors that differ from view to view. The residual noise model takes every corner's error as independent, but on real images much of the error is shared by a view's corners: the lens model, the target or the detector does not fit exactly, differently in each view. The covariance measures this by leaving out one view at a time:view_scattersays how many times the shared parameters vary more across views than the noise model predicts, and the covariance is scaled by it. It is set only when the excess is larger than its own sampling noise (from six views), so data that fit the noise model keep the plain covariance. On the public real-image entries it is 2.8 to 4.9, and 25 for the joint three-camerads8rig. An error common to every view, such as a wrong board cell size, still leavesσsmall and the estimate biased. Watch the per-feature residuals for structure.σshrinks with more data roughly as1/√viewsand grows quickly when the views are similar. The most effective way to reduceσoffxandcxis board tilt: views at varied angles separate focal length from distance.
With a robust loss (Huber, Cauchy, Arctan), the covariance is that of
the robust estimate actually computed, including the bounded pull of the
residuals the loss down-weights. It does not ignore the outliers, and it does
not treat them as Gaussian noise either. Without a loss it is the classical
σ̂² (JᵀJ)⁻¹.
These σ were checked against Monte-Carlo scatter on synthetic scenes (single cameras, a stereo rig, an eye-in-hand camera, a laserline device with corner and laser residuals together), a few hundred noise realizations each. The predicted σ matched the empirical scatter within 10 % on clean data, and within 20 % with 5 % of the corners displaced by 10–30 px under Huber or Cauchy. The projection σ (Projection Uncertainty) was checked the same way.
In This Part
- Reading the Covariance: the labels, the views, the fixed parameters and the noise estimate, from Rust, Python and JSON.
- Per-Point Pixel Covariance: stating each point's noise, and what it changes in the solve and the noise estimate.
- Projection Uncertainty: what the parameter covariance implies for where a ray lands in the image.
- Limits and Unavailable Results: when no covariance can be given, what it is conditional on, and what it does not cover.
- Judging a Result: a pass, warn or fail verdict on a result, built from its covariance, coverage and residuals.
Reading the Covariance
The covariance is part of every result. This chapter shows how to read it and what each part of it contains.
Rust
#![allow(unused)] fn main() { use vision_calibration::core::{NoiseLevel, Uncertainty}; let export = session.export()?; match &export.uncertainty { Uncertainty::Available(cov) => { let fx = export.params.camera.intrinsics.fx; println!("fx = {fx:.1} ± {:.2} px", cov.std_dev("fx").unwrap()); println!("corr(fx, cx) = {:.2}", cov.correlation("fx", "cx").unwrap()); for noise in &cov.noise { match noise.level { NoiseLevel::Sigma(s) => println!("{:?} noise σ ≈ {s:.3}", noise.kind), NoiseLevel::Sigma0(s) => println!("{:?} noise σ̂₀ ≈ {s:.3}", noise.kind), } } } Uncertainty::Unavailable { reason } => println!("no covariance: {reason:?}"), } }
Python
result = vc.run_planar_intrinsics(dataset, vc.PlanarIntrinsicsConfig())
u = result.uncertainty
if u.available:
print(f"fx = {result.camera.intrinsics.fx:.1f} ± {u.std_dev('fx'):.2f} px")
print(f"corr(fx, cx) = {u.correlation('fx', 'cx'):.2f}")
else:
print("no covariance:", u.reason, u.reason_labels)
JSON
In JSON the field is {"status": "available", "labels": [...], "covariance": [[...]], ...} or {"status": "unavailable", "reason": {"kind": "...", ...}}.
What Is Reported
-
labelsandcovariance: the shared parameters and their covariance matrix, one row per label:- intrinsics
fx, fy, cx, cy; - distortion by coefficient name (
k1, k2, k3, p1, p2for Brown-Conrady,lambdafor the division model, and so on); - the Scheimpflug tilt
tilt_x, tilt_y, in radians; - the poses a result reports once, under their export names, with six
labels each (
<pose>.rx…<pose>.tz, seeviewsbelow):cam_se3_rigper camera, the hand-eye pose (gripper_se3_rigorrig_se3_base,gripper_se3_cameraorcamera_se3_basefor a single camera) and the target pose (base_se3_targetorgripper_se3_target); - a laser plane
n · p + d = 0in its camera's frame:plane.nx,plane.ny,plane.nzandplane.d(metres). The normal is a unit vector, so its three components have a covariance of rank 2.
In a result with several cameras, each camera's labels carry its index:
cam1.fx,cam1.k1,cam1.cam_se3_rig.tx.std_dev(label)is the square root of a diagonal entry, andcorrelation(a, b)the normalized off-diagonal one. - intrinsics
-
views: the parameters that belong to one view, each view's block separately: the board posecamera_se3_targetfor intrinsics results,rig_se3_targetfor rig results, and the robot-pose correctionrobot_deltafor hand-eye results that refine the robot poses. A pose's six labels arerx, ry, rz(radians) andtx, ty, tz(metres) of the perturbationT · Exp(δ)on the right. Both are expressed in the pose's source frame, which is the target frame forcamera_se3_target. The correlation of a view's parameters with the shared ones is not reported. -
fixed: the parameters the solve held fixed, such ask3with the default configuration, orcam0.cam_se3_rig, the reference camera that defines the rig frame. A fixed pose is named once, without its six coordinates. The covariance is conditional on them: it does not include their uncertainty, so fixing a parameter that the data cannot pin down makes the others look better determined than they are. Holding the intrinsics in a rig's final solve (rig.refine_intrinsics: false), for example, leaves their uncertainty out of the extrinsics' σ; by default the rig workflows refine them there. -
noise: for each kind of residual, the Gaussian-equivalent standard deviation of one scalar residual. For reprojection that is the corner noise per pixel axis; for the laser, the line distance in pixels or the point-to-plane distance in metres. It is estimated from the median absolute residual, so a few outliers do not inflate it. In JSON the level issigma, in the residual's unit, or, when the observations stated a covariance per point,sigma0: the dimensionlessσ̂₀of the whitened residuals, about 1 when the stated covariances hold (Per-Point Pixel Covariance). Rust types the two asNoiseLevel::SigmaandNoiseLevel::Sigma0; Python'sResidualNoise.whitenedsays whichsigmaholds. The rig laserline solves each camera's plane on its own, with the rig calibration held fixed, so it reports each camera's noise levels apart (cameranames the camera). When the upstream calibration carries its covariance, the planes' covariance carries its share and the planes of different cameras are correlated through it; without one, it is conditional on the upstream and the planes are uncorrelated. -
num_residuals,dof: the scalar residuals and the redundancy they leave after the estimated parameters. -
view_scatter: how many times the shared parameters vary more across views than the residual noise predicts, when significantly more; the covariance is already scaled by it (Parameter Uncertainty). -
second_order: how far the error of the shared parameters is predicted to depart from the covariance once its second-order term is counted. The covariance is the error's first-order term, linear in the noise. Weak views pin some combinations of parameters down only through larger errors of others that cancel in them (centred, mildly tilted boards leave a camera's rotation in a rig and its principal point to trade off this way); there the second-order term, quadratic in the noise, can exceed the first, while every parameter's own σ still holds.max_scatter_ratiois the predicted scatter of the worst combination over the σ the covariance states for it,nees_ratiothe predicted mean ofeᵀ Σ⁻¹ eper parameter (both 1 when the first order describes the error), andcombinationnames the combination asΣ wᵢ pᵢ / σᵢ, its largest weights first. The prediction comes from the residuals' curvature along the covariance's own errors, at the result's noise level, so it grows with the noise variance. Judging a Result warns when the ratio is above 1.2. It costss (s + 1)residual evaluations forsshared parameters, so it is left out (null) above 64 of them, and for noise-free data.
Per-Point Pixel Covariance
By default every observed image point is trusted alike, and the noise level is estimated from the residuals in pixels. When you know more about each point's noise, say a detector that reports how well it located each corner, state it as a covariance per point. The solve then weighs each point by what it states: a point with a large covariance counts less, and an elongated one is trusted more across its long axis than along it.
Stating covariances
A covariance is the symmetric 2 × 2 matrix [[xx, xy], [xy, yy]] in px²,
one per point of a view. It must be finite and positive definite (xx > 0,
yy > 0, xy² < xx·yy); anything else is rejected when the view is built
or read.
#![allow(unused)] fn main() { use vision_calibration::core::{CorrespondenceView, PointCovariance}; // σ = 0.3 px along u, 0.1 px along v. let cov = PointCovariance::new(0.09, 0.0, 0.01)?; let view = CorrespondenceView::new_with_covariances(points_3d, points_2d, vec![cov; n])?; }
cov = vc.PointCovariance(0.09, 0.0, 0.01) # or vc.PointCovariance.isotropic(0.2)
obs = vc.Observation(points_3d=p3, points_2d=p2, cov_px=[cov] * len(p2))
In JSON a view carries them as cov_px, one object per point:
{"points_3d": [[0.0, 0.0, 0.0]], "points_2d": [[412.3, 228.9]],
"cov_px": [{"xx": 0.09, "xy": 0.0, "yy": 0.01}]}
All or none. Within one calibration input either every view states
covariances or none does, across every camera of a rig. Residuals in pixels
and whitened residuals are not one noise population, so an input that mixes
them is rejected (MixedCovariance) before any work. There is no setting
to switch the weighting: the solve uses what the observations carry.
A uniform weight w on every point is the covariance I / w; covariances
that are all the same multiple of the identity give the same estimate and
covariance as stating none.
What changes in the solve
Each reprojection residual is whitened: the pixel error e becomes L e,
with L the upper-triangular square root of Σ⁻¹. The whitened residual has
unit covariance when the stated covariance is right, so it is dimensionless.
Two consequences:
- Noise level. The result's reprojection noise is reported as
sigma0(σ̂₀) instead ofsigma: the Gaussian-equivalent standard deviation of the whitened residuals, the square root of the a-posteriori variance factor. It is about 1 when the stated covariances describe the noise; 2 means the actual noise is twice what they state, 0.5 half. It reads a few percent below 1 even when the covariances are exact, since the fit absorbs some of the residuals' freedom (0.976 for 68 parameters over 1620 residuals). The parameter covariance is scaled byσ̂₀², so it is right even when the covariances are right only up to a common factor. - Robust loss scale. A loss's scale applies to the whitened residual,
in units of each point's own σ:
Huber { scale: 2.0 }starts to down-weight a point at twice its stated σ, whatever that is in pixels.
The reported reprojection errors stay in pixels: the mean and per-camera
errors, the histograms, and each feature's error_px. Beside error_px,
each target feature carries error_whitened, √(eᵀ Σ⁻¹ e), its error in
units of its own σ.
On synthetic planar and two-camera scenes whose points state covariances of
0.1 to 1 px with up to 4:1 anisotropy in any orientation, and whose noise
is drawn from them, σ̂₀ was 0.98 ± 0.03, the predicted σ of every
parameter matched the Monte-Carlo scatter within 4 %, and 95 % of the
normalized errors fell within 2σ. Weighing the same points alike instead
gave errors 2.5 to 3 times larger.
Reading the noise
A result's noise entry for reprojection is either:
| JSON key | Rust | Python | Meaning |
|---|---|---|---|
sigma | NoiseLevel::Sigma(s) | whitened == False | σ̂ in pixels per axis |
sigma0 | NoiseLevel::Sigma0(s) | whitened == True | σ̂₀, dimensionless |
The laser residuals keep their own units (pixels for the line distance, metres for the point-to-plane distance) and are never whitened.
The quality report uses the distinction: with whitened
residuals the variance-factor check tests σ̂₀² against 1 and needs no
expected_feature_noise_px, and the view-outlier check averages
error_whitened / σ̂₀ per view.
Projection Uncertainty
The covariance of a camera's parameters implies how precisely it places a
ray in the image. For the ray through a pixel, the pixel it projects to
moves with the parameters θ as J = ∂q/∂θ, so its covariance is the 2×2
J Σ Jᵀ. projection_uncertainty reports the major-axis standard deviation
of that ellipse, in pixels, on a grid of 16 × 12 nodes from pixel (0, 0) to
(width − 1, height − 1), together with its value at the image centre, the
largest of the four corners and the largest on the grid. The export does not
record the image size, so the call takes it:
#![allow(unused)] fn main() { use vision_calibration::common::CalibratedCameras; let export = session.export()?; let map = export.projection_uncertainty(0, [1280, 1024])?; println!( "σ: centre {:.3} px, corners up to {:.3} px", map.center, map.corner.unwrap_or(f64::NAN) ); }
p = vc.projection_uncertainty(result, (1280, 1024), camera=0)
print(f"σ: centre {p.center:.3f} px, max {p.max:.3f} px")
heatmap = p.grid() # 12 rows of 16 values, top row first
camera is the camera index (0 for a single camera). A node no ray reaches,
as where a strong distortion folds the image over, has no value.
For the whole ellipse at pixels of your choice, projection_covariance
returns J Σ Jᵀ itself, in px², one entry per pixel (None where no ray
reaches it). It is Rust only:
#![allow(unused)] fn main() { let ellipses = export.projection_covariance(0, &[[640.0, 512.0], [10.0, 10.0]])?; }
Reading the map:
- Where the views were. σ is smallest where the board covered the image and grows toward corners the views did not reach, where the distortion is extrapolated. A large corner value is a reason to add views with the board in that corner; Planning the Next View ranks candidate views by how much each would reduce it.
- Camera frame. The ray is held fixed in the camera's own frame and only the camera's parameters vary: intrinsics, distortion, sensor tilt. A pinhole camera maps every point of a ray to one pixel, so the map does not depend on range. It does not include the uncertainty of the camera's pose.
- Conservative where the pose is re-estimated. A principal-point shift
and a small rotation of the camera move the image almost alike. A use that
estimates the camera's pose again, such as a pose from a known target,
absorbs that part, so the map overstates the error such uses see. A
Scheimpflug camera shows this strongly: its sensor tilt and principal point
trade off, so σ of
cxandcy, and with them the whole map, can be several pixels while the corners still reproject to a fraction of one. - Conditional. Parameters the final solve held fixed count as exact.
The rig and rig hand-eye workflows refine each camera's intrinsics in
their final solve by default, so each camera has its map; with
rig.refine_intrinsics(or the rig hand-eye'shandeye_optimize.refine_intrinsics) set tofalsethe intrinsics are held, the result has no covariance to project and the call returns an error saying so. - The image size matters. Pass the size of the images the camera was calibrated on. Nodes beyond the area the views covered are extrapolated through the distortion, and far outside the field of view σ grows without bound.
Planning the Next View
A planar calibration's covariance says where its camera is poorly
determined; the projection map shows where in the image that matters. The
next question during capture is which view to take next. predict_with_views
answers it without a re-solve: it predicts the covariance the calibration
would have with candidate views of its target added, and rank_candidates
orders candidate target poses by how much each would reduce the largest
projection σ.
#![allow(unused)] fn main() { use vision_calibration::analysis::{CandidateGrid, candidate_poses, predict_with_views, rank_candidates}; let export = session.export()?; let board = vision_calibration::synthetic::planar::grid_points(9, 7, 0.03); let image = [1280, 960]; // 3 × 3 image regions, the board 0.55 m away, fronto-parallel and tilted // by ±0.4 rad about each of its axes: 45 candidates. let grid = CandidateGrid::new(0.55, 0.4, [3, 3]); let candidates = candidate_poses(&export.params.camera, &board, image, &grid)?; let ranked = rank_candidates(&export, &candidates, &board, image)?; let best = &ranked[0]; println!( "take candidate {}: max projection σ {:.2} px (−{:.2} px)", best.index, best.max_projection_sigma_px, best.projection_sigma_reduction_px ); // The covariance with the best three taken together. let picks: Vec<_> = ranked[..3].iter().map(|r| candidates[r.index]).collect(); let predicted = predict_with_views(&export, &picks, &board, image)?; println!("σ(cx) {:.3} px", predicted.covariance.std_dev("cx").unwrap()); }
board = [(0.03 * i, 0.03 * j, 0.0) for j in range(7) for i in range(9)]
image = (1280, 960)
candidates = vc.candidate_poses(result.camera, board, image, vc.CandidateGrid(0.55, 0.4, (3, 3)))
ranked = vc.rank_candidates(result, candidates, board, image)
best = ranked[0]
print(f"take candidate {best.index}: max σ {best.max_projection_sigma_px:.2f} px")
predicted = vc.predict_with_views(result, [candidates[r.index] for r in ranked[:3]], board, image)
print(f"σ(cx) {predicted.covariance.std_dev('cx'):.3f} px")
A candidate is a target pose, camera_se3_target; any pose works, so an
application can propose its own (a pose the operator can reach, a robot
station) instead of the grid. target_points are the target's points in its
own frame, as the calibration observed them.
How the prediction works
Each candidate's target points are projected through the current estimate;
points behind the camera or outside the image are dropped. What the view
would teach the camera is its Fisher information JᵀJ, over the camera's
parameters and the view's own pose. A re-solve would estimate that pose too,
so it is marginalized out (a Schur complement), and the rest is added to the
calibration's information:
Σ′ = (Σ⁻¹ + Σ_c S_c / σ²)⁻¹
Σ is the result's covariance of its estimated parameters, so the
prediction is conditional on the same fixed parameters and every existing
view's pose is already accounted for. σ is the pixel noise that covariance
implies, read off the result itself (effective_noise_px): the existing
views' information is rebuilt from the export's observed corners and poses,
and σ² is the scalar that relates it to Σ. A robust loss enters the
covariance as that one scalar, so the prediction is on the same scale as the
result, whatever loss the calibration used.
A candidate that sees fewer than four target points, or whose points do not determine its pose, adds nothing: a calibration would reject the view.
Reading the ranking
- The score is the largest projection σ over the image after adding the
candidate alone, on the same 16 × 12 grid as the projection map and the
quality check's limit.
projection_sigma_reduction_pxis how much it drops. log_det_reductionisln det Σ − ln det Σ′, the shrinkage of the whole covariance's volume. Unlike a trace, it does not add pixels² of the focal length to squared distortion coefficients, and a change of units does not change it. A candidate can reduce it a lot while leaving the worst corner where it was.- Candidates are scored one at a time. Two candidates that each fix the
same corner are not worth twice one of them. To plan several views, call
predict_with_viewswith the views already chosen plus each further candidate and compareprojection.max, or capture, calibrate and rank again. - Ties keep the order of the candidate list, and the result is deterministic: the same export and candidates give the same ranking.
How far to trust it
On synthetic scenes (a 9 × 7 board, six views over the central part of a 1280 × 960 image, 0.1–0.3 px noise), the predicted σ of each parameter and the predicted largest projection σ match a re-solve with the view actually added to within 0.1–2.1 % on average, for Brown-Conrady, the division model and a Scheimpflug camera, and for one or three views at once.
The prediction is first order around the current estimate. It holds while the current uncertainty is small against the distortion's curvature; a calibration whose corners are extrapolated to tens of pixels of σ is far enough from the truth that its Jacobians are not the re-solve's, and the prediction can then be off by tens of percent. Take a few spread views first; plan from there.
Planning the next robot pose
A rig hand-eye calibration estimates more than its cameras: the rig, the
hand-eye and the target pose. Its next view is a robot pose, and the target
pose follows from the calibration's own chain. predict_with_robot_poses
predicts the covariance with views at candidate robot poses added, and
rank_robot_poses orders the candidates.
#![allow(unused)] fn main() { use vision_calibration::analysis::{predict_with_robot_poses, rank_robot_poses}; let export = session.export()?; // a rig hand-eye export let board = vision_calibration::synthetic::planar::grid_points(9, 7, 0.03); let image_sizes = [[1280, 960], [1280, 960]]; // one per camera // Robot poses the arm can reach (`base_se3_gripper`), from the application. let ranked = rank_robot_poses(&export, &reachable, &board, &image_sizes)?; let best = &ranked[0]; println!( "move to pose {}: ln det Σ −{:.2}, max projection σ {:.2} px", best.index, best.log_det_reduction, best.max_projection_sigma_px ); let picks: Vec<_> = ranked[..3].iter().map(|r| reachable[r.index]).collect(); let predicted = predict_with_robot_poses(&export, &picks, &board, &image_sizes)?; println!( "σ(hand-eye tz) {:.3} mm", 1e3 * predicted.covariance.std_dev("gripper_se3_rig.tz").unwrap() ); }
ranked = vc.rank_robot_poses(result, reachable, board, [(1280, 960), (1280, 960)])
best = ranked[0]
print(f"move to pose {best.index}: ln det Σ −{best.log_det_reduction:.2}")
picks = [reachable[r.index] for r in ranked[:3]]
predicted = vc.predict_with_robot_poses(result, picks, board, [(1280, 960), (1280, 960)])
print(f"σ(hand-eye tz) {1e3 * predicted.covariance.std_dev('gripper_se3_rig.tz'):.3f} mm")
A candidate is a base_se3_gripper pose. Each camera sees the target
through the result's chain at that pose: eye-in-hand, the rig on the gripper
and the target in the base; eye-to-hand, the rig in the base and the target
on the gripper. Points behind a camera or outside its image are dropped, and
a camera that keeps fewer than four adds nothing.
What the view adds. Its information is exact over every parameter the
result estimated: each camera's intrinsics and distortion, each camera's
cam_se3_rig, the hand-eye and the target pose, in the coordinates the
covariance reports them. A robot pose adds no unknown when the calibration
took the robot poses as exact. A calibration that corrected them
(robot_poses.refine, the default) corrects the candidate's too: the
correction is marginalized under the result's robot pose error, weighed
against its reprojection noise as the solve weighed it.
The scale is read off the result as for a planar calibration: the
existing views' information is rebuilt from the export's observed target
points at the robot poses it solved with, and σ² relates it to Σ. With
exact robot poses that relation is exact. With corrected ones it holds when
the corner noise is Gaussian: the solve weighs its robot pose priors at a
median-based estimate of that noise, which overstates a heavy-tailed noise's
σ.
The ranking is by log_det_reduction, the shrinkage of the whole
covariance's volume. A view that pins down the hand-eye counts as much as one
that pins down a lens, and the score does not depend on units. The largest
projection σ over every camera's image is reported alongside, for the lenses
alone.
How far to trust it. On synthetic two-camera rigs (a 9 × 7 board, six
base robot poses, 0.2 px Gaussian noise, both modes, with and without robot
pose corrections), the predicted σ of each parameter matches a re-solve with
the views actually added to within 0.1–1.2 % on average, for the best-,
median- and worst-ranked candidate and the best three at once; the largest
single-parameter difference is 9.6 %. The best-ranked candidate's real
reduction of ln det Σ beats the worst one's in every scene.
A rig hand-eye laserline calibration is not planned this way: its laser residuals are not part of the prediction.
Limits and Unavailable Results
A covariance is an honest statement about noise, not about everything that can be wrong with a calibration. This chapter lists the cases where none can be given, and where one is given but answers a narrower question than it seems to.
When It Is Unavailable
reason | Meaning | What to do |
|---|---|---|
not_converged | The solve stopped on its iteration budget | Raise solver.max_iters |
rank_deficient | The data does not determine the parameters listed in unobservable (alone or in combination) | Add views that differ in the way the listed parameters need, e.g. tilted boards for fx, cx; or fix one of them |
at_bound | A parameter ended on a bound (labels) | Widen or remove the bound, or fix the parameter |
no_redundancy | No more observations than parameters | Add views |
not_computed | The result was produced or stored without uncertainty | — |
rank_deficient is a statement about the data, not a failure of the
calibration: the solve still returns its best parameters. A Scheimpflug
camera whose views do not separate the sensor tilt from a principal-point
shift, for example, reports tilt_y, cx and the board translations as
unobservable together.
Scope
Every workflow reports the uncertainty of its final solve. When a hand-eye
solve refines the robot poses (the default), it also estimates the robot's
orientation and position error from the corrections, weighed against the
corner noise the residuals show (robot_poses.estimate_sigma). The
estimate is then the maximum-likelihood one and its covariance holds: on
synthetic eye-in-hand scenes the normalized errors had an RMS of 1.02–1.13,
from priors that matched the robot's error and from priors ten times too
tight or too loose. With the estimate off, the configured
robot_poses.rot_sigma and trans_sigma are taken as the robot's true
accuracy: too loose, and the hand-eye and target standard deviations are
pessimistic; too tight, and they are too small, with nothing in the
residuals to say so.
Without refinement the robot poses are taken as exact. Their errors then show up in the corner residuals, but as an error shared by every corner of a view rather than independent noise, and the covariance treats it as independent: with 476 corners a view, it was 16–17 times too small on the same scenes. Refine the robot poses whenever their error is not well below the corner noise.
What a Covariance Does Not Cover
- Systematic error. The covariance measures the scatter caused by noise. An error that differs from view to view (a lens model or target that does not fit exactly) is in it, through the view scatter that scales the covariance (Parameter Uncertainty). An error common to every view, such as a wrong board cell size, leaves it small and the estimate biased; watch the per-feature residuals for structure. Calibrating
kuka_1, the threeds8cameras and the twostereocameras from two disjoint halves of their views, the two projections differ by 0.8 to 1.2 times their predicted σ, and by 1.0 on average (2.2 to 5 times without the view scatter). One camera is a noisy check: with an exactly known σ, a camera's ratio still spans about 0.7 to 1.2. - Second-order error. The covariance is first order. Weak views (a rig whose boards stay centred and barely tilted) can leave each parameter's σ right while a combination of them errs several times its σ;
second_orderpredicts it and the quality report'scovariance_linearitycheck warns (Reading the Covariance, Capturing Calibration Images). The covariance itself is not corrected for it. - Fixed parameters. It is conditional on the parameters the final solve held fixed (
fixedin the result). Fixing a parameter the data cannot pin down makes the others look better determined than they are: a rig withrig.refine_intrinsics: falseholds each camera's intrinsics, and its extrinsics' σ leaves their uncertainty out. - Robot poses. They are taken as exact unless the hand-eye solve refines them, as described in the Scope section above.
- The shape of stated point covariances. Per-point covariances are trusted up to one common factor, which
σ̂₀estimates. Covariances that are right for some points and wrong for others (one image overstated, say) are not detected byσ̂₀and weigh the points wrongly. - The camera's own pose. The projection map varies only the camera's parameters (Projection Uncertainty).
Judging a Result
A calibration can finish without error and still not be fit to use: the
views may leave a parameter undetermined, cover one corner of the image, or
include a misdetected board. assess reads any calibration result and says
so, as a verdict — pass, warn or fail — and the checks behind it. It
builds on the result's uncertainty and residuals; it does
not re-run the calibration.
#![allow(unused)] fn main() { use vision_calibration::analysis::{CheckStatus, QualityThresholds, assess}; let export = session.export()?; let mut thresholds = QualityThresholds::default(); thresholds.expected_feature_noise_px = Some(0.2); // the detector's σ per axis let report = assess(&export, &[[1280, 1024]], &thresholds)?; println!("verdict: {:?}", report.verdict); for check in &report.checks { if check.status != CheckStatus::Pass { println!("{:?} {:?}: {}", check.kind, check.status, check.message); } } }
report = vc.assess(
result,
vc.QualityThresholds(expected_feature_noise_px=0.2),
image_sizes=[(1280, 1024)],
)
print(report.verdict)
for check in report.checks:
if check.status != "pass":
print(check.kind, check.camera, check.status, check.message)
image_sizes gives each camera's [width, height] in pixels, in camera
order; pass an empty list (Python: None) when the result's image manifest
records them. The thresholds' defaults suit a perspective camera; every one
can be changed, and in JSON they reject unknown fields.
The checks
| Check | Per | Fails when | Warns when |
|---|---|---|---|
observability | result | the data does not determine some parameters (rank_deficient), or there is no redundancy | a parameter ended on a bound |
convergence | result | the final solve stopped on its iteration budget | — |
variance_factor | noise level | σ̂²/σ₀² (σ̂₀² with stated point covariances) is above its χ² range | it is below the range |
projection_sigma | camera | — | σ somewhere in the image exceeds 1 px |
feature_coverage | camera | features reached under 30 % of the image | under 50 % |
view_outliers | camera | — | a view's mean residual exceeds 3 σ̂ |
parameter_correlation | camera | — | two parameters correlate above 0.99 |
covariance_linearity | result | — | a combination of the shared parameters is predicted to scatter more than 1.2× its σ |
Each check carries its status, the measured value with its unit and the
threshold it was compared to (measurement), what it names (subjects:
parameter labels, parameter pairs such as fx/cx, or views such as
view 3) and a one-sentence message.
- Observability reads the uncertainty's status. A
rank_deficientresult names the parameters the views do not separate, such as a sensor tilt and the principal point; add views that differ in the way those parameters need, or fix one of them. - Convergence fails a solve that ran out of iterations: its numbers are
not a minimum. Raise
solver.max_iters. - Variance factor. σ̂ is the noise level the residuals imply (see
noise); σ₀ is the noise you expect from
the detector,
expected_feature_noise_px. There is no universal value, so the check is not applicable until you state one. Then σ̂²/σ₀² is tested against the χ² distribution at the residuals' degrees of freedom, two-sided atvariance_test_alpha(0.01). Above the range, the residuals exceed what the detector explains: a wrong model, a board that is not flat, or bad detections. Below it, σ₀ was overstated. Because σ̂ is a median-based estimate, which scatters more than a sample variance, the test uses 37 % of the degrees of freedom, the estimate's efficiency under Gaussian noise. When the observations state a covariance per point, the residuals are whitened and their level is σ̂₀, which is 1 when the stated covariances hold: the check then tests σ̂₀² against 1 and needs noexpected_feature_noise_px(Per-Point Pixel Covariance). - Projection σ is the largest value of the camera's projection-uncertainty map, over the grid and its corners. It exceeds a pixel where the views did not reach and the distortion is extrapolated. The map is conservative for uses that re-estimate the camera's pose, so this check only warns.
- Feature coverage splits the image into 8 × 6 cells and counts those holding at least one observed feature. Views bunched in one part of the image leave the rest to extrapolation; add views with the board near the empty cells, corners first.
- View outliers averages
|r| / σ̂per view and camera (with stated point covariances, each feature's whitened error over σ̂₀). Over Gaussian inliers that mean is about 1.25; a view above 3 has residuals well over twice the typical ones, as from a misdetection, motion blur or a wrong robot pose. Inspect or drop the named views. - Parameter correlation looks at the camera's own parameters
(intrinsics, distortion, tilt). Above 0.99 the data determines a
combination of the pair, not each.
fxandfyare left out: the board's distance scales both, so they correlate almost fully in every planar calibration while their ratio is well determined. - Covariance linearity reads the uncertainty's
second_orderprediction (Reading the Covariance). The covariance is the error's first-order term. When the views pin a combination of parameters down only through larger errors of others that cancel in it, the error's second-order term can make that combination scatter several times its σ while each parameter's σ still holds. A use that takes the whole covariance as a Gaussian, such as a calibration prior in bundle adjustment, then trusts the combination too much. The check warns abovemax_second_order_scatter(1.2) and names the combination's parameters, with their weights in units of their σ, in the message;subjectslists them. The usual cause is a rig whose boards stay in the middle of the image with little tilt: one camera's rotation in the rig and the principal points then trade off. Tilt the board 15–30° and move it into the image corners (Capturing Calibration Images). On synthetic stereo rigs and single cameras whose error the covariance describes, the prediction stayed at or below 1.06; every stereo capture predicted above 1.3 scattered 1.4 to 3 times its σ along the named combination, and a single camera tilted by only 3° (predicted 1.3–1.65) had a heavy tail of calibrations far off.
Not applicable is not a pass
A check whose inputs the result does not carry is not_applicable, with the
reason in its message, and does not count toward the verdict:
- no image size for the camera: the projection and coverage checks;
- no stated σ₀ and no stated point covariances: the variance factor;
- no covariance (the uncertainty is unavailable): the checks that read it;
- a camera whose parameters the final solve held fixed — the rig and rig hand-eye workflows by default — has no covariance to project or correlate;
- no second-order prediction (more than 64 shared parameters, noise-free data, or a covariance assembled from others, such as a rig laserline's planes with the rig calibration's share): covariance linearity.
The report's verdict is the worst status of the checks that were
assessed. A report in which no check could be assessed is warn, since then
nothing vouches for the result.
Image sizes
A camera's image size comes from the result's image manifest when its frames
are regions of a larger image (all of the same size), and otherwise from
image_sizes. Sizes that disagree, regions of different sizes, or a list
with the wrong number of cameras are an error rather than a guess.
In JSON
The report serializes as
{
"verdict": "warn",
"checks": [
{"kind": "observability", "status": "pass", "message": "..."},
{"kind": "feature_coverage", "camera": 0, "status": "warn",
"measurement": {"value": 0.44, "threshold": 0.5, "unit": "fraction"},
"message": "features reached 21 of 48 cells (8 × 6) of the image, 44 %"}
]
}
and the thresholds as
{
"expected_feature_noise_px": null,
"variance_test_alpha": 0.01,
"max_projection_sigma_px": 1.0,
"coverage_warn_below": 0.5,
"coverage_fail_below": 0.3,
"max_view_residual": 3.0,
"max_parameter_correlation": 0.99,
"max_second_order_scatter": 1.2
}
where an absent field takes its default.
The Calibration Report
html_report turns a calibration result into one HTML page to read, attach
to a ticket or archive next to the export. The page loads nothing from
elsewhere, no script, font or stylesheet, so it opens offline in any
browser, and the same result always gives the same page. It follows the
reader's light or dark theme.
What the page holds
- The verdict of Judging a Result, pass, warn or fail, with the overall reprojection error: mean, RMS, 95th percentile and maximum.
- Every quality check, with its finding, so a warning says what to change.
- Per camera: the camera model, each parameter with its σ (
fixedfor one the solve held), the reprojection-error statistics, and four figures:- the histogram of the per-feature errors, with the mean marked;
- the mean error by image region. A pattern, such as larger errors toward the corners, points at the camera model, while empty cells at the edges show where the capture never put the board;
- the mean error of each view, where a view far above the others is the one to inspect or drop;
- the projection-σ map (Projection Uncertainty): how far the calibration's own uncertainty moves where a ray lands, over the image.
A parameter's value is rounded to one digit past what its σ supports: with
σ(k1) = 0.0073, k1 shows as 0.00065, not 0.000650049. A parameter
without a σ keeps six significant figures.
The region and per-view figures read the export's per-feature residuals, which every workflow records. The projection-σ map needs the camera's image size and the parameter covariance; without either it is left out.
Making one
From the command line, --report adds report.html to what vcal run
writes (The Command Line):
vcal run data/my_camera/dataset.toml --report
The desktop app writes report.html into every saved run's folder
(The Desktop App).
In Rust, html_report takes any export and the image sizes that
assess takes; dataset_runner::calibrate_dataset returns
them with the export:
use std::path::Path; use vision_calibration::analysis::{ReportOptions, html_report}; use vision_calibration::dataset::DatasetSpec; use vision_calibration::control::RunControl; use vision_calibration::dataset_runner::{Detection, calibrate_dataset}; fn main() -> anyhow::Result<()> { let dir = Path::new("data/my_camera"); let spec = DatasetSpec::from_path(dir.join("dataset.toml"))?; let run = calibrate_dataset(&spec, dir, None, Detection::Uncached, None, &RunControl::default())?; let mut options = ReportOptions::default(); options.title = "Bench camera".to_string(); options.camera_names = spec.cameras.iter().map(|c| c.id.clone()).collect(); let page = html_report(&run.export, &run.image_sizes, &options)?; std::fs::write(dir.join("report.html"), page)?; Ok(()) }
In Python, vc.html_report takes a result or an export loaded from JSON;
dataset_runner.camera_image_sizes reads the sizes from a manifest:
from pathlib import Path
import vision_calibration as vc
from vision_calibration.dataset_runner import camera_image_sizes
manifest = Path("data/my_camera/dataset.toml")
result = vc.calibrate_dataset(manifest)
page = vc.html_report(
result,
image_sizes=camera_image_sizes(manifest),
title="Bench camera",
)
(manifest.parent / "report.html").write_text(page, encoding="utf-8")
ReportOptions::thresholds (Python: thresholds=) sets the quality checks'
limits, as for assess.
Multiple-View Geometry
Calibration tells you what each camera is: intrinsics, distortion, pose. Multiple-view geometry (MVG) is what you do with calibrated cameras: recover the relative pose between two views, triangulate 3D structure, refine both with bundle adjustment, rectify a stereo pair and match it densely. This part is the tour of that surface, reachable through the facade as vision_calibration::mvg.
It is aimed at someone who already has calibrated cameras (for example from Stereo Rig with Real Data) and wants to reconstruct or rectify. The mathematics behind the building blocks is in the theory parts: Epipolar Geometry, Linear Triangulation, Homography Estimation and RANSAC.
Conventions
The calibrated functions take a CalibratedRig: the cameras, where each sits in the rig (cam_se3_rig, the reference camera's the identity) and the uncertainty of their parameters. A single camera is a rig of one. Observations are pixels; two conventions matter:
- Pixels in, bearings inside. Each pixel is unprojected through its own camera's model into a bearing, a unit ray, and the closed-form steps (the 5-point essential matrix, cheirality, the linear triangulation) work on bearings. Distortion, fisheye and tilted sensors need no separate undistortion, and a fisheye point more than 90° from the optical axis, which has no normalized coordinates, is an ordinary bearing.
- Stated noise. The calibrated two-view functions take
PixelMatches: two pixels with their covariances (PointCovariance::new,isotropic, orrig.reprojection_noise(camera)). Their residual is the epipolar constraint over its first-order σ, so RANSAC thresholds are in σ, not radians or pixels.
Poses are named <frame>_se3_<frame> and map the right frame into the left: second_se3_first takes a point from the first camera's frame into the second's, , and a world-to-camera pose is cam_se3_world.
The Surface
In dependency order:
mvg::pose_recovery recover_relative_pose ← 5-point + cheirality (also triangulates)
mvg::triangulation triangulate ← N-view DLT on bearings, refined, with covariance
mvg::laser triangulate_laser ← laser pixels on a known plane, with covariance
mvg::types Captures ← the frame: rig, world, or the robot's gripper or base
mvg::pose estimate_pose ← object pose from known points, RANSAC in σ, with covariance
mvg::bundle_adjust bundle_adjust ← capture poses + points together, with covariance
mvg::metrology distance, fit_plane, ... ← measurements with σ from the points' joint covariance
mvg::rectification rectify ← rectified pair from a calibration, Q, points with σ
mvg::dense match_block ← block matching and semi-global matching
mvg::robust *_robust ← RANSAC wrappers for outlier-laden data
Further modules: mvg::degeneracy (pure rotation, planar scene and poor baseline detection), mvg::homography (homography decomposition and transfer) and mvg::residuals (Sampson, epipolar and symmetric transfer errors). The low-level solvers underneath (8-point and 5-point essential matrix, DLT homography, camera-matrix decomposition, linear triangulation) are at the root of vision_calibration::mvg.
The chapters of this part follow the pipeline:
- Pose Recovery and Triangulation: relative pose from correspondences, with RANSAC for outliers.
- Object Pose with Uncertainty: the pose of a known object from its pixels in one or several cameras, with RANSAC in σ and the calibration's share in the covariance.
- Bundle Adjustment: refining poses and structure together, with a stated gauge and the calibration's uncertainty.
- Measurements with Uncertainty: distances, angles and plane fits over triangulated points, each with the σ from the points' joint covariance.
- Stereo from a Calibration: rows that line up for any camera model, the disparity-to-depth matrix and the uncertainty of the points.
- Dense Stereo: per-pixel disparity by block matching or semi-global matching.
The Companion Example
The mvg_two_view example builds a synthetic calibrated stereo pair with known ground truth and runs pose recovery, bundle adjustment and rectification:
cargo run -p vision-calibration --example mvg_two_view
scene: 36 correspondences visible in both views
1. pose recovery : rotation Δ = 2.43e-15, |t·t_gt| = 1.000000, 36 points triangulated
3. bundle adjust : RMS 0.0000 px over 2 captures, 36 points; worst point error 1.75e-7 m
4. rectification : baseline = 0.610, max row disagreement = 1.14e-13 px
From a Calibration to MVG Inputs
Every export with cameras gives its calibration as a CalibratedRig, with the cameras' parameters, their poses in the rig and the uncertainty of the estimated parameters (labelled as in the export: fx for one camera, cam1.k1 and cam1.cam_se3_rig.rx in a rig):
#![allow(unused)] fn main() { use vision_calibration::common::CalibratedCameras; let rig = export.calibrated_rig()?; // rig.cameras(), rig.cam_se3_rig(), rig.uncertainty(), rig.model(i) }
cam_se3_rig[i] is the pose of the rig frame in camera i (T_Ci_R). The relative pose of two cameras of a rig is composed:
#![allow(unused)] fn main() { let cam1_se3_cam0 = rig.cam_se3_rig()[1] * rig.cam_se3_rig()[0].inverse(); }
A single cam_se3_rig[i] equals the relative pose only in the special case where camera 0 is the reference frame (its pose is the identity). The reference camera need not be camera 0.
Pose recovery, triangulation, bundle adjustment and rectification all take the pixels as detected, through the rig's camera models.
In Python
vision_calibration.mvg measures with a calibration. Each function takes the calibration result (what a run_* function or a session returns, or an export loaded as a dict) and builds the rig from it, so there is no rig object to construct. Poses are 4x4 numpy matrices with the same naming as above, and every σ and covariance is a numpy array:
import numpy as np
import vision_calibration as vc
from vision_calibration import mvg
noise = mvg.reprojection_noise(result, camera=0)
tracks = [[mvg.RigObservation(0, 0, px0, noise), mvg.RigObservation(0, 1, px1, noise)]
for px0, px1 in pixel_pairs]
found = mvg.triangulate(result, np.eye(4), tracks)
length = mvg.distance(mvg.PointSet.from_triangulation(found), 0, 1)
print(f"{length.value:.4f} m ± {length.sigma:.4f} m")
The chapters of this part show the Python call beside the Rust one. An input the caller can fix (an index out of range, too few matches, a calibration without a covariance where one is needed) raises ValueError; a degenerate geometry or a failed numerical step raises RuntimeError.
The closed-form building blocks (the 5-point and 8-point solvers, the DLT homography and camera matrix, homography decomposition and transfer, the epipolar residuals) are Rust only: Python measures through the calibrated functions above, and numpy and OpenCV cover raw linear algebra.
In the Desktop App
The app's Epipolar and Depth workspaces run the same code on a calibrated rig export: epipolar lines between two cameras, and rectification plus dense matching with disparity, depth and point-cloud views.
Pose Recovery and Triangulation
Given matched pixels in two calibrated views, recover_relative_pose finds the rotation and translation direction between the cameras and triangulates the points.
Recovering the Relative Pose
Input is the rig, the camera of each view and a list of PixelMatch, pairs of pixels with their stated noise: pt1 in rig camera cameras[0] at the first capture, pt2 in rig camera cameras[1] at the second ([0, 0] for one camera that moved). Each pixel is unprojected through its camera's model into a bearing, so a distorted lens or a fisheye needs no undistortion first. The function runs the 5-point algorithm on several evenly spaced subsets of the correspondences, scores each candidate essential matrix by its covariance-weighted Sampson error (below) and by cheirality over all correspondences, keeps the pose for which the most points lie ahead along both bearings, and triangulates:
#![allow(unused)] fn main() { use vision_calibration::mvg::pose_recovery::recover_relative_pose; use vision_calibration::mvg::types::PixelMatch; let matches: Vec<PixelMatch> = /* (pixel and its covariance in the first view, the same in the second) */; let rel = recover_relative_pose(&rig, [0, 0], &matches)?; // rel.second_se3_first : pose of the first camera's frame in the second's, unit translation // rel.essential : b2ᵀ E b1 = 0 on the bearings // rel.points : triangulated points with reprojection and parallax diagnostics // rel.scene : parallax, pure-rotation and planarity diagnostics }
In Python the same call takes the calibration result itself, and the matches are mvg.PixelMatch values:
import vision_calibration as vc
from vision_calibration import mvg
noise = mvg.reprojection_noise(result, camera=0) # the calibration's own level
matches = [mvg.PixelMatch(p1, noise, p2, noise) for p1, p2 in pixel_pairs]
rel = mvg.recover_relative_pose(result, (0, 1), matches)
rel.second_se3_first # 4x4 numpy matrix, unit translation
rel.points # list of TriangulatedPoint
rel.scene # SceneDiagnostics
Stating the Pixel Noise
Every pixel of a PixelMatch carries its covariance (a PointCovariance, px²). The noise is stated, never guessed, and there are three ways to state it:
#![allow(unused)] fn main() { use vision_calibration::core::PointCovariance; // Per observation: a detector's own 2×2 covariance, elongated or correlated. let cov = PointCovariance::new(0.25, 0.08, 0.09)?; // One σ for every pixel, in pixels. let iso = PointCovariance::isotropic(0.5)?; // The calibration's level: the reprojection σ̂ it measured on its own data. let level = rig.reprojection_noise(0)?; // camera 0 of the rig let m = PixelMatch::new(pt1, level, pt2, iso); }
reprojection_noise is an error when the calibration has no covariance, no reprojection level for that camera, or a level that is the dimensionless of residuals whitened by stated covariances; state the noise instead.
The functions work on bearings, so each pixel covariance is mapped onto its bearing through the camera model's projection Jacobian: with (computed by central differences of the model's forward projection, so every distortion and fisheye model is served). For a bearing pair and an essential matrix , the covariance-weighted Sampson error is the epipolar constraint over its first-order standard deviation:
It is dimensionless, in σ, and does not change with the scale of .
At least 5 matches are required. The translation is recovered up to scale: two bare views contain no metric anchor, so rel.second_se3_first has a unit-norm translation. Fix the scale with a known baseline (for a calibrated rig, the extrinsics already carry it) or a known 3D distance.
Each entry of rel.points is a TriangulatedPoint:
| Field | Meaning |
|---|---|
point | the 3D position, in the first camera's frame at unit baseline |
reprojection_error | RMS distance in pixels between each observation and the point projected through that camera's model; None when the point does not project in some camera |
parallax_deg | the largest angle between two observing rays; small angles mean poor triangulation geometry |
in_front | whether the point lies ahead along every observing bearing |
Degenerate Geometry
The 5-point estimator needs a genuinely three-dimensional scene. A near-planar cloud, a pure rotation (no baseline) or nearly parallel rays make the essential matrix poorly determined. rel.scene (a SceneDiagnostics) reports the median parallax, pure-rotation and planar flags and a baseline ratio (the unit baseline over the median depth, measured along the first camera's bearing); for a planar scene the homography model (mvg::homography::decompose_homography) is the right one instead.
Outliers: the Robust Variants
Real matches contain outliers. The mvg::robust wrappers put a RANSAC loop around the minimal solvers:
#![allow(unused)] fn main() { use vision_calibration_core::RansacOptions; use vision_calibration::control::RunControl; use vision_calibration::mvg::robust::{estimate_essential, recover_relative_pose_robust}; let opts = RansacOptions { min_inliers: 12, ..RansacOptions::new(3.0) // weighted Sampson error, in σ of the stated noise }; let robust = recover_relative_pose_robust(&rig, [0, 0], &matches, &opts, &RunControl::default())?; // robust.second_se3_first, robust.essential, robust.inliers (indices), robust.inlier_rms if let Some(cov) = robust.pose_covariance() { // 6×6 over [rx ry rz x y z]: the rotation's tangent (rad), the unit translation println!("σ(rotation about y) = {:.3}°", cov[(1, 1)].sqrt().to_degrees()); } }
ransac = mvg.RansacOptions(thresh=3.0, min_inliers=12) # thresh in σ of the stated noise
robust = mvg.recover_relative_pose_robust(result, (0, 1), matches, ransac)
estimate = mvg.estimate_essential(result, (0, 1), matches, ransac)
robust.inliers, robust.inlier_rms
robust.pose_covariance() # (6, 6), or None
estimate.essential_covariance() # (9, 9), or None
The threshold is in σ: the residual is the weighted Sampson error above, so 3.0 keeps about 99.7 % of the inliers when the noise is Gaussian and as stated, independent of the focal length and of the camera model. The stated noise is the scale: understating the pixel noise by a factor of two doubles every residual and rejects good matches. There is no default threshold: RansacOptions::new(thresh) requires one, and a threshold that is not finite and positive is an error. The run is deterministic for a given seed field.
RansacOptions is defined in vision-calibration-core, which the facade does not re-export; add that crate to your dependencies to name it.
estimate_essential returns the essential matrix with its inlier indices and the RMS of the weighted Sampson error over the inliers (in σ, about 1 when the stated noise describes the pixels); estimate_homography does the same for a homography (4-point DLT with symmetric transfer error, on plain image points). In Python, mvg.estimate_homography(correspondences, mvg.RansacOptions(thresh=...)) takes an (N, 2, 2) array (or a list of ((x1, y1), (x2, y2))) and returns the homography (3, 3), inliers and inlier_rms; its threshold is in pixels. recover_relative_pose_robust selects the inliers first and disambiguates the four decompositions of the essential matrix with cheirality on the inliers only, so outliers cannot corrupt the positive-depth vote.
The Refined Estimate and Its σ
The consensus search scores models fitted to minimal samples. Its inliers are then fitted linearly (8-point) and refined to the least squares of the weighted Sampson error above, as a relative pose: with the rotation and the unit translation direction , starting from the decomposition that puts the most inliers ahead of both cameras. The inliers are re-selected with the refined estimate and the fit refined again until they settle, so the result is the estimate of its own inliers and inliers lists the matches it is fitted to. The refinement minimizes the error itself, its denominator's dependence on included; holding each match's variance fixed within a step settles elsewhere, at a point biased by up to 2σ of the pose with a thousand matches.
Two views do not observe the length of the translation, so is the gauge: the pose has five degrees of freedom, three of rotation and two of direction. robust.uncertainty holds its covariance from the inliers' stated pixel noise, labelled rotation.rx, rotation.ry, rotation.rz (rad, the right-perturbation tangent of the rotation of second_se3_first) and direction.x, direction.y, direction.z (the components of the unit translation): the direction block has rank 2, with no variance along . estimate.uncertainty holds the covariance of the nine entries of (essential.e00 … essential.e22, row-major, with scaled to ), of rank 5. Neither includes the calibration's uncertainty: the cameras enter as exact. Fewer than six inliers leave no redundancy, and the uncertainty says why it is unavailable.
Checked against Monte-Carlo. A fisheye with an anisotropic, correlated pixel noise matched with a distorted pinhole, 120 points and 600 draws of the noise: the scatter of each of the five pose coordinates is 0.98 to 1.06 times its σ, and that of 's entries 1.03 times (pooled).
estimate_essential and recover_relative_pose_robust take a RunControl as their last argument (in Python, progress=): its observers receive each iteration of the refinement and its token stops the call with MvgError::Cancelled.
Triangulating Points from N Views
mvg::triangulation::triangulate takes the rig, where the rig was at each capture (Captures) and, for each point, a track of observations: the pixel, its noise, the rig camera that saw it and the capture. It returns a Triangulation: a TriangulatedPoint per track in the frame of the captures, with the same diagnostics, and the joint covariance of the points. Captures::Rig is one capture in the rig frame and Captures::World(&rig_se3_world) the rig's pose at each capture, taken as exact; a rig calibrated on a robot measures in its gripper or base frame (below).
#![allow(unused)] fn main() { use vision_calibration::mvg::triangulation::{RigObservation, triangulate}; use vision_calibration::control::RunControl; use vision_calibration::core::PointCovariance; use vision_calibration::mvg::types::Captures; // One stereo capture, in the rig frame. Each pixel is stated to 0.3 px. let noise = PointCovariance::isotropic(0.3)?; let track = vec![ RigObservation::new(0, 0, pixel_in_camera_0, noise), RigObservation::new(0, 1, pixel_in_camera_1, noise), ]; let found = triangulate(&rig, Captures::Rig, &[track], &RunControl::default())?; println!("{:?} parallax {:.2}°", found.points[0].point, found.points[0].parallax_deg); if let Some(cov) = found.point_covariance(0) { // The 3×3 covariance in m²; the depth's σ along the world z axis. println!("σ_z = {:.2} mm", cov[(2, 2)].sqrt() * 1e3); } }
import numpy as np
noise = vc.PointCovariance.isotropic(0.3) # stated per pixel
track = [
mvg.RigObservation(0, 0, pixel_in_camera_0, noise),
mvg.RigObservation(0, 1, pixel_in_camera_1, noise),
]
found = mvg.triangulate(result, np.eye(4), [track]) # rig_se3_world: (4, 4) or (K, 4, 4)
found.points[0].point, found.points[0].parallax_deg
cov = found.point_covariance(0) # (3, 3) in m², or None
The last argument is a RunControl (Sessions describes it): its observers receive each iteration of the point refinement and its token stops the call with MvgError::Cancelled. PoseOptions::control and BundleAdjustOptions::control do the same for estimate_pose and bundle_adjust, and StereoRectification::triangulate, estimate_essential and recover_relative_pose_robust take a control as triangulate does. In Python these functions take progress=, a callable given each report; raising from it, or Ctrl-C, stops the call.
Camera at capture sees the world through rig.cam_se3_rig()[i] * rig_se3_world[k] (with Captures::World), so a moving rig, several cameras and a single moving camera are all the same call. The call is all-or-nothing: a track with fewer than two observations, an index out of range or a zero-parallax system is an error that names the track.
The estimate. The linear algorithm of Linear Triangulation on bearings starts each point; all the points are then refined together on the reprojection error, each pixel weighted by its stated covariance (a general PointCovariance, an isotropic σ, or rig.reprojection_noise(camera) for the calibration's own noise level). The noise is stated because a point seen twice leaves one degree of freedom, too few to estimate a noise level from.
The covariance has two sources. The first is the observations' noise. The second is the calibration's: the rig's cameras and extrinsics enter as consider parameters, held at their values while their covariance is propagated into the points'. Every point of a call shares the calibration's error, so the points are correlated: cross_covariance(i, j) gives the 3×3 block between two points, and a distance or an angle between points needs it. The points are solved in one problem and the covariance is dense over all coordinates, so the cost grows as the cube of the number of points; for thousands of points, call per group of points that need a joint covariance.
A rig exported without a covariance still gives refined points, and the uncertainty says why it has none rather than reporting the observations' share alone.
Why the calibration's share matters. Take a stereo pair calibrated from eight views spread over the image and tilted by up to 0.5 rad at 0.3 px noise, and ten points 0.6 to 0.8 m ahead measured with 0.3 px noise. Over 200 repeated calibrations the depth of a point scatters by 0.97 times the reported σ (3.0 mm, root mean square). The σ from the observations' noise alone is 2.1 mm, and the scatter is 1.46 times it: a camera taken as exact would report error bars a third too small. A weaker calibration widens the gap: from eight centred views tilted by at most 0.2 rad the reported σ is 6.7 mm and the observations' alone are 3.2 times too small.
Laser Points with σ
A laser line seen by a calibrated camera, with its plane known, gives a 3D point per line pixel: the viewing ray meets the plane. mvg::laser::triangulate_laser takes the rig, which carries each camera's laser plane (an export of a laser workflow gives such a rig from calibrated_rig()), and the laser pixels with their stated noise:
#![allow(unused)] fn main() { use vision_calibration::core::{PointCovariance, Pt2}; use vision_calibration::mvg::laser::{LaserObservation, triangulate_laser}; use vision_calibration::mvg::metrology::{PointSet, distance}; use vision_calibration::mvg::types::Captures; // The laser-peak detector's noise, 0.1 px. let noise = PointCovariance::isotropic(0.1)?; let observations: Vec<LaserObservation> = laser_pixels .iter() .map(|&pixel: &Pt2| LaserObservation::new(0, 0, pixel, noise)) // capture 0, camera 0 .collect(); let found = triangulate_laser(&rig, Captures::Rig, &observations)?; println!("{:?} seen at {:.0}°", found.points[0].point, found.points[0].incidence_deg); // The joint covariance feeds the measurements. let set = PointSet::from_laser_triangulation(&found)?; let length = distance(&set, 0, observations.len() - 1)?; println!("{:.3} m ± {:.3} mm", length.value, length.sigma * 1e3); }
observations = [mvg.LaserObservation(0, px, noise) for px in laser_pixels]
found = mvg.triangulate_laser(result, observations) # a laser calibration's result
points = mvg.PointSet.from_laser_triangulation(found)
length = mvg.distance(points, 0, len(observations) - 1)
print(f"{length.value:.3f} m ± {length.sigma * 1e3:.3f} mm")
With Captures::Rig the points are in the rig frame (the single camera's own frame for a laserline device); Captures::World states the rig's pose at each capture of a scan, and each observation names its capture. The pixel goes through its camera's model, so distortion, fisheye (beyond 90° from the axis) and tilted sensors need no separate undistortion; a ray parallel to the plane, or meeting it behind the camera, is an error that names the observation. incidence_deg is the angle between the ray and the plane: depth is ill-determined as it nears zero.
The covariance has two sources, as for triangulation: the pixels' noise, and the calibration's, here the camera's parameters, the laser plane and the camera's pose in the rig, with their correlations as the calibration estimated them together. Each pixel fixes its point through and , so the point moves with the pixel, the camera, the plane and the pose by the implicit-function derivatives of those two equations, computed exactly from the camera model. Every point of a call shares the calibration, so the points are correlated and cross_covariance(i, j) gives the block between two. The covariance is conditional on what the calibration held fixed (a plane or camera it did not estimate counts as exact), and the pixel noise is the detector's, stated by you. The cost grows as the square of the number of points times the number of calibration parameters, with a dense covariance over the coordinates.
A rig whose covariance lists none of an observing camera's intrinsics, distortion or sensor parameters (a laser plane fitted with the cameras held fixed and their covariance left out) gives points whose uncertainty says it is unavailable, rather than a σ from the pixels alone. A rig without a covariance does the same. A rig laserline device result whose upstream rig calibration carried its covariance (as RigHandeyeExport::to_upstream_calibration and the dataset runner pass it) lists the cameras and their extrinsics with the planes, so its points carry the whole calibration.
Why the calibration's share matters. Take a laserline device calibrated from six views at 0.3 px noise, and eight points of the plane 0.4 to 0.6 m ahead measured with 0.1 px noise. Over 300 repeated calibrations the depth of a point scatters by 1.02 times the reported σ (4.7 mm, root mean square), and the length between the first and last point by 0.99 times the σ that metrology derives from the joint covariance. The σ from the pixels alone is 0.017 mm: a plane and camera taken as exact would report error bars hundreds of times too small.
The same holds for a two-camera rig on a robot arm: a rig hand-eye calibration from ten stations at 0.3 px, then each camera's laser plane fitted against it from six laser views, then eight points of each camera's plane measured with 0.1 px noise. Over 300 repeated calibrations the depth of a point scatters by 0.99 times the reported σ (0.45 mm). The board poses the planes are fitted against come through the hand-eye chain, so most of that σ is the hand-eye's: the planes' own fit alone, with the rig calibration taken as exact, would report 0.025 mm, 18 times too small.
Points in the Robot's Frames
A rig calibrated on a robot arm measures in the robot's frames: the gripper (flange) frame or the base. Its calibrated_rig() carries the hand-eye (rig.handeye(): Handeye::EyeInHand { gripper_se3_rig } for a rig on the gripper, Handeye::EyeToHand { rig_se3_base } for one standing in the base), and Captures states the frame:
Captures::Handeye(frame), the frame the rig is fixed in: the gripper eye-in-hand, the base eye-to-hand. One capture; the robot does not enter.Captures::Robot { frame, robot }, the frame the rig moves in: the base eye-in-hand, the gripper eye-to-hand.robot[k]is the robot's posebase_se3_gripperat capturek, exact (RobotPose::exact) or with the covariance of its error (RobotPose::new, orRobotPose::isotropic(pose, rot_sigma, trans_sigma)in rad and m, about and along the gripper's axes). A laser scan is one call: each observation names the capture it was seen at.
Naming the other frame for either variant, or a robot frame for a rig without a hand-eye, is an error before any work.
#![allow(unused)] fn main() { use vision_calibration::core::{RobotFrame, RobotPose}; use vision_calibration::mvg::laser::{LaserObservation, triangulate_laser}; use vision_calibration::mvg::types::Captures; // A joint rig hand-eye laserline export: cameras, planes and hand-eye. let rig = export.calibrated_rig()?; // The robot reports its pose to 0.1 mrad and 50 µm per axis. let robot: Vec<RobotPose> = stations .iter() .map(|pose| RobotPose::isotropic(*pose, 1e-4, 5e-5)) .collect::<Result<_, _>>()?; // capture k, camera c: the pixels of each station of the scan. let observations: Vec<LaserObservation> = scan .iter() .map(|&(k, c, pixel)| LaserObservation::new(k, c, pixel, noise)) .collect(); let in_base = triangulate_laser( &rig, Captures::Robot { frame: RobotFrame::Base, robot: &robot }, &observations, )?; let in_gripper = triangulate_laser(&rig, Captures::Handeye(RobotFrame::Gripper), &single)?; }
robot = [mvg.RobotPose.isotropic(pose, 1e-4, 5e-5) for pose in stations] # (4, 4) each
observations = [mvg.LaserObservation(c, px, noise, capture=k) for k, c, px in scan]
in_base = mvg.triangulate_laser(result, observations, mvg.RobotCaptures("base", robot))
in_gripper = mvg.triangulate_laser(result, single, mvg.RobotCaptures("gripper"))
found = mvg.triangulate(result, mvg.RobotCaptures("base", robot), tracks)
triangulate takes the same Captures: a single camera on the gripper moved between stations triangulates in the base frame, and a fixed rig measures points held by the gripper in its frame.
The covariance gains two sources. The hand-eye is a parameter of the calibration, so it enters with the cameras, planes and extrinsics, correlated with them as the calibration estimated them in one solve. Each robot pose's stated covariance enters on its own, independent of the calibration's and of the other poses' (a robot's systematic error across poses is not modelled). A hand-eye the calibration's covariance does not list was held fixed and counts as exact, as a robot pose without a covariance does. Points of one capture share its robot pose, and every point shares the calibration, so the points are correlated.
Why the correlations matter. Take the two-camera rig above, calibrated jointly with its laser planes and the hand-eye from ten board stations and six laser stations at 0.3 px, moved to a station it never took and reporting its pose to 0.1 mrad and 50 µm per axis; eight points of each camera's plane are measured with 0.1 px noise in the base frame, and eight points ahead of the rig triangulated from both cameras. Over 300 repeated calibrations each coordinate scatters by 1.01 times the reported σ, for the laser points (0.100 mm) and the triangulated ones (0.45 mm). The calibration pins the board's pose in the base frame, so the hand-eye and the planes err together: with their cross terms dropped, as a frame change applied after the measurement would state it, the laser points' σ is 0.41 mm, four times too large, and without the robot's error it is 0.068 mm, 1.6 times too small.
A staged laser calibration (the planes of RigLaserlineDevice fitted against a frozen rig hand-eye calibration) does not list the hand-eye in its covariance: a rig built from it with with_handeye counts the hand-eye as exact. The joint RigHandeyeLaserline export carries all of it.
Object Pose with Uncertainty
mvg::pose::estimate_pose gives the pose of an object whose points are known in its own frame, from the pixels where one or several cameras of a calibrated rig saw them. The result is in the rig frame (rig_se3_object) with its 6×6 covariance, the calibration's share included. It is the calibrated counterpart of the PnP solvers, which take a pinhole and return a bare pose.
Usage
#![allow(unused)] fn main() { use vision_calibration::mvg::pose::{PoseObservation, PoseOptions, estimate_pose}; // rig: CalibratedRig // board: the object's points in its own frame, metres // pixels: where camera 0 saw each of them let noise = PointCovariance::isotropic(0.3)?; // stated per pixel let observations: Vec<PoseObservation> = board .iter() .zip(&pixels) .map(|(p, px)| PoseObservation::new(0, *p, *px, noise)) .collect(); let pose = estimate_pose(&rig, &observations, &PoseOptions::default())?; println!("{}", pose.rig_se3_object); if let Some(cov) = pose.pose_covariance() { // 6x6 over [rx ry rz tx ty tz], rad and m, right-perturbation tangent } }
import vision_calibration as vc
from vision_calibration import mvg
noise = vc.PointCovariance.isotropic(0.3) # stated per pixel
observations = [
mvg.PoseObservation(camera=0, object=p, pixel=px, noise=noise)
for p, px in zip(board, pixels)
]
pose = mvg.estimate_pose(result, observations)
pose.rig_se3_object # 4x4 numpy matrix
pose.pose_covariance() # 6x6 over [rx ry rz tx ty tz], rad and m; None if unavailable
# RANSAC in σ and a robust loss for outliers:
robust = mvg.estimate_pose(
result, observations, ransac=mvg.RansacOptions(thresh=3.0), loss=vc.robust_huber(2.0)
)
An observation names the rig camera that saw the point (camera), so a rig of several cameras that see the same object contributes all of them to one pose. pose.inliers indexes the observations the pose used (all of them without RANSAC), and pose.reprojection_rms_px is the RMS pixel error over those.
Steps
- Start. The camera with the most observations (the lowest index on a tie) unprojects its pixels through its own model into bearings; those ahead of the camera (z > 0) become normalized coordinates and EPnP starts the pose on them (a planar object, such as a calibration board, is started from its plane homography). The camera-frame pose is composed into the rig frame with
cam_se3_rig. - Refine. The pose is refined on the reprojection error of every inlier of every camera (a generalized PnP), each pixel weighted by its stated noise, with the rig's calibration as consider parameters: they stay at their values and their covariance is carried into the pose's. Observations the start could not use, such as a fisheye point beyond 90° from the axis, enter here.
- Covariance. The pose covariance has two sources, the pixels' noise and the calibration's uncertainty. A rig without a covariance still gives the pose; the uncertainty then says it is missing and
pose_covariance()isNone.
Stated Noise, RANSAC in σ
Every observation carries its pixel covariance (PointCovariance::new, isotropic, or rig.reprojection_noise(camera)), as in triangulation. The noise is stated, not estimated.
PoseOptions::ransac runs RANSAC on the initializing camera: P3P on three points, the fourth choosing among its candidates, and as residual the reprojection distance through the camera model in σ of the stated noise (the Mahalanobis distance). A threshold of 3.0 keeps about 99.7 % of the inliers whatever the focal length or camera model, and means the same for every camera of the rig. The inliers are then taken from the start pose over all cameras, the pose is refined on them, the inliers are re-selected by the refined pose and refined again until the set settles (at most three rounds). PoseOptions::loss adds a robust loss to the refinement.
Errors come before any solve: a camera index out of range, a non-finite point or pixel, and fewer than four observations in the initializing camera are InvalidInput; collinear points are a Geometry error; RANSAC without a consensus is NoConsensus.
What Was Measured
On synthetic rigs with pixels drawn from the stated covariance (anisotropic and correlated):
- Ground truth. Without noise the pose is exact to rad and m for pinhole, Brown-Conrady and Kannala-Brandt fisheye cameras, planar and spatial objects, seen by either camera or both.
- RANSAC. With 30 % gross outliers (40 to 140 px) on a two-camera rig, RANSAC at 3σ accepted none of the 24 outliers and lost none of the 46 inliers; without it the translation is off by more than 5 mm.
- Pixel noise. Over 600 draws (a ratio from draws has a relative error of , 2.9 %), the scatter over the stated σ of each tangent component, rotation then translation, is 1.02, 0.99, 1.01, 1.02, 0.98, 1.01 with one camera and 1.02, 1.06, 1.03, 1.02, 1.01, 0.99 with two.
- Two cameras. For an object seen by both cameras of the rig, the σ of the translation is 2.0 times smaller than with the better camera alone and the σ of the rotation 1.4 times smaller.
- The calibration's share. Over 300 stereo calibrations from freshly noisy board views spread over the image, with a held-out board's pose estimated from one camera each time, the scatter over the stated σ is 1.07, 0.99, 0.99, 0.96, 1.04, 0.93 with the calibration considered. The σ from the pixels alone understates the translation by a factor of 3.4 to 26 and the rotation by 1.8 to 2.3: the pose of an object measured with a calibrated rig is limited by the calibration, not by the pixels.
Bundle Adjustment
bundle_adjust jointly refines the capture poses of a rig and the 3D points it observed, minimizing the reprojection error of the stated pixel noise. It is the polish of an already initialized reconstruction (for example the output of pose recovery and triangulation), not a structure-from-motion pipeline: there is no feature matching, pose-graph optimization or loop closure.
The cameras come from a CalibratedRig, so every camera model works (a fisheye beside a pinhole, distortion, tilted sensors) with no separate undistortion, and the result carries the joint covariance of what it refined, the calibration's uncertainty included. It runs on the same problem builder as your own residuals; bundle adjustment is always available, with no cargo feature.
Usage
#![allow(unused)] fn main() { use vision_calibration::mvg::bundle_adjust::{BundleAdjustOptions, Scale, bundle_adjust}; use vision_calibration::mvg::triangulation::RigObservation; // rig: CalibratedRig, the cameras and where each sits in the rig // rig_se3_world: &[Iso3], the initial pose of the rig at each capture (world → rig) // points: &[Pt3], the initial world points // tracks: Vec<Vec<RigObservation>>, tracks[i] observes points[i] let tracks = vec![vec![RigObservation::new(0, 0, pixel, noise) /* , ... */]]; let opts = BundleAdjustOptions::new(Scale::Rig); let adjusted = bundle_adjust(&rig, &rig_se3_world, &points, &tracks, &opts)?; println!("RMS {:.3} px", adjusted.reprojection_rms_px); let cov = adjusted.point_covariance(0); // Option<Mat3>, m² let pose = adjusted.pose_covariance(1); // Option<Matrix6>, capture 1 }
import numpy as np
from vision_calibration import mvg
# rig_se3_world: (K, 4, 4) initial capture poses; points: (N, 3) initial points;
# tracks[i] is a list of mvg.RigObservation of points[i].
adjusted = mvg.bundle_adjust(result, rig_se3_world, points, tracks, scale="rig")
# or fix the scale by a stated distance between points 0 and 1: 0.250 m, σ 0.1 mm
adjusted = mvg.bundle_adjust(
result, rig_se3_world, points, tracks, scale=("distance", (0, 1), 0.250, 1e-4)
)
adjusted.reprojection_rms_px
adjusted.point_covariance(0) # (3, 3) in m², or None
adjusted.pose_covariance(1) # (6, 6), capture 1 (capture 0 is the gauge: None)
calibration="consider" (the default) or "prior", loss=vc.robust_huber(...) and max_iters are the remaining options; the refined cameras are in adjusted.cameras and adjusted.cam_se3_rig.
The observations are the ones triangulate takes: RigObservation::new(capture, camera, pixel, noise), grouped into tracks, with the noise of each pixel stated as a PointCovariance. A pixel is weighted by its covariance, and the stated noise is also what the result's covariance is built from. The result carries the refined rig_se3_world and points, the rig (the given one, or the refined one under a prior), the uncertainty, the solver's report and the RMS reprojection distance in pixels (√2 σ for isotropic noise of σ per axis).
Everything is checked before any solve, and a mistake is an MvgError::InvalidInput naming the track, point or capture: points and tracks that do not pair up, a track with fewer than two observations, an index out of range, a non-finite start, fewer than two captures (or one capture seen by one camera, where there is nothing to adjust), and a gauge the data cannot fix.
The Gauge
Reprojection error cannot see the world frame or the scale of a reconstruction: moving or rescaling every capture and point together changes no pixel. So both are stated:
- Frame. Capture 0 is fixed at the pose given: the world frame is capture 0's rig frame as given. It must have observations. Every other capture and every point is refined relative to it, and capture 0 has no covariance.
- Scale.
Scalehas no default, because a silent scale would put a wrong metric answer in every result.Scale::Rigtakes it from the rig's extrinsics: the baseline between two cameras that see points in the same capture. It needs a rig of two or more cameras whose centres differ, and some capture seen by two of them.Scale::Distance { points: [a, b], distance, sigma }takes it from a stated distance between two points (metres), as a residual of its own weighed bysigma. This is the way for one moving camera, which sees only up to scale.
A scale the data cannot fix, such as Scale::Rig with one camera or with two cameras at one centre, is an error that names the alternative.
The covariance is in this gauge. Under Scale::Rig the uncertainty of the baseline is part of it; under Scale::Distance the stated sigma is.
The Calibration: Consider or Prior
BundleAdjustOptions::calibration chooses how the rig's uncertainty enters:
Use::Consider(the default). The cameras and extrinsics stay at their values, and their covariance is propagated into the capture poses' and points': the result is the one a fixed camera gives, with the covariance of one that is not exact. Every point and capture shares the calibration, so they are correlated (point_covariance(i)is a block of a dense joint covariance). A rig without a covariance still adjusts, and its uncertainty says why it has none.Use::Prior. The cameras and extrinsics are refined too, held by a Gaussian prior with the calibration's covariance, so the new observations sharpen them. It needs a rig with a covariance. The result'srigis the refined rig, with its covariance after the adjustment (labels of the cameras and extrinsics only, as a calibration export has them), and can be passed totriangulateor to another adjustment.
Noise and Robust Losses
BundleAdjustOptions::loss puts a robust loss on every reprojection (RobustLoss::Huber, Cauchy, Arctan). Because the residuals are whitened by their stated noise, the loss scale is in σ units: Huber { scale: 2.0 } is quadratic up to two σ. A loss changes the estimate and not the covariance, which takes every residual at its stated variance.
On a stereo rig with 4 captures of 30 points and 5 % of the observations 25 to 60 px off, the points' RMS error was 1.0 cm on clean data, 32 cm with no loss, 1.6 cm with Huber and 1.2 cm with Cauchy.
Reading σ
adjusted.uncertainty is the joint covariance of the free parameters, with the labels capture{k}.rx … tz for the capture poses (a right-perturbation tangent, T·Exp(δ): rotation in radians, translation in metres in the pose's own frame), p{i}.x/.y/.z for the points, and under Use::Prior the cameras' own. point_covariance(i) and pose_covariance(k) slice it. A capture or point that no connected observation constrains makes it unavailable, with the unobservable parameters named; so does a solve that stops on its iteration budget (max_iters, 100 by default).
The covariance is dense, so its cost grows with the cube of the parameter count: for thousands of points, adjust a window of captures.
How it was checked
Over repeated draws of the pixel noise, with a calibration of negligible covariance, the scatter of the points and of capture 1's pose against the reported σ was within 3 % over 300 trials, for all three coordinates of the points and all six components of the pose.
With a real calibration in the loop, each trial calibrated a stereo rig from freshly noisy board views (eight views spread over the image, tilted up to 0.5 rad) and adjusted three captures of forty points. Over 500 such calibrations:
| scatter / stated | |
|---|---|
Use::Consider, point depth | 0.95 |
Use::Consider, capture 1 translation (x, y, z) | 0.98, 1.06, 0.96 |
Use::Prior, refined fx (cameras 0, 1) | 0.95, 0.95 |
Use::Prior, refined rig, NEES per parameter (22) | 1.00 |
A second set of calibrations gave 0.94, (0.96, 0.92, 0.97), (0.94, 0.96) and 0.99. The NEES (the error weighed by the whole covariance) checks every direction of the refined rig at once, not only each parameter's own σ. Under Use::Prior each camera's σ(fx) after the adjustment was no larger than the calibration's (2.8 px before, 2.7 px after, on average); the scene sharpens the distortion and camera 1's pose in the rig more, by a quarter to a third of their σ.
A Weak Calibration as a Prior
A prior takes the calibration's covariance as a Gaussian, which is exact to first order. A calibration from board views bunched in the middle of the image and tilted by at most 0.2 rad hardly tells a camera's principal point from its rotation in the rig (σ(cx) ≈ 5 px, σ of camera 1's yaw ≈ 9 mrad). Their large errors cancel in the combinations the views do pin down, and through second-order terms they still reach them: along two such combinations the calibration's error was 2.5 to 3 times its stated σ, although every parameter's own σ was right. The refined cameras inherit it: their σ comes out 10 to 25 % optimistic. At a tenth of the pixel noise, or with views that fill the image and tilt further, the calibration's error matches its covariance and so does the refined rig's. The calibration's uncertainty predicts this (second_order), and the quality report's covariance_linearity check warns, naming the combination (Judging a Result). Before refining a calibration with Use::Prior, give it views that cover the image.
Relation to Calibration
Calibration workflows such as the rig extrinsics refine intrinsics, distortion and poses together, against a known target. bundle_adjust is the complement for scenes without a known target: the points are unknowns, and the cameras are given, with their uncertainty.
Measurements with Uncertainty
mvg::metrology measures with error bars. Given points and the joint covariance of their coordinates, it returns distances, angles, plane fits and point-to-plane distances, each with its standard deviation. The points come from triangulate or bundle_adjust, which return the covariance of the points they refined, the calibration's uncertainty included.
Usage
#![allow(unused)] fn main() { use vision_calibration::mvg::metrology::{PointSet, angle, distance, fit_plane, point_to_plane}; use vision_calibration::control::RunControl; use vision_calibration::mvg::triangulation::triangulate; use vision_calibration::mvg::types::Captures; // rig: CalibratedRig; tracks: the corners of a board, one track per corner let triangulation = triangulate(&rig, Captures::Rig, &tracks, &RunControl::default())?; let set = PointSet::from_triangulation(&triangulation)?; let length = distance(&set, 0, 7)?; // Scalar { value, sigma }, metres let corner = angle(&set, 3, 0, 24)?; // the angle at point 0, radians let board = fit_plane(&set, &all_corners)?; // normal, distance, covariance, rms let height = point_to_plane(&set, &board.indices, 49)?; println!("{:.4} m ± {:.4} m", length.value, length.sigma); }
from vision_calibration import mvg
found = mvg.triangulate(result, np.eye(4), tracks)
points = mvg.PointSet.from_triangulation(found)
length = mvg.distance(points, 0, 7) # Scalar(value, sigma), metres
corner = mvg.angle(points, 3, 0, 24) # the angle at point 0, radians
board = mvg.fit_plane(points, all_corners) # normal, distance, covariance, rms
height = mvg.point_to_plane(points, board, 49)
print(f"{length.value:.4f} m ± {length.sigma:.4f} m")
PointSet::new(points, covariance) takes any points with a 3N × 3N covariance (rows ordered x, y, z per point), and PointSet::independent takes one 3 × 3 covariance per point when the errors are not correlated. from_triangulation and from_bundle_adjustment read the covariance out of the result and fail with an InvalidInput naming the point (and the reason, when the whole uncertainty is unavailable) if a point has none. In Python, mvg.PointSet(points, covariance) takes an (N, 3) array and a (3N, 3N) one, and from_triangulation, from_laser_triangulation and from_bundle_adjustment raise ValueError in the same cases.
Why the Joint Covariance
Every point measured with one calibration shares that calibration's error: a focal length that is 0.1 % too long scales every depth, a baseline error shifts them all. The errors of two points are correlated, and a distance between them feels the shared part differently from the independent part. A σ built from each point's own covariance alone ignores that and is wrong in either direction.
All the points of a measurement must therefore come from one call that produced their joint covariance: two separate triangulate calls lose the cross terms.
The check: eight points with a covariance made of shared terms (a common translation, a scale, a stretch along the depth, a shear) plus independent noise, 20 000 draws. The scatter of each measurement over its stated σ:
| measurement | joint covariance | cross terms dropped |
|---|---|---|
| distance (short) | 0.994 | 0.52 |
| distance (long) | 0.992 | 0.74 |
| angle | 0.992 | 0.44 |
| point-to-plane, outside the plane | 0.990 | 0.64 |
| point-to-plane, a point of the plane | 1.002 | 0.44 |
plane distance d | 1.000 | 0.91 |
| plane normal, two components | 0.995, 1.000 | 0.67, 0.46 |
With the joint covariance the stated σ is right to the 1 % that the draws resolve. Without the cross terms it is off by up to a factor of two.
On a calibrated stereo rig the same holds end to end: over 300 calibrations from freshly noisy board views spread over the image, with a held-out board triangulated each time, the scatter of a 7.5 cm length over its stated σ is 1.00, of a 17.5 cm length 1.02, of the right angle at a board corner 0.97 and of the board plane's distance 0.89. The mean error over σ stays within 0.2 for each. The σ built from the observations alone matches the lengths and the angle (the board's own geometry is dominated by the pixel noise) but understates the plane's distance 2.2-fold: that is the calibration's share.
Propagation
A measurement over the coordinates of its points has
with the block of the joint covariance for the points involved. comes from dual numbers: the measurement is written once and differentiated exactly, with no finite-difference step. This is first-order propagation: it holds while the points' errors are small against what they measure. For a length the next term is a bias of about ( the error across the segment, its length), which is why a short distance between noisy points is biased high even when its σ is right.
angle is the angle at the vertex, in ; three collinear points are an error, since the angle is not differentiable there.
Plane Fits
fit_plane fits by total least squares (the centroid and the smallest eigenvector of the scatter matrix), with a unit vector oriented so that . It needs at least three points that are not collinear; fewer, repeated or out-of-range indices are errors before any work.
Its covariance holds the points as consider parameters. The fit is the stationary point of the sum of squared orthogonal distances over , where is the normalized for a tangent basis of the fitted normal. The implicit-function theorem gives how the plane moves with the points,
PlaneFit::covariance is in the units rad², rad·m and m², with the basis in tangent_basis. It comes from the points' covariance, not from how well the points lie on the plane: a noisy but planar scene does not widen it, and rms (the orthogonal distance of the points to the plane) is a planarity diagnostic beside it.
Point to Plane
point_to_plane(set, plane, p) is the signed distance of a point to the plane fitted through plane, positive on the side the normal points to. The plane and the point are correlated, so the gradient has two parts,
and the σ uses every involved point's covariance with the cross terms. p may be one of the plane's points (a flatness measure of a corner against the board), and the result accounts for that.
Stereo from a Calibration
mvg::rectification::rectify turns two cameras of a CalibratedRig into a rectified pair: two virtual pinhole cameras that share one set of intrinsics and an orientation whose x axis is the baseline, so corresponding points lie on the same image row. That is the precondition for one-dimensional disparity search (Dense Stereo), and the pair then turns disparities into 3D points and states how uncertain they are.
The maps go through each camera's own unprojection and projection, so raw pixels go in and every camera model works: lens distortion, a fisheye, a tilted (Scheimpflug) sensor. There is no separate undistortion step and no model-specific case.
Usage
#![allow(unused)] fn main() { use vision_calibration::mvg::rectification::{RectifyOptions, rectify}; let rect = rectify(&rig, [0, 1], &RectifyOptions::default())?; // A raw pixel of each camera, and the rectified pixels they map to: let left = rect.rectified_pixel(0, &raw_left).unwrap(); let right = rect.rectified_pixel(1, &raw_right).unwrap(); // left.y == right.y for a corresponding pair, and let disparity = left.x - right.x; // positive for a point ahead of the pair }
from vision_calibration import mvg
rect = mvg.rectify(result, (0, 1)) # k_rect=..., scale=... as RectifyOptions
left = rect.rectified_pixel(0, raw_left) # (x, y), or None
right = rect.rectified_pixel(1, raw_right)
disparity = left[0] - right[0] # positive for a point ahead of the pair
point = rect.point(left, disparity) # (3,) in the left camera's frame
cov = rect.disparity_covariance(left, disparity, sigma_d=0.2) # (3, 3), m²
q = rect.disparity_to_depth() # (4, 4)
rectified = rect.rectify_image(0, raw_image, 640, 480) # (480, 640) uint8
found = rect.triangulate(result, [(left, disparity)], noise) # with the calibration's σ
StereoRectification is an opaque object in Python: it is rebuilt from a calibration with mvg.rectify, not saved. Images are (H, W) uint8 numpy arrays.
cameras = [left, right] names two different cameras of the rig (any pair: the reference camera need not be one of them). RectifyOptions has two settings. By default the rectified intrinsics are the mean of the two cameras' fx, fy, cx, cy, multiplied by scale (1 by default; 0.5 gives a half-size rectified image); k_rect sets them explicitly. Both rectified cameras share them, which is what aligns the rows.
| Method | Meaning |
|---|---|
rectified_pixel(side, raw) | the rectified pixel of a raw pixel (side 0 is the left camera, 1 the right); None when its ray points behind the rectified image plane |
raw_pixel(side, rectified) | the raw pixel a rectified pixel comes from; None outside the camera's projection domain |
rectify_image(side, src, width, height) | the rectified image, sampled bilinearly from the raw one; 0 where the source has no pixel |
k_rect(), baseline(), rect_r_cam(side) | the rectified intrinsics, the distance between the camera centres (metres for a calibration in metres) and the rotation into the rectified frame |
rectify_image takes the output size freely, so a rectification with scale: 0.5 of a full-size source fills a half-size image. It samples the source rather than filtering it: reduce a large source first, or keep the reduction small, to avoid aliasing.
Disparity to Depth
On the rectified pair a match is a left pixel and a disparity . With baseline and rectified focal length , the depth is . disparity_to_depth() returns the matrix (OpenCV's convention) that does this for a whole map: is the homogeneous point in the rectified left frame.
point(&left, d) gives the same point in the left camera's frame (cameras[0]), or None for :
#![allow(unused)] fn main() { let point = rect.point(&Pt2::new(x, y), disparity).unwrap(); }
Disparity Uncertainty
A disparity error moves the point along its viewing ray, , with the rectified row and column exact. disparity_covariance(&left, d, sigma_d) returns the covariance of point that a disparity noise of sigma_d pixels alone gives it, in the left camera's frame:
It has rank 1, along the ray, and it grows with the square of the depth. Over 2000 draws of the disparity noise the scatter of the depth over this is 0.97 to 1.06 at 0.7, 1.5 and 3 m, which is the sampling error of the draws. This is the covariance a dense point cloud carries per point; it does not include the calibration's uncertainty.
Measurements with the Calibration's Uncertainty
The calibration is itself uncertain, and every point measured with it shares that error. For matches you select (corners, a few points to measure), triangulate includes it:
#![allow(unused)] fn main() { use vision_calibration::control::RunControl; use vision_calibration::core::PointCovariance; use vision_calibration::mvg::rectification::RectifiedMatch; let matches = [RectifiedMatch::new(Pt2::new(x, y), disparity)]; let noise = PointCovariance::isotropic(0.3)?; // each raw pixel's σ let found = rect.triangulate(&rig, &matches, noise, &RunControl::default())?; let covariance = found.point_covariance(0); // 3×3, m², the calibration's share included }
Each match is mapped back to the two cameras' raw pixels and triangulated as one stereo capture with triangulate: refined on the reprojection error of the stated noise, the rig's calibration entering as consider parameters. The points are in the rig frame (the left camera's frame only when it is the reference camera), and the covariance is joint over the points, so cross_covariance(i, j) gives the correlation the shared calibration causes. rig must be the rig the rectification was built from.
Measured on a stereo rig calibrated 200 times from noisy board views spread over the image, with ten held-out points measured from freshly noisy pixels, the scatter of the depth over the stated depth σ is 0.97 with the calibration's share (3.0 mm). The σ from the observations alone (2.1 mm) is 1.46 times too small, and so is the disparity covariance of the previous section, because the calibration is a large part of the error of a short baseline at a short range (with centred, barely tilted calibration views, 3.2 times). A disparity covariance is the right σ for the matcher's noise, and triangulate is the one for a measurement.
Failure Modes
rectify fails with a typed error before any work: the same camera twice, a camera out of range, or a scale or k_rect that is not finite and positive is MvgError::InvalidInput; coincident camera centres (a zero baseline) is MvgError::Degenerate. triangulate returns InvalidInput for a rig other than the one rectified, or for a match whose rectified pixel has no raw pixel in a camera.
A baseline along the optical axis (forward stereo) is handled, but the rectified field of view degrades as the baseline leaves the image plane.
Dense Stereo
Once a pair is rectified, mvg::dense::match_block produces a per-pixel disparity map. It is a pure-Rust matcher: no OpenCV and no GPU.
Block Matching
The matcher scores each pixel and each candidate disparity with the zero-mean normalized cross-correlation (ZNCC) over a square window, which tolerates a per-camera gain and bias. It selects the winner per pixel, refines it to sub-pixel precision with a parabola through the correlation peak, and filters with left-right consistency, a uniqueness check and a minimum correlation. Pixels that fail a check are NaN in the result.
#![allow(unused)] fn main() { use vision_calibration::mvg::dense::{match_block, BlockMatchOptions, GrayImage}; // `left` / `right` are rectified grayscale images: `rect.rectify_image(0, ..)` // and `rect.rectify_image(1, ..)` of the raw pair (see Stereo from a Calibration). let disp = match_block(&left, &right, &BlockMatchOptions { min_disparity: 0, num_disparities: 64, // search [min, min + num) block_size: 9, // odd; larger = smoother, fewer matches near edges ..Default::default() })?; // disp.get(x, y) is x_left − x_right in pixels, or NaN where the matcher abstains. }
from vision_calibration import mvg
left = rect.rectify_image(0, raw_left, 640, 480) # (H, W) uint8 arrays
right = rect.rectify_image(1, raw_right, 640, 480)
disp = mvg.match_block(left, right, mvg.BlockMatchOptions(num_disparities=64, block_size=9))
# disp is a (H, W) float32 array: x_left − x_right in pixels, NaN where the matcher abstains.
GrayImage::new(width, height, data) wraps row-major 8-bit pixels. The options and their defaults:
| Option | Default | Meaning |
|---|---|---|
min_disparity, num_disparities | 0, 64 | the searched disparity range [min, min + num) |
block_size | 7 | square window side in pixels; odd |
min_correlation | 0.5 | reject a pixel whose best ZNCC is below this |
uniqueness_ratio | 0.03 | reject a pixel unless its best ZNCC beats the best non-adjacent alternative by this margin |
lr_consistency, lr_max_diff | on, 1.0 px | the left-right cross-check and its tolerance |
subpixel | on | parabolic refinement of the peak |
semi_global, sgm_p1, sgm_p2 | off, 0.1, 1.5 | semi-global aggregation (below) |
match_block returns an MvgError::InvalidInput for images of different sizes, an even or zero block_size, a non-positive num_disparities, or images smaller than the window. The result is a DisparityMap with get, is_valid, valid_count and valid_range.
Semi-Global Matching
For low-texture surfaces (the flat interiors of chessboard squares, for instance) set semi_global: true. The matching cost is then aggregated along 8 paths with the sgm_p1 penalty for a one-pixel disparity step (small, so slanted surfaces survive) and the sgm_p2 penalty for larger jumps (large, so depth discontinuities survive; sgm_p2 must be at least sgm_p1). Disparity then propagates into regions block matching leaves blank: on a real chessboard rig coverage rose from about 21 % to about 49 %.
Semi-global matching is strictly additive: block matching stays the default. In SGM mode min_correlation and uniqueness_ratio (which gate on the raw per-pixel correlation) are bypassed; the left-right consistency check and the disparity window are the filters. It costs extra time and memory.
Disparity to Depth
For a rectified pair with shared focal length (pixels) and baseline , a disparity gives depth . StereoRectification converts for you (point, the matrix disparity_to_depth, and the disparity's covariance; see Stereo from a Calibration). The desktop app's Depth workspace uses it and renders depth and a reprojected point cloud (see Inspecting Results).
Try It
A runnable demo writes inspectable PNGs (left, right, ground truth, estimate and error) to target/fixtures/:
cargo run -p vision-calibration-bench --example dense_synth --features tier-b --release
Measuring a Board with a Stereo Calibration
The question this chapter answers: when a calibrated system reports a length as d ± σ, is the σ honest? A board of known size makes it checkable. Its squares have a published size, so the distance between any two corners is known, and a measurement made on views the calibration never saw can be compared with it.
The experiment runs on real images: OpenCV's fisheye stereo test set, 34 synchronised views of a chessboard with 6 × 8 corners and 24.4 mm squares, 1280 × 800 pixels, two fisheye cameras (the sample opencv_fisheye_stereo, pinned at opencv_extra revision ebf9366b3845). Both examples download it from its publisher on the first run.
cargo run --release -p vision-calibration --features fetch --example measure_board
python crates/vision-calibration-py/examples/measure_board.py
Both take a sample id: opencv_stereo runs the same procedure on a pinhole pair (below).
Procedure
- Fetch and detect.
dataset::samples::fetchdownloads the sample; the dataset runner detects the board in every image and returns 34 usable rig views, each holding both cameras' detections. - Split. The even views (17) calibrate; the odd views (17) are held out. The split is by view index, fixed in advance, and each view keeps both cameras' detections.
- Calibrate. The rig-extrinsics workflow runs on the 17 views with the fisheye model (equidistant projection, Kannala-Brandt distortion).
export.calibrated_rig()is the rig with its covariance. - Triangulate each held-out view. Every corner both cameras saw is a track of two observations. The pixel noise is what the calibration states for its own detector,
rig.reprojection_noise(camera), not a number chosen for the experiment. The 48 corners are triangulated in one call, so their joint covariance carries the calibration's uncertainty. - Measure. For every pair of corners on one board row or column, 1 to 7 squares apart,
metrology::distancegivesd ± σ; the truth isk × 24.4 mm.fit_planeover the corners gives the plane'srms, a planarity check. - Compare. Per length, the mean error, the RMS error, the mean stated σ and the z-score RMS: the RMS of
(d − truth) / σ. A z RMS near 1 means the σ describes the error.
The same run is repeated with the calibration's covariance replaced by zero, which leaves the share of the pixel noise alone. The difference between the two is what the calibration's uncertainty adds.
#![allow(unused)] fn main() { let rig = export.calibrated_rig()?; let noise = [rig.reprojection_noise(0)?, rig.reprojection_noise(1)?]; let tracks: Vec<Vec<RigObservation>> = /* one per corner seen by both cameras */; let set = PointSet::from_triangulation(&triangulate(&rig, Captures::Rig, &tracks, &RunControl::default())?)?; let length = distance(&set, a, b)?; // Scalar { value, sigma } let error = length.value - truth; // against the board's known size }
The examples keep the measurement in a few functions and print the table below; the Rust one is tested on a synthetic stereo board without the network.
The Result
Run on 2026-11-03, library 0.14.0, the sample revision above. 17 calibration views; 14 of the 17 held-out views measured (in three, one camera did not see the whole board). The calibration has a mean reprojection error of 0.280 px and a stated pixel noise σ̂ of 0.217 px in both cameras. Over the 14 views the board's triangulated corners lie 0.43 mm RMS from their best-fit plane. All lengths in millimetres.
| squares | pairs | mean error | RMS error | mean σ | z RMS | mean σ, observations only | z RMS, observations only |
|---|---|---|---|---|---|---|---|
| 1 | 1146 | +0.012 | 0.216 | 0.333 | 0.64 | 0.329 | 0.65 |
| 2 | 950 | +0.005 | 0.281 | 0.343 | 0.74 | 0.332 | 0.78 |
| 3 | 754 | −0.034 | 0.370 | 0.362 | 0.80 | 0.341 | 0.87 |
| 4 | 558 | −0.025 | 0.454 | 0.388 | 0.85 | 0.357 | 0.95 |
| 5 | 362 | −0.033 | 0.556 | 0.419 | 0.98 | 0.378 | 1.11 |
| 6 | 167 | −0.080 | 0.633 | 0.457 | 0.98 | 0.402 | 1.13 |
| 7 | 83 | −0.163 | 0.765 | 0.507 | 0.98 | 0.434 | 1.17 |
A 7-square length is 170.8 mm: the RMS error of 0.77 mm is 0.45 %, and the stated σ 0.30 %.
What the z-Scores Say
The σ is not tight everywhere, and it is not too small anywhere. The z RMS runs from 0.64 for a single square to 0.98 for five squares and more. No length has a z above 1. The error therefore stays inside the stated σ on average, and the σ is conservative for short lengths.
With and without the calibration's share. The calibration adds 1 % to the σ of a one-square length and 17 % to that of a seven-square length: the further apart the corners, the more of their distance error is the calibration's (scale and baseline), which both corners share. The observations-only σ is too small for the long lengths (z 1.11 to 1.17); with the calibration's share the z falls to 0.98. For short lengths it makes no practical difference. So the share belongs in the σ of a measurement, and it matters where the measurement is long.
The trend with length. The RMS error grows 3.5 times from one to seven squares while the stated σ grows 1.5 times. Two mechanisms are consistent with this data; the run does not isolate either:
- Short lengths are over-stated. The σ treats the 0.217 px level as independent noise on every corner. If neighbouring corners of a real detection share errors (the same blur, illumination or lens-model residual), the common part cancels in their distance, and the distance is better than independent noise says. The effect fades as corners move apart.
- Long lengths pick up a per-view scale error. A per-view breakdown of the same measurements (not printed by the examples) gives the mean relative error of each held-out view's seven-square lengths: within ±0.3 % in 13 of 14 views, and −1.5 % in one. The one-square lengths of a view have the same sign in 12 of 14 views and a similar size (−1.0 % in that outlying view): the error is a scale of the view's whole reconstruction, not noise on single corners. That view alone has a z of 2.4 at seven squares; 11 of the 14 views are below 0.9. Where the view's board sits in the image, or how well the fisheye model fits there, would be the first suspects.
How reliable is the table. The pairs of one view are strongly correlated, so the effective sample is the 14 views, not the 4 000 pairs. The z of a long length rests on a few views and moves with one of them: it would be lower without the −1.5 % view. The table says the σ is of the right order and, for long lengths, close; it does not support a statement more precise than that.
The Calibration's View Scatter
The calibration reports view_scatter 12.2: the variance of its shared parameters across the views is 12 times what independent corner noise predicts, measured by leaving out one view at a time, and its covariance is already scaled by it (see Judging a Result). That scaling is where the calibration's 17 % at seven squares comes from; with the covariance as the corner noise alone predicts, the share would be a small fraction of that and the σ would sit near the observations-only column, z 1.17 at seven squares.
The real-data picture is therefore this: the fisheye model fits these images to a few tenths of a pixel, not to the detector's accuracy, so a σ built from independent corner noise understates what the calibration does not know. The scatter-corrected covariance the library reports closes that gap for long lengths here, and leaves the short ones conservative.
A Pinhole Pair
The same procedure runs on OpenCV's pinhole stereo sample (opencv_stereo, pinned at opencv revision d195a5ad7eb5): 13 synchronised views, 640 × 480 pixels, of a chessboard with 6 × 9 corners, seen turned by 90 degrees in most views. OpenCV does not publish the printed square size, so the sample declares squares of 1 and every length below is in squares. The cameras are calibrated with the default model, pinhole with Brown-Conrady distortion.
cargo run --release -p vision-calibration --features fetch --example measure_board -- opencv_stereo
python crates/vision-calibration-py/examples/measure_board.py opencv_stereo
Run on 2026-11-03. All 13 views are usable; in two of them one camera's detection misses part of the board and is dropped, so those views carry one camera. 7 views calibrate (5 with both cameras) and 6 are held out, all 6 measured with all 54 corners. The calibration has a mean reprojection error of 0.199 px, a stated pixel noise σ̂ of 0.146 px in both cameras and a view scatter of 4.17. The triangulated corners lie 0.013 squares RMS from their best-fit plane.
| squares | pairs | mean error | RMS error | mean σ | z RMS | mean σ, observations only | z RMS, observations only |
|---|---|---|---|---|---|---|---|
| 1 | 558 | −0.0009 | 0.0096 | 0.0103 | 0.85 | 0.0102 | 0.86 |
| 2 | 468 | −0.0021 | 0.0111 | 0.0107 | 1.00 | 0.0103 | 1.04 |
| 3 | 378 | −0.0043 | 0.0125 | 0.0113 | 1.15 | 0.0107 | 1.22 |
| 4 | 288 | −0.0067 | 0.0153 | 0.0122 | 1.30 | 0.0113 | 1.40 |
| 5 | 198 | −0.0101 | 0.0177 | 0.0133 | 1.41 | 0.0120 | 1.54 |
| 6 | 108 | −0.0138 | 0.0203 | 0.0147 | 1.51 | 0.0130 | 1.69 |
| 7 | 72 | −0.0123 | 0.0202 | 0.0160 | 1.40 | 0.0139 | 1.59 |
Here the σ is honest for short lengths and too small for long ones: z RMS 0.85 at one square, 1.4 to 1.5 from five squares on. The mean error is negative and grows with the length, from −0.0009 squares at one square to −0.0138 at six (−0.23 %): the held-out boards come out slightly small, a scale error of the reconstruction that every corner pair of a view shares, and noise on single corners does not produce it. The calibration's share adds 1 % to the σ of one square and 15 % to that of seven, and lowers the long lengths' z from about 1.6 to 1.4 without closing the gap. Two features of the run are consistent with a scale the calibration states too tightly: it rests on 7 views, two of them seen by one camera, and the rig calibrated from all 13 views has a baseline of 3.327 ± 0.005 squares against 3.338 from these 7, a difference of about two of the 13-view σ. Six held-out views are a small sample, so the table supports the order of the σ, not its second digit.
What to Take from It
- State the pixel noise with
rig.reprojection_noise, and pass the whole set of points of a measurement through onetriangulatecall; the joint covariance is what makes a length's σ right. - Check a calibration on held-out views of a known object before relying on its σ; the two examples are templates for it.
- Expect conservative σ for short lengths on real images and a few views that fall outside it: a per-view scale error is not a noise a σ on pixels can describe.
The Desktop App
Everything the library does is reachable from Rust and Python. The desktop app exists so that you do not have to write code to run a calibration or look at its result: point it at a dataset folder, click Run, and get an interactive view of the reprojection errors, the laser residuals, the parameter uncertainty and the 3D rig geometry. It calls the same solvers as the vision-calibration crate.
Launching
The app is built from a local checkout with bun. It uses bun exclusively (never npm, pnpm or yarn); the Tauri CLI is a development dependency, so no global install is needed.
cd app
bun install # first-time setup, and after package.json changes
bun run tauri dev # launches the desktop app
bun run tauri build # bundles the app (installer or binary)
Always use bun run tauri dev. Plain bun run dev starts only the Vite development server in a browser: the file dialog and every command that reaches the Rust backend (running a calibration, computing dense stereo, sniffing a folder) are absent, and the app shows a banner saying so as soon as you try to open a file. Signed installers are not provided.
Mental Model
- Five workspaces, reachable from the left rail (hotkeys
⌘1to⌘5): Diagnose, 3D viewer, Epipolar, Depth and Run calibration. - One shared store holds the currently loaded
*ExportJSON plus derived pose and camera indices. Every workspace except Run reads from it; they all react to the same loaded export, the selected pose and the selected camera. - Three ways to fill the store. The top bar's Open Export… button loads a
*Export.jsonfile directly. Running a calibration from the Run workspace hands off to Diagnose automatically on success. With a project open, choosing one of its saved runs loads that run's export. - The export needs
diagnostics.image_manifestto render anything visual. An export with residual numbers but no manifest produces an error banner ("This export has no image_manifest field") instead of a blank viewer. Runs launched from the Run workspace fill it in; a hand-written export may not have one.
What Is Where
| Workspace | Hotkey | Use it to |
|---|---|---|
| Run | ⌘5 | configure and launch a calibration from a dataset folder (Running Calibrations) |
| Diagnose | ⌘1 | look at per-corner residuals on the source images, per-pose statistics and the parameter uncertainty (Inspecting Results) |
| 3D viewer | ⌘2 | see the calibrated rig: camera frustums, target boards, laser planes |
| Epipolar | ⌘3 | click a point in one image, see its epipolar line in another |
| Depth | ⌘4 | rectify a stereo pair and compute disparity, depth and a point cloud |
The Run workspace is the entry point for a new dataset; the other four work on any export, whether produced by the app or by session.export() in your own code.
Projects
A project is a folder that keeps a dataset's manifest and the runs calibrated from it, so the work survives closing the app:
my-project/
calibration-project.json marks the folder as a project; names the dataset folder
dataset.json the dataset manifest
runs/run-001/ manifest.json, config.json, export.json, diagnostics.json, report.html
The Project menu in the top bar does four things:
- New project from this dataset… makes a chosen folder a project for the dataset the Run workspace has loaded, whether from a preset, a sniffed folder or a manifest file. The images stay where they are: the project records the dataset folder, relative to the project when it is inside it and absolute otherwise. A
dataset.tomlyou wrote by hand is read once and never rewritten; the project keeps its owndataset.json. - Open project… loads the project's manifest into the Run workspace and lists its runs in a panel on the left; choosing a run loads its export into the viewers. A folder without
calibration-project.jsonis reported as "not a project folder"; the app never guesses one from a folder's contents. - Save manifest writes the Run workspace's edited manifest back to
dataset.json. - Recent reopens one of the last projects you opened.
While a project is open, every successful run is saved as the next runs/run-NNN/ folder: the manifest and configuration it ran with, the export, its diagnostics (views used, run time) and report.html, the run as one page to read or share (The Calibration Report). A saved run is never overwritten. Its export.json points back at the dataset's images, so it also opens on its own with Open Export….
The app reads images only from folders you opened (a project's dataset, an export's images); a frame elsewhere fails to load with an error instead of being read silently.
Relation to the Library
The app's forms are generated from the same JSON Schemas as the Rust configuration and manifest types (cargo xtask emit-schemas), so every field the pipeline accepts is editable without hand-writing JSON. The manifest it edits is the dataset manifest; the results it displays are the exports and uncertainty described in this book.
To work on the app itself, see app/DEVELOPING.md in the repository.
Running Calibrations
The Run calibration workspace (⌘5) drives a calibration end to end without leaving the app: choose a dataset, review the manifest and the configuration, click Run. It runs any of the seven problem types and all four target detectors; the manifest it edits is the dataset manifest.
Choosing a Dataset
Presets
A card grid of public datasets. Two are built in:
kuka_1: single-camera hand-eye, a KUKA arm with a 17×28 chessboard;ds8: a three-camera rig hand-eye dataset, published as open data (doi:10.15482/USDA.ADC/1340592).
The app carries no dataset. The first click on a card downloads the sample from its publisher into the platform cache (VISION_CALIBRATION_DATA overrides the location), checks every file against its SHA-256 and reuses it afterwards, so the cards work in an installed app as well as in a checkout; the first click needs a network connection, and a failed download is reported in the status banner. Clicking a card loads its manifest and applies the configuration overrides the preset carries. Some datasets need non-default settings that the topology's schema defaults do not give, for example Scheimpflug sensors or an eye-to-hand rig; the preset states them. The registry lives in app/src/workspaces/RunWorkspace/presets.ts.
Sniff Folder
Pick an arbitrary dataset folder and the app infers a draft manifest with the folder sniffer. It reads file names and structure only. Fields it cannot determine are left unresolved and block Run until you fill them in; a red badge marks each one. This is the unresolved-fields rule of the manifest in action.
Reviewing the Setup
The Manifest and Calibration config sections are collapsible, schema-driven forms generated from the same JSON Schema the Rust types derive. Every field shows its default and description. An Advanced JSON editor is available for anything the forms do not expose.
Editing the form clears the preset selection, so nothing silently reverts your edits. To re-run with different settings, edit the config and click Run again.
Run
While the solve runs, a stage checklist (detecting features, solving, exporting) with a live elapsed clock replaces a plain spinner; beside the active stage it shows the images detected so far, then the calibration step and its solver iteration with the cost. A Cancel button stops the run at its next image or solver iteration. A cancelled run shows a distinct "Run cancelled" banner, not an error, and produces no export.
On success the app flashes a status banner and navigates to Diagnose after a short delay, with the fresh export loaded. With a project open, the run is first saved as the project's next runs/run-NNN/ and the export is loaded from there; the banner names the run. Detection goes through the detection cache, so a second run on the same images skips it.
When the runner meets an ambiguity it refuses to guess (a hand-eye dataset with no pose_pairing, for example), a modal dialog asks you to pick one of the suggested options instead of continuing. Validation failures and other errors appear inline.
Walkthrough: Calibrate the KUKA Dataset
- Open the Run workspace and click the KUKA handeye preset card.
- Review the manifest (a chessboard of 17x28 corners with 20 mm squares and
by_indexpairing) and the config (eye-in-hand is the default). - Click Run.
- On success you land in Diagnose with the export loaded: the intrinsics, the hand-eye transform and the reprojection error (well under a pixel on this dataset) are in the numbers, and the residual heatmap is on the images.
Laser Datasets
Laser datasets run in two stages (rig_handeye, then rig_laserline_device); the manifest shape is described in Laser Datasets. Such setups usually need non-default configuration (Scheimpflug sensors, eye-to-hand mounting, point-to-plane laser residuals); set it in the config form, or put it in a preset.
Inspecting Results
The four result workspaces work on the currently loaded export. Load one with Open Export… in the top bar (any *Export.json produced by a calibration session, with diagnostics.image_manifest populated), or arrive with one from a run. The viewer infers the export kind from its JSON shape (planar, rig hand-eye, rig plus laserline, ...) and shows the matching header label. It works for all eight export kinds, single-camera or rig, with or without a laser stage.
Diagnose
⌘1, the default workspace: the residual viewer.
- Step through
(pose, camera)frames with the pose and camera steppers or the arrow keys. The source image renders with the detected corners colour-coded by reprojection error, a heatmap on which the brightest corners are the worst. These are the per-feature residuals of the export. - A histogram panel summarizes the distribution under the cursor's region of interest.
- Compare mode shows two frames side by side with a linked viewport, so pan and zoom stay in sync.
- The laser frame toggle switches a target frame to its paired laser frame and shows the point-to-plane residuals instead of the corner reprojection error.
- Stats opens a side panel with a sortable per-pose residual table (mean, median and maximum in pixels, and the feature count; click a row to jump the viewer to that pose) and, for multi-camera exports, a cameras by poses residual matrix coloured by mean error (click a cell to jump to that
(pose, camera)). - Uncertainty shows the result's parameter uncertainty: a table of each estimated parameter's value and σ (angles in degrees, translations in millimetres; poses show σ only), the parameters held fixed, the residual noise, a correlation heatmap of |ρ| (hover a cell for the signed value) and the projection σ map of the camera on screen, computed at its frame's size. A result without a covariance says why, for example which parameters the data does not determine. Rig results hold each camera's intrinsics fixed in the final solve unless
rig.refine_intrinsicsis set, so their projection map is unavailable by default.
3D Viewer
⌘2: a Three.js scene of the calibrated rig. It draws one frustum per camera (bounded by the back-projected image border, so lens distortion shows), the target board at the selected pose or at all poses at once, and each laser plane as a light-sheet fan rooted beside its camera when the export carries them.
Click a frustum to select a camera and see its intrinsics and its pose relative to a chosen reference camera; click a board to select a pose. Rig exports render directly. A single-camera laserline_device export also renders: its camera sits at the rig origin, and its camera-frame laser plane and per-view poses are drawn in that frame. The other single-camera exports (planar, Scheimpflug, single-camera hand-eye) carry no extrinsic to place a frustum and show an empty-state message instead.
Epipolar
⌘3: click a point in one camera's image and the corresponding epipolar line is drawn in a second camera's image, computed from the calibrated relative pose, raw or undistorted. It is a quick visual sanity check that two cameras in a rig are calibrated consistently with each other (see Epipolar Geometry for the maths). It needs a rig export with at least two cameras.
Depth
⌘4: pick two cameras and a pose from a loaded rig export and click compute. The app rectifies the pair (Stereo from a Calibration), runs block matching or semi-global matching (Dense Stereo) and gives you rectified, disparity, overlay, depth and 3D point-cloud views. Toggle semi-global aggregation to fill in the low-texture regions that plain block matching leaves blank. It needs a stereo rig export (two cameras with extrinsics), the same gate as Epipolar.
Walkthrough: Load an Export into Diagnose
- Click Open Export… in the top bar.
- Pick any
*Export.jsonproduced by a calibration session, from a Run or written bysession.export()in your own code withdiagnostics.image_manifestpopulated. - Diagnose infers the export kind from the JSON and shows the right header label. Step through frames with the arrow keys or the steppers.
Composable Camera Pipeline
A camera model maps 3D points in the camera frame to 2D pixel coordinates. In calibration-rs, this mapping is decomposed into four composable stages, each implemented as a trait:
where:
- — projection: maps a 3D direction to normalized coordinates on the image plane
- — distortion: warps normalized coordinates to model lens imperfections
- — sensor: applies a homography for tilted sensor planes (identity for standard cameras)
- — intrinsics: scales and translates to pixel coordinates
The Camera Struct
The camera is a generic struct parameterized over the four model traits:
#![allow(unused)] fn main() { pub struct Camera<S, P, D, Sm, K> where S: RealField, P: ProjectionModel<S>, D: DistortionModel<S>, Sm: SensorModel<S>, K: IntrinsicsModel<S>, { pub proj: P, pub dist: D, pub sensor: Sm, pub k: K, } }
The scalar type S is generic over RealField, enabling the same camera to work with f64 for evaluation and with dual numbers for automatic differentiation.
Forward Projection
Given a 3D point in the camera frame, projection to pixel coordinates proceeds through the four stages:
#![allow(unused)] fn main() { pub fn project_point_c(&self, p_c: &Vector3<S>) -> Option<Point2<S>> }
-
Projection: Compute normalized coordinates . For pinhole: . Returns
Noneif the point is behind the camera (). -
Distortion: Apply lens distortion . Warps normalized coordinates according to the distortion model (e.g., Brown-Conrady radial + tangential).
-
Sensor transform: Apply sensor model . For standard cameras this is identity; for tilted sensors it applies a homography.
-
Intrinsics: Map to pixels . Applies focal lengths, and principal point.
Inverse (Back-Projection)
The inverse maps a pixel to a ray in the camera frame:
#![allow(unused)] fn main() { pub fn backproject_pixel(&self, px: &Point2<S>) -> Ray<S> }
- — pixel to sensor coordinates
- — sensor to distorted normalized
- — undistort (iterative)
- — normalized to 3D direction
The result is a point on the plane in the camera frame, defining a ray from the camera center.
Common Type Aliases
For the most common configuration (pinhole + Brown-Conrady + no tilt):
#![allow(unused)] fn main() { type PinholeCamera = Camera<f64, Pinhole, BrownConrady5<f64>, IdentitySensor, FxFyCxCy<f64>>; }
The make_pinhole_camera(k, dist) convenience function constructs this type.
Projecting World Points
To project a world point through a posed camera, first transform from world to camera frame using the extrinsic pose :
Then apply the camera projection:
Trait Composition
Each stage is an independent trait. This design allows mixing and matching:
| Projection | Distortion | Sensor | Intrinsics |
|---|---|---|---|
Pinhole | NoDistortion | IdentitySensor | FxFyCxCy |
Pinhole | BrownConrady5 | IdentitySensor | FxFyCxCy |
Pinhole | BrownConrady5 | HomographySensor | FxFyCxCy |
Note: ScheimpflugParams stores the tilt angles and has a compile() method that produces a HomographySensor suitable for the Camera struct. See the Sensor Models chapter.
The subsequent sections detail each stage.
Pinhole Projection
The pinhole model is the simplest and most widely used camera projection. It models a camera as an ideal point through which all light rays pass — no lens, no aperture, just a geometric center of projection.
Mathematical Definition
Given a 3D point in the camera frame, the pinhole projection maps it to normalized image coordinates:
This is perspective division: the point is projected onto the plane. The projection is defined only for points in front of the camera ().
The inverse (back-projection) maps normalized coordinates to a 3D direction:
This defines a ray from the camera center through the image point.
Camera Frame Convention
calibration-rs uses a right-handed camera frame:
- : right
- : down
- : forward (optical axis)
This matches the convention used by OpenCV and most computer vision libraries. A point with positive is in front of the camera; negative is behind it.
The ProjectionModel Trait
#![allow(unused)] fn main() { pub trait ProjectionModel<T: RealField> { fn in_domain(&self, p_c: &Vector3<T>) -> bool; fn project(&self, p_c: &Vector3<T>) -> Point2<T>; fn unproject(&self, n: &Point2<T>) -> Ray<T>; fn is_central(&self) -> bool; } }
The projection takes the camera-frame point, and back-projection returns a
Ray (origin and direction). For Pinhole, in_domain is z > 0,
project is (x / z, y / z) and unproject returns the direction
(n_x, n_y, 1) from the camera centre. project is total: callers that need
validity check in_domain first.
Note the use of .clone() — this is required for compatibility with dual numbers used in automatic differentiation (see Autodiff and Generic Residual Functions).
Limitations
The pinhole model assumes:
- No lens distortion — addressed by the distortion stage
- No sensor tilt — addressed by the sensor stage
- Central projection — all rays pass through a single point
For wide-angle lenses the distortion model corrects for lens effects after pinhole projection to normalized coordinates. A fisheye lens that sees near or beyond 90° from the axis needs a different projection: see Fisheye Projection.
Brown-Conrady Distortion
Real lenses introduce geometric distortion: straight lines in the world project as curves in the image. The Brown-Conrady model captures the two dominant distortion effects — radial and tangential — as polynomial functions of the distance from the optical axis.
Distortion Model
Given undistorted normalized coordinates , the distorted coordinates are:
Radial distortion (barrel/pincushion):
Tangential distortion (decentering):
Combined:
The model has five parameters: radial coefficients and tangential coefficients .
OpenCV equivalence: This is identical to OpenCV's
distortPointswith the 5-parameter model(k1, k2, p1, p2, k3). Note OpenCV's parameter ordering differs from the mathematical ordering.
Physical Interpretation
- : barrel distortion (lines bow outward from center)
- : pincushion distortion (lines bow inward)
- : higher-order radial corrections
- : tangential distortion from imperfect lens-sensor alignment (lens elements not perfectly centered)
Typical values for industrial cameras: , , .
When to Fix
The sixth-order radial term is only significant for wide-angle lenses where reaches large values. For typical industrial cameras with moderate field of view:
- is poorly constrained by the data and often absorbs noise
- Estimating can cause overfitting, leading to worse extrapolation outside the calibration region
- The library defaults to
fix_k3: truein most problem configurations
Recommendation: Only estimate with high-quality data covering the full image area, or for lenses with field of view > 90°.
Undistortion (Inverse)
Given distorted coordinates , recovering undistorted coordinates requires inverting the distortion model. Since the forward model is a polynomial without a closed-form inverse, calibration-rs uses iterative fixed-point refinement:
This rearranges the distortion equation to isolate and iterates to convergence. The default is 8 iterations, which provides accuracy well below sensor noise for typical distortion magnitudes.
Convergence
The fixed-point iteration converges when the Jacobian of the distortion residual has spectral radius less than 1, which holds for physically realistic distortion values (small , relative to ). For extreme distortion, more iterations may be needed via the iters parameter.
OpenCV equivalence:
cv::undistortPointsperforms the same iterative inversion.
The DistortionModel Trait
#![allow(unused)] fn main() { pub trait DistortionModel<S: RealField> { fn distort(&self, n_undist: &Point2<S>) -> Point2<S>; fn undistort(&self, n_dist: &Point2<S>) -> Point2<S>; } }
The BrownConrady5<S> struct:
#![allow(unused)] fn main() { pub struct BrownConrady5<S: RealField> { pub k1: S, pub k2: S, pub k3: S, pub p1: S, pub p2: S, pub iters: u32, // undistortion iterations (default: 8) } }
NoDistortion is the identity implementation for distortion-free cameras.
Distortion in the Projection Pipeline
Distortion operates in normalized image coordinates (after pinhole projection, before intrinsics):
This is the standard convention: distortion is defined on the plane, not in pixel space. The advantage is that distortion parameters are independent of image resolution and focal length.
Fisheye Projection
A pinhole projection divides by depth, so it cannot represent a ray at or beyond 90° from the optical axis, and it stretches the image toward that angle. A fisheye lens instead maps the incidence angle to the image radius.
Equidistant projection
For a camera-frame point , with and incidence angle :
The normalized point lies in the direction of the point's azimuth, at a
radius equal to in radians, so fx, fy are pixels per radian.
Every direction but straight back () projects; points with
are visible. Back-projection returns the unit direction
.
Kannala-Brandt distortion
KannalaBrandt4 is an odd radial polynomial in the radius of the
normalized point:
On equidistant coordinates , and the camera is the
Kannala-Brandt fisheye model, the one OpenCV's fisheye module calibrates:
ProjectionKind::Equidistant with DistortionKind::KannalaBrandt4. On
pinhole coordinates the same polynomial is a radial-only distortion with a
fourth term. Undistortion inverts the polynomial by Newton's method, which is
unique where increases: across the field a calibration covers.
#![allow(unused)] fn main() { use vision_calibration::core::{ CameraModelSpec, CameraParams, DistortionKind, DistortionParams, FxFyCxCy, ProjectionKind, ProjectionParams, SensorKind, SensorParams, }; let spec = CameraModelSpec::new( ProjectionKind::Equidistant, DistortionKind::KannalaBrandt4, SensorKind::Identity, ); let camera = CameraParams { projection: ProjectionParams::Equidistant, distortion: DistortionParams::from_values(spec.distortion, &[-0.02, 0.002, 0.0, 0.0])?, sensor: SensorParams::Identity, intrinsics: FxFyCxCy { fx: 300.0, fy: 300.0, cx: 640.0, cy: 400.0 }, }; let model = camera.build()?; }
In Python the same camera is
vc.Camera(intrinsics=..., distortion=vc.KannalaBrandt4Distortion(...), projection=vc.EquidistantProjection()), and vc.CameraModel projects and
back-projects it.
In the workflows
Every workflow that calibrates a camera accepts an equidistant one: set
camera.model.projection to Equidistant (a rig can do so per camera).
Its linear stage is the
bearing-based fisheye initializer in place of Zhang's
method, so no focal length or image size is needed; the initializer fits
k1, k2 unless the camera holds them, and the refinement frees the
rest.
#![allow(unused)] fn main() { use vision_calibration::core::{CameraModelSpec, DistortionKind, ProjectionKind, SensorKind}; use vision_calibration::planar_intrinsics::PlanarIntrinsicsConfig; let mut config = PlanarIntrinsicsConfig::default(); config.camera.model = CameraModelSpec::new( ProjectionKind::Equidistant, DistortionKind::KannalaBrandt4, SensorKind::Identity, ); }
Fisheye Calibration: OpenCV's Test Set runs this on real fisheye images and compares the result with OpenCV's.
The analysis tools — projection uncertainty, next-view planning, the information matrix and the reprojection report — follow the camera's rays, so they cover the whole fisheye image, beyond 90° included.
Some combinations have no initializer and are configuration errors:
- an equidistant camera with a Scheimpflug sensor;
- the line-distance laser residual (
LineDistNormalized), which needs the laser line's image to be straight: a fisheye laser workflow usesPointToPlane.
Seeds are values in the camera's own model: a fisheye camera takes seeded
intrinsics and a KannalaBrandt4 distortion seed, and poses then come from
the bearings of the pixels back-projected through the seeded camera.
Intrinsics Matrix
The intrinsics matrix maps from the sensor coordinate system (normalized or sensor-transformed coordinates) to pixel coordinates. It encodes the camera's internal geometric properties: focal lengths and principal point.
Definition
where:
- — focal lengths in pixels (horizontal and vertical). These combine the physical focal length with the pixel pitch: where is the focal length in mm and is the pixel width in mm.
- — principal point in pixels. The projection of the optical axis onto the image plane. Typically near the image center but not exactly at it.
The pixel axes are assumed perpendicular, so has no skew term (it is effectively zero for modern sensors).
Forward and Inverse Transform
Sensor to pixel (forward):
where are sensor coordinates (output of the distortion + sensor stages).
Pixel to sensor (inverse):
The IntrinsicsModel Trait
#![allow(unused)] fn main() { pub trait IntrinsicsModel<S: RealField> { fn sensor_to_pixel(&self, sensor: &Point2<S>) -> Point2<S>; fn pixel_to_sensor(&self, pixel: &Point2<S>) -> Point2<S>; } }
The FxFyCxCy<S> struct implements this trait:
#![allow(unused)] fn main() { pub struct FxFyCxCy<S: RealField> { pub fx: S, pub fy: S, pub cx: S, pub cy: S, } }
Coordinate Utilities
Four functions at the root of vision_calibration_core combine intrinsics with distortion:
pixel_to_normalized(pixel, K)— apply to get normalized coordinates on the planenormalized_to_pixel(normalized, K)— apply to get pixel coordinatesundistort_pixel(pixel, K, distortion)— pixel → normalized → undistort → return normalizeddistort_to_pixel(normalized, K, distortion)— distort → apply → return pixel
These functions are used extensively in the linear initialization algorithms, which need to convert between pixel and normalized coordinate spaces.
Aspect Ratio
For most cameras, because pixels are not perfectly square. The ratio reflects the pixel aspect ratio. Typical industrial cameras have aspect ratios very close to 1.0 (within 1-3%).
OpenCV equivalence:
cv::initCameraMatrix2Dprovides initial intrinsics estimates. The matrix format is identical to OpenCV'scameraMatrix.
Sensor Models and Scheimpflug Tilt
In standard cameras, the sensor plane is perpendicular to the optical axis. Some industrial cameras — particularly laser triangulation profilers — deliberately tilt the sensor to achieve a larger depth of field along the laser plane (the Scheimpflug condition). The sensor model stage accounts for this tilt.
The SensorModel Trait
#![allow(unused)] fn main() { pub trait SensorModel<S: RealField> { fn normalized_to_sensor(&self, n: &Point2<S>) -> Point2<S>; fn sensor_to_normalized(&self, s: &Point2<S>) -> Point2<S>; } }
The sensor model sits between distortion and intrinsics in the pipeline:
IdentitySensor
For standard cameras with untilted sensors:
This is the default and most common case.
Scheimpflug Tilt Model
The Scheimpflug condition states that when the lens plane, image plane, and object plane intersect along a common line, the entire object plane is in sharp focus. Laser profilers exploit this by tilting the sensor to keep the laser line in focus.
Tilt Projection Matrix
The tilt is parameterized by two rotation angles:
- — tilt around the horizontal (X) axis
- — tilt around the vertical (Y) axis
The tilt homography is constructed as:
The projection onto the tilted sensor plane normalizes by the third row of applied to the point in homogeneous coordinates:
where normalizes by the -component (perspective division through the tilted plane). In practice, the first two rows of divided by the third row give a rational transform in the image plane.
OpenCV equivalence: This matches OpenCV's
computeTiltProjectionMatrixused in the 14-parameter rational model (CALIB_TILTED_MODEL).
ScheimpflugParams
#![allow(unused)] fn main() { pub struct ScheimpflugParams { pub tilt_x: f64, // tau_x in radians pub tilt_y: f64, // tau_y in radians } }
The compile() method generates a HomographySensor containing the homography and its precomputed inverse. ScheimpflugParams::default() returns zero tilt (identity sensor).
HomographySensor
The general homography sensor applies an arbitrary projective transform:
This is used as the runtime representation compiled from ScheimpflugParams.
When to Use Scheimpflug
- Laser triangulation profilers: The laser plane is at an angle to the camera axis. Tilting the sensor brings the entire laser line into focus.
- Machine vision with tilted object planes: When the depth of field is insufficient to keep the entire object in focus.
For standard cameras (no tilt), use SensorKind::Identity (the default).
Optimization of Tilt Parameters
In the non-linear optimization stage, Scheimpflug parameters are treated as additional optimization variables. The tilt homography is generic over the scalar type, so it is differentiated by automatic differentiation, enabling joint optimization of tilt with intrinsics, distortion, and poses.
Serialization and Runtime-Dynamic Types
The generic Camera<S, P, D, Sm, K> type provides compile-time composition of camera stages. However, for JSON serialization (session checkpointing, data exchange) and runtime-dynamic camera construction, calibration-rs provides an enum-based parameter system.
CameraParams
The CameraParams struct holds camera parameters as serializable enums:
#![allow(unused)] fn main() { pub struct CameraParams { pub projection: ProjectionParams, pub distortion: DistortionParams, pub sensor: SensorParams, pub intrinsics: FxFyCxCy<Real>, // fx, fy, cx, cy } }
The projection, distortion and sensor components are enums of supported variants (serialized with a "type" tag in snake_case); the intrinsics are the plain {fx, fy, cx, cy} struct:
#![allow(unused)] fn main() { pub enum ProjectionParams { Pinhole } pub enum DistortionParams { None, BrownConrady5 { params: BrownConrady5<Real> }, // k1, k2, k3, p1, p2, iters Rational { params: RationalPolynomial<Real> }, // k1..k6, p1, p2, iters ThinPrism { params: ThinPrism<Real> }, // Brown-Conrady + s1..s4 Division { lambda: Real }, // Fitzgibbon division model } pub enum SensorParams { Identity, Homography { h: [[Real; 3]; 3] }, Scheimpflug { params: ScheimpflugParams }, // tilt_x, tilt_y } }
The params payloads are flattened in JSON, so a Brown-Conrady camera serializes as {"type": "brown_conrady5", "k1": ..., "k2": ..., ...}.
The enums are #[non_exhaustive]: new models arrive in non-breaking releases, so a match on one needs a wildcard arm. Code that should work for every model reads the values instead, in the order the model's kind names them:
#![allow(unused)] fn main() { let d = &camera.distortion; for (name, value) in d.kind().param_names().iter().zip(d.values()) { println!("{name} = {value}"); } let adjusted = d.with_values(&new_values)?; // same model, new coefficients let fresh = DistortionParams::from_values(DistortionKind::Rational8, &values)?; }
ProjectionParams and SensorParams have the same kind(), values() and from_values(), and DistortionKind::ALL (likewise for projections and sensors) lists the supported models.
Building a Camera from Parameters
The build() method constructs a concrete CameraModel from serialized parameters. It returns an error if a SensorParams::Homography matrix is not invertible (reachable with hand-edited or corrupted files):
#![allow(unused)] fn main() { let camera = params.build()?; }
This is used internally by the session framework to reconstruct cameras from JSON-checkpointed state.
In Python
vc.CameraModel is the built model, made from a result's camera (a vc.Camera: intrinsics plus projection, distortion and sensor) or a CameraParams payload, with any model in each slot. Its methods are vectorized over numpy arrays and release the GIL for large ones:
import numpy as np
import vision_calibration as vc
cam = vc.CameraModel(result.camera)
pixels = cam.project(points_cam) # (N, 3) camera-frame points -> (N, 2)
origins, directions = cam.unproject(pixels) # (N, 3) rays; pinhole directions have z = 1
ideal = cam.undistort(pixels) # (N, 2) in a distortion-free pinhole image, same K
undistort removes the distortion and the sensor tilt, so the output is where each pixel lands in an ideal pinhole image with the same intrinsics. A point behind the camera projects to NaN.
Reading the Result
The calibrated planar-intrinsics parameters (PlanarIntrinsicsParams, export.params) hold the camera in their camera field and the per-view poses in camera_se3_target:
#![allow(unused)] fn main() { export.params.camera.intrinsics // FxFyCxCy<f64> export.params.camera.distortion // DistortionParams: match on the model export.params.build_camera()? // CameraModel, ready to project }
Use in Session Framework
CameraParams appears throughout the pipeline:
- Export records store calibrated parameters as
CameraParams - Session JSON serializes intermediate and final camera parameters
This provides a stable, version-safe serialization format decoupled from the generic type system.
Rigid Transformations and SE(3)
Camera calibration is fundamentally about estimating rigid transformations — rotations and translations that relate different coordinate frames: camera, world, robot gripper, calibration target, rig, etc. This chapter covers the rotation representations and transformation algebra used throughout calibration-rs.
Rotation Representations
Rotation Matrix
A orthogonal matrix with determinant :
The set of all such matrices forms the special orthogonal group . It has 3 degrees of freedom (9 entries minus 6 constraints).
Unit Quaternion
A 4-element vector with unit norm . The rotation of a vector is:
where is quaternion multiplication and is the conjugate.
Quaternions are preferred for optimization because they avoid gimbal lock and have simpler interpolation properties than Euler angles. calibration-rs (via nalgebra) stores quaternions as [i, j, k, w] internally, which maps to .
Axis-Angle
A rotation of angle around unit axis is represented as:
with and .
The Rodrigues formula converts axis-angle to rotation matrix:
where is the skew-symmetric matrix of :
Rigid Transformations: SE(3)
A rigid body transformation combines a rotation and translation :
The special Euclidean group has 6 degrees of freedom (3 rotation + 3 translation).
Applying a Transform
A point transformed by :
or in homogeneous coordinates:
Composition
Transforms compose by matrix multiplication:
Inverse
Naming Convention
calibration-rs uses the notation to mean "the transform from frame to frame ":
Common transforms:
| Symbol | Meaning |
|---|---|
| World to camera (camera extrinsics) | |
| Calibration board to camera | |
| Camera to rig (rig extrinsics) | |
| Camera to gripper (hand-eye) | |
| Target to base (hand-eye target pose) |
nalgebra Type: Iso3
calibration-rs uses nalgebra's Isometry3<f64> (aliased as Iso3) for SE(3) transforms:
#![allow(unused)] fn main() { pub type Iso3 = nalgebra::Isometry3<f64>; }
This stores a UnitQuaternion (rotation) and a Translation3 (translation) separately, avoiding the redundancy of a full matrix.
SE(3) Storage for Optimization
In the optimization IR, SE(3) parameters are stored as a 7-element vector:
The quaternion is unit-constrained. The optimizer uses the SE(3) manifold (see Manifold Optimization) to maintain this constraint during parameter updates.
The Exponential Map
The Lie algebra provides a 6-dimensional tangent space at the identity. A tangent vector maps to an SE(3) element via the exponential map:
where is the left Jacobian of SO(3):
The exponential map is central to manifold optimization: parameter updates are computed in the tangent space and then retracted to the manifold (see Manifold Optimization).
RANSAC
RANSAC (Random Sample Consensus) is a robust estimation method that fits a model to data containing outliers. Unlike least-squares methods that use all data points and can be corrupted by even a single outlier, RANSAC repeatedly samples minimal subsets, fits a model to each, and selects the model with the most support (inliers).
Algorithm
Given data points and a model requiring points to fit:
-
Repeat for up to iterations:
- Sample random data points
- Fit a model to the -point sample (skip if degenerate)
- Score: count inliers — points with residual
- If this model has more inliers than the current best, update the best
- Optionally refit the model on all inliers for a tighter fit
- Update the iteration bound dynamically based on the current inlier ratio
-
Return the best model and its inlier set
Dynamic Iteration Bound
After finding a model with inlier ratio , the number of iterations needed to find an all-inlier sample with probability is:
where is the minimum sample size. As more inliers are found, increases and decreases, allowing early termination. The iteration count is clamped to — it can only decrease.
Example: For (homography), 50% inliers, and 99% confidence: iterations.
The Estimator Trait
calibration-rs provides a generic RANSAC engine that works with any model implementing the Estimator trait:
#![allow(unused)] fn main() { pub trait Estimator { type Datum; type Model; const MIN_SAMPLES: usize; fn fit(data: &[Self::Datum], sample_indices: &[usize]) -> Option<Self::Model>; fn residual(model: &Self::Model, datum: &Self::Datum) -> f64; fn is_degenerate(data: &[Self::Datum], sample_indices: &[usize]) -> bool { false } fn refit(data: &[Self::Datum], inliers: &[usize]) -> Option<Self::Model> { Self::fit(data, inliers) } } }
| Method | Purpose |
|---|---|
MIN_SAMPLES | Minimum points for a model (e.g., 4 for homography) |
fit | Fit model from selected points |
residual | Distance from a point to the model |
is_degenerate | Reject degenerate samples (e.g., collinear points for homography) |
refit | Optional: refit model on full inlier set (may differ from fit) |
Configuration
#![allow(unused)] fn main() { pub struct RansacOptions { pub max_iters: usize, // Maximum iterations pub thresh: f64, // Inlier distance threshold pub min_inliers: usize, // Minimum consensus set size pub confidence: f64, // Desired probability (0-1) for dynamic bound pub seed: u64, // RNG seed for reproducibility pub refit_on_inliers: bool, // Refit model on full inlier set } }
Choosing the threshold : There is no default threshold; construct the options with RansacOptions::new(thresh) (the other fields keep defaults, so RansacOptions { max_iters: 500, ..RansacOptions::new(thresh) } works). A threshold that is not finite and positive is an error. The threshold should reflect the expected noise level, in the unit of the estimator's residual (see the table below). For pixel-space residuals, pixels is typical. For the calibrated essential-matrix residual the unit is σ of the stated pixel noise (3.0 keeps about 99.7 % of Gaussian inliers). The residual is always a distance, never a squared distance.
Result
#![allow(unused)] fn main() { pub struct RansacResult<M> { pub success: bool, pub model: Option<M>, pub inliers: Vec<usize>, pub inlier_rms: f64, pub iters: usize, } }
Instantiated Models
RANSAC is used with several models in calibration-rs:
| Model | MIN_SAMPLES | Residual (unit of thresh) | Usage |
|---|---|---|---|
| Homography | 4 | Forward transfer error, pixels | dlt_homography_ransac |
| Homography (two-view) | 4 | Symmetric transfer error, unit of the points | mvg::robust::estimate_homography |
| Fundamental matrix | 8 | Sampson distance, unit of the points | fundamental_8point_ransac |
| Essential matrix | 5 | Covariance-weighted Sampson error on bearings, σ of the stated pixel noise | mvg::robust::estimate_essential, recover_relative_pose_robust |
| PnP (DLT) | 6 | Reprojection error, pixels | dlt_ransac |
Deterministic Seeding
All RANSAC calls in calibration-rs use a deterministic seed for the random number generator. This ensures:
- Reproducible results across runs
- Deterministic tests that do not flake
- Debuggable behavior — the same input always produces the same output
The seed can be configured via RansacOptions::seed.
Best-Model Selection
When multiple models achieve the same inlier count, RANSAC selects the model with the lowest inlier RMS (root mean square of inlier residuals). This breaks ties in favor of more accurate models.
Robust Loss Functions
Standard least squares minimizes the sum of squared residuals . This objective is highly sensitive to outliers: a single point with a large residual can dominate the entire cost function and corrupt the solution. Robust loss functions (M-estimators) reduce the influence of large residuals, making optimization tolerant to outliers in the data.
Problem Setup
In non-linear least squares, calibration-rs minimizes
where is a residual block (a reprojection's 2D pixel error, say) and is the loss applied to its squared norm. Without a robust loss , which gives the standard . This is the Ceres convention: and , so every loss agrees with least squares for small residuals. RobustLoss::rho evaluates it.
Available Loss Functions
RobustLoss has four variants: None (plain squared loss) and three robust loss functions, each parameterized by a scale that controls the transition from quadratic (inlier) to robust (outlier) behavior.
Huber Loss
Properties:
- Quadratic in for small residuals, linear for large ones
- Continuous first derivative
- Influence: bounded — an outlier contributes a constant-magnitude gradient, not a growing one
When to use: The default robust loss. Good general-purpose choice when you expect a moderate number of outliers.
Cauchy Loss
Properties:
- Grows logarithmically for large residuals (slower than linear)
- Smooth everywhere
- Influence: decreases to zero for large residuals, effectively down-weighting far outliers
When to use: When outliers are far from the bulk of the data and should have near-zero influence.
Arctan Loss
Here the scale is in squared-residual units: the transition is near .
Properties:
- Bounded: as
- Influence: approaches zero for large residuals (redescending)
When to use: When very strong outlier rejection is needed. More aggressive than Cauchy but can make convergence harder.
Comparison
| Loss | Large- growth | Outlier influence | Convergence |
|---|---|---|---|
| None () | Quadratic | Unbounded | Best |
| Huber | Linear | Bounded (constant) | Good |
| Cauchy | Logarithmic | Decreasing | Moderate |
| Arctan | Bounded | Approaching zero | Can be tricky |
Choosing the Scale Parameter
The scale sets the boundary between "inlier" and "outlier" behavior:
- Too small: Treats good data as outliers, reducing effective sample size
- Too large: Outliers still dominate (approaches standard least squares)
- Rule of thumb: For Huber and Cauchy, set to the expected residual magnitude for good data points; for reprojection residuals pixels is typical. Arctan's scale is in squared units, so the equivalent is .
Usage in calibration-rs
Robust losses are selected with the RobustLoss enum:
#![allow(unused)] fn main() { pub enum RobustLoss { None, Huber { scale: f64 }, Cauchy { scale: f64 }, Arctan { scale: f64 }, } }
Each non-laser problem type exposes the loss function via its shared
solver: SolverConfig group:
#![allow(unused)] fn main() { session.update_config(|c| { c.solver.robust_loss = RobustLoss::Huber { scale: 2.0 }; })?; }
Laser-carrying stages (laserline device, rig-handeye-laserline) do not use
solver.robust_loss — they track calibration and laser residuals as
independent families with their own calib_loss/laser_loss fields
instead.
The backend applies the loss function during residual evaluation, modifying both the cost and the Jacobian.
How the Solver Applies a Loss
The Levenberg–Marquardt loop always measures progress with itself. To build each step's linear model, a backend folds the loss into the residual block and its Jacobian:
- tiny-solver uses the Triggs correction, which matches to second order.
- factrs uses iterative reweighting: the block and its Jacobian are scaled by .
Both models have the same first-order change as , so both converge to the same minimum; see Solver Backends.
Interaction with RANSAC
RANSAC and robust losses address outliers at different stages:
- RANSAC (linear initialization): Binary inlier/outlier classification. Used during model fitting to reject gross outliers before any optimization.
- Robust losses (non-linear refinement): Soft down-weighting. Used during optimization to reduce the influence of moderate outliers that passed RANSAC.
The two approaches are complementary: RANSAC handles gross outliers during initialization, while robust losses handle smaller outliers during refinement.
Why Linear Initialization Matters
Non-linear optimization is the workhorse of camera calibration: it achieves sub-pixel reprojection error by jointly optimizing all parameters. But non-linear optimizers — Levenberg-Marquardt, Gauss-Newton, and their variants — are local methods. They converge to the nearest local minimum, which is only the global minimum if the starting point is sufficiently close.
The role of linear initialization is to provide that starting point.
The Init-Then-Refine Paradigm
Every calibration workflow in calibration-rs follows a two-phase pattern:
-
Linear initialization: Closed-form solvers estimate approximate parameters. These are fast, deterministic, and require no initial guess — but they achieve only ~5-40% accuracy on camera parameters.
-
Non-linear refinement: Levenberg-Marquardt bundle adjustment minimizes reprojection error starting from the linear estimate. This converges to <2% parameter accuracy and <1 px reprojection error.
The linear estimate does not need to be highly accurate. It needs to be in the basin of convergence of the non-linear optimizer — close enough that gradient-based iteration converges to the correct solution rather than a local minimum.
Why Not Just Optimize Directly?
Without initialization, you would need to guess intrinsics (), distortion (), and all camera poses ( per view). For a typical 20-view calibration:
- 4 intrinsics + 5 distortion + 20 × 6 pose = 129 parameters
- The cost landscape has many local minima
- A random starting point will almost certainly diverge or converge to a wrong solution
Linear initialization eliminates this problem by computing a reasonable estimate from the data structure alone.
Linear Solvers in calibration-rs
| Solver | Estimates | Method | Chapter |
|---|---|---|---|
| Homography DLT | homography | SVD nullspace | Homography |
| Zhang's method | Intrinsics | IAC constraints | Zhang |
| Distortion fit | Linear LS on residuals | Distortion Fit | |
| Iterative intrinsics | + distortion | Alternating refinement | Iterative |
| Planar pose | from | Decomposition + SVD | Planar Pose |
| P3P (Kneip) | from 3 points | Quartic polynomial | PnP |
| DLT PnP | from points | SVD + SO(3) projection | PnP |
| 8-point fundamental | matrix | SVD + rank constraint | Epipolar |
| 7-point fundamental | matrix | Cubic polynomial | Epipolar |
| 5-point essential | matrix | Action matrix eigenvalues | Epipolar |
| Camera matrix DLT | SVD + RQ decomposition | Camera Matrix | |
| Triangulation | 3D point | SVD nullspace | Triangulation |
| Hand-eye (Tsai-Lenz, Park-Martin, Daniilidis, Shah) | Hand-eye and target | Quaternion / rotation-vector / dual-quaternion / Kronecker + linear LS | Hand-Eye |
| Rig extrinsics | per camera | SE(3) averaging | Rig Init |
| Laser plane | Plane normal + distance | SVD on covariance | Laser Init |
Common Mathematical Tools
Most linear solvers share a common toolkit:
- SVD (Singular Value Decomposition): Solves homogeneous systems via the right singular vector corresponding to the smallest singular value. This is the core of DLT methods.
- Hartley normalization: Conditions the DLT system for numerical stability by centering and scaling the input points.
- Polynomial root finding: Minimal solvers (P3P, 7-point F, 5-point E) reduce to polynomial equations.
The following chapters derive each algorithm in detail.
Hartley Normalization
DLT algorithms build large linear systems from point coordinates and solve them via SVD. When the coordinates span vastly different scales (e.g., pixel values in the hundreds vs. homogeneous scale factor of 1), the resulting system is ill-conditioned: small perturbations in the data cause large changes in the solution. Hartley normalization is a simple preprocessing step that dramatically improves numerical stability.
The Problem
Consider the homography DLT: we build a matrix from pixel coordinates and solve . If , the entries of range from to . The condition number of becomes large, and the SVD solution is sensitive to floating-point errors.
The Solution
Normalize the input points so they are centered at the origin with a controlled scale, solve the system, then denormalize the result.
2D Normalization
Given points :
-
Centroid: ,
-
Mean distance from centroid:
-
Scale factor:
-
Normalization matrix:
- Normalized points:
After normalization, the points have centroid at the origin and mean distance from the origin.
3D Normalization
For 3D points , the analogous normalization uses:
- Mean distance target: (instead of )
- A normalization matrix
Denormalization
After solving the normalized system, the result must be denormalized. For a homography estimated from normalized points:
For a fundamental matrix:
The denormalization formula depends on the specific problem. Each chapter states the appropriate denormalization.
When It Is Used
Hartley normalization is applied in every DLT-based solver in calibration-rs:
| Solver | Normalized spaces |
|---|---|
| Homography DLT | World 2D + image 2D |
| Fundamental 8-point | Image 1 2D + image 2 2D |
| Essential 5-point | Image 1 2D + image 2 2D |
| Camera matrix DLT | World 3D + image 2D |
| PnP DLT | World 3D (image points use normalized coordinates via ) |
Reference
Hartley, R.I. (1997). "In Defense of the Eight-Point Algorithm." IEEE Transactions on Pattern Analysis and Machine Intelligence, 19(6), 580-593.
The normalization technique is described in Algorithm 4.2 of Hartley & Zisserman (2004).
Homography Estimation (DLT)
A homography is a projective transformation between two planes. In calibration, the most common case is the mapping from a planar calibration board to the image plane. Homography estimation is the first step in Zhang's calibration method.
Problem Statement
Given: point correspondences where are points on the world plane and are the corresponding image points.
Find: such that (equality up to scale) for all .
Assumptions:
- The world points are coplanar (the calibration board is flat)
- Correspondences are correct (or RANSAC is used to handle outliers)
- At least 4 non-collinear point correspondences
Derivation
From Projective Relation to Linear System
The relation in homogeneous coordinates means:
Expanding and eliminating the scale factor using cross-product (the constraint ), we get two independent equations per correspondence:
The DLT System
Stacking all correspondences into a matrix :
where .
Solving via SVD
We seek minimizing subject to (to avoid the trivial solution ). This is the standard homogeneous least squares problem, solved by the SVD of :
The solution is the last column of (corresponding to the smallest singular value). Reshaping this 9-vector into a matrix gives .
Normalization and Denormalization
The complete algorithm with Hartley normalization:
- Normalize world and image points: and
- Build the matrix from normalized points
- Solve via SVD to get
- Denormalize:
- Scale:
Degrees of Freedom
is a matrix with 9 entries, but it is defined up to scale, giving 8 degrees of freedom. Each correspondence provides 2 equations, so the minimum is correspondences (giving equations for 8 unknowns).
With , the system is overdetermined and the SVD solution minimizes the algebraic error in a least-squares sense.
RANSAC Wrapper
For real data with potential mismatched correspondences, dlt_homography_ransac wraps the DLT solver in RANSAC:
#![allow(unused)] fn main() { let (H, inliers) = dlt_homography_ransac(&world_pts, &image_pts, &opts)?; }
MIN_SAMPLES = 4- Residual: Euclidean reprojection error in pixels:
- Degeneracy check: Tests if the first 3 world points are collinear (a degenerate configuration for homography estimation)
OpenCV equivalence:
cv::findHomographywithRANSACmethod.
API
#![allow(unused)] fn main() { // Direct DLT (all points assumed inliers) let H = dlt_homography(&world_2d, &image_2d)?; // With RANSAC let opts = RansacOptions { max_iters: 1000, confidence: 0.99, ..RansacOptions::new(3.0) // threshold in pixels (forward transfer error) }; let (H, inliers) = dlt_homography_ransac(&world_2d, &image_2d, &opts)?; }
Geometric Interpretation
For a planar calibration board at in world coordinates, the camera projection matrix reduces to a homography:
where are the first two columns of . This relationship is the foundation of Zhang's calibration method (next chapter).
Zhang's Intrinsics from Homographies
Zhang's method is the most widely used technique for estimating camera intrinsics from a planar calibration board. Given homographies from multiple views of the board, it recovers the intrinsics matrix using constraints from the image of the absolute conic (IAC).
Problem Statement
Given: homographies from a planar calibration board to the image, computed from different viewpoints.
Find: Camera intrinsics .
Assumptions:
- The calibration board is planar
- At least 3 homographies from distinct viewpoints
- The camera intrinsics are constant across all views
- Distortion is negligible (or has been accounted for — see Iterative Intrinsics)
Derivation
Homography and Intrinsics
From the previous chapter, the homography from a board plane is:
where is an arbitrary scale and are columns of the rotation matrix. Let . Then:
Orthogonality Constraints
Since and are columns of a rotation matrix, they satisfy:
Substituting :
The Image of the Absolute Conic (IAC)
Define the symmetric matrix:
Since is symmetric, it has 6 independent entries. The constraints become:
Each homography gives 2 linear equations in the 6 unknowns of .
The Vectors
To write the constraints as a linear system, define the 6-vector:
where is the -th element of (1-indexed, column , row ). Then:
where .
The Linear System
For each homography , the two constraints become:
Stacking homographies gives a system:
With (giving equations), this is solved via SVD: is the right singular vector of corresponding to the smallest singular value.
Extracting Intrinsics from
Given , the intrinsics are recovered by a Cholesky-like factorization of .
The closed-form extraction:
Validity Checks
is definite, so the extraction fails when:
- is not positive, or
- and have different signs (negative focal length squared)
Either way no camera fits the homographies, and the solve returns
DegenerateViews: the views do not constrain the intrinsics, typically
because the board planes are parallel in every view (all fronto-parallel, or
all tilted the same way).
Parallel Boards
A board's vanishing line is ,
so parallel boards share one vanishing line and give every view the same two
constraints: has rank 2. With noisy corners the solve can then return an
arbitrary rather than fail. The dataset-level initializer
(estimate_intrinsics_iterative) therefore checks the views first: it takes
as each board's normal, with a
square-pixel camera centred on the observed corners whose focal length is
their extent, and returns DegenerateViews when no normal lies more than 1°
from the mean plane. Parallel boards read zero through any , up to the
corner noise (about 0.15° for a board 40 % of the image wide and 0.2 px of
noise); a focal length of the image extent reads the perspective the views
show, which is what the constraints feed on.
Minimum Number of Views
- homographies: 6 equations for the 6 unknowns of — the minimum for a unique solution. The solve also yields a skew term ; it is needed to recover and then discarded, because the camera model has no skew
- : Only 4 equations, not enough for the general solve
- : Overdetermined system; SVD gives the least-squares solution
Practical Considerations
View diversity: The views should include significant rotation around both axes. Pure translations leave the board planes parallel, and the intrinsics undetermined. Two orientations only, such as tilts about one axis both ways, give four constraints for the five unknowns, and leave one focal length nearly free; three distinct tilts about one axis determine the camera.
Distortion: Zhang's method assumes distortion-free observations. When applied to distorted pixels, the estimated is biased. The Iterative Intrinsics chapter addresses this with an alternating refinement scheme.
OpenCV equivalence: Zhang's method is the internal initialization step of
cv::calibrateCamera. OpenCV does not expose it as a separate API.
API
#![allow(unused)] fn main() { let K = estimate_intrinsics_from_homographies(&homographies)?; }
Returns FxFyCxCy with the estimated intrinsics. Typically followed by distortion estimation and non-linear refinement.
Reference
Zhang, Z. (2000). "A Flexible New Technique for Camera Calibration." IEEE Transactions on Pattern Analysis and Machine Intelligence, 22(11), 1330-1334.
Distortion Estimation from Homography Residuals
After estimating intrinsics via Zhang's method (which assumes distortion-free observations), the residuals between observed and predicted pixel positions encode the lens distortion. This chapter derives a linear method to estimate Brown-Conrady distortion coefficients from these residuals.
Problem Statement
Given:
- Camera intrinsics (from Zhang's method)
- homographies from the calibration board to the image
- Point correspondences: board points and observed pixels
Find: Distortion coefficients .
Assumptions:
- is a reasonable estimate (possibly biased by distortion)
- Distortion is moderate (the linear approximation holds)
- The homographies were computed from distorted pixel observations
Derivation
Ideal vs. Observed Coordinates
For each correspondence in view , the ideal (undistorted) pixel position predicted by the homography is:
Convert both observed and ideal pixels to normalized coordinates:
The residual in normalized coordinates encodes the distortion effect:
Linearized Distortion Model
The Brown-Conrady distortion applied to point with produces a displacement:
This is linear in the distortion coefficients .
Building the Linear System
For each correspondence, write the and components:
where are the ideal normalized coordinates and are the observed residuals.
Stacking all correspondences from all views gives an overdetermined system:
where is (or smaller if some coefficients are fixed), and .
Solving
The least-squares solution is:
In practice, this is solved via SVD of for numerical stability.
Options
#![allow(unused)] fn main() { pub struct DistortionFitOptions { pub fix_tangential: bool, // Fix p1 = p2 = 0 pub fix_k3: bool, // Fix k3 = 0 (default: true) pub iters: u32, // Undistortion iterations } }
fix_k3(defaulttrue): Removes from the system, reducing to a 4-parameter or 2-parameter model. Recommended unless the lens has strong higher-order radial distortion.fix_tangential(defaultfalse): Removes , estimating only radial distortion. Useful when tangential distortion is known to be negligible.
When parameters are fixed, the corresponding columns are removed from .
Accuracy
This linear estimate is typically within 10-50% of the true distortion values. The accuracy depends on:
- Quality of : If intrinsics are biased (from distorted observations), the estimated distortion absorbs some of the bias
- Number of points: More correspondences improve the overdetermined system
- Point distribution: Points across the full image area constrain distortion better than points clustered near the center (where distortion is small)
This accuracy is sufficient for initializing non-linear refinement.
API
#![allow(unused)] fn main() { let dist = estimate_distortion_from_homographies( &k_matrix, &views, opts )?; }
Returns BrownConrady5 with the estimated coefficients.
OpenCV equivalence: OpenCV estimates distortion jointly with intrinsics inside
cv::calibrateCamera. The separate distortion estimation step is specific to calibration-rs's initialization approach.
Iterative Intrinsics + Distortion
Zhang's method assumes distortion-free observations. When applied to images from a camera with significant distortion, the estimated intrinsics are biased because the distorted pixels violate the linear homography model. The iterative intrinsics solver addresses this by alternating between intrinsics estimation and distortion correction.
Problem Statement
Given: views of a planar calibration board, with observed (distorted) pixel coordinates.
Find: Camera intrinsics and distortion coefficients jointly.
Assumptions:
- The observations are distorted (raw detector output)
- Distortion is moderate enough that Zhang's method on distorted pixels gives a usable initial
- 1-3 iterations of alternating refinement suffice
Why Alternation Works
The joint estimation of and distortion is a non-convex problem. However, it decomposes naturally:
- Given : Distortion estimation is a linear problem (see Distortion Fit)
- Given distortion: Undistorting the pixels and re-estimating is a linear problem (Zhang's method)
Each step solves a convex subproblem. The alternation converges because:
- The initial Zhang estimate (ignoring distortion) is typically within the basin where the alternation contracts
- Each step reduces the residual between the model and observations
- The coupling between and distortion is relatively weak for moderate distortion
Algorithm
Input: Views with distorted observations
Output: Intrinsics , distortion
-
Compute homographies from distorted pixels via DLT
-
Estimate initial from via Zhang's method
-
For :
a. Estimate distortion from homography residuals using
b. Undistort all observed pixels:
c. Recompute homographies from undistorted pixels
d. Re-estimate from via Zhang's method
e. Drop the skew term of the estimate:
-
Return
Convergence
Typically 1-2 iterations suffice:
- Iteration 0 (Zhang on distorted pixels): 10-40% intrinsics error
- Iteration 1: Distortion estimate corrects the dominant radial effect; intrinsics error drops to 5-20%
- Iteration 2: Further refinement; diminishing returns
More iterations are safe but rarely improve the estimate significantly. The default is 2 iterations.
Configuration
#![allow(unused)] fn main() { pub struct IterativeIntrinsicsOptions { pub iterations: usize, // 1-3 typical (default: 2) pub distortion_opts: DistortionFitOptions, // Controls fix_k3, fix_tangential, iters } }
The Undistortion Step
Step 3b converts distorted pixels back to "ideal" pixels by inverting the distortion model:
- Convert distorted pixel to normalized coordinates:
- Undistort using the current distortion estimate: (iterative fixed-point, see Brown-Conrady Distortion)
- Convert back to pixel:
Note that this uses both for normalization and for converting back — the undistorted pixels are in the same coordinate system as the original distorted pixels, just without the distortion effect.
Accuracy Expectations
| Stage | Typical intrinsics error |
|---|---|
| Zhang on distorted pixels | 10-40% |
| After 1 iteration | 5-20% |
| After 2 iterations | 5-15% |
| After non-linear refinement | <2% |
The iterative linear estimate is not meant to be highly accurate. Its purpose is to provide a starting point good enough for the non-linear optimizer to converge.
API
#![allow(unused)] fn main() { use vision_calibration_linear::iterative_intrinsics::*; use vision_calibration_linear::distortion_fit::DistortionFitOptions; let opts = IterativeIntrinsicsOptions { iterations: 2, distortion_opts: DistortionFitOptions { fix_k3: true, fix_tangential: false, iters: 8, }, }; let camera = estimate_intrinsics_iterative(&dataset, opts)?; // camera is PinholeCamera = Camera<f64, Pinhole, BrownConrady5, IdentitySensor, FxFyCxCy> // camera.k: FxFyCxCy (intrinsics) // camera.dist: BrownConrady5 (distortion) }
Pose from Homography
Given camera intrinsics and a homography from a planar calibration board to the image, we can decompose to recover the camera pose (rotation and translation ) relative to the board.
Problem Statement
Given: Intrinsics and homography such that for board points at .
Find: Rigid transform (board to camera).
Assumptions:
- The board lies at in world coordinates
- is known (or has been estimated)
- The homography was computed from correct correspondences
Derivation
Extracting Rotation and Translation
Recall from the Homography chapter:
where are the first two columns of the rotation matrix and is the translation.
Removing the intrinsics:
Let . The scale factor is recovered from the constraint that and have unit norm:
Then:
The third rotation column is:
Projecting onto SO(3)
Due to noise, the matrix is not exactly orthonormal. We project it onto using SVD:
If , flip the sign of the third column of and recompute.
Ensuring Forward-Facing
If , the board is behind the camera. In this case, flip the sign of both and :
This resolves the sign ambiguity inherent in the scale factor .
Accuracy
The pose from homography is an approximate estimate because:
- The homography itself is subject to noise (DLT algebraic error minimization, not geometric)
- The SVD projection onto SO(3) corrects non-orthogonality but introduces additional error
- Distortion (if not corrected) biases the homography
Typical rotation error: 1-5°. Typical translation direction error: 5-15%. These estimates are refined in non-linear optimization.
OpenCV equivalence:
cv::decomposeHomographyMatprovides a similar decomposition, returning up to 4 pose candidates. calibration-rs returns a single pose by resolving ambiguities via the forward-facing constraint.
API
#![allow(unused)] fn main() { let pose = estimate_planar_pose_from_h(&K, &H)?; // pose: Iso3 (T_C_B: board to camera transform) }
Usage in Calibration Pipeline
In the planar intrinsics calibration pipeline, pose estimation is applied to every view after has been estimated:
- Compute homography per view
- Estimate from all homographies (Zhang's method)
- Decompose each to get pose
- Use poses as initial values for non-linear optimization
Pose from bearings
A fisheye camera's board-to-pixel map is not a homography, but its back-projected pixels are still directions from one centre. With bearings (any length, any direction — points beside or behind the image plane included), the board-to-bearing map is the homography itself, with no :
Each point gives three rows (rank two) of a linear system in the nine entries of , solved on Hartley-normalized board points. The scale makes , unit length, and the sign is the one that points along — not , which fails for a board beside the camera.
#![allow(unused)] fn main() { let pose = estimate_planar_pose_from_bearings(&board_xy, &bearings)?; }
For a pinhole camera, the bearings of undistorted pixels give the same pose as the homography decomposition.
Fisheye Intrinsics from Bearings
Zhang's method needs the board-to-pixel map to be a homography, which a
fisheye lens breaks. estimate_equidistant_intrinsics initializes a
Kannala-Brandt camera (equidistant projection, KannalaBrandt4
distortion) by scoring candidate cameras on their pixel error:
- For a candidate focal length , principal point and coefficients , every pixel back-projects to a bearing.
- Each view's pose follows from its bearings (pose from bearings).
- The board reprojects through the candidate; the sum of squared pixel errors is its cost.
The search runs in three stages:
- Focal sweep. Forty log-spaced focal lengths, without distortion, with at the centre of the observations. They span fields of view from a few degrees to beyond 270°, so neither the image size nor a nominal focal length is needed.
- Simplex over , still without distortion.
- Radial seed, then all five. At the incidence angles of the current poses, the observed radii follow , linear in . Its least-squares solution seeds a simplex over .
Pixels are square (), and , start at zero; the
non-linear refinement frees them. A coefficient the caller holds is not
fitted and stays at zero (EquidistantInitOptions).
#![allow(unused)] fn main() { use vision_calibration_linear::equidistant_init::{ EquidistantInitOptions, estimate_equidistant_intrinsics, }; let init = estimate_equidistant_intrinsics(&dataset, EquidistantInitOptions::default())?; // init.intrinsics, init.distortion, init.poses, init.rms_error }
On exact views of a lens seeing up to 115° off-axis, with coefficients compressing the edge by about 5 %, the result lands within 1 % in focal length and 1 px in principal point.
Perspective-n-Point Solvers
The Perspective-n-Point (PnP) problem estimates the camera pose from known 3D-2D point correspondences. Unlike homography-based pose estimation, PnP does not require coplanar points.
Problem Statement
Given: 3D world points and their corresponding 2D image points , plus camera intrinsics .
Find: Camera pose such that .
Assumptions:
- Camera intrinsics are known
- Correspondences are correct (or RANSAC is used)
- Points are not degenerate (e.g., not all collinear)
P3P: Kneip's Minimal Solver
The P3P solver uses exactly 3 correspondences — the minimum for a finite number of solutions. It returns up to 4 candidate poses.
Algorithm
Input: 3 world points , 3 pixel points , intrinsics .
- Bearing vectors: Convert pixels to unit bearing vectors in the camera frame:
- Inter-point distances in world frame:
- Bearing vector cosines:
-
Quartic polynomial: Using the ratios and , Kneip derives a quartic polynomial in a distance ratio . The coefficients are functions of , , , , .
-
Solve quartic for up to 4 real roots .
-
For each root:
- Compute the second distance ratio
- Compute the three camera-frame distances (depths of the three points)
- Back-project to 3D points in camera frame:
- Recover pose from the 3D-3D correspondence using SVD-based rigid alignment
Disambiguation
P3P returns up to 4 poses. To select the correct one, use a fourth point (or more points with RANSAC) and pick the pose with the smallest reprojection error.
DLT PnP: Linear Solver for
The DLT (Direct Linear Transform) PnP uses an overdetermined system for points.
Derivation
The projection equation in normalized coordinates is:
where are normalized coordinates (after applying to pixels) and are rows of .
Cross-multiplying gives two equations per point:
The 12 unknowns are the entries of the matrix .
The Linear System
For each point :
Stacking gives matrix . Solve via SVD.
Post-Processing
- Reshape the 12-vector into a matrix
- Normalize the scale using the row norms of the block
- Project the rotation block onto SO(3) via SVD (same as in Pose from Homography)
- Extract translation from the fourth column
Hartley Normalization
The 3D world points are normalized before building the system (center at origin, scale mean distance to ). The image points are normalized via . The result is denormalized after solving.
RANSAC Wrappers
Both solvers have RANSAC variants for handling outliers:
#![allow(unused)] fn main() { // DLT PnP + RANSAC let (pose, inliers) = dlt_ransac( &world_pts, &image_pts, &K, &RansacOptions::new(5.0) // pixel reprojection error )?; }
The DLT PnP solver uses MIN_SAMPLES = 6 with RANSAC. P3P (p3p()) returns up to 4 candidates and is intended for manual disambiguation (e.g., using a 4th point), not as a RANSAC estimator.
These solvers take pixels and a pinhole and return a pose without an uncertainty. For a pose from any camera model, RANSAC in units of the stated pixel noise and the covariance of the pose, see Object Pose with Uncertainty.
Comparison
| Solver | Min. points | Solutions | Strengths |
|---|---|---|---|
| P3P | 3 | Up to 4 | Best for RANSAC (minimal sample) |
| DLT PnP | 6 | 1 | Simple, no polynomial solving |
OpenCV equivalence:
cv::solvePnPwithSOLVEPNP_P3PorSOLVEPNP_DLS;cv::solvePnPRansacfor robust estimation.
References
- Kneip, L., Scaramuzza, D., & Siegwart, R. (2011). "A Novel Parametrization of the Perspective-Three-Point Problem for a Direct Computation of Absolute Camera Position and Orientation." CVPR.
Epipolar Geometry
Epipolar geometry describes the geometric relationship between two views of the same scene. It is encoded in the fundamental matrix (for pixel coordinates) or the essential matrix (for calibrated bearings). These matrices are central to stereo vision, visual odometry, and structure from motion.
Fundamental Matrix
Problem Statement
Given: point correspondences in pixel coordinates from two views.
Find: Fundamental matrix satisfying for all correspondences.
Properties of :
- Rank 2 (by the epipolar constraint)
- 7 degrees of freedom (9 entries, scale ambiguity, rank constraint)
- Maps a point in one image to its epipolar line in the other:
8-Point Algorithm
The simplest method, requiring correspondences.
Derivation: The constraint expands to:
Each correspondence gives one linear equation in the 9 entries of .
Algorithm:
- Normalize both point sets using Hartley normalization
- Build design matrix where each row is:
- Solve via SVD; is the last column of
- Enforce rank 2: Decompose and set :
- Denormalize:
The rank-2 enforcement is critical: without it, the epipolar lines do not intersect at a common epipole.
7-Point Algorithm
A minimal solver using exactly 7 correspondences, producing 1 or 3 candidate matrices.
Derivation: With 7 equations for 9 unknowns (modulo scale), the null space of is 2-dimensional. Let the null space basis be and . The fundamental matrix is:
for some scalar . The rank-2 constraint gives a cubic equation in :
This cubic has 1 or 3 real roots, each giving a candidate .
Essential Matrix
Problem Statement
Given: point correspondences as bearings , the unit rays from each camera's own unprojection of the observed pixel. For a pinhole, of the normalized coordinates is a bearing; a fisheye's bearing may point more than 90° from its axis.
Find: Essential matrix satisfying .
Properties of :
- where is the relative rotation and is the translation direction
- Has exactly two equal non-zero singular values: ,
- 5 degrees of freedom (3 rotation + 2 translation direction)
5-Point Algorithm (Nister)
The minimal solver, producing up to 10 candidate matrices.
Algorithm:
- Normalize both point sets using Hartley normalization
- Build matrix (same form as 8-point)
- Null space: SVD of gives a 4-dimensional null space. The essential matrix is: for unknowns , where are the null space basis vectors
- Polynomial constraints: The essential matrix constraint (9 equations) and (1 equation) generate polynomial equations in
- Action matrix: These constraints are assembled into a polynomial system solved via the eigenvalues of a action matrix
- Extract real solutions: Each real eigenvalue determines and thus
Up to 10 candidate essential matrices may be returned.
Decomposing into
Given , there are 4 possible decompositions:
where and is the third column of .
Chirality check: Of the 4 candidates , only one places all triangulated points in front of both cameras. This is verified by triangulating a test point and checking that in both views.
Coordinate Conventions
| Matrix | Input coordinates | Usage |
|---|---|---|
| Fundamental | Pixel coordinates | When intrinsics are unknown |
| Essential | Normalized coordinates () | When intrinsics are known |
The relationship is: .
RANSAC
Both solvers have RANSAC wrappers for outlier rejection:
#![allow(unused)] fn main() { let (F, inliers) = fundamental_8point_ransac(&pts1, &pts2, &opts)?; }
Currently, only the 8-point fundamental matrix solver has a RANSAC wrapper. The 5-point essential matrix solver (essential_5point) returns multiple candidates and is used directly; the calibrated mvg::robust::estimate_essential wraps it in RANSAC on bearings, scoring each match by its covariance-weighted Sampson error: the epipolar constraint over its first-order σ, from the pixels' stated noise (see Pose Recovery and Triangulation).
OpenCV equivalence:
cv::findFundamentalMat(8-point and 7-point),cv::findEssentialMat(5-point with RANSAC).
References
- Nister, D. (2004). "An Efficient Solution to the Five-Point Relative Pose Problem." IEEE TPAMI, 26(6), 756-770.
- Hartley, R.I. (1997). "In Defense of the Eight-Point Algorithm." IEEE TPAMI, 19(6), 580-593.
Camera Matrix and RQ Decomposition
The camera matrix (or projection matrix) is a matrix that directly maps 3D world points to 2D pixel coordinates. Estimating via DLT and decomposing it into intrinsics and extrinsics via RQ decomposition provides an alternative initialization path.
Camera Matrix DLT
Problem Statement
Given: correspondences between 3D world points and 2D pixel points .
Find: projection matrix such that .
Derivation
The projection (with in homogeneous coordinates) gives, after cross-multiplication:
where is the -th row of .
This gives a system , solved via SVD with Hartley normalization of the 3D and 2D points.
Post-Processing
After denormalization, is but not guaranteed to decompose cleanly into due to noise. The RQ decomposition extracts the components.
RQ Decomposition
Problem Statement
Given: The left submatrix of (where ).
Find: Upper-triangular and orthogonal such that .
Algorithm
RQ decomposition is computed by transposing QR decomposition:
- Compute QR decomposition of :
- Then , where is lower-triangular and is orthogonal
- Apply a permutation matrix to flip the matrix to upper-triangular form:
- (upper-triangular)
- (orthogonal)
Sign Conventions
After decomposition, ensure:
- has positive diagonal entries: if , negate column of and row of
- : if , negate a column of (and the corresponding column of )
Translation Extraction
Full Decomposition
The CameraMatrixDecomposition struct:
#![allow(unused)] fn main() { pub struct CameraMatrixDecomposition { pub k: Mat3, // Upper-triangular intrinsics pub r: Mat3, // Rotation matrix (orthonormal, det = +1) pub t: Vec3, // Translation vector } }
API
#![allow(unused)] fn main() { // Estimate the full 3×4 camera matrix let P = dlt_camera_matrix(&world_pts, &image_pts)?; // Decompose into K, R, t let decomp = decompose_camera_matrix(&P)?; println!("Intrinsics: {:?}", decomp.k); println!("Rotation: {:?}", decomp.r); println!("Translation: {:?}", decomp.t); // Or just RQ decompose any 3×3 matrix let (K, R) = rq_decompose(&M); }
When to Use
Camera matrix DLT is useful when:
- You have non-coplanar 3D-2D correspondences and want to estimate both intrinsics and pose simultaneously
- You need a quick estimate of from a single view (without multiple homographies)
For calibration with a planar board, Zhang's method is preferred because it uses the planar constraint to get more constraints per view.
Linear Triangulation
Triangulation reconstructs a 3D point from its projections in two or more calibrated views. It is the inverse of the projection operation: given pixel observations and camera poses, recover the world point.
Problem Statement
Given: views with poses (world to camera ) and, in each, the bearing along which the point is seen: the unit ray from the camera's own unprojection of the observed pixel.
Find: 3D point in the world frame such that .
Assumptions:
- Camera poses and models are known
- Correspondences are correct
- The point is visible from all views (not occluded)
Working on bearings rather than on image points serves every camera model: a pinhole's bearing is of its normalized coordinates, and a fisheye's may point more than 90° from its axis, where normalized coordinates do not exist.
Derivation (DLT)
With and the point , the constraint says is parallel to , i.e. their cross product vanishes:
This gives three equations per view, of rank two. Stacking all views gives a system:
Solve via the smallest eigenvector of (the smallest-singular direction): is the right singular vector corresponding to the smallest singular value. Dehomogenize: . A near-zero second-smallest singular value (identical cameras, coincident rays) is reported as a degenerate system.
Limitations
- Algebraic error: The DLT minimizes algebraic error, not geometric (reprojection) error, and carries no covariance
- Baseline sensitivity: For small baselines (nearly parallel views), the triangulation is poorly conditioned — small pixel errors lead to large depth errors
- Outlier sensitivity: A single incorrect correspondence corrupts the result; no robustness mechanism
For accurate points, refine them on the reprojection error with their noise stated: the calibrated entry point, mvg::triangulation::triangulate, does so, and returns the points' covariance with the calibration's share (see Pose Recovery and Triangulation). The linear estimate is its starting point.
API
#![allow(unused)] fn main() { let X = triangulate_point_linear(&cam_se3_world, &bearings)?; }
Where cam_se3_world holds each view's Iso3 pose and bearings the matching rays (any nonzero length). The calibrated entry point, mvg::triangulation::triangulate, builds the bearings from pixels through a CalibratedRig, refines the points and adds diagnostics and covariance; see Pose Recovery and Triangulation.
In Python, vision_calibration.mvg.triangulate is the calibrated entry point; the raw linear solver is Rust only (numpy and OpenCV cover linear algebra on plain arrays).
Deterministic two-view solvers, all free functions in per-topic modules:
| Module | Functions |
|---|---|
homography | dlt_homography, dlt_homography_ransac |
epipolar | fundamental_8point, essential_5point |
triangulation | triangulate_point_linear |
camera_matrix | dlt_camera_matrix |
The facade re-exports the main solvers at the root of its mvg module (vision_calibration::mvg::dlt_homography, …); the full set is in the vision-geometry crate.
Hand-Eye Initialization
Hand-eye calibration estimates the rigid transform between a camera and a robot gripper (or between a camera and a robot base). It is essential for any application where a camera is mounted on a robot arm and the robot needs to localize objects in its own coordinate frame. The workflows start the hand-eye bundle adjustment from a closed-form estimate; four methods are available.
The Equations
Frames
- Base (B): the robot's fixed base frame
- Gripper (G): the robot's end-effector frame (known from robot kinematics)
- Camera (C): the camera frame (observations come from here)
- Target (T): the calibration board frame
Each view pairs a robot pose (base_se3_gripper) with the board pose the camera observes, (camera_se3_target).
Robot-world form:
In eye-in-hand (camera on the gripper, board fixed) the chain from base to camera closes through the board:
In eye-to-hand (board on the gripper, camera fixed) it closes through the camera:
Both are with :
| mode | |||
|---|---|---|---|
| eye-in-hand | gripper_se3_camera | base_se3_target | |
| eye-to-hand | gripper_se3_target | base_se3_camera (returned as camera_se3_base) |
For a rig the observer is the rig frame instead of a camera.
Motion form:
Two views eliminate : with and ,
which splits into a rotation and a translation part:
The Methods
handeye_init.method selects the solver (HandeyeMethod, serialized tsai_lenz, park_martin, daniilidis, shah). The first three use all view pairs; Shah's method uses the views directly.
- Tsai-Lenz (default). The rotation from the quaternion form of : each pair gives with the left and right quaternion-multiplication matrices; the stacked system's null vector is . Then from the stacked translation equations by least squares.
- Park-Martin. With the rotation vectors , , the rotation constraint reads ; is the rotation closest to (orthogonal Procrustes). It weighs a pair by its rotation angle squared, Tsai-Lenz by . The translation as for Tsai-Lenz.
- Daniilidis. Rotation and translation together, from dual quaternions : each pair gives six linear equations in the eight entries of , whose two-dimensional solution space is fixed by and .
- Shah. and together from , without motion pairs. The rotations satisfy , which is linear in the entries of and (a Kronecker-product system); the translations then follow from by least squares.
The pair methods close through the first view, . Shah's method estimates it over all views, so the fixed target pose (eye-in-hand) or camera pose (eye-to-hand) the bundle adjustment starts from no longer depends on one view's noise.
Motion requirements
- Minimum rotation per pair: a pair whose rotation, in the robot or in the camera, is below
min_motion_angle_deg(default 5°) is dropped by the pair methods. Small rotations amplify noise into the translation. - Two rotation axes: the pairs that remain must rotate the robot about at least two non-parallel axes. With every rotation about one axis, the rotation of about that axis and its translation along it are undetermined, for every method (Shah's included). The initialization then fails with a typed error naming the cause, instead of returning an arbitrary transform.
- Pure translations carry no rotation information; they are filtered out like any pair below the angle threshold.
Rotation-Poor Motion
How much do the methods differ when the robot barely rotates? A synthetic study, run on every method from the same data:
- an eye-in-hand camera (fixed, known intrinsics; 9×7 board of 30 mm squares) and a random hand-eye transform (up to ~30°, 10 cm);
- ten robot stations whose viewing direction is spread by up to ±2°, ±5°, ±15° or ±45° about each axis around the board, at ~0.6 m; optionally four more stations that only translate;
- low noise: 0.1 px pixel noise, exact robot poses; high noise: 0.5 px, robot poses off by up to 0.05° and 0.3 mm per axis;
min_motion_angle_deglowered to 1° so the ±2° cells have pairs to use;- the hand-eye bundle adjustment from each method's estimate, compared with the same refinement started from the truth. "Basin" is the share of trials whose refinement ends within half a standard deviation of that reference.
Median errors of the initial estimate over 100 trials per row (stations without the translation-only extras): hand-eye rotation (°) / hand-eye translation (mm) / fixed target translation (mm). Tsai-Lenz and Park-Martin agree to 0.001° and 0.01 mm in every row, so they share a column.
| spread | noise | Tsai-Lenz, Park-Martin | Daniilidis | Shah |
|---|---|---|---|---|
| ±2° | low | 0.45 / 7.2 / 4.8 | 0.24 / 5.3 / 5.0 | 0.45 / 7.3 / 4.9 |
| ±2° | high | 2.37 / 34.7 / 26.8 | 1.30 / 39.4 / 36.2 | 2.35 / 35.8 / 26.8 |
| ±5° | low | 0.17 / 2.4 / 1.6 | 0.19 / 2.2 / 2.1 | 0.17 / 2.3 / 1.6 |
| ±5° | high | 0.89 / 12.2 / 7.8 | 0.95 / 11.4 / 9.4 | 0.89 / 12.3 / 7.9 |
| ±15° | low | 0.041 / 0.56 / 0.39 | 0.063 / 0.62 / 0.45 | 0.041 / 0.58 / 0.38 |
| ±15° | high | 0.22 / 2.9 / 2.1 | 0.33 / 3.3 / 2.4 | 0.22 / 3.0 / 2.0 |
| ±45° | low | 0.008 / 0.09 / 0.07 | 0.009 / 0.10 / 0.07 | 0.008 / 0.10 / 0.06 |
| ±45° | high | 0.052 / 0.50 / 0.59 | 0.053 / 0.51 / 0.56 | 0.052 / 0.50 / 0.39 |
Basin. Every refinement, from every method's estimate in every trial, ended at the same minimum as the refinement from the truth: within 0.82 standard deviations of it at worst — the solver's stopping tolerance, the same for every method — with either solver backend and also with the robot poses refined. Even at ±2° and high noise, where the initial rotation is off by 1–2.5° and the translation by 3–4 cm, the bundle adjustment converges.
Reading.
- No method is better everywhere. Daniilidis halves the rotation error when the rotations are tiny (±2°), at the price of a larger translation error under noise; from ±5° on it is level or slightly worse. Shah's hand-eye matches Tsai-Lenz's; its fixed target, estimated over all views rather than closed through the first, is better under noise when the motion is rich (0.39 vs 0.59 mm at ±45°).
- Four translation-only stations improve every method by roughly 5–30 % (Daniilidis's rotation at ±2°, high noise: 0.94°): each adds pairs against the rotated stations.
- At the default
min_motion_angle_degof 5°, 91 of 100 ±2° captures have too few pairs and every method returns the typed error; lower the threshold to use rotation-poor data. - Since the basin does not depend on the method, the default stays Tsai-Lenz. Choose Shah when the initial target (or camera) pose matters on its own, Daniilidis when the robot rotates by only a few degrees and the rotation matters more than the translation.
API
The workflows run the initialization in step_handeye_init; the method comes from the config, or from the step's options for one call:
#![allow(unused)] fn main() { use vision_calibration::common::HandeyeInitOptions; use vision_calibration::init::HandeyeMethod; let mut config = SingleCamHandeyeConfig::default(); config.handeye_init.method = HandeyeMethod::Shah; session.set_config(config)?; // ... set the input, run the intrinsics steps ... let mut opts = HandeyeInitOptions::default(); opts.method = Some(HandeyeMethod::Daniilidis); // this call only step_handeye_init(&mut session, Some(opts))?; }
The closed-form solvers are also available on their own, to start a problem of your own:
#![allow(unused)] fn main() { use vision_calibration::init::{HandeyeMethod, estimate_eye_in_hand}; let estimate = estimate_eye_in_hand( HandeyeMethod::TsaiLenz, &base_se3_gripper, // &[Iso3]: robot poses &camera_se3_target, // &[Iso3]: observed board poses 5.0, // minimum rotation of a pair, degrees )?; let x = estimate.gripper_se3_camera; let z = estimate.base_se3_target; }
estimate_eye_to_hand takes the same arguments and returns gripper_se3_target and camera_se3_base.
OpenCV equivalence:
cv::calibrateHandEyewithCALIB_HAND_EYE_TSAI,CALIB_HAND_EYE_PARKandCALIB_HAND_EYE_DANIILIDIS;cv::calibrateRobotWorldHandEyewithCALIB_ROBOT_WORLD_HAND_EYE_SHAH.
References
- Tsai, R.Y. & Lenz, R.K. (1989). "A New Technique for Fully Autonomous and Efficient 3D Robotics Hand/Eye Calibration." IEEE Transactions on Robotics and Automation, 5(3), 345-358.
- Park, F.C. & Martin, B.J. (1994). "Robot Sensor Calibration: Solving AX=XB on the Euclidean Group." IEEE Transactions on Robotics and Automation, 10(5), 717-721.
- Daniilidis, K. (1999). "Hand-Eye Calibration Using Dual Quaternions." The International Journal of Robotics Research, 18(3), 286-298.
- Shah, M. (2013). "Solving the Robot-World/Hand-Eye Calibration Problem Using the Kronecker Product." Journal of Mechanisms and Robotics, 5(3), 031007.
Multi-Camera Rig Initialization
A multi-camera rig is a set of cameras rigidly mounted on a common frame. The extrinsics of the rig describe the relative pose of each camera with respect to a reference camera (or a rig frame). This chapter covers the linear initialization of rig extrinsics from per-camera pose estimates.
Problem Statement
Given: Per-camera, per-view pose estimates (camera , view , target to camera).
Find: Camera-to-rig transforms and rig-to-target poses .
Assumptions:
- All cameras observe the same calibration target simultaneously
- Per-camera poses have been estimated (e.g., via PnP or homography decomposition)
- The rig is rigid (camera-to-rig transforms are constant across views)
Algorithm
Reference Camera
One camera is designated as the reference camera (default: camera 0). Its camera-to-rig transform is identity:
This defines the rig frame to coincide with the reference camera frame.
Rig-to-Target from Reference
For each view , the rig-to-target pose is the reference camera's pose:
Camera-to-Rig via Averaging
For each non-reference camera , collect the camera-to-rig estimate from each view where both cameras have observations:
Then average across views:
- Rotation: Quaternion averaging with hemisphere correction. Before averaging, flip quaternions to the same hemisphere (since and represent the same rotation). Then compute the mean quaternion and normalize.
- Translation: Arithmetic mean of translation vectors.
Cameras That Never See the Board With the Reference
A camera does not need a view in common with the reference camera. Two cameras are linked when they see the board in the same view, and the cameras are placed outward from the reference through these links: first every camera linked to the reference, then every camera linked to one of those, and so on. A camera reached through camera takes
over the views the two share. Each camera takes a path with the fewest links, and among those the neighbour it shares the most views with, so a camera that does share views with the reference is placed from them directly. A ring of cameras where only neighbours overlap calibrates this way. A camera no chain of shared views reaches is an error that names it: add views in which it and an already placed camera both see the board.
Quaternion Hemisphere Correction
Quaternions have a double-cover of SO(3): both and represent the same rotation. Before averaging, all quaternions are flipped to the same hemisphere as the first:
This prevents averaging artifacts where opposite quaternions cancel out.
Accuracy
The initialization accuracy depends on:
- Per-camera pose accuracy: Noisy single-camera poses (from homography decomposition or PnP) propagate to the rig extrinsics
- Number of views: More views improve the averaging
- View diversity: Diverse viewpoints reduce systematic errors
Typical accuracy: 1-5° rotation, 5-15% translation. This is refined in the rig bundle adjustment (see Multi-Camera Rig Extrinsics).
API
#![allow(unused)] fn main() { let extrinsic_poses = estimate_extrinsics_from_cam_target_poses( &cam_se3_target, // Vec<Vec<Option<Iso3>>>: per-view, per-camera poses ref_cam_idx, // usize: reference camera index )?; // extrinsic_poses.cam_to_rig: Vec<Iso3> (T_{R,C} for each camera) // extrinsic_poses.rig_from_target: Vec<Iso3> (T_{R,T} for each view) }
Usage in Calibration Pipeline
In the rig extrinsics calibration pipeline:
- Per-camera intrinsics are estimated and optimized independently
- Per-camera poses are recovered from the calibration board
- Rig extrinsics are initialized via averaging (this chapter)
- Rig bundle adjustment jointly optimizes all parameters
Laser Plane Initialization
In laser triangulation, a laser line is projected onto the scene and a camera observes the line. The laser plane — defined by a normal vector and distance from the camera origin — must be calibrated. This chapter describes the linear initialization of the laser plane from observations of the laser line on a calibration target.
Problem Statement
Given:
- Camera intrinsics and distortion
- Per-view camera poses (target to camera)
- Per-view laser pixel observations (pixels where the laser line intersects the target)
Find: Laser plane in the camera frame, where is the unit normal and is the signed distance from the camera origin:
for all 3D points on the laser plane (in camera coordinates).
Algorithm
Step 1: Back-Project Laser Pixels to 3D
For each laser pixel in each view:
- Undistort the pixel to get normalized coordinates:
- Form a ray in the camera frame:
- Intersect with the target plane: The target at in target coordinates, transformed to camera frame by , defines a plane. Solve for that places the ray on this plane.
- Compute 3D point: in camera coordinates
This gives a set of 3D points on the laser plane.
Step 2: Fit a Plane via SVD
Given 3D points on the laser plane:
- Centroid:
- Center the points:
- Covariance matrix:
- SVD:
- Normal: is the eigenvector corresponding to the smallest singular value (last column of )
- Distance:
Degeneracy Detection
If all 3D points are nearly collinear (e.g., because all views have the same target pose), the plane is underdetermined. The algorithm checks the ratio of the two smallest singular values:
If the points are too close to collinear, the plane fit fails with an error.
Plane Representation for Optimization
In the non-linear optimization, the plane is parameterized as:
- Normal: A unit vector (2 degrees of freedom on the unit sphere)
- Distance: A scalar (1 degree of freedom)
The manifold is used for the normal to maintain the unit-norm constraint during optimization (see Manifold Optimization).
Accuracy
The linear plane initialization accuracy depends on:
- View diversity: Different target orientations provide points at different locations on the laser plane, improving the fit
- Camera calibration accuracy: Errors in intrinsics, distortion, or poses propagate to the 3D point estimates
- Number of laser pixels: More pixels (from more views or more detected pixels per view) improve the SVD fit
Typical accuracy: 1-5° normal direction error, 5-20% distance error. Refined in the Laserline Device Calibration optimization.
API
#![allow(unused)] fn main() { use vision_calibration_linear::laserline::{LaserlinePlaneSolver, LaserlineView}; // Each view carries laser pixels and the camera-to-target pose let views: Vec<LaserlineView> = poses.iter().zip(laser_pixels_per_view.iter()) .map(|(pose, pixels)| LaserlineView { camera_se3_target: *pose, laser_pixels: pixels.clone(), }) .collect(); let plane = LaserlinePlaneSolver::from_views(&views, &camera)?; // plane.normal: UnitVector3<f64> (unit normal in camera frame) // plane.distance: f64 (signed distance from camera origin) // plane.rmse: f64 (fit residual) }
Polynomial Solvers
Several minimal geometry solvers in calibration-rs reduce to polynomial equations. This chapter documents the polynomial root-finding utilities used by P3P, 7-point fundamental, and 5-point essential solvers.
Quadratic
Solved via the standard discriminant formula: . Returns 0, 1, or 2 real roots depending on the discriminant sign.
Cubic
Solved using Cardano's formula:
- Convert to depressed cubic via substitution
- Compute discriminant
- For (three real roots): use trigonometric form
- For (one real root): use Cardano's formula with cube roots
Returns 1 or 3 real roots.
Quartic
Solved via the companion matrix approach:
- Normalize to monic form: divide by
- Construct the companion matrix
- Compute eigenvalues using Schur decomposition (via nalgebra)
- Extract real eigenvalues: those with
Returns 0 to 4 real roots.
Usage in Minimal Solvers
| Solver | Polynomial | Degree | Max roots |
|---|---|---|---|
| P3P (Kneip) | Distance ratio | Quartic | 4 |
| 7-point fundamental | Cubic | 3 | |
| 5-point essential | Action matrix eigenvalues | Degree 10 | 10 |
The 5-point solver uses eigendecomposition of a matrix rather than a polynomial solver directly, but the underlying mathematics involves degree-10 polynomial constraints.
Numerical Considerations
- All roots are deduplicated (roots closer than are merged)
- Real root extraction uses a threshold on the imaginary part ()
- The companion matrix approach is more numerically stable than analytical quartic formulas for extreme coefficient ratios
Non-Linear Least Squares Overview
Non-linear least squares (NLLS) is the mathematical framework underlying all refinement in calibration-rs. After linear initialization provides approximate parameter estimates, NLLS minimizes the reprojection error to achieve sub-pixel accuracy.
Problem Formulation
Objective: Minimize the sum of squared residuals:
where is the parameter vector and is the stacked residual vector.
In camera calibration, a typical residual is the reprojection error: the difference between an observed pixel and the predicted projection:
where is the camera projection function, and the parameters include intrinsics , distortion , and poses .
Gauss-Newton Method
The Gauss-Newton method exploits the least-squares structure. At the current estimate , linearize the residuals:
where is the Jacobian.
Substituting into the objective and minimizing with respect to :
gives the normal equations:
The matrix is the Gauss-Newton approximation to the Hessian ( when residuals are small).
Update:
Levenberg-Marquardt Method
Gauss-Newton can diverge when the linearization is poor (far from the minimum) or when is singular. Levenberg-Marquardt (LM) adds a damping term:
The damping parameter interpolates between:
- : Pure Gauss-Newton (fast convergence near minimum)
- : Gradient descent with small step (safe far from minimum)
Trust Region Interpretation
LM is a trust region method: controls the size of the region where the linear approximation is trusted.
- If the update reduces the cost: accept the step, decrease (expand trust region)
- If the update increases the cost: reject the step, increase (shrink trust region)
Convergence Criteria
LM terminates when any of:
- Cost threshold:
- Absolute decrease:
- Relative decrease:
- Maximum iterations:
- Parameter change:
Sparsity
In bundle adjustment problems, the Jacobian is sparse: each residual depends on only a few parameter blocks (one camera intrinsics, one distortion, one pose). has a block-arrow structure, so calibration-rs solves the damped normal equations with a sparse Cholesky factorization: the sparsity pattern and its symbolic analysis are computed once per solve, and only the numeric factorization repeats per step. The Marquardt damping keeps the system positive definite, which is what makes Cholesky (rather than QR) sufficient. See Solver Backends.
Cost Function vs. Reprojection Error
The optimizer minimizes the cost , which is without a robust loss. The commonly reported mean reprojection error is:
These are related but not identical: the cost weights large errors quadratically, while the mean error weighs them linearly. calibration-rs reports both: the cost from the solver and the mean reprojection error computed post-optimization.
With Robust Losses
When a robust loss is used, the objective becomes:
The normal equations are modified to incorporate the loss function's weight:
where is the iteratively reweighted diagonal matrix. See Robust Loss Functions for details.
Manifold Optimization
Camera poses are elements of the Lie group , which is a 6-dimensional smooth manifold — not a Euclidean space. Laser plane normals live on the unit sphere . Optimizing these parameters with standard Euclidean updates (adding to the parameter vector) would violate the manifold constraints: rotations would become non-orthogonal, quaternions would lose unit norm, and normals would lose unit length.
Manifold optimization solves this by computing updates in the tangent space and then retracting back to the manifold.
The Retract-Update Pattern
For a parameter on a manifold :
- Compute update in the tangent space (a Euclidean space of dimension equal to the manifold's degrees of freedom)
- Retract: where maps from tangent space back to the manifold
This is also called local parameterization: the optimizer sees a Euclidean space of the correct dimension, and the manifold structure handles the constraint.
SE(3): Rigid Transforms
Ambient dimension: 7 (quaternion + translation )
Tangent dimension: 6 ()
Plus operation (): Given current pose and tangent vector :
where is the SE(3) exponential map (see Rigid Transformations):
Minus operation (): The inverse — compute the tangent vector between two poses — uses the SE(3) logarithm.
Implementation in calibration-rs
The se3_exp function in the factor module computes the exponential map generically over RealField for autodiff compatibility:
#![allow(unused)] fn main() { fn se3_exp<T: RealField>(xi: &[T; 6]) -> [[T; 4]; 4] }
This uses Rodrigues' formula with special handling for the small-angle case () to avoid division by zero.
SO(3): Rotations
Ambient dimension: 4 (unit quaternion)
Tangent dimension: 3 (axis-angle )
Plus operation:
SO(3) is used for rotation-only parameters. In calibration-rs, most poses use the full SE(3), but SO(3) is available for specialized problems.
: Unit Sphere
Ambient dimension: 3 (unit vector , )
Tangent dimension: 2 ()
The manifold is used for laser plane normals — 3D unit vectors with 2 degrees of freedom.
Plus operation: Given current normal and tangent vector :
- Compute orthonormal basis of the tangent plane at
- Tangent vector in 3D:
- Retract:
For small , this is equivalent to the exponential map on .
Basis Construction
The tangent plane basis is constructed deterministically:
- Choose a reference vector not parallel to (e.g., or )
- , normalized
Euclidean Parameters
For intrinsics (), distortion (), and other unconstrained parameters, the manifold is simply with:
No special treatment needed.
ManifoldKind in the IR
The optimization IR specifies the manifold for each parameter block:
#![allow(unused)] fn main() { pub enum ManifoldKind { Euclidean, // R^n, standard addition SE3, // 7D ambient, 6D tangent SO3, // 4D ambient, 3D tangent S2, // 3D ambient, 2D tangent } }
The backend maps these to solver-specific manifold implementations.
Fixed Parameters on Manifolds
For Euclidean parameters, individual indices can be fixed (e.g., fix but optimize ). For manifold parameters (SE3, SO3, S2), fixing is all-or-nothing: either the entire parameter block is optimized or the entire block is fixed. This is because the tangent space decomposition does not naturally support partial fixing on non-Euclidean manifolds.
Backend-Agnostic IR Architecture
calibration-rs separates the definition of optimization problems from their execution by a specific solver. Problems are expressed as an Intermediate Representation (IR) that is compiled to a solver-specific form. This design allows swapping backends without changing problem definitions and makes it straightforward to add new problem types.
Three-Stage Pipeline
Problem Builder → ProblemIR → Backend.compile() → Backend.solve()
(domain code) (generic) (solver-specific) (optimization)
- Problem Builder: Domain code (e.g.,
build_planar_intrinsics_ir()) constructs aProblemIRfrom calibration data and initial parameter estimates - Backend Compilation: The backend the solver config selects (tiny-solver or factrs) translates the IR into its own variables and residuals
- Solving: The shared Levenberg–Marquardt loop runs on that backend's linearization and returns a
BackendSolution(see Solver Backends)
ProblemIR
The central type that describes a complete optimization problem:
#![allow(unused)] fn main() { pub struct ProblemIR { pub params: Vec<ParamBlock>, pub residuals: Vec<ResidualBlock>, } }
It is validated on construction to ensure all parameter references are valid, dimensions match, and manifold constraints are respected.
ParamBlock
Each parameter block represents a group of optimization variables:
#![allow(unused)] fn main() { pub struct ParamBlock { pub id: ParamId, pub name: String, pub dim: usize, pub manifold: ManifoldKind, pub fixed: FixedMask, pub bounds: Option<Vec<Bound>>, // per-index box constraints } }
| Field | Description |
|---|---|
id | Unique identifier within the problem |
name | Human-readable name (e.g., "cam", "pose/0", "plane") |
dim | Ambient dimension (e.g., 4 for intrinsics, 7 for SE3) |
manifold | Geometry of the parameter space |
fixed | Which indices (or the whole block) are held constant |
bounds | Optional box constraints on parameter values |
Naming Conventions
Problem builders use consistent naming:
"intrinsics"or"cam"— intrinsics (), dimension 4"distortion"or"dist"— distortion (), dimension 5"sensor"— Scheimpflug tilt (), dimension 2"pose/0","pose/1", ... — per-view SE(3) poses (planar, rig), dimension 7"pose_0","pose_1", ... — per-view SE(3) poses (laserline), dimension 7"plane_normal"— laser plane unit normal, dimension 3, manifold"plane_distance"— laser plane distance, dimension 1"handeye"— hand-eye SE(3), dimension 7"extrinsics/0", ... — per-camera SE(3) extrinsics, dimension 7
FixedMask
Controls which parameters are held constant during optimization:
#![allow(unused)] fn main() { // Construct via factory methods: FixedMask::all_free() // nothing fixed FixedMask::all_fixed(dim) // everything fixed FixedMask::fix_indices(&[2]) // fix specific indices }
- Euclidean parameters: Individual indices can be fixed. For example,
FixedMask::fix_indices(&[2])on intrinsics fixes while allowing to vary. - Manifold parameters: All-or-nothing. If any index is fixed, the entire block is fixed. (The tangent space does not support partial fixing.)
ResidualBlock
Each residual block connects parameter blocks through a factor:
#![allow(unused)] fn main() { pub struct ResidualBlock { pub params: Vec<ParamId>, pub loss: RobustLoss, pub factor: FactorKind, pub residual_dim: usize, } }
The params vector references ParamBlocks by their ParamId. The ordering must match what the factor expects.
FactorKind
A factor is one of four residual families. The camera model and pose chain are data carried by the factor, not separate enum variants:
#![allow(unused)] fn main() { pub enum FactorKind { /// 2D pixel reprojection residual. ReprojPoint { model: CameraModelSpec, chain: ReprojChain, pw: [f64; 3], uv: [f64; 2], sqrt_info: ReprojSqrtInfo, }, /// 1D point-to-plane laser residual (meters). LaserPointToPlane { model: CameraModelSpec, chain: LaserChain, laser_pixel: [f64; 2], w: f64, }, /// 1D line-distance laser residual (pixels). LaserLineDistance { model: CameraModelSpec, chain: LaserChain, laser_pixel: [f64; 2], w: f64, }, /// Zero-mean prior on a 6D se(3) tangent block. Se3TangentPrior { sqrt_info: [f64; 6], residual_sigma: f64 }, } }
Each factor also carries the per-residual data (3D point coordinates, observed pixel, weight, measured robot pose, …) that is not part of the optimizable parameters.
Camera model as data
#![allow(unused)] fn main() { pub struct CameraModelSpec { pub projection: ProjectionKind, // Pinhole pub distortion: DistortionKind, // None | BrownConrady5 | Rational8 | ThinPrism9 | Division1 pub sensor: SensorKind, // Identity | Scheimpflug } }
A kind with no parameters contributes no parameter block; the layout is
[intrinsics, projection?, distortion?, sensor?]. Evaluation dispatches per
slot through the kinds' methods in vision-calibration-core, generic over the
autodiff scalar type, so adding a camera model (a distortion, a projection)
extends a core enum and adds no factor kind.
Chains as data
ReprojChain selects the pose chain and the trailing parameter blocks:
| Chain | Chain blocks | Transform |
|---|---|---|
SinglePose | [pose] | |
TwoSe3 | [extrinsics, pose] | |
Handeye { base_se3_gripper, mode } | [extrinsics, handeye, target] | mode-dependent hand-eye chain |
HandeyeRobotDelta { … } | [extrinsics, handeye, target, robot_delta] | hand-eye + per-view se(3) correction |
LaserChain mirrors this for the laser families (SinglePose,
RigHandeye, RigHandeyeRobotDelta); every laser chain ends with the
plane_normal (S², dim 3) and plane_distance (dim 1) blocks, with
robot_delta last when present.
Layout-derived validation
FactorKind::param_layout() derives the full expected block list —
[intrinsics, distortion?, sensor?, <chain blocks…>] — as
Vec<ParamSlotSpec { dim, manifold, role }>. Validation is a single zip of
this layout against the referenced parameter blocks; there are no
per-variant validation arms to maintain.
See the Factor Catalog for the full chain math and residual definitions.
Validation
ProblemIR::new() validates:
- All
ParamIdreferences in residual blocks resolve to existing parameter blocks - Parameter dimensions match what each factor expects
- Manifold kinds are compatible with the factor requirements
- No duplicate parameter IDs
Initial Values
Parameter blocks carry only their structural definition (dimension, manifold, fixing). The initial values are passed separately as a HashMap<String, DVector<f64>> (keyed by parameter block name) to the backend's solve() method.
Example: Planar Intrinsics IR
For a 10-view planar intrinsics problem with 48 points per view:
Parameters:
- "cam": dim=4, Euclidean (fx, fy, cx, cy)
- "dist": dim=5, Euclidean (k1, k2, k3, p1, p2)
- "pose/0"..."pose/9": dim=7, SE3
Residuals: 480 blocks
- Each: ReprojPoint { model: PINHOLE4_DIST5, chain: SinglePose, pw, uv, w=1.0 }
- Params: [cam_id, dist_id, pose_k_id]
- Residual dim: 2
Total residuals: 960 scalar values
Total parameters: 4 + 5 + 10*7 = 79
Autodiff and Generic Residual Functions
The Levenberg-Marquardt algorithm requires the Jacobian of the residual vector. Computing this analytically for complex camera models (distortion, Scheimpflug tilt, SE(3) transforms) is error-prone and tedious. calibration-rs uses automatic differentiation (autodiff) via dual numbers to compute exact Jacobians from the same code that evaluates residuals.
Dual Numbers
A dual number extends a real number with an infinitesimal part:
Arithmetic preserves the chain rule automatically:
By setting for the parameter of interest and for others, evaluating simultaneously computes both and .
For multi-variable functions, hyper-dual numbers or multi-dual numbers generalize this to compute full Jacobians. The num-dual crate provides these types.
The RealField Pattern
All residual functions in calibration-rs are written generic over the scalar type:
A simplified pinhole + Brown-Conrady reprojection residual looks like this (the shipped residuals additionally take the camera model and pose chain as data):
#![allow(unused)] fn main() { fn reproj_residual_generic<T: RealField>( intr: DVectorView<'_, T>, // [fx, fy, cx, cy] dist: DVectorView<'_, T>, // [k1, k2, k3, p1, p2] pose: DVectorView<'_, T>, // [qx, qy, qz, qw, tx, ty, tz] pw: [f64; 3], // 3D world point (constant) uv: [f64; 2], // observed pixel (constant) w: f64, // weight (constant) ) -> SVector<T, 2> }
When called with T = f64, this computes the residual value. When called with a dual-number type T, it simultaneously computes the residual and its derivatives with respect to all parameters.
Design Rules for Autodiff-Compatible Code
-
Use
.clone()liberally: Dual numbers are small structs; cloning is cheap and avoids borrow checker issues with operator overloading. -
Avoid in-place operations: Operations like
x += yorv[i] = exprcan cause issues with dual number tracking. Prefer functional style. -
Convert constants: Use
T::from_f64(1.0).unwrap()to convert floating-point literals. Do not use1.0directly where aTis expected. -
Constant data as
f64: World points, observed pixels, and weights are constant data — not optimized. Keep them asf64and convert toTinside the function.
Walkthrough: Reprojection Residual
The core reprojection residual computes:
Step by step:
1. Unpack Parameters
#![allow(unused)] fn main() { let fx = intr[0].clone(); let fy = intr[1].clone(); let cx = intr[2].clone(); let cy = intr[3].clone(); let k1 = dist[0].clone(); let k2 = dist[1].clone(); // ... }
2. SE(3) Transform: World to Camera
Apply the pose to the 3D world point using the exponential map or direct quaternion rotation:
The implementation unpacks the quaternion and applies the rotation formula.
3. Pinhole Projection
4. Apply Distortion
5. Apply Intrinsics
6. Compute Residual
Every arithmetic operation on T values propagates derivatives automatically through the dual number mechanism.
Backend Integration
Each backend wraps the same generic residual function in its own residual type. The factor carries its camera model as data, and the residual evaluates it per slot through the core camera-model kinds:
FactorKind::ReprojPoint { model: pinhole + Brown-Conrady, chain: SinglePose, pw, uv, sqrt_info }
↓ (compile step)
residual(params) = reproj_residual(model, chain, params, pw, uv, sqrt_info)
Both evaluate it with dual numbers to get residuals and Jacobians in one pass. tiny-solver's duals have a dynamic size; factrs' are sized at compile time to the factor's tangent dimension (for example 15 for a Brown-Conrady camera and one SE(3) pose), so they live on the stack.
SE(3) Exponential Map
A critical autodiff-compatible function is se3_exp<T>(), which maps a 6D tangent vector to an SE(3) transform:
#![allow(unused)] fn main() { fn se3_exp<T: RealField>(xi: &[T; 6]) -> [[T; 4]; 4] }
This implements Rodrigues' formula with a special case for small angles () using Taylor expansions to avoid numerical issues with the dual number's infinitesimal part.
Performance Considerations
Autodiff with dual numbers is typically 2-10x slower than hand-coded Jacobians, but:
- Correctness: Eliminates the risk of Jacobian implementation bugs
- Maintainability: Adding a new factor only requires writing the forward evaluation
- Flexibility: The same code works for any parameter configuration
For calibration problems (hundreds to thousands of residuals, tens to hundreds of parameters), the autodiff overhead is negligible compared to the linear solve in each LM iteration.
Solver Backends
Every non-linear solve in calibration-rs runs one Levenberg–Marquardt (LM) loop. A backend supplies what the loop needs at the current parameters: the residuals and their Jacobian with the robust loss folded in, the robust objective, and the retraction that applies a step to each parameter manifold. Two backends are available:
- tiny-solver (the default): forward-mode autodiff with dynamic-size dual numbers; a robust loss enters through the Triggs correction.
- factrs: forward-mode autodiff with static-size dual numbers; a robust loss enters through iterative reweighting.
Both evaluate the same residual kernels, retract SE(3) and S² the same way, and minimize the same objective, so they reach the same minimizer. They differ in how fast they build the Jacobian and, on robust problems, in the path they take to the minimum.
Selecting a Backend
The backend is the backend field of the solver settings of every
workflow.
Rust:
#![allow(unused)] fn main() { use vision_calibration::optim::SolverBackend; use vision_calibration::planar_intrinsics::PlanarIntrinsicsConfig; let mut config = PlanarIntrinsicsConfig::default(); config.solver.backend = SolverBackend::Factrs; }
JSON config:
{ "solver": { "max_iters": 50, "robust_loss": "None", "backend": "factrs" } }
Python:
config = vc.PlanarIntrinsicsConfig(solver=vc.SolverConfig(backend="factrs"))
In the desktop app, the backend is a field of the solver group of each config form. Workflows with several solve stages (for example the joint rig, hand-eye and laser workflow) have one solver group per stage, each with its own backend.
In code, set it on the config's solver group:
#![allow(unused)] fn main() { let mut config = PlanarIntrinsicsConfig::default(); config.solver.backend = SolverBackend::Factrs; }
The Levenberg–Marquardt Loop
The objective is the robust cost
where is the residual block's loss ( without one; see Robust Loss Functions). Each iteration:
- Linearizes at : the backend returns a residual and Jacobian with the loss folded in, so that the model has the same first-order change as .
- Scales the Jacobian's columns by , computed once at the first iteration, so that parameters of different magnitude (focal lengths in pixels, distortion coefficients, rotations) are damped comparably.
- Solves the damped normal equations with sparse Cholesky, where is the diagonal of clamped to . The sparsity pattern of and the symbolic factorization are computed once per solve; each iteration fills the values of the lower triangle only.
- Applies the step through each block's retraction (vector addition, on SE(3), the exponential map on S²), then clamps bounded parameters to their bounds.
- Accepts or rejects by the gain ratio of the actual to the predicted decrease of . An accepted step scales by ; a rejected one doubles and retries, up to 32 times.
The loop stops when falls below min_error, when its absolute or
relative decrease falls below min_abs_decrease / min_rel_decrease, when
a step can no longer change the parameters
(), when no step is accepted after 32 retries, or after max_iters
iterations. Parameters held fixed never move, and the same input gives
bit-identical results run to run.
How the Backends Differ
| tiny-solver | factrs | |
|---|---|---|
| Dual numbers | dynamic size (heap) | static size: the factor's tangent dimension |
| Robust loss | Triggs correction: second-order exact in | iterative reweighting: block scaled by |
| Variables | one per parameter block | a camera's intrinsics, distortion and sensor fuse into one vector; a laser plane's normal and distance into one plane variable |
| Fixed parameters | removed from the system | kept, with their Jacobian columns zeroed |
Without a robust loss the two linearizations are the same, and so is every step up to rounding. With a robust loss they take different steps towards the same minimum, so iteration counts can differ.
factrs supports what the calibration problems use. It rejects an IR it
cannot map, with an InvalidInput error: a partially fixed SE(3) or S²
block, bounds on a non-vector block, or one camera's parameter blocks
combined differently in two factors.
Performance and Quality
Measured on a synthetic benchmark of the seven workflows, with planar intrinsics and rig extrinsics each run with a straight and a tilted (Scheimpflug) sensor: 150 scenes across three sizes (8 to 36 views), two pixel-noise levels and 5 % outliers under Huber or Cauchy losses, timed as the median of three runs on an Apple M4 Pro (8 performance + 4 efficiency cores). Times are the median non-linear solve per scene, all stages included.
| Problem | tiny-solver | factrs | factrs / tiny-solver | final-cost agreement |
|---|---|---|---|---|
| Planar intrinsics (pinhole) | 27 ms | 33 ms | 1.24× | ≤ 4e-07 |
| Planar intrinsics (Scheimpflug) | 195 ms | 423 ms | 2.19× | ≤ 3e-07 |
| Single-camera hand-eye | 186 ms | 254 ms | 1.39× | ≤ 1e-08 |
| Laserline device | 73 ms | 158 ms | 2.14× | ≤ 2e-05 |
| Rig extrinsics | 92 ms | 135 ms | 1.49× | ≤ 9e-06 |
| Rig extrinsics (Scheimpflug) | 388 ms | 791 ms | 2.04× | ≤ 4e-07 |
| Rig hand-eye | 414 ms | 611 ms | 1.46× | ≤ 2e-04 |
| Rig laserline | 28 ms | 70 ms | 2.48× | — |
| Rig hand-eye + laser | 819 ms | 1499 ms | 1.68× | ≤ 2e-05 |
Quality is the same. The backends end at the same final cost (the largest relative difference is 2e-4, typically below 1e-7), take the same number of iterations in 137 of 144 scenes, and recover the same parameters.
Speed depends on the cores available. tiny-solver evaluates residual blocks in parallel; factrs evaluates them on one thread. On a single thread factrs is 2.4–3.3× faster (measured on the planar intrinsics, laserline device and rig hand-eye scenes); its dual numbers have a fixed size and live on the stack, while tiny-solver's are allocated on the heap. With all cores, tiny-solver's 3.7–5.7× parallel speed-up wins.
Which to use. tiny-solver, the default, is faster whenever the solve has the machine's cores to itself. factrs uses a core more efficiently, so it is the better choice when cores are scarce: a single-threaded deployment, or an application running many calibrations at once.
Solver Options
Each solve stage's SolverConfig sets the backend, the iteration budget
max_iters, the verbosity and the robust_loss. The loop stops when the
cost decreases by less than 1e-5 in absolute or relative terms, or falls
below 1e-10.
The damped normal equations are always solved by sparse Cholesky. When the factorization fails (the damped is not numerically positive definite), the loop treats it as a rejected step: it raises the damping and retries.
Solve Report
#![allow(unused)] fn main() { pub struct SolveReport { pub final_cost: f64, pub num_iters: usize, } }
final_cost is the robust objective at the solution, the same
definition for both backends, so it is comparable between them.
num_iters counts the LM iterations.
Typical Convergence
For a well-initialized planar intrinsics problem:
- Final cost: – (sub-pixel residuals)
- Iterations: 5–50 (depends on problem size and initial quality)
- Termination: usually the relative decrease falling below
min_rel_decrease
Error Handling
A solve fails with an error for:
- a missing or wrongly sized initial value for a parameter block;
- an invalid IR (for example a non-positive robust-loss scale);
- an IR the selected backend cannot map (see above);
- a non-finite initial cost.
Factor Catalog Reference
This chapter is a complete reference for the factor kinds (residual computations) supported by the optimization IR. A factor is described along three independent axes carried as data, not baked into variant names:
- Camera model (
CameraModelSpec) — which projection / distortion / sensor kinds apply and which leading parameter blocks exist. - Pose chain (
ReprojChain/LaserChain) — how a target-frame point reaches the camera frame and which trailing parameter blocks exist. - Residual family (the
FactorKindvariant) — what is measured.
pub enum FactorKind {
ReprojPoint { model: CameraModelSpec, chain: ReprojChain, pw: [f64; 3], uv: [f64; 2], sqrt_info: ReprojSqrtInfo },
LaserPointToPlane { model: CameraModelSpec, chain: LaserChain, laser_pixel: [f64; 2], w: f64 },
LaserLineDistance { model: CameraModelSpec, chain: LaserChain, laser_pixel: [f64; 2], w: f64 },
Se3TangentPrior { sqrt_info: [f64; 6], residual_sigma: f64 },
}
Camera-Model Spec
pub struct CameraModelSpec {
pub projection: ProjectionKind, // Pinhole
pub distortion: DistortionKind, // None | BrownConrady5 | Rational8 | ThinPrism9 | Division1
pub sensor: SensorKind, // Identity | Scheimpflug
}
A slot whose kind has no parameters contributes no parameter block. The expected layout of every factor is therefore derived, never hand-listed:
[intrinsics, projection?, distortion?, sensor?, <chain blocks…>]
FactorKind::param_layout() returns this layout as
Vec<ParamSlotSpec { dim, manifold, role }>, and ProblemIR::validate
checks each referenced block against it.
Evaluation dispatches per slot through the kind's own methods in
vision-calibration-core (ProjectionKind::project,
DistortionKind::distort, SensorKind::to_sensor, generic over the autodiff
scalar T: RealField), so the optimizer holds no camera math: a new camera
model is a new kind variant in core, not a new factor.
Reprojection Chains
ReprojPoint computes the weighted pixel residual
where (ReprojSqrtInfo) is for a point without a stated
covariance, or the upper-triangular square root of () for a point stating the covariance , times for a
family weight ; one problem holds one kind or the other. is the camera
model selected by the spec and
comes from the chain:
ReprojChain::SinglePose
Chain blocks: [camera_se3_target(7)]
Use case: single-camera calibration (planar / Scheimpflug intrinsics, laserline device bundles).
ReprojChain::TwoSe3
Chain blocks: [extrinsics(7), pose(7)]
where extrinsics maps camera → rig and pose maps target → rig.
Use case: multi-camera rig extrinsics.
ReprojChain::Handeye { base_se3_gripper, mode }
Chain blocks: [extrinsics(7), handeye(7), target(7)]
Chain data: the measured robot pose and the HandeyeMode.
Eye-in-hand:
Eye-to-hand:
For single-camera hand-eye, .
Use case: hand-eye calibration (camera on robot arm, or rig observing a robot-mounted target).
ReprojChain::HandeyeRobotDelta { base_se3_gripper, mode }
Chain blocks: [extrinsics(7), handeye(7), target(7), robot_delta(6)]
Same as Handeye, but with a per-view se(3) tangent correction applied to
the robot pose in the gripper frame:
The correction is regularized by an Se3TangentPrior factor.
Use case: hand-eye calibration with imprecise robot kinematics.
Laser Factors
Both laser families have residual dimension 1. Their chains mirror the
reprojection chains but end with the laser-plane blocks
[plane_normal(3, S²), plane_distance(1)]; a robot-delta block, when
present, always comes last:
LaserChain::SinglePose— blocks[camera_se3_target, plane_normal, plane_distance].LaserChain::RigHandeye { base_se3_gripper, mode }— blocks[cam_se3_rig, handeye, target_ref, plane_normal, plane_distance]; the per-view pose is composed exactly like the hand-eye reprojection chain.LaserChain::RigHandeyeRobotDelta { … }— addsrobot_delta(6)last.
LaserPointToPlane
Computation:
- Undistort the laser pixel to normalized coordinates (inverting sensor and distortion kernels)
- Back-project to a ray in camera frame
- Intersect the ray with the target plane (Z = 0 in the target frame)
- Compute the 3D point in camera frame
- Measure the signed distance from the 3D point to the laser plane
Residual: — distance in meters.
LaserLineDistance
Computation:
- Compute the 3D intersection line of laser plane and target plane (camera frame)
- Project this line onto the normalized camera plane
- Undistort the laser pixel to normalized coordinates
- Measure the perpendicular distance from pixel to projected line (2D geometry)
- Scale by for pixel-comparable units
Residual: — distance in effective pixels.
Advantages over LaserPointToPlane: residuals in pixel units (directly comparable to reprojection error) and simpler 2D geometry.
Default: this is the recommended laser residual type
(LaserlineResidualType::LineDistNormalized).
Prior Factors
Se3TangentPrior
Parameters: [robot_delta(6)] (Euclidean se(3) tangent block)
Computation: element-wise scaling of the tangent block by the diagonal square-root information:
Use case: zero-mean Gaussian prior on robot pose corrections
(robot_delta) to penalize deviations from the nominal robot kinematics.
Summary Table
| Factor | Blocks | Res. dim | Units |
|---|---|---|---|
ReprojPoint | camera blocks + 1–4 chain blocks | 2 | pixels |
LaserPointToPlane | camera blocks + 3–6 chain blocks | 1 | meters |
LaserLineDistance | camera blocks + 3–6 chain blocks | 1 | pixels |
Se3TangentPrior | 1 | 6 | normalized |
Camera blocks = 1 (intrinsics) + 1 for each of projection, distortion and
sensor whose kind has parameters. For example, ReprojPoint with a pinhole,
Brown-Conrady, Scheimpflug camera and HandeyeRobotDelta connects 7 blocks:
[intrinsics, distortion, sensor, extrinsics, handeye, target, robot_delta].
Your Own Residual
The calibration workflows solve the problems the library knows. When you have a measurement they do not use, such as a distance taken with a laser rangefinder, a constraint from a CAD drawing or a reading from another sensor, write it as a residual and solve it together with the library's own. The solver is the one the workflows run, and the result comes with the covariance of everything it estimates.
This is Rust only: a residual is generic over its scalar type, which a Python function cannot be.
The pieces
optim::problem
holds them:
- a
Problemof parameter blocks: a Euclidean vector, an SE(3) pose or a unit 3-vector, each with a name; - residuals that read blocks: ones you write (the
Residualtrait), and the library's reprojection residual through a camera you add; solve, which returns aSolution: each block's value, the solve report, the noise level of each residual family and the parameter covariance.
Writing a residual
A residual states its length, the dimension of each block it reads, and its value:
#![allow(unused)] fn main() { use vision_calibration::nalgebra::{DVector, RealField}; use vision_calibration::optim::problem::{Residual, se3}; /// A measured distance from the camera centre to the board's origin: the /// length of the translation of `camera_se3_target`. struct Range { measured: f64, } impl Residual for Range { fn dim(&self) -> usize { 1 } fn block_dims(&self) -> Vec<usize> { vec![7] // one SE(3) pose } fn eval<T: RealField>(&self, blocks: &[DVector<T>]) -> DVector<T> { let range = se3(&blocks[0]).translation.vector.norm(); DVector::from_element(1, range - T::from_f64(self.measured).unwrap()) } } }
eval is generic over T: RealField. The solver evaluates it at f64
for the cost and at dual numbers for the Jacobian, so the derivatives are
exact and you write none. Write it with RealField operations and nalgebra
types over T, and lift constants with T::from_f64(c).unwrap().
Each block arrives as its stored values: a Euclidean block's components, a
pose's [qx, qy, qz, qw, tx, ty, tz] (se3 turns it into an
Isometry3<T>), a unit vector's [x, y, z], a rotation's [qx, qy, qz, qw] (Manifold::So3; so3 turns it into a UnitQuaternion<T>). eval must return dim
values and must not panic. A value that is not finite makes the solver
reject that step, as for any residual.
Building the problem
Here the board's distance in the first view joins a planar calibration.
camera, poses, board and pixels are the starting values and the
observations, from the closed-form initializers in
init
or a workflow's result (Compose Your Own Pipeline
computes them):
#![allow(unused)] fn main() { use vision_calibration::core::CameraFix; use vision_calibration::optim::RobustLoss; use vision_calibration::optim::problem::{ Noise, PixelNoise, PoseChain, Problem, SolveOptions, TargetPoint, solve, }; let mut problem = Problem::new(); let cam = problem.add_camera(&camera, &CameraFix::default())?; let mut views = Vec::new(); for (v, (pose, pixels)) in poses.iter().zip(&pixels).enumerate() { let pose = problem.add_pose(format!("view{v}"), pose)?; for (point, pixel) in board.iter().zip(pixels) { problem.add_reprojection( cam, PoseChain::Pose(pose), TargetPoint::Known(*point), *pixel, PixelNoise::Estimated, RobustLoss::None, )?; } views.push(pose); } problem.add_residual( Range { measured: 0.4524 }, &[views[0]], RobustLoss::None, Noise::Known(2e-4), // the rangefinder's σ: 0.2 mm )?; let solution = solve(&problem, &SolveOptions::default())?; let refined = solution.camera(cam).unwrap(); let board_pose = solution.pose(views[0]).unwrap(); }
add_camera lays out the camera's blocks from its model, holding what the
CameraFix names, just as a workflow's config does (by default k3 for
Brown–Conrady). add_reprojection takes the chain that moves a point into
the camera frame (here one camera_se3_target pose block), the point in
the target frame, its observed pixel and that pixel's noise.
Noise: what the covariance needs to know
The covariance needs the standard deviation of every residual. A residual gets it in one of two ways:
Noise::Known(σ)for a measurement with a stated σ, or a prior. The solve divides the residual by σ, so it weighs as much as its precision says, and the covariance counts it as known information.Noise::Estimated(name)for residuals that share an unknown noise level. They are pooled into a family of that name, and its σ is estimated from the family's scatter at the solution, as the reprojection residuals' σ is. A family needs enough residuals to estimate a spread. It is never pooled with the library's own residuals.
The reprojection residuals of PixelNoise::Estimated form the reprojection
family, in pixels; PixelNoise::Stated gives a pixel a known covariance
instead.
An estimated family enters the solve in its own unit, unweighted. The
covariance accounts for whatever weights the solve used, so it stays right.
But a family in metres next to residuals in pixels barely moves the
solution. To have it count, divide its residual by its expected σ, as
Noise::Known does.
solution.noise lists each estimated family's level. A family of yours
appears as ResidualKind::Custom(name).
Reading the result
solution.uncertainty is the same Uncertainty the workflows' results
carry, labelled by coordinate:
- a camera's parameters are labelled as in a workflow's result:
fx,k1, … with one camera, orcam0.fx,cam1.fx, … with several. Soprojection_uncertaintyreads a camera solved here; - a block of yours is labelled
<name>.<i>by default, or<name>.<label>afterset_labels; - a pose is labelled
<name>.rx … <name>.tz. These are its right-perturbation tangentT·Exp(δ):tx … tzstep along the axes of the frame the pose is of (the board's), not the camera's. To get the variance of the board's depth in the camera frame, rotate the translation block:r₃ᵀ Σ_t r₃, withr₃the third row of the pose's rotation.
The range does more than place one board. A planar calibration determines the focal length and the boards' depths only together: a longer focal length with the boards farther away fits the pixels almost as well. One measured distance fixes that scale. Over 120 repetitions of 8 views of a 9×6 board with 0.3 px pixel noise, the 0.2 mm range on view 0 gives:
| pixels only | with the range | |
|---|---|---|
| σ(fx) | 3.66 px | 0.84 px |
| σ(depth), view 0 (frontal) | 2.05 mm | 0.49 mm |
| σ(depth), view 3 (tilted) | 2.14 mm | 0.66 mm |
The stated σ matches the scatter of the estimates. With the range, the
ratio of actual scatter to stated σ was 0.93 for fx, 1.05 for cx, and
1.00 and 1.01 for the two depths.
What is checked, and when
Every input is checked as it is added, so a problem that was built is one
solve runs:
- a block's values are finite. A pose has a unit quaternion and a unit vector has unit norm;
- a residual's blocks match its
block_dims, and its value at the initial blocks hasdimfinite entries. An error names the residual by its index; - a reprojected point lies in front of the camera at the initial pose;
- bounds hold the initial value, and a known σ is positive.
At the solution:
- a block no residual reads has no information, and the uncertainty says
so (
RankDeficient, naming its coordinates); - a free parameter that ends on a bound has no Gaussian covariance either
(
AtBound).
Limits
- Backend. A problem with a residual of yours runs on the default tiny-solver backend. Asking for factrs is an error before any work, because factrs fixes each variable's size at compile time. The library's residuals alone run on both.
- Independent residuals. The covariance treats every residual as
independent. The workflows also measure how much the estimate varies
between views beyond that (
view_scatter). A problem of yours does not. - The library's residuals. Reprojection through a single pose is the only built-in residual a problem can use so far.
Compose Your Own Pipeline
A calibration workflow runs four stages:
- it takes the observations;
- it computes starting values in closed form;
- it refines them with a non-linear solve;
- it reports the covariance of the result.
The piece behind each stage is public. When your setup is not one of the workflows, build it from the same pieces. This chapter builds a measurement this way: the pose of a machined part, seen by a camera calibrated from board images. The σ of the measurement includes the uncertainty of the calibration.
This is Rust only, as is Your Own Residual, which this chapter builds on.
The pieces
| Stage | Public piece |
|---|---|
| Observations | core::CorrespondenceView (points and their pixels), core::PlanarDataset |
| Starting values | init: a camera from views of a plane, a plane's pose from its homography (mvg::dlt_homography), a pose from 3D–2D points (pnp), hand-eye, a plane through points |
| Solve | optim::problem: blocks, a camera, reprojection residuals, residuals of your own, solve |
| Uncertainty | Solution::uncertainty, labelled as a workflow's; projection_uncertainty for a camera |
| Testing | synthetic (feature synthetic): boards, poses and noise with a known truth |
Starting values
The camera comes from Zhang's method with a distortion fit, over the board views. Each board pose comes from its homography, and the part's pose from EPnP on its eight fiducials, whose positions on the part are known:
#![allow(unused)] fn main() { use vision_calibration::core::{CorrespondenceView, PlanarDataset, Pt2, View}; use vision_calibration::init::{ IterativeIntrinsicsOptions, estimate_intrinsics_iterative, estimate_planar_pose_from_h, pnp, }; use vision_calibration::mvg::dlt_homography; let mut views = Vec::new(); for pixels in &board_pixels { views.push(View::without_meta(CorrespondenceView::new(board.clone(), pixels.clone())?)); } let start = estimate_intrinsics_iterative( &PlanarDataset::new(views)?, IterativeIntrinsicsOptions::default(), )?; let board_2d: Vec<Pt2> = board.iter().map(|p| Pt2::new(p.x, p.y)).collect(); let mut board_poses = Vec::new(); for pixels in &board_pixels { let h = dlt_homography(&board_2d, pixels)?; board_poses.push(estimate_planar_pose_from_h(&start.k.k_matrix(), &h)?); } let part_start = pnp::epnp(&fiducials, &part_pixels, &start.k)?; }
EPnP uses the pinhole intrinsics and ignores distortion. That is enough for a starting value, because the solve refines it with the full camera model.
One problem for the calibration and the measurement
The board views and the part enter one problem, through one camera. Each has its own pose block:
#![allow(unused)] fn main() { use vision_calibration::core::{CameraFix, pinhole_camera_params}; use vision_calibration::optim::RobustLoss; use vision_calibration::optim::problem::{ PixelNoise, PoseChain, Problem, SolveOptions, TargetPoint, solve, }; let mut problem = Problem::new(); let cam = problem.add_camera(&pinhole_camera_params(&start), &CameraFix::default())?; for (v, (pose, pixels)) in board_poses.iter().zip(&board_pixels).enumerate() { let pose = problem.add_pose(format!("view{v}"), pose)?; for (point, pixel) in board.iter().zip(pixels) { problem.add_reprojection( cam, PoseChain::Pose(pose), TargetPoint::Known(*point), *pixel, PixelNoise::Estimated, RobustLoss::None, )?; } } let part = problem.add_pose("part", &part_start)?; for (point, pixel) in fiducials.iter().zip(&part_pixels) { problem.add_reprojection( cam, PoseChain::Pose(part), TargetPoint::Known(*point), *pixel, PixelNoise::Estimated, RobustLoss::None, )?; } let solution = solve(&problem, &SolveOptions::default())?; let camera_se3_part = solution.pose(part).unwrap(); }
Each reprojection names how its point reaches the camera (PoseChain:
one pose, or a camera of a rig), the point (TargetPoint: a known point
such as a board corner, or a block being estimated) and the pixel's noise
(PixelNoise: estimated from the scatter of the problem's reprojections,
or a stated covariance).
The board pixels determine the camera. The part's pixels determine its pose through that camera. Its covariance block then holds both sources: the noise of the part's own pixels, and the uncertainty of the camera, which comes from the board pixels.
A calibration you have as a result
A calibration's result carries its cameras and their covariance, without
the board pixels it came from. add_calibrated_rig adds it to a problem
with that covariance:
#![allow(unused)] fn main() { use vision_calibration::core::CalibratedRig; use vision_calibration::optim::problem::Use; let rig: CalibratedRig = export.calibrated_rig()?; let mut problem = Problem::new(); let blocks = problem.add_calibrated_rig(&rig, Use::Consider)?; let part = problem.add_pose("part", &part_start)?; for (point, pixel) in fiducials.iter().zip(&part_pixels) { problem.add_reprojection( blocks.camera(0).unwrap(), PoseChain::Pose(part), TargetPoint::Known(*point), *pixel, PixelNoise::Estimated, RobustLoss::None, )?; } let solution = solve(&problem, &SolveOptions::default())?; }
Every calibration result has calibrated_rig(). The calibration enters in
one of two ways:
Use::Consider: the cameras stay fixed at their calibrated values, so the estimate is the one a fixed camera gives. The covariance adds the calibration's share,S Σ_c Sᵀ, withΣ_cthe calibration's covariance andShow the estimate moves with the calibration. This is the use for a measurement.Use::Prior: the cameras are free, held by a prior with the calibration's covariance, so the new observations refine them too. The prior is weighed by its covariance, so every pixel's noise is stated (PixelNoise::Stated).
For a rig of several cameras, blocks.chain(camera, rig_se3_target) is the
chain from a target posed in the rig frame to one camera.
The measurement's σ
The part's covariance is labelled part.rx … part.tz. As for any pose,
part.tx … part.tz are steps in the part's own frame
(see Reading the result). The
variance of its depth in the camera frame is r₃ᵀ Σ_t r₃, with r₃ the
third row of the pose's rotation.
To check that σ against the actual scatter, the test setup is:
- the calibration: 8 views of a 9×6 board with 30 mm squares, at about 0.45 m;
- the part: the corners of an 80 × 60 × 30 mm block, at 0.5 m;
- noise: 0.3 px in every image.
Over 120 repetitions, each with new noise in the calibration images and in the part's image, the depth's σ was:
| stated σ(depth) | actual scatter / stated σ | |
|---|---|---|
| one problem, as above | 2.53 mm | 0.91 |
calibrate first, then measure with Use::Consider | 2.54 mm | 0.91 |
| calibrate first, then measure with the camera fixed | 0.91 mm | 2.52 |
The depth estimates are nearly the same every way and scatter by 2.3 mm. Only the stated σ differs.
A camera held fixed is treated as exact, so the σ counts only the part's 16 pixel coordinates. At half a metre, the calibration's uncertainty in the focal length dominates: the depth scales with it. Fixing the camera states σ about 2.5 times too small.
Measuring well
- Several measurements with one calibration. Put them all in one problem. Their covariance then includes the correlation that the shared calibration induces between them; two separate solves lose it.
- A calibration as a result. Add it with
Use::Consider, not as a fixed camera: fixed, it gives the understated σ above. - Outliers. A measurement image with false detections takes a robust loss on its reprojections (see Robust Loss Functions).
Testing a pipeline of your own
The synthetic feature generates boards, poses and noisy pixels from a
known truth. Two checks are worth running:
- run your pipeline on that data and compare its result with the truth;
- repeat the run over many noise draws and compare the stated σ with the scatter of the estimates.
The table above came from such a run.
Synthetic Data Generation
calibration-rs provides utilities for generating synthetic calibration data, used in examples and tests. Synthetic data allows testing with known ground truth, verifying convergence, and debugging algorithm issues. The generators are behind the synthetic feature of vision-calibration (cargo add vision-calibration --features synthetic, or as a dev-dependency feature for tests).
Board Point Generation
#![allow(unused)] fn main() { use vision_calibration::synthetic::planar; // 8×6 grid of 3D points at Z=0, spaced 40mm apart let board_points = planar::grid_points(8, 6, 0.04); // Returns Vec<Pt3>: [(0,0,0), (0.04,0,0), ..., (0.28,0.20,0)] }
Points are generated in the plane with the specified column count, row count, and spacing (in meters).
Pose Generation
#![allow(unused)] fn main() { // 6 camera poses with varying yaw and distance let poses = planar::poses_yaw_y_z( 6, // number of views -0.2, // start yaw (radians) 0.08, // yaw step 0.5, // start Z distance (meters) 0.05, // Z step ); // Returns Vec<Iso3>: cam_se3_target (board-to-camera) transforms }
Poses are generated looking at the board center, with rotation around the Y axis (yaw) and varying distance along Z.
Projection
#![allow(unused)] fn main() { // Project board points through all poses let views = planar::project_views_all(&camera, &board_points, &poses)?; // Returns Vec<CorrespondenceView>: 2D-3D point pairs per view }
This applies the full camera model (projection + distortion + intrinsics) to generate pixel observations.
Adding Noise
For realistic testing, project with deterministic uniform pixel noise. UniformPixelNoise is a pure function of (seed, view index, point index), so datasets are identical across platforms and library versions:
#![allow(unused)] fn main() { use vision_calibration::synthetic::noise::UniformPixelNoise; let noise = UniformPixelNoise { seed: 42, max_abs_px: 0.5 }; // uniform in [-0.5, 0.5] px per axis let views = planar::project_views_noisy(&camera, &board_points, &poses, &noise)?; }
Typical noise levels (max_abs_px):
| Scenario | Noise (px) |
|---|---|
| Ideal (testing convergence) | 0.0 |
| High-quality detector | 0.1 - 0.3 |
| Standard detector | 0.3 - 1.0 |
| Noisy conditions | 1.0 - 3.0 |
Set a fixed seed in examples and tests for reproducibility.
Common Test Scenarios
Minimal (3 views, no noise)
For verifying algorithm correctness. Should converge to machine precision.
Moderate (6-10 views, 0.5 px noise)
For testing realistic convergence. Should achieve <1% intrinsics error after optimization.
Challenging (20 views, 1.0 px noise, outliers)
For testing robustness. Should achieve <2% intrinsics error with robust loss functions.
Distortion stress test
Use large distortion (, ) to verify the iterative intrinsics solver handles strong distortion.
Data Types Quick Reference
Core Math Types
All based on nalgebra with f64 precision:
| Type | Definition | Description |
|---|---|---|
Real | f64 | Scalar type |
Pt2 | Point2<f64> | 2D point |
Pt3 | Point3<f64> | 3D point |
Vec2 | Vector2<f64> | 2D vector |
Vec3 | Vector3<f64> | 3D vector |
Mat3 | Matrix3<f64> | 3×3 matrix |
Mat4 | Matrix4<f64> | 4×4 matrix |
Iso3 | Isometry3<f64> | SE(3) rigid transform |
Camera Model Types
| Type | Parameters | Description |
|---|---|---|
FxFyCxCy<S> | fx, fy, cx, cy | Intrinsics matrix |
BrownConrady5<S> | k1, k2, k3, p1, p2, iters | Distortion model |
Pinhole | (none) | Projection model |
IdentitySensor | (none) | No sensor tilt |
ScheimpflugParams | tilt_x, tilt_y | Scheimpflug tilt |
Camera<S,P,D,Sm,K> | proj, dist, sensor, k | Composable camera |
CameraParams | projection, distortion, sensor, intrinsics | Serializable camera description (see Serialization) |
Selectors and Modes
| Type | Variants | Used by |
|---|---|---|
DistortionKind | None, BrownConrady5, Rational8, ThinPrism9, Division1 | camera.model.distortion in every config that calibrates a camera (per camera through camera_overrides in a rig) |
SensorKind | Identity, Scheimpflug | camera.model.sensor in the same configs |
CameraModelSpec | { projection, distortion, sensor } | camera.model in the planar, single-camera hand-eye, laserline-device, rig extrinsics and rig hand-eye configs |
CameraFix | { intrinsics, projection, distortion, sensor }, each Option<Vec<String>> of parameter names | camera.fix in the same configs, and joint_optimize.fix (one per camera) of the joint rig laser solve; None keeps the model's default |
CameraOverride | { camera, model, fix } | camera_overrides in RigExtrinsicsConfig and RigHandeyeConfig: replaces one camera's model and fix lists (see Scheimpflug and Laser Rigs) |
RobustLoss | None, Huber { scale }, Cauchy { scale }, Arctan { scale } | solver.robust_loss and the laser calib_loss / laser_loss |
HandeyeMode | EyeInHand, EyeToHand | handeye_init.handeye_mode |
Observation Types
| Type | Fields | Description |
|---|---|---|
CorrespondenceView | points_3d, points_2d, optional cov_px | 2D-3D correspondences (build with CorrespondenceView::new(..)?, or new_with_covariances(..)? with a PointCovariance per point) |
View<Meta> | obs, meta | Observation + metadata |
PlanarDataset | views: Vec<View<NoMeta>> | Planar calibration input |
RigViewObs | cameras: Vec<Option<CorrespondenceView>> | Per-camera observations of one rig frame |
RigView<Meta> | obs: RigViewObs, meta | Multi-camera view |
RigDataset<Meta> | num_cameras, views | Multi-camera input |
ReprojectionStats | mean, rms, max, count | Error statistics |
Shared Config Groups
Every problem's *Config embeds the same small structs by stage:
| Type | Fields | Purpose |
|---|---|---|
IntrinsicsInitConfig | iterations, fix_k3 | Per-camera linear initialization; fits only the distortion terms the camera's solves estimate |
SolverConfig | max_iters, verbosity, robust_loss | Non-linear solve |
RobotPoseConfig | refine, rot_sigma, trans_sigma, estimate_sigma | Robot-pose refinement in hand-eye bundle adjustment |
HandeyeInitConfig | handeye_mode, min_motion_angle_deg | Hand-eye linear initialization |
RigConfig | reference_camera_idx, refine_intrinsics, refine_sensor | Rig frame options |
Session Types
| Type | Description |
|---|---|
CalibrationSession<P> | Generic session container |
SessionMetadata | Problem name, version, timestamps (read via session.metadata()) |
LogEntry | Audit log entry (timestamp, operation, success, notes; read via session.log()) |
ExportRecord<E> | Timestamped export |
InvalidationPolicy | What to clear on input/config change |
Optimization Backend Types
vision-calibration-optim keeps the IR (ProblemIR, ParamBlock, ResidualBlock, FactorKind, ManifoldKind, ...), the backend dispatch and the optimize_* entry points internal; see Backend-Agnostic IR Architecture. Each estimate carries a SolveReport (final_cost, num_iters); a config's SolverConfig (backend, max_iters, verbosity, robust_loss) sets how each stage solves.
Hand-Eye Types
| Type | Description |
|---|---|
HandeyeMode | EyeInHand or EyeToHand |
RobotPoseMeta | base_se3_gripper: Iso3, a view's robot pose (every hand-eye workflow) |
References and Further Reading
Foundational Textbooks
-
Hartley, R.I. & Zisserman, A. (2004). Multiple View Geometry in Computer Vision. 2nd edition. Cambridge University Press. — The definitive reference for projective geometry, fundamental/essential matrices, homography, triangulation, and bundle adjustment.
-
Ma, Y., Soatto, S., Kosecka, J., & Sastry, S.S. (2004). An Invitation to 3-D Vision. Springer. — Rigorous treatment of Lie groups and geometric vision with emphasis on SE(3) and optimization on manifolds.
Camera Calibration
-
Zhang, Z. (2000). "A Flexible New Technique for Camera Calibration." IEEE Transactions on Pattern Analysis and Machine Intelligence, 22(11), 1330-1334. — Zhang's planar calibration method: intrinsics from homographies via the image of the absolute conic.
-
Brown, D.C. (1966). "Decentering Distortion of Lenses." Photometric Engineering, 32(3), 444-462. — The original Brown-Conrady distortion model with radial and tangential components.
Minimal Solvers
-
Nister, D. (2004). "An Efficient Solution to the Five-Point Relative Pose Problem." IEEE Transactions on Pattern Analysis and Machine Intelligence, 26(6), 756-770. — The 5-point essential matrix solver used in stereo and visual odometry.
-
Kneip, L., Scaramuzza, D., & Siegwart, R. (2011). "A Novel Parametrization of the Perspective-Three-Point Problem for a Direct Computation of Absolute Camera Position and Orientation." IEEE Conference on Computer Vision and Pattern Recognition (CVPR). — The P3P solver used for camera pose estimation.
Hand-Eye Calibration
-
Tsai, R.Y. & Lenz, R.K. (1989). "A New Technique for Fully Autonomous and Efficient 3D Robotics Hand/Eye Calibration." IEEE Transactions on Robotics and Automation, 5(3), 345-358. — The Tsai-Lenz method for solving AX=XB.
-
Park, F.C. & Martin, B.J. (1994). "Robot Sensor Calibration: Solving AX=XB on the Euclidean Group." IEEE Transactions on Robotics and Automation, 10(5), 717-721. — Rotation from the Lie-algebra (rotation-vector) alignment.
-
Daniilidis, K. (1999). "Hand-Eye Calibration Using Dual Quaternions." The International Journal of Robotics Research, 18(3), 286-298. — Rotation and translation together.
-
Shah, M. (2013). "Solving the Robot-World/Hand-Eye Calibration Problem Using the Kronecker Product." Journal of Mechanisms and Robotics, 5(3), 031007. — AX=ZB from absolute poses.
Robust Estimation
-
Fischler, M.A. & Bolles, R.C. (1981). "Random Sample Consensus: A Paradigm for Model Fitting with Applications to Image Analysis and Automated Cartography." Communications of the ACM, 24(6), 381-395. — The original RANSAC paper.
-
Hartley, R.I. (1997). "In Defense of the Eight-Point Algorithm." IEEE Transactions on Pattern Analysis and Machine Intelligence, 19(6), 580-593. — Demonstrates that data normalization makes the 8-point algorithm competitive with iterative methods.
Non-Linear Optimization
-
Levenberg, K. (1944). "A Method for the Solution of Certain Non-Linear Problems in Least Squares." Quarterly of Applied Mathematics, 2(2), 164-168.
-
Marquardt, D.W. (1963). "An Algorithm for Least-Squares Estimation of Nonlinear Parameters." Journal of the Society for Industrial and Applied Mathematics, 11(2), 431-441.
-
Triggs, B., McLauchlan, P.F., Hartley, R.I., & Fitzgibbon, A.W. (2000). "Bundle Adjustment — A Modern Synthesis." International Workshop on Vision Algorithms. Springer. — Comprehensive survey of bundle adjustment techniques.
Lie Groups in Vision
- Sola, J., Deray, J., & Atchuthan, D. (2018). "A Micro Lie Theory for State Estimation in Robotics." arXiv:1812.01537. — Accessible introduction to Lie groups for robotics and vision, covering SO(3), SE(3), and their tangent spaces.
Adding a New Optimization Problem
This chapter is for contributors working inside vision-calibration-optim: it walks through adding a new optimization problem to the crate, following the pattern established by the planar intrinsics implementation. To solve a problem of your own from your own crate, see Your Own Residual and Compose Your Own Pipeline.
Overview
Most new problems compose the existing factor families (ReprojPoint,
LaserPointToPlane, LaserLineDistance, Se3TangentPrior) with a
CameraModelSpec and a chain — in that case skip straight to Step 3 and emit
the existing factors from your builder. The steps below cover the rarer case
of a genuinely new residual family:
- Define the generic residual function in
factors/ - Add a
FactorKindvariant inir/types.rs - Create a problem builder in
problems/ - Integrate with both backends (
backend/tiny_solver_backend.rsandbackend/factrs_backend/) - Write tests with synthetic ground truth
(Adding a new camera model is a different, smaller recipe: a variant of
the relevant kind enum in vision-calibration-core with its parameter names
and its evaluation method; no optimizer change. See the
Factor Catalog.)
Step 1: Generic Residual Function
Create a new file or add to an existing file in crates/vision-calibration-optim/src/factors/:
#![allow(unused)] fn main() { pub fn my_residual_generic<T: RealField>( param_a: DVectorView<'_, T>, // optimizable parameters param_b: DVectorView<'_, T>, constant_data: [f64; 3], // per-residual constants (f64, not T) w: f64, // weight ) -> SVector<T, 2> { // residual dimension // Convert constants to T let cx = T::from_f64(constant_data[0]).unwrap(); // Compute residual using param_a, param_b, cx // Use .clone() liberally, avoid in-place ops let r_x = /* ... */; let r_y = /* ... */; let wt = T::from_f64(w).unwrap(); SVector::<T, 2>::new(r_x * wt.clone(), r_y * wt) } }
Key rules:
- Generic over
T: RealFieldfor autodiff - Optimizable parameters as
DVectorView<'_, T> - Constants as
f64(converted inside) - Use
.clone(), no in-place mutation - Return
SVector<T, N>for fixed residual dimension
Step 2: FactorKind Variant
Add a new variant to FactorKind in crates/vision-calibration-optim/src/ir/types.rs:
#![allow(unused)] fn main() { pub enum FactorKind { // ... existing variants ... MyNewFactor { constant_data: [f64; 3], w: f64, }, } }
Describe its parameter layout in param_layout() — validation is derived
from the layout, so there is no separate validation arm to write:
#![allow(unused)] fn main() { FactorKind::MyNewFactor { .. } => vec![ ParamSlotSpec { dim: 4, manifold: ManifoldKind::Euclidean, role: "param_a" }, ParamSlotSpec { dim: 7, manifold: ManifoldKind::SE3, role: "param_b" }, ], }
Update the residual_dim() and name() methods:
#![allow(unused)] fn main() { FactorKind::MyNewFactor { .. } => 2, }
Step 3: Problem Builder
Create crates/vision-calibration-optim/src/problems/my_problem.rs:
#![allow(unused)] fn main() { pub struct MyProblemParams { pub param_a: DVector<f64>, pub param_b: Iso3, } pub struct MySolveOptions { pub fix_param_a: FixedMask, pub robust_loss: RobustLoss, } pub fn build_my_problem_ir( data: &MyDataset, initial: &MyProblemParams, opts: &MySolveOptions, ) -> Result<(ProblemIR, HashMap<String, DVector<f64>>)> { let mut ir = ProblemIR::new(); let mut initial_values = HashMap::new(); // Add parameter blocks via the builder API let param_a_id = ir.add_param_block( "param_a", 4, ManifoldKind::Euclidean, opts.fix_param_a.clone(), None, ); initial_values.insert("param_a".to_string(), initial.param_a.clone()); let param_b_id = ir.add_param_block( "param_b", 7, ManifoldKind::SE3, FixedMask::all_free(), None, ); initial_values.insert( "param_b".to_string(), iso3_to_se3_dvec(&initial.param_b), ); // Add residual blocks for obs in &data.observations { ir.add_residual_block(ResidualBlock { params: vec![param_a_id, param_b_id], loss: opts.robust_loss, factor: FactorKind::MyNewFactor { constant_data: obs.constant_data, w: obs.weight, }, residual_dim: 2, }); } Ok((ir, initial_values)) } }
Step 4: Backend Integration
Both backends compile every factor kind. In
crates/vision-calibration-optim/src/backend/tiny_solver_backend.rs, add a
factor struct implementing tiny-solver's Factor<T> plus a match arm in
compile_factor():
#![allow(unused)] fn main() { #[derive(Debug, Clone)] struct TinyMyNewFactor { constant_data: [f64; 3], w: f64, } impl<T: nalgebra::RealField> Factor<T> for TinyMyNewFactor { fn residual_func(&self, params: &[DVector<T>]) -> DVector<T> { let r = my_residual_generic( params[0].as_view(), params[1].as_view(), self.constant_data, self.w, ); DVector::from_row_slice(r.as_slice()) } } // in compile_factor(): FactorKind::MyNewFactor { constant_data, w } => Ok(( Box::new(TinyMyNewFactor { constant_data: *constant_data, w: *w }), loss, )), }
If the new family is camera-model-aware, carry a CameraModelSpec in the
factor and evaluate the camera through CameraBlocks, the way
TinyReprojFactor does.
In backend/factrs_backend/residuals.rs, add the factrs residual: a struct
implementing factrs' Residual and the ResidualN trait for its variable
count, whose residualN unpacks the variables into the IR's parameter
blocks and calls the same generic function, plus an arm in
build_factor(). Camera-aware factors use the camera_residual! macro.
Then add the new kind to the cross-backend parity cases in
backend/parity_tests.rs, which check that both backends produce the same
residual and Jacobian.
Step 5: Tests
Write a test with synthetic ground truth in crates/vision-calibration-optim/tests/:
#![allow(unused)] fn main() { #[test] fn my_problem_converges() { // 1. Define ground truth parameters let gt_param_a = /* ... */; let gt_param_b = /* ... */; // 2. Generate synthetic observations let data = generate_synthetic_data(>_param_a, >_param_b); // 3. Create perturbed initial values let initial = perturb(>_param_a, >_param_b); // 4. Build and solve let (ir, init_vals) = build_my_problem_ir(&data, &initial, &opts)?; let solution = backend::solve(&ir, &init_vals, &backend_opts)?; // 5. Verify convergence let solved_a = &solution.params["param_a"]; assert!((solved_a[0] - gt_param_a[0]).abs() < tolerance); } }
Checklist
-
Generic residual function with
T: RealField -
FactorKindvariant with validation -
Problem builder producing
ProblemIR+ initial values - Backend compilation for the new factor
- Synthetic ground truth test verifying convergence
- (Optional) Pipeline integration with session framework
Adding a New Pipeline Problem Type
This chapter is a guide for contributors working inside the vision-calibration-pipeline crate. ProblemType is sealed, so new problem types are added to the crate itself rather than from downstream code; to build a pipeline of your own from your own crate, see Compose Your Own Pipeline. The laserline device module (laserline_device/) is a good template.
Module Structure
Create a new folder under crates/vision-calibration-pipeline/src/:
my_problem/
├── mod.rs # Module re-exports
├── problem.rs # ProblemType implementation + Config/Export
├── state.rs # Intermediate state type (crate-private)
└── steps.rs # Step functions + pipeline function
Step 1: Define the State (state.rs)
The state holds intermediate results between steps. It stays crate-private; users see typed step results instead.
#![allow(unused)] fn main() { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub(crate) struct MyState { // Initialization results pub initial_intrinsics: Option<FxFyCxCy<Real>>, pub initial_poses: Option<Vec<Iso3>>, // Optimization results pub final_cost: Option<f64>, pub mean_reproj_error: Option<f64>, } }
The state must implement Default (empty state) and Clone + Serialize + Deserialize (for checkpointing).
Step 2: Define the Problem Type (problem.rs)
#![allow(unused)] fn main() { use crate::Error; use crate::session::{InvalidationPolicy, ProblemState, ProblemType}; pub struct MyProblem; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MyConfig { pub solver: SolverConfig, pub init: IntrinsicsInitConfig, } // `MyInput`, `MyEstimate` and `MyExport` are ordinary // `Clone + Serialize + DeserializeOwned + Debug` types. The output is the // final solve's estimate; an input that is a shared data type is an alias // (`pub type MyInput = PlanarDataset;`). impl ProblemState for MyProblem { type State = MyState; // defined in state.rs } impl ProblemType for MyProblem { type Config = MyConfig; type Input = MyInput; type Output = MyEstimate; type Export = MyExport; fn name() -> &'static str { "my_problem_v1" } fn schema_version() -> u32 { 1 } fn validate_input(input: &MyInput) -> Result<(), Error> { if input.views.len() < 3 { return Err(Error::InsufficientData { need: 3, got: input.views.len() }); } Ok(()) } fn on_input_change() -> InvalidationPolicy { InvalidationPolicy::CLEAR_COMPUTED } fn on_config_change() -> InvalidationPolicy { InvalidationPolicy::KEEP_ALL } fn export( input: &MyInput, output: &MyEstimate, _config: &MyConfig, ) -> Result<MyExport, Error> { // Build the user-facing export (attach per-feature residuals, etc.). todo!() } } }
Shared config groups (SolverConfig, IntrinsicsInitConfig, RobotPoseConfig, HandeyeInitConfig, RigConfig) live in common/config.rs; embed them as named fields rather than re-declaring the same settings flat.
Step 3: Implement Step Functions (steps.rs)
Each step takes Option<<Stage><Action>Options> (reuse the shared ones in common/mod.rs; None keeps the session config) and returns a typed <Stage><Action>Result, so callers never need to read the state:
#![allow(unused)] fn main() { use crate::Error; use crate::common::{IntrinsicsInitOptions, IntrinsicsOptimizeOptions}; use crate::session::CalibrationSession; use super::problem::MyProblem; #[derive(Debug, Clone)] #[non_exhaustive] pub struct IntrinsicsInitResult { pub intrinsics: FxFyCxCy<Real>, pub poses: Vec<Iso3>, } pub fn step_init( session: &mut CalibrationSession<MyProblem>, opts: Option<IntrinsicsInitOptions>, ) -> Result<IntrinsicsInitResult, Error> { let opts = opts.unwrap_or_default(); let input = session.require_input()?; let config = &session.config; // Run linear initialization let intrinsics = /* ... */; let poses = /* ... */; // Store the results in the problem's crate-private state // (the session's `state` field, in-crate access only): // initial_intrinsics = Some(intrinsics); // initial_poses = Some(poses.clone()); session.log_success("init"); Ok(IntrinsicsInitResult { intrinsics, poses }) } pub fn step_optimize( session: &mut CalibrationSession<MyProblem>, opts: Option<IntrinsicsOptimizeOptions>, ) -> Result<IntrinsicsOptimizeResult, Error> { let input = session.require_input()?; // Require initialization // Read the initialization results back from the crate-private state, // failing with a typed error when `step_init` has not run: // .ok_or_else(|| Error::not_available("initial intrinsics (call step_init first)"))? let init_k = /* state.initial_intrinsics */; // Build and solve the optimization problem, then store the output let output: MyEstimate = /* ... */; session.set_output(output); session.log_success_with_notes("optimize", "cost=..., reproj_err=..."); Ok(/* IntrinsicsOptimizeResult { .. } */) } /// Convenience pipeline function: the same signature in every problem. pub fn run_calibration(session: &mut CalibrationSession<MyProblem>) -> Result<(), Error> { step_init(session, None)?; step_optimize(session, None)?; Ok(()) } }
Add step_*_with_seed variants when the problem supports manual seeds, following planar_intrinsics/steps.rs: the seed is a <Stage>ManualInit and comes before the options.
Step 4: Module Re-exports (mod.rs)
#![allow(unused)] fn main() { mod problem; mod state; mod steps; pub use problem::{MyConfig, MyEstimate, MyExport, MyInput, MyProblem}; pub use steps::{ IntrinsicsInitOptions, IntrinsicsInitResult, IntrinsicsOptimizeOptions, IntrinsicsOptimizeResult, run_calibration, step_init, step_optimize, }; }
Step 5: Register in the Pipeline Crate
In crates/vision-calibration-pipeline/src/lib.rs:
#![allow(unused)] fn main() { pub mod my_problem; }
Step 6: Wire into the Facade Crate
In crates/vision-calibration/src/lib.rs, list the public items explicitly (the facade curates its surface):
#![allow(unused)] fn main() { pub mod my_problem { pub use vision_calibration_pipeline::my_problem::{ IntrinsicsInitOptions, IntrinsicsInitResult, IntrinsicsOptimizeOptions, IntrinsicsOptimizeResult, MyConfig, MyEstimate, MyExport, MyInput, MyProblem, run_calibration, step_init, step_optimize, }; } }
Do not add new workflows to the prelude by default; keep the prelude minimal for planar hello-world usage.
Add the Python binding in vision-calibration-py: one session_class! invocation in src/session.rs (the problem type, its steps with their option and result types), a python/vision_calibration/<module>.py with the session class and the seed and result dataclasses, a run_<module> runner, and an entry per new facade item in scripts/binding_parity.toml. The parity check fails until every facade item is bound or marked Rust-only with a reason.
Testing
Write a test next to the module (it can read the crate-private state) or an integration test using only the public API:
#![allow(unused)] fn main() { #[test] fn my_problem_session_workflow() -> Result<(), Error> { let input = make_synthetic_input(); // local test helper let mut session = CalibrationSession::<MyProblem>::new(); session.set_input(input)?; let init = step_init(&mut session, None)?; assert!(!init.poses.is_empty()); step_optimize(&mut session, None)?; assert!(session.output().is_some()); // Test JSON round-trip let json = session.to_json()?; let restored = CalibrationSession::<MyProblem>::from_json(&json)?; assert!(restored.output().is_some()); Ok(()) } }
Regression Suite
The repository carries a versioned regression suite in regression/. Each
entry pairs a set of observations — the 2D–3D target correspondences, robot
poses and image sizes a calibration consumes, extracted once from the images —
with the reference result calibration-rs produces from them. Every change to
the library is checked against these results, and because the observations are
plain JSON, other calibration tools can run on exactly the same data.
Entries
| Id | Problem | Data | Licence |
|---|---|---|---|
kuka_1 | single_cam_handeye | 30 eye-in-hand views of a 17×28 chessboard (20 mm) from a KUKA arm | None stated |
stereo_left | planar_intrinsics | 20 views of a 7×11 chessboard (30 mm), left camera | None stated |
stereo_right | planar_intrinsics | The same 20 views, right camera | None stated |
stereo_rig | rig_extrinsics | Both cameras of the stereo pair together | None stated |
ds8 | rig_handeye | Three cameras on a Denso arm, 33 of 42 poses with a complete 10×14 chessboard (52 mm) | U.S. public domain |
opencv_stereo | rig_extrinsics | OpenCV's pinhole stereo sample: 13 views of a 6×9 chessboard (squares of 1) turned by 90° in most views, 640×480 | Apache-2.0 |
opencv_fisheye_left | planar_intrinsics | Kannala-Brandt fisheye: 34 views of an 8×6 chessboard (24.4 mm), 1280×800, left camera | None stated |
opencv_fisheye_right | planar_intrinsics | The same 34 views, right camera | None stated |
opencv_fisheye_stereo | rig_extrinsics | Both fisheye cameras together | None stated |
synth_planar | planar_intrinsics | Synthetic: 20 views of a 13×9 board, 0.5 px noise | CC0 |
synth_scheimpflug | planar_intrinsics | Synthetic: a tilted (Scheimpflug) sensor, 20 views, 0.5 px noise | CC0 |
synth_handeye | single_cam_handeye | Synthetic: eye-in-hand, 20 robot stations, 0.5 px noise | CC0 |
synth_rig | rig_extrinsics | Synthetic: two cameras, intrinsics refined with the rig, 20 views | CC0 |
synth_laserline | laserline_device | Synthetic: one camera and a laser plane, 20 views | CC0 |
synth_rig_handeye_laserline | rig_handeye_laserline | Synthetic: two cameras on a robot, each with a laser plane, solved jointly | CC0 |
The real-image entries come from their publishers:
kuka_1is datasetkuka_1of RWHE-Calib, from I. Ali, O. Suominen, A. Gotchev and E. Ruiz Morales, Methods for Simultaneous Robot-World-Hand–Eye Calibration: A Comparative Study, Sensors 19(12), 2837 (2019), https://doi.org/10.3390/s19122837. The images and robot poses are published at https://github.com/ihtishamaliktk/RWHE-Calib as open-access data; the authors state no licence and ask users to cite the paper.- The stereo entries use Chessboard Pictures for Stereocamera Calibration
by Daniel (Kaggle user
danielwe14), version 3, https://www.kaggle.com/datasets/danielwe14/stereocamera-chessboard-pictures, which states no licence. ds8is dataset 8 of A. Tabb and K. M. Ahmad Yousef, Solving the robot-world hand-eye(s) calibration problem with iterative methods, Machine Vision and Applications 28 (2017); the images are published at https://doi.org/10.15482/USDA.ADC/1340592 in the U.S. public domain.opencv_stereois the stereo calibration sample of OpenCV (samples/data/left*.jpg,right*.jpgin https://github.com/opencv/opencv), under OpenCV's Apache-2.0 licence. OpenCV does not publish the printed square size, so the board is declared with squares of 1.- The fisheye entries use
calib-3_stereo_from_JY, the stereo fisheye set of OpenCV's ownfisheyecalibration tests, from OpenCV's test data repository https://github.com/opencv/opencv_extra (testdata/cv/cameracalibration/fisheye/), which states no licence for it. The set ships the chessboard corners with the images, and the entries are frozen from those corners, so calibration-rs and OpenCV calibrate the same points (see Fisheye Calibration: OpenCV's Test Set).
regression/suite.toml records each entry's licence (an SPDX identifier,
NOASSERTION where the publisher states none), citation and source.
The synthetic entries come with their ground truth, so they check accuracy as well as stability, and the laser entries are the suite's laser coverage. Their data was generated once with fixed seeds and is frozen like the rest: a change to the generator cannot change it.
Layout
regression/
├── suite.toml entries: description, licence, config
├── SHA256SUMS checksums of the observation and truth files
├── observations/<id>.json the entry's observations
├── truth/<id>.json a synthetic entry's true parameters
├── results/<id>/calibration-rs.json the entry's reference result
├── results/<id>/<tool>.json another library's result, for comparison
├── fetch/<id>.sha256 the files `suite fetch` downloads, with checksums
└── schema/ JSON Schemas of the three file formats
SHA256SUMS is in the format of sha256sum, so the data can be verified
without any of our tools:
cd regression && sha256sum -c SHA256SUMS # shasum -a 256 -c on macOS
Running it
cargo run --release -p vision-calibration-bench --bin calib-bench -- suite check
suite check verifies each observation file against SHA256SUMS (a missing
or mismatched checksum fails the entry; it is never skipped), calibrates the
entry with its config and compares every number in the result with the
reference. Each entry reports PASS or FAIL with the values that moved, and
the largest change of an estimated parameter in units of the reference's
standard deviation (see Parameter Uncertainty); the command
exits non-zero when any entry fails. --only <id> selects entries.
--sigma <k> compares only the estimated parameters, each within k
standard deviations of the reference, for results that may legitimately
differ in their last digits. --backend factrs (or tiny_solver) runs every
solver stage on that backend:
calib-bench suite check --backend factrs --sigma 1
Two more subcommands maintain the suite:
suite blessrecalibrates the selected entries and overwrites their reference results. Run it when a change is meant to move the results, and review the diff ofresults/like any other code change.suite freezere-extracts an entry's observations from the images its dataset manifest names (built with--features tier-b), reads them from the corner files the publisher released, or regenerates a synthetic entry's observations and truth from its scene, and rewritesSHA256SUMS. Observations change only when the detector does. The images are read from the manifest's folder, or with--images <dir>from the cachesuite fetchfills (below); corner files only from that cache.
Fetching the images
The observations are all suite check needs. To re-extract them, or to run
another tool on the images themselves, suite fetch downloads each entry's
images from its publisher and checks every file against the entry's
fetch/<id>.sha256:
cargo run --release -p vision-calibration-bench --features tier-b \
--bin calib-bench -- suite fetch
cargo run --release -p vision-calibration-bench --features tier-b \
--bin calib-bench -- suite freeze --images target/suite-images
The files land in target/suite-images/<id>/ (--images <dir> picks
another folder), laid out as the dataset manifest expects. A file already
there that matches its checksum is not downloaded again, so the command is
cheap to repeat; a downloaded file that does not match fails the entry, as
it means the publisher's files changed. suite freeze --images <dir>
verifies the cached files again, freezes the entries suite fetch
downloads (or those named with --only) and rewrites SHA256SUMS. On
Apple-silicon macOS, where the observations are frozen, a clean checkout
reproduces them byte for byte. Corner detection is not bit-identical across
platforms: on x86-64 Linux the same images give corners up to about
0.001 px apart, so the files differ there while every view and corner is
the same.
| Entry | Downloaded from |
|---|---|
kuka_1 | The 30 images and the robot poses of Datasets/kuka_1 in the RWHE-Calib repository, at a pinned commit |
ds8 | DS8.zip of the Ag Data Commons record; the images as published (RGB TIFF for the two JAI cameras, colour JPEG for the third) |
opencv_stereo | The 26 images of samples/data in the OpenCV repository, at a pinned commit |
stereo_left, stereo_right, stereo_rig | Not fetched: Kaggle needs an account. suite fetch prints the dataset page; unpack the download into data/stereo/ |
opencv_fisheye_left, opencv_fisheye_right, opencv_fisheye_stereo | The corner files (left.xml, right.xml, object.xml) of calib-3_stereo_from_JY in opencv_extra, at a pinned commit |
suite.toml gives each fetchable entry a fetch source: files, each file
downloaded from a base_url followed by its path, or zip, one archive
whose root folder is the dataset's. An entry frozen from published corners
names them under corners: the file format (opencv_storage, the
cv::FileStorage XML of OpenCV's calibration tests, one node image_<i>
per view), the problem (planar_intrinsics or rig_extrinsics), the
object file of target points and, per camera, its points file and
image_size, which corner files do not record. A scheduled CI job fetches the images
from the publishers every week, re-freezes these entries and fails when the
observations no longer match SHA256SUMS.
The image entries are also the public datasets of the benchmark commands
that run detection: calib-bench run --dataset <id> and calib-bench feature-cov read each entry's dataset manifest, and --images <dir> reads
the fetchable entries from the suite fetch cache.
The observation format
{
"version": 1,
"cameras": [{ "id": "cam0", "image_size": [1928, 1208] }],
"problem": "single_cam_handeye",
"input": {
"views": [
{
"obs": {
"points_3d": [[0.0, 0.0, 0.0], [0.02, 0.0, 0.0]],
"points_2d": [[109.811, 92.798], [140.950, 91.702]]
},
"meta": {
"base_se3_gripper": {
"rotation": [0.0, 0.0, 0.0, 1.0],
"translation": [1.205, -0.808, 0.524]
}
}
}
]
}
}
problemnames the problem type andinputis its input, the same payloadCalibrationSession::set_inputtakes in Rust and therun_*functions take in Python (see Data Types Quick Reference). Rig inputs holdnum_camerasand, per view, one observation per camera incamerasorder,nullwhere that camera did not see the target.cameraslists the cameras in input order with their image size[width, height]in pixels.points_3dare target points in metres in the target frame; planar targets lie inz = 0.points_2dare the matching image points in pixels, with integer coordinates at pixel centres (the OpenCV convention).- A pose is
{"rotation": [qx, qy, qz, qw], "translation": [x, y, z]}: a unit quaternion, scalar last, and a translation in metres.a_se3_bmaps coordinates in framebto framea, sobase_se3_grippertakes gripper coordinates to robot-base coordinates — OpenCV'sR_gripper2base,t_gripper2base. versionis the format version; it changes only with an incompatible format change.
schema/observation_set.json is the format's JSON Schema, generated from the
types the library reads, with the input of every problem type; truth.json
and external_result.json describe the truth files and the results of other
libraries below.
Ground truth
truth/<id>.json names each true value by the label the result's
covariance uses (see Parameter Uncertainty):
{
"parameters": { "fx": 1000.0, "fy": 990.0, "k1": -0.12, "plane.nx": 0.287 },
"poses": {
"gripper_se3_camera": { "rotation": [0.05, -0.02, 0.01, 0.998], "translation": [0.05, -0.03, 0.08] }
}
}
Scalars sit under parameters: intrinsics, distortion coefficients, the
sensor tilt and laser-plane components, prefixed cam<i>. in a rig with more
than one camera. Poses sit under poses by their export names. suite check
compares every estimated parameter the truth names with its true value, in
units of the result's own standard deviation (a pose by the right
perturbation T_true⁻¹ · T), and fails the entry when one is 5σ or more
off. Each entry reports its largest error against the truth.
Comparing with other libraries
The reference results are the problem's export, minus the per-feature residual lists and the projected laser lines (they follow from the parameters and the observations; the residual histograms and the reprojection statistics stay). When comparing them with another tool's output:
- Intrinsics
kholdsfx,fy,cxandcyin pixels, in the same pixel convention as the observations. - Distortion
distholds the Brown-Conrady coefficients by name; OpenCV's five-elementdistCoeffsis(k1, k2, p1, p2, k3)of the same model. Most configs fixk3 = 0. A fisheye camera'sKannalaBrandt4coefficientsk1 … k4are the four elements ofcv::fisheye'sD. - Reprojection error:
mean_reproj_errorandper_cam_reproj_errorsare the mean Euclidean distance in pixels between observed and reprojected points. OpenCV'scalibrateCamerareturns the root-mean-square distance, which is larger for the same fit. - Poses follow the
a_se3_bconvention above. - Scheimpflug tilt:
tilt_x,tilt_yare OpenCV'sτx,τyofCALIB_TILTED_MODEL, with the same sign and the same place in the projection (after distortion, beforeK).
OpenCV
tools/compare/opencv/compare.py calibrates every entry OpenCV has a method
for and writes results/<id>/opencv.json: the parameters under the labels of
our results, the poses under our export names, OpenCV's standard deviations
and its mean reprojection error. The OpenCV version is pinned in the
harness's uv project:
uv run --project tools/compare/opencv tools/compare/opencv/compare.py
cargo run --release -p vision-calibration-bench --bin calib-bench -- suite compare
| Problem | OpenCV |
|---|---|
planar_intrinsics | calibrateCameraExtended with CALIB_FIX_K3 |
planar_intrinsics with a Scheimpflug sensor | the same with CALIB_TILTED_MODEL and CALIB_ZERO_TANGENT_DIST, the parameters our Scheimpflug model estimates (higher terms fixed) |
single_cam_handeye | calibrateCameraExtended, then calibrateRobotWorldHandEye (Shah) |
rig_extrinsics | calibrateCameraExtended per camera, then stereoCalibrate with CALIB_FIX_INTRINSIC, or refining the intrinsics when the entry does |
rig_handeye | per camera as for single_cam_handeye; the rig frame is camera 0's |
planar_intrinsics with a fisheye camera | fisheye.calibrate with CALIB_RECOMPUTE_EXTRINSIC, CALIB_CHECK_COND and CALIB_FIX_SKEW, the flags of OpenCV's own fisheye tests |
rig_extrinsics with fisheye cameras | fisheye.stereoCalibrate with the same flags, intrinsics refined with the rig |
OpenCV has no laser-plane calibration, so the laser entries have no
comparison. suite compare sets each result against our reference:
differences in units of our standard deviation, OpenCV's standard deviations
against ours, the mean reprojection error by the same definition, and for a
synthetic entry each library's largest error against the truth, in units of
our σ:
| Entry | Library | Parameters | Largest difference | Median | σ ratio (ours / theirs) | Mean reprojection (ours / theirs) | Truth (ours / theirs) |
|---|---|---|---|---|---|---|---|
kuka_1 | opencv 4.12.0 | 20 | 7.32σ (base_se3_target.rz) | 1.35σ | 1.464–1.655 | 0.1347 / 2.2545 px | — |
stereo_left | opencv 4.12.0 | 8 | 0.01σ (cy) | 0.01σ | 1.939–1.939 | 0.2339 / 0.2339 px | — |
stereo_right | opencv 4.12.0 | 8 | 0.00σ (cy) | 0.00σ | 2.205–2.205 | 0.2442 / 0.2442 px | — |
stereo_rig | opencv 4.12.0 | 22 | 3.74σ (cam1.cam_se3_rig.tz) | 0.60σ | — | — | — |
ds8 | opencv 4.12.0 | 36 | 3.75σ (cam2.p2) | 0.40σ | 1.720–6.631 | — | — |
opencv_fisheye_left | opencv 4.12.0 | 8 | 0.01σ (k3) | 0.00σ | — | 0.2227 / 0.2227 px | — |
opencv_fisheye_right | opencv 4.12.0 | 8 | 0.00σ (k2) | 0.00σ | — | 0.2366 / 0.2366 px | — |
opencv_fisheye_stereo | opencv 4.12.0 | 22 | 0.00σ (cam1.k3) | 0.00σ | — | — | — |
synth_planar | opencv 4.12.0 | 8 | 0.00σ (cx) | 0.00σ | 1.592–1.592 | 0.6570 / 0.6570 px | 1.17σ / 1.17σ |
synth_scheimpflug | opencv 4.12.0 | 8 | 0.01σ (cx) | 0.01σ | 1.000–1.000 | 0.6568 / 0.6568 px | 1.52σ / 1.53σ |
synth_handeye | opencv 4.12.0 | 20 | 10.09σ (base_se3_target.tx) | 1.82σ | 0.303–0.631 | 0.6645 / 0.7521 px | 1.20σ / 10.46σ |
synth_rig | opencv 4.12.0 | 22 | 0.09σ (cam0.cy) | 0.02σ | — | — | 2.07σ / 2.06σ |
Reading it:
- On plain and Scheimpflug intrinsics the two libraries agree within
0.02σ. Both start from the classical covariance; ours is scaled by its
view scatter when the views scatter significantly more than the noise
predicts, so on the real
stereocameras our standard deviations are 1.9–2.2 times OpenCV's. Onsynth_planarthey are 1.6 times: its views happen to pass that test (about one calibration in forty does on data that fit the noise model exactly). The Scheimpflug fit needs the same parameter set on both sides: with the tangential termsp1,p2free, a tilt and a decentring distortion explain the same images, and OpenCV's fit wanders along that valley (its own σ ofcyis 40 px). - Hand-eye results differ by design. Our hand-eye workflows refine the robot
poses within their priors, so on
kuka_1the reprojection error is the corner noise (0.13 px) rather than the robot's pose error (2.3 px with the measured poses). OpenCV takes the robot poses as exact for the hand-eye pose, but calibrates the intrinsics with a free pose per view, as the refined robot poses effectively are here: our intrinsics standard deviations are 1.5–1.7 times OpenCV's, the view scatter's scale. The synthetic hand-eye entry's robot poses are exact, and our solve estimates a robot error near zero from the corrections: it lands 1.2σ from the truth with standard deviations a third to two thirds of OpenCV's, whose hand-eye, from the same poses, is 10σ from the truth in our units. - Rig results refine each camera's intrinsics jointly with the rig, from
every view of every camera. On
stereo_rigOpenCV'sstereoCalibraterefines them too, but from the views both cameras see; the two estimates differ by up to 4σ, along the coupling between camera 0's focal length and camera 1's depth offset. On the synthetic rig the joint estimate lands within 2.1σ of the truth. ds8is a three-camera rig; OpenCV calibrates each camera's intrinsics alone and camera 0's hand-eye alone, while ours refines all three cameras with the rig and the hand-eye, so the difference includes what the joint solve moves.- On OpenCV's fisheye set the two libraries agree within 0.01σ, on each
camera alone and on the stereo pair, where both refine the intrinsics
with the rig; OpenCV's results are the values its own tests expect.
cv::fisheyereports no standard deviations, so there is no σ ratio.
Drift tolerance
A number passes when it is within abs + rel · max(|reference|, |result|) of
its reference, with rel = 1e-6 and abs = 1e-9 unless the entry sets its own
tolerance. That flags any change in the solution while allowing the last-bit
differences between platforms and compilers. Strings, flags and the shape of
the result must match exactly.
With --sigma k, a parameter passes when its change is within k of the
reference's standard deviations. A pose's change is measured as the right
perturbation T_ref⁻¹ · T in the coordinates of its covariance. The two
solver backends agree within 0.02σ on every entry.
Adding an entry
A synthetic entry names a scene of the solver benchmark's full matrix under
synthetic, by its id <problem>/<camera>/<scale>/n<σ>/<clean|huber|cauchy>
(as calib-bench solver run --preset full reports them), with the config the
scene needs, and carries the CC0 licence. Run suite freeze --only <id> and
suite bless --only <id> and commit the observation, truth and result files
with SHA256SUMS. For an entry with images:
- Check that the data's licence allows publishing observations derived from it, and record the licence (and citation, if the authors ask for one) in the entry.
- Write a dataset manifest (
dataset.toml, the file the desktop app's Run workspace reads;data/kuka_1/dataset.tomlis an example) and an[[entry]]insuite.tomlthat pointsmanifestat it. - Run
suite freeze --only <id>, thensuite bless --only <id>, and commit the observation file,SHA256SUMSand the reference result. - If the publisher serves the files without a login, give the entry a
fetchsource and list every file the manifest reads, with its SHA-256, infetch/<id>.sha256(thesha256sumformat, paths relative to the manifest's folder).suite fetch --only <id>andsuite freeze --only <id> --images target/suite-imagesmust then reproduce the observation file.
When the publisher releases the target corners with the images, as OpenCV
does for its test sets, the entry can name them under corners instead of a
dataset manifest; it then needs a fetch source whose list holds the corner
files.