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.

API reference

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-calibration crate, 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:

  1. Linear initialization — closed-form solvers (Zhang's method, DLT, PnP, Tsai-Lenz hand-eye, epipolar geometry) that produce approximate parameter estimates
  2. Non-linear refinement — Levenberg-Marquardt bundle adjustment that minimizes reprojection error to sub-pixel accuracy
  3. Session framework — a high-level API with step functions, configuration, and JSON checkpointing
  4. 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:

OpenCVcalibration-rs
cv::calibrateCameraPlanar intrinsics pipeline (Zhang init + bundle adjustment)
cv::solvePnPpnp::p3p(), pnp::dlt()
cv::findHomographydlt_homography(), dlt_homography_ransac()
cv::findFundamentalMatfundamental_8point()
cv::findEssentialMatessential_5point()
cv::stereoCalibrateRig 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 CalibrationSession API 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

RouteWhenStart at
Desktop appYou have a folder of images and want a result and its diagnostics without writing codeThe Desktop App
PythonYou script your lab work or analysis in PythonGetting Started, the Python half
RustYou embed calibration in a Rust program or need the session stepsGetting 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 systemWorkflowManifest topologyPython runner
One camera; you want its intrinsics and distortionPlanar intrinsicsplanar_intrinsicsrun_planar_intrinsics
One camera on a robot, or a fixed camera watching a target the robot holdsSingle-camera hand-eyesingle_cam_handeyerun_single_cam_handeye
Two or more rigidly mounted cameras: a stereo pair or a multi-camera rigRig extrinsicsrig_extrinsicsrun_rig_extrinsics
A camera rig on a robot, or a fixed rig watching the robot's targetRig hand-eyerig_handeyerun_rig_handeye
One camera and a laser line projector mounted togetherLaserline devicelaserline_devicerun_laserline_device
A rig on a robot with one laser per camera, rig already calibratedRig laserline devicerig_laserline_devicerun_rig_laserline_device
The same rig with the rig, hand-eye and laser planes in one solveRig hand-eye laserlinerig_handeye_laserlinerun_rig_handeye_laserline
One camera and its laser on a robot, with the camera, plane and hand-eye in one solveRig hand-eye laserline (a rig of one)rig_handeye_laserlinerun_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:

PartChoicesPick
Projectionpinhole; 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
DistortionBrown-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
Sensorstraight (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 Estimator trait 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:

ModuleFunctions
homographydlt_homography, dlt_homography_ransac
epipolarfundamental_8point, essential_5point
triangulationtriangulate_point_linear
camera_matrixdlt_camera_matrix
pnpdlt, 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:

SolverInputOutput
Zhang's methodHomographiesIntrinsics
Distortion fit + homographiesBrown-Conrady coefficients
Iterative intrinsicsObservationsJoint + distortion
Planar pose + homographySE(3) pose
Tsai-Lenz hand-eyeRobot + camera motionsHand-eye SE(3)
Rig extrinsicsPer-camera posesCamera-to-rig SE(3)
Laser planeLaser pixels + target posesPlane (normal + distance)

vision-calibration-optim

Non-linear refinement with a backend-agnostic architecture:

  1. IR (Intermediate Representation) — ProblemIR with ParamBlock and ResidualBlock types that describe optimization problems independently of any solver
  2. Factors — generic residual functions parameterized over RealField for automatic differentiation
  3. Backends — tiny-solver and factrs: two linearization engines driven by one Levenberg–Marquardt loop with sparse linear solvers
  4. 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 take camera plus per-camera camera_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:

SampleWorkflowDataLicence
opencv_leftplanar intrinsics13 views of a 6×9 chessboard, 640×480, OpenCV's stereo sampleApache-2.0
opencv_stereorig extrinsicsboth cameras of the same stereo sample, the board turned by 90° in most viewsApache-2.0
opencv_fisheye_stereorig extrinsics, fisheye34 views of a 6×8 chessboard, 1280×800, OpenCV's fisheye test setnone stated
kuka_1single-camera hand-eye30 views of a 17×28 chessboard and the robot posesnone stated
ds8rig hand-eyethree cameras on a robot arm, 42 poses of a 10×14 chessboardUS 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):

  1. Computes a homography from each view's 2D-3D correspondences using DLT (direct linear transform)
  2. Estimates intrinsics from the homographies using Zhang's method
  3. Estimates lens distortion coefficients from homography residuals
  4. Iterates steps 2-3 to refine the joint + distortion estimate
  5. 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

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.

CommandDoes
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 listlists 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

StatusMeaning
0done; for run, the verdict is pass or warn
1an error, printed on standard error
2a wrong command line
3run wrote its files, but the verdict is fail
130interrupted 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
42.311.440.00101.453.09
81.240.760.00070.801.49
160.860.500.00050.500.92
320.680.400.00040.400.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

TiltUsedσ(fx) pxσ(cx) pxσ max px
0°16fails
3°1636.81.1029.0
6°168.440.776.15
11°162.180.561.71
20°160.860.500.92
29°120.630.620.95
40°412.991.1912.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 wayfails
about one axis, both ways95.90.38282
about both axes, both ways1.240.761.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

BoardWidth in the imageσ(fx) pxσ(cx) pxσ max px
6 × 5 corners18 %5.643.196.39
9 × 7 corners29 %1.861.092.05
12 × 9 corners40 %0.860.500.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 only0.790.00370.7814.4
each quadrant0.860.00050.500.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:

ViewsPredicted worst scatter / σMeasuredNEES per parameter (measured)
centred, tilted up to 0.2 rad2.833.001.64
centred, tilted up to 0.3 rad1.872.051.27
centred, tilted up to 0.5 rad1.451.661.15
off centre by 12 cm and 9 cm, tilted up to 0.2 rad1.551.561.10
off centre, tilted up to 0.5 rad1.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)

  1. Homographies: Compute for each view via DLT (from board points at to observed pixels)
  2. Intrinsics: Estimate from homographies using Zhang's method, iteratively refined with distortion estimation (see Iterative Intrinsics)
  3. Distortion: Estimate from homography residuals (see Distortion Fit)
  4. 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.backend selects (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::None is 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::calibrateCamera performs both initialization and optimization internally. calibration-rs separates these steps for inspection and customization.

Accuracy Expectations

StageIntrinsics errorReprojection error
After step_init10-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

AspectSyntheticReal
Corner accuracyExact (no detection noise)~0.1-0.5 px (detector dependent)
DistortionKnown ground truthUnknown, estimated
View coverageControlledMay have gaps
Typical reprojection error<0.01 px0.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_k3 on (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:

KinddimPacked layoutModel
None0(no distortion block)no distortion
BrownConrady55[k1, k2, k3, p1, p2]Brown-Conrady radial and tangential (default)
Rational88[k1, k2, k3, k4, k5, k6, p1, p2]rational polynomial
ThinPrism99[k1, k2, k3, p1, p2, s1, s2, s3, s4]Brown-Conrady plus thin prism
Division11[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 of fx, 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: k3 fixed. k3 often 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's IntrinsicsInitConfig::fix_k3, which defaults to true for 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's lambda) 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 (or lambda) free: the tilt can absorb tangential-distortion-like signal, so leaving p1 and p2 free overfits.
  • The linear stage holds p1 and p2 at 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 px median mean reprojection error (the extra k4-k6 and s1-s4 coefficients stay at their zero seed under the radial-only list and never move); Division1 = 0.3358 px (stable, but its worst camera sits alone at 0.838 px).
  • Rig B: 0.4739 px for the three polynomial models, 0.4745 px for Division1.

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 BrownConrady5 with k3 fixed (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 on BrownConrady5 because, 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.distortion when 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![]) and init.fix_k3 = false so the linear stage also fits k3. 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 rejects None with a typed error.
  • Hand-eye and laser workflows: the same camera.model.distortion choice 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 camerafxfycxcyk1k2k3k4Mean reprojection
calibration-rs558.479560.507620.458381.938−0.00150−0.003170.00590−0.003670.2227 px
OpenCV 4.12558.478560.507620.459381.939−0.00146−0.003300.00606−0.003740.2227 px
OpenCV's test values558.478560.507620.459381.939−0.00146−0.003300.00606−0.00374—
calibration-rs σ1.191.210.820.750.00470.0160.0200.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):

  1. Compute relative motions from robot poses and camera poses
  2. Filter pairs by rotation magnitude (reject small rotations)
  3. Estimate the hand-eye rotation and translation
  4. 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 for gripper_se3_camera () and base_se3_target ().
  • HandeyeMode::EyeToHand: the camera is fixed in the scene and observes a target mounted on the gripper. The calibration solves for camera_se3_base () and gripper_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::calibrateHandEye provides 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.png through 30.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. Image NN.png pairs with pose row NN.
  • 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:

  1. Dataset summary (total images, used views, skipped views)
  2. Per-step results (initialization, optimization)
  3. 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

  1. 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.

  2. Incorrect pose convention: Robot poses must be (base_se3_gripper). If the convention is inverted, the calibration will diverge.

  3. Mismatched corner ordering: If the chessboard detector assigns corners in a different order than expected, the 2D-3D correspondences are wrong.

  4. 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:

  1. Compute homographies from the views where that camera has observations
  2. Run iterative intrinsics estimation (Zhang + distortion fit)
  3. 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:

  1. Define the rig frame by the reference camera ()
  2. For each non-reference camera, compute by averaging across views
  3. 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::stereoCalibrate for 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:

  1. Extract relative camera motions from rig-to-target poses
  2. Extract relative robot motions from base-to-gripper poses
  3. Solve for (gripper_se3_rig)
  4. 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:

  1. Calibration points: 2D-3D correspondences from the chessboard (same as planar intrinsics)
  2. 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:

  1. Undistort laser pixel to normalized coordinates
  2. Back-project to a ray in camera frame
  3. Intersect the ray with the target plane (at , transformed by pose ) to get a 3D point
  4. Compute signed distance from to the laser plane:

Residual dimension: 1 (meters)

LineDistNormalized (2D Line Distance) — Default

Algorithm:

  1. Compute the 3D intersection line of the laser plane and the target plane (in camera frame)
  2. Project this 3D line onto the normalized camera plane
  3. Undistort the laser pixel to normalized coordinates (done once, not per-iteration)
  4. Measure the perpendicular distance from the undistorted pixel to the projected line
  5. Scale by for pixel-comparable units:

Residual dimension: 1 (effective pixels)

Comparison

PropertyPointToPlaneLineDistNormalized
Residual unitsmeterspixels
UndistortionPer-iterationOnce per pixel
GeometryRay-plane intersection2D line distance
SpeedSlowerFaster
RecommendedAlternativeDefault

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.

PieceSolves
PlanarIntrinsicsProblem with a Scheimpflug sensorSingle-camera intrinsics, distortion, and sensor tilt from planar views
camera / camera_overridesSelect the lens and sensor of every rig camera, or of single cameras
RigLaserlineDeviceProblemOne laser plane per camera of an already-calibrated rig
RigHandeyeLaserlineProblemRig, 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ᵀ, with A how 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 Σ_u with 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) and cargo 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) and crates/vision-calibration/tests/rig_extrinsics_scheimpflug.rs (a rig).
  • Python: crates/vision-calibration-py/examples/laserline_device_session.py and rig_handeye_synthetic.py; a tilted single camera runs through run_planar_intrinsics with camera.model.sensor = "scheimpflug", and the package also exposes run_rig_laserline_device and run_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_seed function that takes a *ManualInit struct before its options;
  • a typed *ManualInit struct whose fields are all Option<T>. None means "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: a RigHandeyeExport of the same views, for example from a RigHandeyeProblem session 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

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 (with projected_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 = None on 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 CameraParams carries 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:

MemberAccessPurpose
configpub fieldAlgorithm parameters (iterations, fix masks, loss functions)
exportspub fieldTimestamped ExportRecords created by export()
inputinput(), require_input(), set_input(), clear_input()Observation data
outputoutput(), require_output()Final calibration result, set by the last step
metadata()methodSessionMetadata: problem type name, schema version, timestamps, description
log()methodAudit 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>:

GroupMethods
Constructionnew(), with_description(..), with_input(..)
Inputset_input, input, input_mut, require_input, require_input_mut, has_input, clear_input
Configset_config, update_config
Outputoutput, output_mut, require_output, set_output, has_output, clear_output
Exportexport, export_with_notes, export_peek
Validationvalidate
Loglog, log_success, log_success_with_notes, log_failure
Metadatametadata
Resetreset_state, reset_output, reset
Serializationto_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:

EventDefault 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?,
}
ReportWhen
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

TypePurposeRequirements
ConfigAlgorithm parameters, <Name>ConfigDefault (sensible defaults)
InputObservation data, <Name>InputValidated on set
OutputThe final solve's estimate, an *EstimateSet by last step
ExportUser-facing result, <Name>ExportCreated 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:

HookWhen 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

Typename()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:

  1. Reads the input, config, and the results of earlier steps from the session
  2. Performs one phase of the calibration (e.g., initialization or optimization)
  3. Updates the session (and possibly its output)
  4. Logs the operation
  5. 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>Config and <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 in vision_calibration::common.
  • A step of stage <Stage> and action <Action> takes Option<<Stage><Action>Options> (None keeps the session config) and returns <Stage><Action>Result; its _with_seed variant takes a <Stage>ManualInit before the options. The stages are Intrinsics, Rig and Handeye; the single-stage laser problems use Device (camera and laser plane, laserline_device) and Plane (the laser planes, rig_laserline_device); the joint rig laser solve is the Joint stage (JointOptimizeConfig, JointManualInit).
  • Option types shared by several problems (IntrinsicsInitOptions, RigOptimizeOptions, HandeyeInitOptions, …) are one type each, imported from vision_calibration::common. Step results and manual seeds are per problem: rig_handeye::IntrinsicsInitResult and planar_intrinsics::IntrinsicsInitResult are 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>).

ProblemModuleStepsPipeline function
PlanarIntrinsicsProblemplanar_intrinsicsstep_init, step_optimize, step_filterrun_calibration(session), run_calibration_with_filtering(session, opts)
SingleCamHandeyeProblemsingle_cam_handeyestep_intrinsics_init, step_intrinsics_optimize, step_handeye_init, step_handeye_optimizerun_calibration(session)
LaserlineDeviceProblemlaserline_devicestep_init, step_optimizerun_calibration(session)
RigExtrinsicsProblemrig_extrinsicsstep_intrinsics_init_all, step_intrinsics_optimize_all, step_rig_init, step_rig_optimizerun_calibration(session)
RigHandeyeProblemrig_handeyethe four rig steps above plus step_handeye_init, step_handeye_optimizerun_calibration(session)
RigLaserlineDeviceProblemrig_laserline_devicestep_init, step_optimizerun_calibration(session)
RigHandeyeLaserlineProblemrig_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.

kindFieldsWhat it detects
chessboardrows, cols (interior corners), square_size_mSaddle corners of a plain checkerboard
charucorows, cols (squares), square_size_m, marker_size_m, dictionaryChessboard corners identified by embedded ArUco markers
puzzleboardlayout ("puzzle_<R>x<C>"), cell_size_mSaddles of a self-identifying chessboard variant
ringgridpitch_m, rows, long_row_cols, marker_outer_radius_m, marker_inner_radius_m, marker_ring_width_mCentres 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, cols across and rows down, 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 as TurnedChessboard. 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.

FieldMeaning
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_valueFloor 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_strengthFloor 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.
covarianceReport 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. chessboard and charuco. The PuzzleBoard detector runs on an upscaled image, for which no covariance is reported, and ring grids have none; a manifest that sets covariance for 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 a laser:<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.

topologyProblem typeCamerasRobot posesLaser images
planar_intrinsicsPlanarIntrinsicsProblemexactly 1nono
single_cam_handeyeSingleCamHandeyeProblemexactly 1requiredno
laserline_deviceLaserlineDeviceProblemexactly 1norequired
rig_extrinsicsRigExtrinsicsProblem2 or morenono
rig_handeyeRigHandeyeProblem2 or morerequiredno
rig_laserline_deviceRigLaserlineDeviceProblem2 or morerequiredrequired
rig_handeye_laserlineRigHandeyeLaserlineProblem1 or morerequiredrequired

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.png before 10.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.

formatFileNotes
rowmajor4x4headerless text, 16 floats per lineOne row-major 4x4 homogeneous matrix per pose (KUKA-style exports). Needs rotation_format = "matrix4x4_row_major" and no columns.
counted4x4headerless text, a count then that many 4x4 matricesThe layout of the robot-world hand-eye datasets of Tabb and Ahmad Yousef. Same requirements as rowmajor4x4.
csv, json, jsonltabularNeed 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_tcp is the gripper pose in the robot base frame (KUKA and UR controllers), t_tcp_base its inverse (some ABB exports), t_world_tcp and t_tcp_world the 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_rad or matrix4x4_row_major.
  • translation_units: m or mm.

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 the pose_id column 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 *.ext glob 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 for rowmajor4x4, a count-then-matrices file for counted4x4);
  • by_index pairing, 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:

PathWhy it cannot be inferred
targetboard geometry is never inferred from images
topologyseveral cameras without poses can be a rig or independent single-camera calibrations
pose_pairingcamera and pose counts differ
pose_convention.transform, pose_convention.translation_unitsa 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_conventiontabular 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:

TopologyConverter
planar_intrinsicsbuild_planar_input
single_cam_handeyebuild_single_cam_handeye_input
rig_extrinsicsbuild_rig_extrinsics_input
rig_handeyebuild_rig_handeye_input
laserline_devicebuild_laserline_device_input
rig_laserline_devicebuild_rig_laserline_device_input
rig_handeye_laserlinebuild_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_images per camera mirrors images: a glob or an explicit list. It pairs with the target views through the same pose_pairing: with by_index the i-th laser image pairs with the i-th target image, with shared_filename_token the 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 (cols walks image columns, one point per column, for lines running mostly left to right; rows walks rows, for vertical lines), sigma (Gaussian derivative scale in pixels), pos_thresh and neg_thresh (edge-response thresholds) and min_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.
  • RigLaserlineDevice is a second stage: upstream_calibration points at a frozen RigHandeyeExport JSON. 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.
  • RigHandeyeLaserline is the joint solve: it computes its own hand-eye warm start and takes no upstream_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 (None slots), 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.

FieldTypeRequiredDefaultDescription
camerasarray of CameraSourceyesPer-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).
targetTargetSpecyesCalibration-target specification. Tagged enum on kind.
topologyTopologyyesCalibration topology — selects which problem type the runner will dispatch to.
_unresolvedarray of stringnoField paths the manifest generator could not determine. Validated to be empty before the runner accepts the manifest. Populated as e.g. ["pose_convention.transform"].
descriptionstringnoFree-form human-readable description.
detectorDetectorSpecnoDetector-stage overrides shared by target families. Defaults preserve the detector's built-in behavior.
laserLaserExtractionSpecnoLaser-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_conventionPoseConventionnoFrame conventions for robot poses. No defaults — every component is required when robot_poses is set.
pose_pairingPosePairingnoHow 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_posesRobotPoseSourcenoRobot-pose source for hand-eye topologies. None for pure-intrinsics calibrations.
upstream_calibrationstringnoPath 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.
versionintegerno1Format-version sentinel. Always 1 for now; bumped on breaking schema revisions.

Unknown fields are rejected.

CameraSource

Per-camera image source.

FieldTypeRequiredDescription
idstringyesStable camera identifier (e.g. "cam0"). Used as a key in pose pairings and downstream exports.
imagesImagePatternyesGlob pattern (e.g. "cam0/*.png") or explicit list of image paths, relative to the manifest's directory unless absolute.
laser_imagesImagePatternnoLaser-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_xywharray of 4 integernoOptional 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".

FieldTypeRequiredDescription
patternstringyesThe glob pattern.

kind = "list": Explicit ordered list of paths.

FieldTypeRequiredDescription
pathsarray of stringyesPaths 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.

FieldTypeRequiredDescription
colsintegeryesNumber of interior corners across the board (the image's columns axis when the board is seen as declared).
rowsintegeryesNumber of interior corners down the board (the image's rows axis when the board is seen as declared).
square_size_mnumberyesEdge length of one square in metres.

kind = "charuco": ChArUco board. Marker IDs and dictionary live here so detection is fully deterministic from the manifest.

FieldTypeRequiredDescription
colsintegeryesNumber of squares along the cols axis.
dictionarystringyesArUco dictionary identifier (e.g. "DICT_4X4_50"). The detector validates this against the supported set.
marker_size_mnumberyesEdge length of an embedded marker in metres.
rowsintegeryesNumber of squares along the rows axis (full grid, not interior corners).
square_size_mnumberyesEdge 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.

FieldTypeRequiredDescription
cell_size_mnumberyesEdge length of one cell in metres.
layoutstringyesBoard 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.

FieldTypeRequiredDescription
long_row_colsintegeryesNumber of columns in the longest (even-indexed) row. Shorter rows are derived by the hex-lattice layout.
marker_inner_radius_mnumberyesInner ring radius in metres.
marker_outer_radius_mnumberyesOuter ring radius in metres.
marker_ring_width_mnumberyesWidth of each ring band in metres.
pitch_mnumberyesCenter-to-center spacing between adjacent markers in metres.
rowsintegeryesNumber 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.

ValueDescription
planar_intrinsicsSingle camera, planar target — PlanarIntrinsicsProblem (any camera model, including a tilted sensor).
single_cam_handeyeSingle camera mounted on a robot — SingleCamHandeyeProblem.
laserline_deviceCamera + laser plane — LaserlineDeviceProblem.
rig_extrinsicsMulti-camera rig, target only — RigExtrinsicsProblem.
rig_handeyeMulti-camera rig + robot — RigHandeyeProblem.
rig_handeye_laserlineMulti-camera rig + robot + laser planes — joint hand-eye/laser BA.
rig_laserline_deviceMulti-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.

FieldTypeRequiredDescription
chess_cornersChessCornersDetectorSpecnoChESS corner extractor options shared by chessboard-like detectors (plain chessboard and ChArUco).
min_features_per_viewintegernoMinimum 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.

FieldTypeRequiredDescription
covarianceCornerCovarianceSpecnoAttach 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_strengthnumbernoMinimum 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.
strategyCornerStrategySpecnoCorner detector. None keeps ChESS.
threshold_valuenumbernoAcceptance 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].

FieldTypeRequiredDefaultDescription
image_noiseImageNoiseSpecno"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.

FieldTypeRequiredDescription
knownnumberyes

Unknown fields are rejected.

CornerStrategySpec

The corner detector a chessboard-like detector runs.

ValueDescription
chessThe ChESS response. Fast; the default.
radonThe 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.

FieldTypeRequiredDefaultDescription
min_pointsintegerno20Minimum 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_threshnumberno4.0Negative-edge response threshold.
pos_threshnumberno4.0Positive-edge response threshold.
scan_axisLaserScanAxisno"cols"Scan direction. cols walks image columns (one point per column; for laser lines running mostly left-to-right), rows walks image rows.
sigmanumberno1.2Gaussian derivative sigma in pixels for the edge detector.

Unknown fields are rejected.

LaserScanAxis

Laser scan direction.

ValueDescription
colsOne detection per image column.
rowsOne 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.

FieldTypeRequiredDescription
rotation_formatRotationFormatyesHow rotation is parameterised in the file.
transformTransformConventionyesWhich transform the pose row encodes.
translation_unitsTranslationUnitsyesTranslation units in the file.

Unknown fields are rejected.

RotationFormat

Rotation parameterisation in the file.

ValueDescription
quat_xyzwHamilton quaternion as [qx, qy, qz, qw].
quat_wxyzHamilton quaternion as [qw, qx, qy, qz].
euler_xyz_degIntrinsic XYZ Euler angles in degrees.
euler_xyz_radIntrinsic XYZ Euler angles in radians.
euler_zyx_degIntrinsic ZYX Euler angles in degrees.
euler_zyx_radIntrinsic ZYX Euler angles in radians.
axis_angle_radAxis-angle vector (length encodes magnitude, radians).
matrix4x4_row_majorRow-major 4×4 homogeneous matrix flattened across 16 columns.

TransformConvention

Which transform a pose row encodes.

ValueDescription
t_base_tcpT_base_tcp — gripper pose in the robot base frame (KUKA, UR default).
t_tcp_baseT_tcp_base — base pose in the gripper frame (some ABB exports).
t_world_tcpT_world_tcp — gripper pose in a fixed world frame (less common; some lab-frame setups).
t_tcp_worldT_tcp_world — world pose in the gripper frame.

TranslationUnits

Translation units in the file.

ValueDescription
mMetres (SI, preferred).
mmMillimetres (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.

FieldTypeRequiredDescription
groupstringyesName of the capture group whose value identifies the view.
regexstringyesRegex with at least one named capture group.

RobotPoseSource

Where and how robot poses are stored on disk.

FieldTypeRequiredDescription
formatRobotPoseFormatyesFile-format selector.
pathstringyesPath to the pose file, relative to the manifest directory unless absolute.
columnsPoseColumnMapnoColumn / 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_fieldstringnoFor 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.

ValueDescription
csvComma-separated values with a header row.
jsonJSON array of objects.
jsonlJSONL (one JSON object per line).
rowmajor4x4Headerless 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.
counted4x4Headerless 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.

FieldTypeRequiredDescription
rotationarray of stringyesRotation columns. Length and meaning depend on the pose_convention.rotation_format.
txstringyesTranslation x column.
tystringyesTranslation y column.
tzstringyesTranslation z column.
pose_idstringnoOptional 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_scatter says 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-camera ds8 rig. 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 as 1/√views and grows quickly when the views are similar. The most effective way to reduce σ of fx and cx is 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 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

  • labels and covariance: the shared parameters and their covariance matrix, one row per label:

    • intrinsics fx, fy, cx, cy;
    • distortion by coefficient name (k1, k2, k3, p1, p2 for Brown-Conrady, lambda for 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, see views below): cam_se3_rig per camera, the hand-eye pose (gripper_se3_rig or rig_se3_base, gripper_se3_camera or camera_se3_base for a single camera) and the target pose (base_se3_target or gripper_se3_target);
    • a laser plane n · p + d = 0 in its camera's frame: plane.nx, plane.ny, plane.nz and plane.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, and correlation(a, b) the normalized off-diagonal one.

  • views: the parameters that belong to one view, each view's block separately: the board pose camera_se3_target for intrinsics results, rig_se3_target for rig results, and the robot-pose correction robot_delta for hand-eye results that refine the robot poses. A pose's six labels are rx, ry, rz (radians) and tx, ty, tz (metres) of the perturbation T · Exp(δ) on the right. Both are expressed in the pose's source frame, which is the target frame for camera_se3_target. The correlation of a view's parameters with the shared ones is not reported.

  • fixed: the parameters the solve held fixed, such as k3 with the default configuration, or cam0.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 is sigma, 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 as NoiseLevel::Sigma and NoiseLevel::Sigma0; Python's ResidualNoise.whitened says which sigma holds. 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 (camera names 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_ratio is the predicted scatter of the worst combination over the σ the covariance states for it, nees_ratio the predicted mean of eᵀ Σ⁻¹ e per parameter (both 1 when the first order describes the error), and combination names 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 costs s (s + 1) residual evaluations for s shared 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 of sigma: 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 keyRustPythonMeaning
sigmaNoiseLevel::Sigma(s)whitened == Falseσ̂ in pixels per axis
sigma0NoiseLevel::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 cx and cy, 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's handeye_optimize.refine_intrinsics) set to false the 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_px is how much it drops.
  • log_det_reduction is ln 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_views with the views already chosen plus each further candidate and compare projection.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

reasonMeaningWhat to do
not_convergedThe solve stopped on its iteration budgetRaise solver.max_iters
rank_deficientThe 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_boundA parameter ended on a bound (labels)Widen or remove the bound, or fix the parameter
no_redundancyNo more observations than parametersAdd views
not_computedThe 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 three ds8 cameras and the two stereo cameras 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_order predicts it and the quality report's covariance_linearity check 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 (fixed in the result). Fixing a parameter the data cannot pin down makes the others look better determined than they are: a rig with rig.refine_intrinsics: false holds 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

CheckPerFails whenWarns when
observabilityresultthe data does not determine some parameters (rank_deficient), or there is no redundancya parameter ended on a bound
convergenceresultthe final solve stopped on its iteration budget—
variance_factornoise levelσ̂²/σ₀² (σ̂₀² with stated point covariances) is above its χ² rangeit is below the range
projection_sigmacamera—σ somewhere in the image exceeds 1 px
feature_coveragecamerafeatures reached under 30 % of the imageunder 50 %
view_outlierscamera—a view's mean residual exceeds 3 σ̂
parameter_correlationcamera—two parameters correlate above 0.99
covariance_linearityresult—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_deficient result 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 at variance_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 no expected_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. fx and fy are 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_order prediction (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 above max_second_order_scatter (1.2) and names the combination's parameters, with their weights in units of their σ, in the message; subjects lists 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 σ (fixed for 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, or rig.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:

  1. Pose Recovery and Triangulation: relative pose from correspondences, with RANSAC for outliers.
  2. 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.
  3. Bundle Adjustment: refining poses and structure together, with a stated gauge and the calibration's uncertainty.
  4. Measurements with Uncertainty: distances, angles and plane fits over triangulated points, each with the σ from the points' joint covariance.
  5. Stereo from a Calibration: rows that line up for any camera model, the disparity-to-depth matrix and the uncertainty of the points.
  6. 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:

FieldMeaning
pointthe 3D position, in the first camera's frame at unit baseline
reprojection_errorRMS 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_degthe largest angle between two observing rays; small angles mean poor triangulation geometry
in_frontwhether 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 pose base_se3_gripper at capture k, exact (RobotPose::exact) or with the covariance of its error (RobotPose::new, or RobotPose::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

  1. 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.
  2. 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.
  3. 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() is None.

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. Scale has no default, because a silent scale would put a wrong metric answer in every result.
    • Scale::Rig takes 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 by sigma. 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's rig is 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 to triangulate or 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 depth0.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 σ:

measurementjoint covariancecross terms dropped
distance (short)0.9940.52
distance (long)0.9920.74
angle0.9920.44
point-to-plane, outside the plane0.9900.64
point-to-plane, a point of the plane1.0020.44
plane distance d1.0000.91
plane normal, two components0.995, 1.0000.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.

MethodMeaning
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:

OptionDefaultMeaning
min_disparity, num_disparities0, 64the searched disparity range [min, min + num)
block_size7square window side in pixels; odd
min_correlation0.5reject a pixel whose best ZNCC is below this
uniqueness_ratio0.03reject a pixel unless its best ZNCC beats the best non-adjacent alternative by this margin
lr_consistency, lr_max_diffon, 1.0 pxthe left-right cross-check and its tolerance
subpixelonparabolic refinement of the peak
semi_global, sgm_p1, sgm_p2off, 0.1, 1.5semi-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

  1. Fetch and detect. dataset::samples::fetch downloads the sample; the dataset runner detects the board in every image and returns 34 usable rig views, each holding both cameras' detections.
  2. 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.
  3. 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.
  4. 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.
  5. Measure. For every pair of corners on one board row or column, 1 to 7 squares apart, metrology::distance gives d ± σ; the truth is k × 24.4 mm. fit_plane over the corners gives the plane's rms, a planarity check.
  6. 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.

squarespairsmean errorRMS errormean σz RMSmean σ, observations onlyz RMS, observations only
11146+0.0120.2160.3330.640.3290.65
2950+0.0050.2810.3430.740.3320.78
3754−0.0340.3700.3620.800.3410.87
4558−0.0250.4540.3880.850.3570.95
5362−0.0330.5560.4190.980.3781.11
6167−0.0800.6330.4570.980.4021.13
783−0.1630.7650.5070.980.4341.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.

squarespairsmean errorRMS errormean σz RMSmean σ, observations onlyz RMS, observations only
1558−0.00090.00960.01030.850.01020.86
2468−0.00210.01110.01071.000.01031.04
3378−0.00430.01250.01131.150.01071.22
4288−0.00670.01530.01221.300.01131.40
5198−0.01010.01770.01331.410.01201.54
6108−0.01380.02030.01471.510.01301.69
772−0.01230.02020.01601.400.01391.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 one triangulate call; 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 ⌘1 to ⌘5): Diagnose, 3D viewer, Epipolar, Depth and Run calibration.
  • One shared store holds the currently loaded *Export JSON 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.json file 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_manifest to 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

WorkspaceHotkeyUse it to
Run⌘5configure and launch a calibration from a dataset folder (Running Calibrations)
Diagnose⌘1look at per-corner residuals on the source images, per-pose statistics and the parameter uncertainty (Inspecting Results)
3D viewer⌘2see the calibrated rig: camera frustums, target boards, laser planes
Epipolar⌘3click a point in one image, see its epipolar line in another
Depth⌘4rectify 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.toml you wrote by hand is read once and never rewritten; the project keeps its own dataset.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.json is 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

  1. Open the Run workspace and click the KUKA handeye preset card.
  2. Review the manifest (a chessboard of 17x28 corners with 20 mm squares and by_index pairing) and the config (eye-in-hand is the default).
  3. Click Run.
  4. 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_intrinsics is 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

  1. Click Open Export… in the top bar.
  2. Pick any *Export.json produced by a calibration session, from a Run or written by session.export() in your own code with diagnostics.image_manifest populated.
  3. 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>>
}
  1. Projection: Compute normalized coordinates . For pinhole: . Returns None if the point is behind the camera ().

  2. Distortion: Apply lens distortion . Warps normalized coordinates according to the distortion model (e.g., Brown-Conrady radial + tangential).

  3. Sensor transform: Apply sensor model . For standard cameras this is identity; for tilted sensors it applies a homography.

  4. 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>
}
  1. — pixel to sensor coordinates
  2. — sensor to distorted normalized
  3. — undistort (iterative)
  4. — 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:

ProjectionDistortionSensorIntrinsics
PinholeNoDistortionIdentitySensorFxFyCxCy
PinholeBrownConrady5IdentitySensorFxFyCxCy
PinholeBrownConrady5HomographySensorFxFyCxCy

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 distortPoints with 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: true in 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::undistortPoints performs 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 uses PointToPlane.

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 plane
  • normalized_to_pixel(normalized, K) — apply to get pixel coordinates
  • undistort_pixel(pixel, K, distortion) — pixel → normalized → undistort → return normalized
  • distort_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::initCameraMatrix2D provides initial intrinsics estimates. The matrix format is identical to OpenCV's cameraMatrix.

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 computeTiltProjectionMatrix used 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:

SymbolMeaning
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:

  1. Repeat for up to iterations:

    1. Sample random data points
    2. Fit a model to the -point sample (skip if degenerate)
    3. Score: count inliers — points with residual
    4. If this model has more inliers than the current best, update the best
    5. Optionally refit the model on all inliers for a tighter fit
    6. Update the iteration bound dynamically based on the current inlier ratio
  2. 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)
    }
}
}
MethodPurpose
MIN_SAMPLESMinimum points for a model (e.g., 4 for homography)
fitFit model from selected points
residualDistance from a point to the model
is_degenerateReject degenerate samples (e.g., collinear points for homography)
refitOptional: 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:

ModelMIN_SAMPLESResidual (unit of thresh)Usage
Homography4Forward transfer error, pixelsdlt_homography_ransac
Homography (two-view)4Symmetric transfer error, unit of the pointsmvg::robust::estimate_homography
Fundamental matrix8Sampson distance, unit of the pointsfundamental_8point_ransac
Essential matrix5Covariance-weighted Sampson error on bearings, σ of the stated pixel noisemvg::robust::estimate_essential, recover_relative_pose_robust
PnP (DLT)6Reprojection error, pixelsdlt_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

LossLarge- growthOutlier influenceConvergence
None ()QuadraticUnboundedBest
HuberLinearBounded (constant)Good
CauchyLogarithmicDecreasingModerate
ArctanBoundedApproaching zeroCan 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:

  1. 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.

  2. 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

SolverEstimatesMethodChapter
Homography DLT homographySVD nullspaceHomography
Zhang's methodIntrinsics IAC constraintsZhang
Distortion fitLinear LS on residualsDistortion Fit
Iterative intrinsics + distortionAlternating refinementIterative
Planar pose from Decomposition + SVDPlanar Pose
P3P (Kneip) from 3 pointsQuartic polynomialPnP
DLT PnP from pointsSVD + SO(3) projectionPnP
8-point fundamental matrixSVD + rank constraintEpipolar
7-point fundamental matrixCubic polynomialEpipolar
5-point essential matrixAction matrix eigenvaluesEpipolar
Camera matrix DLTSVD + RQ decompositionCamera Matrix
Triangulation3D pointSVD nullspaceTriangulation
Hand-eye (Tsai-Lenz, Park-Martin, Daniilidis, Shah)Hand-eye and target Quaternion / rotation-vector / dual-quaternion / Kronecker + linear LSHand-Eye
Rig extrinsics per cameraSE(3) averagingRig Init
Laser planePlane normal + distanceSVD on covarianceLaser 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 :

  1. Centroid: ,

  2. Mean distance from centroid:

  3. Scale factor:

  4. Normalization matrix:

  1. 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:

SolverNormalized spaces
Homography DLTWorld 2D + image 2D
Fundamental 8-pointImage 1 2D + image 2 2D
Essential 5-pointImage 1 2D + image 2 2D
Camera matrix DLTWorld 3D + image 2D
PnP DLTWorld 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:

  1. Normalize world and image points: and
  2. Build the matrix from normalized points
  3. Solve via SVD to get
  4. Denormalize:
  5. 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::findHomography with RANSAC method.

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 (default true): 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 (default false): 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:

  1. The initial Zhang estimate (ignoring distortion) is typically within the basin where the alternation contracts
  2. Each step reduces the residual between the model and observations
  3. The coupling between and distortion is relatively weak for moderate distortion

Algorithm

Input: Views with distorted observations

Output: Intrinsics , distortion

  1. Compute homographies from distorted pixels via DLT

  2. Estimate initial from via Zhang's method

  3. 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:

  4. 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:

  1. Convert distorted pixel to normalized coordinates:
  2. Undistort using the current distortion estimate: (iterative fixed-point, see Brown-Conrady Distortion)
  3. 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

StageTypical intrinsics error
Zhang on distorted pixels10-40%
After 1 iteration5-20%
After 2 iterations5-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:

  1. The homography itself is subject to noise (DLT algebraic error minimization, not geometric)
  2. The SVD projection onto SO(3) corrects non-orthogonality but introduces additional error
  3. 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::decomposeHomographyMat provides 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:

  1. Compute homography per view
  2. Estimate from all homographies (Zhang's method)
  3. Decompose each to get pose
  4. 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:

  1. For a candidate focal length , principal point and coefficients , every pixel back-projects to a bearing.
  2. Each view's pose follows from its bearings (pose from bearings).
  3. 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 .

  1. Bearing vectors: Convert pixels to unit bearing vectors in the camera frame:

  1. Inter-point distances in world frame:

  1. Bearing vector cosines:

  1. Quartic polynomial: Using the ratios and , Kneip derives a quartic polynomial in a distance ratio . The coefficients are functions of , , , , .

  2. Solve quartic for up to 4 real roots .

  3. 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

  1. Reshape the 12-vector into a matrix
  2. Normalize the scale using the row norms of the block
  3. Project the rotation block onto SO(3) via SVD (same as in Pose from Homography)
  4. 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

SolverMin. pointsSolutionsStrengths
P3P3Up to 4Best for RANSAC (minimal sample)
DLT PnP61Simple, no polynomial solving

OpenCV equivalence: cv::solvePnP with SOLVEPNP_P3P or SOLVEPNP_DLS; cv::solvePnPRansac for 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:

  1. Normalize both point sets using Hartley normalization
  2. Build design matrix where each row is:
  3. Solve via SVD; is the last column of
  4. Enforce rank 2: Decompose and set :
  5. 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:

  1. Normalize both point sets using Hartley normalization
  2. Build matrix (same form as 8-point)
  3. Null space: SVD of gives a 4-dimensional null space. The essential matrix is: for unknowns , where are the null space basis vectors
  4. Polynomial constraints: The essential matrix constraint (9 equations) and (1 equation) generate polynomial equations in
  5. Action matrix: These constraints are assembled into a polynomial system solved via the eigenvalues of a action matrix
  6. 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

MatrixInput coordinatesUsage
Fundamental Pixel coordinatesWhen 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:

  1. Compute QR decomposition of :
  2. Then , where is lower-triangular and is orthogonal
  3. 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:

ModuleFunctions
homographydlt_homography, dlt_homography_ransac
epipolarfundamental_8point, essential_5point
triangulationtriangulate_point_linear
camera_matrixdlt_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-handgripper_se3_camerabase_se3_target
eye-to-handgripper_se3_targetbase_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_deg lowered 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.

spreadnoiseTsai-Lenz, Park-MartinDaniilidisShah
±2°low0.45 / 7.2 / 4.80.24 / 5.3 / 5.00.45 / 7.3 / 4.9
±2°high2.37 / 34.7 / 26.81.30 / 39.4 / 36.22.35 / 35.8 / 26.8
±5°low0.17 / 2.4 / 1.60.19 / 2.2 / 2.10.17 / 2.3 / 1.6
±5°high0.89 / 12.2 / 7.80.95 / 11.4 / 9.40.89 / 12.3 / 7.9
±15°low0.041 / 0.56 / 0.390.063 / 0.62 / 0.450.041 / 0.58 / 0.38
±15°high0.22 / 2.9 / 2.10.33 / 3.3 / 2.40.22 / 3.0 / 2.0
±45°low0.008 / 0.09 / 0.070.009 / 0.10 / 0.070.008 / 0.10 / 0.06
±45°high0.052 / 0.50 / 0.590.053 / 0.51 / 0.560.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_deg of 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::calibrateHandEye with CALIB_HAND_EYE_TSAI, CALIB_HAND_EYE_PARK and CALIB_HAND_EYE_DANIILIDIS; cv::calibrateRobotWorldHandEye with CALIB_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:

  1. Per-camera intrinsics are estimated and optimized independently
  2. Per-camera poses are recovered from the calibration board
  3. Rig extrinsics are initialized via averaging (this chapter)
  4. 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:

  1. Undistort the pixel to get normalized coordinates:
  2. Form a ray in the camera frame:
  3. 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.
  4. 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:

  1. Centroid:
  2. Center the points:
  3. Covariance matrix:
  4. SVD:
  5. Normal: is the eigenvector corresponding to the smallest singular value (last column of )
  6. 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:

  1. Convert to depressed cubic via substitution
  2. Compute discriminant
  3. For (three real roots): use trigonometric form
  4. For (one real root): use Cardano's formula with cube roots

Returns 1 or 3 real roots.

Quartic

Solved via the companion matrix approach:

  1. Normalize to monic form: divide by
  2. Construct the companion matrix
  3. Compute eigenvalues using Schur decomposition (via nalgebra)
  4. Extract real eigenvalues: those with

Returns 0 to 4 real roots.

Usage in Minimal Solvers

SolverPolynomialDegreeMax roots
P3P (Kneip)Distance ratioQuartic4
7-point fundamentalCubic3
5-point essentialAction matrix eigenvaluesDegree 1010

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 :

  1. Compute update in the tangent space (a Euclidean space of dimension equal to the manifold's degrees of freedom)
  2. 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 :

  1. Compute orthonormal basis of the tangent plane at
  2. Tangent vector in 3D:
  3. Retract:

For small , this is equivalent to the exponential map on .

Basis Construction

The tangent plane basis is constructed deterministically:

  1. Choose a reference vector not parallel to (e.g., or )
  2. , 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)
  1. Problem Builder: Domain code (e.g., build_planar_intrinsics_ir()) constructs a ProblemIR from calibration data and initial parameter estimates
  2. Backend Compilation: The backend the solver config selects (tiny-solver or factrs) translates the IR into its own variables and residuals
  3. 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
}
}
FieldDescription
idUnique identifier within the problem
nameHuman-readable name (e.g., "cam", "pose/0", "plane")
dimAmbient dimension (e.g., 4 for intrinsics, 7 for SE3)
manifoldGeometry of the parameter space
fixedWhich indices (or the whole block) are held constant
boundsOptional 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:

ChainChain blocksTransform
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 ParamId references 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

  1. Use .clone() liberally: Dual numbers are small structs; cloning is cheap and avoids borrow checker issues with operator overloading.

  2. Avoid in-place operations: Operations like x += y or v[i] = expr can cause issues with dual number tracking. Prefer functional style.

  3. Convert constants: Use T::from_f64(1.0).unwrap() to convert floating-point literals. Do not use 1.0 directly where a T is expected.

  4. Constant data as f64: World points, observed pixels, and weights are constant data — not optimized. Keep them as f64 and convert to T inside 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:

  1. 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 .
  2. 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.
  3. 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.
  4. 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.
  5. 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-solverfactrs
Dual numbersdynamic size (heap)static size: the factor's tangent dimension
Robust lossTriggs correction: second-order exact in iterative reweighting: block scaled by
Variablesone per parameter blocka camera's intrinsics, distortion and sensor fuse into one vector; a laser plane's normal and distance into one plane variable
Fixed parametersremoved from the systemkept, 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.

Problemtiny-solverfactrsfactrs / tiny-solverfinal-cost agreement
Planar intrinsics (pinhole)27 ms33 ms1.24×≤ 4e-07
Planar intrinsics (Scheimpflug)195 ms423 ms2.19×≤ 3e-07
Single-camera hand-eye186 ms254 ms1.39×≤ 1e-08
Laserline device73 ms158 ms2.14×≤ 2e-05
Rig extrinsics92 ms135 ms1.49×≤ 9e-06
Rig extrinsics (Scheimpflug)388 ms791 ms2.04×≤ 4e-07
Rig hand-eye414 ms611 ms1.46×≤ 2e-04
Rig laserline28 ms70 ms2.48×—
Rig hand-eye + laser819 ms1499 ms1.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:

  1. Camera model (CameraModelSpec) — which projection / distortion / sensor kinds apply and which leading parameter blocks exist.
  2. Pose chain (ReprojChain / LaserChain) — how a target-frame point reaches the camera frame and which trailing parameter blocks exist.
  3. Residual family (the FactorKind variant) — 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 { … } — adds robot_delta(6) last.

LaserPointToPlane

Computation:

  1. Undistort the laser pixel to normalized coordinates (inverting sensor and distortion kernels)
  2. Back-project to a ray in camera frame
  3. Intersect the ray with the target plane (Z = 0 in the target frame)
  4. Compute the 3D point in camera frame
  5. Measure the signed distance from the 3D point to the laser plane

Residual: — distance in meters.

LaserLineDistance

Computation:

  1. Compute the 3D intersection line of laser plane and target plane (camera frame)
  2. Project this line onto the normalized camera plane
  3. Undistort the laser pixel to normalized coordinates
  4. Measure the perpendicular distance from pixel to projected line (2D geometry)
  5. 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

FactorBlocksRes. dimUnits
ReprojPointcamera blocks + 1–4 chain blocks2pixels
LaserPointToPlanecamera blocks + 3–6 chain blocks1meters
LaserLineDistancecamera blocks + 3–6 chain blocks1pixels
Se3TangentPrior16normalized

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 Problem of 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 Residual trait), and the library's reprojection residual through a camera you add;
  • solve, which returns a Solution: 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, or cam0.fx, cam1.fx, … with several. So projection_uncertainty reads a camera solved here;
  • a block of yours is labelled <name>.<i> by default, or <name>.<label> after set_labels;
  • a pose is labelled <name>.rx … <name>.tz. These are its right-perturbation tangent T·Exp(δ): tx … tz step 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₃, with r₃ 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 onlywith the range
σ(fx)3.66 px0.84 px
σ(depth), view 0 (frontal)2.05 mm0.49 mm
σ(depth), view 3 (tilted)2.14 mm0.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 has dim finite 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:

  1. it takes the observations;
  2. it computes starting values in closed form;
  3. it refines them with a non-linear solve;
  4. 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

StagePublic piece
Observationscore::CorrespondenceView (points and their pixels), core::PlanarDataset
Starting valuesinit: 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
Solveoptim::problem: blocks, a camera, reprojection residuals, residuals of your own, solve
UncertaintySolution::uncertainty, labelled as a workflow's; projection_uncertainty for a camera
Testingsynthetic (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 Σ_c the calibration's covariance and S how 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 above2.53 mm0.91
calibrate first, then measure with Use::Consider2.54 mm0.91
calibrate first, then measure with the camera fixed0.91 mm2.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):

ScenarioNoise (px)
Ideal (testing convergence)0.0
High-quality detector0.1 - 0.3
Standard detector0.3 - 1.0
Noisy conditions1.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:

TypeDefinitionDescription
Realf64Scalar type
Pt2Point2<f64>2D point
Pt3Point3<f64>3D point
Vec2Vector2<f64>2D vector
Vec3Vector3<f64>3D vector
Mat3Matrix3<f64>3×3 matrix
Mat4Matrix4<f64>4×4 matrix
Iso3Isometry3<f64>SE(3) rigid transform

Camera Model Types

TypeParametersDescription
FxFyCxCy<S>fx, fy, cx, cyIntrinsics matrix
BrownConrady5<S>k1, k2, k3, p1, p2, itersDistortion model
Pinhole(none)Projection model
IdentitySensor(none)No sensor tilt
ScheimpflugParamstilt_x, tilt_yScheimpflug tilt
Camera<S,P,D,Sm,K>proj, dist, sensor, kComposable camera
CameraParamsprojection, distortion, sensor, intrinsicsSerializable camera description (see Serialization)

Selectors and Modes

TypeVariantsUsed by
DistortionKindNone, BrownConrady5, Rational8, ThinPrism9, Division1camera.model.distortion in every config that calibrates a camera (per camera through camera_overrides in a rig)
SensorKindIdentity, Scheimpflugcamera.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 namescamera.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)
RobustLossNone, Huber { scale }, Cauchy { scale }, Arctan { scale }solver.robust_loss and the laser calib_loss / laser_loss
HandeyeModeEyeInHand, EyeToHandhandeye_init.handeye_mode

Observation Types

TypeFieldsDescription
CorrespondenceViewpoints_3d, points_2d, optional cov_px2D-3D correspondences (build with CorrespondenceView::new(..)?, or new_with_covariances(..)? with a PointCovariance per point)
View<Meta>obs, metaObservation + metadata
PlanarDatasetviews: Vec<View<NoMeta>>Planar calibration input
RigViewObscameras: Vec<Option<CorrespondenceView>>Per-camera observations of one rig frame
RigView<Meta>obs: RigViewObs, metaMulti-camera view
RigDataset<Meta>num_cameras, viewsMulti-camera input
ReprojectionStatsmean, rms, max, countError statistics

Shared Config Groups

Every problem's *Config embeds the same small structs by stage:

TypeFieldsPurpose
IntrinsicsInitConfigiterations, fix_k3Per-camera linear initialization; fits only the distortion terms the camera's solves estimate
SolverConfigmax_iters, verbosity, robust_lossNon-linear solve
RobotPoseConfigrefine, rot_sigma, trans_sigma, estimate_sigmaRobot-pose refinement in hand-eye bundle adjustment
HandeyeInitConfighandeye_mode, min_motion_angle_degHand-eye linear initialization
RigConfigreference_camera_idx, refine_intrinsics, refine_sensorRig frame options

Session Types

TypeDescription
CalibrationSession<P>Generic session container
SessionMetadataProblem name, version, timestamps (read via session.metadata())
LogEntryAudit log entry (timestamp, operation, success, notes; read via session.log())
ExportRecord<E>Timestamped export
InvalidationPolicyWhat 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

TypeDescription
HandeyeModeEyeInHand or EyeToHand
RobotPoseMetabase_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:

  1. Define the generic residual function in factors/
  2. Add a FactorKind variant in ir/types.rs
  3. Create a problem builder in problems/
  4. Integrate with both backends (backend/tiny_solver_backend.rs and backend/factrs_backend/)
  5. 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: RealField for 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(&gt_param_a, &gt_param_b);

    // 3. Create perturbed initial values
    let initial = perturb(&gt_param_a, &gt_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
  • FactorKind variant 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

IdProblemDataLicence
kuka_1single_cam_handeye30 eye-in-hand views of a 17×28 chessboard (20 mm) from a KUKA armNone stated
stereo_leftplanar_intrinsics20 views of a 7×11 chessboard (30 mm), left cameraNone stated
stereo_rightplanar_intrinsicsThe same 20 views, right cameraNone stated
stereo_rigrig_extrinsicsBoth cameras of the stereo pair togetherNone stated
ds8rig_handeyeThree cameras on a Denso arm, 33 of 42 poses with a complete 10×14 chessboard (52 mm)U.S. public domain
opencv_stereorig_extrinsicsOpenCV's pinhole stereo sample: 13 views of a 6×9 chessboard (squares of 1) turned by 90° in most views, 640×480Apache-2.0
opencv_fisheye_leftplanar_intrinsicsKannala-Brandt fisheye: 34 views of an 8×6 chessboard (24.4 mm), 1280×800, left cameraNone stated
opencv_fisheye_rightplanar_intrinsicsThe same 34 views, right cameraNone stated
opencv_fisheye_stereorig_extrinsicsBoth fisheye cameras togetherNone stated
synth_planarplanar_intrinsicsSynthetic: 20 views of a 13×9 board, 0.5 px noiseCC0
synth_scheimpflugplanar_intrinsicsSynthetic: a tilted (Scheimpflug) sensor, 20 views, 0.5 px noiseCC0
synth_handeyesingle_cam_handeyeSynthetic: eye-in-hand, 20 robot stations, 0.5 px noiseCC0
synth_rigrig_extrinsicsSynthetic: two cameras, intrinsics refined with the rig, 20 viewsCC0
synth_laserlinelaserline_deviceSynthetic: one camera and a laser plane, 20 viewsCC0
synth_rig_handeye_laserlinerig_handeye_laserlineSynthetic: two cameras on a robot, each with a laser plane, solved jointlyCC0

The real-image entries come from their publishers:

  • kuka_1 is dataset kuka_1 of 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.
  • ds8 is 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_stereo is the stereo calibration sample of OpenCV (samples/data/left*.jpg, right*.jpg in 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 own fisheye calibration 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 bless recalibrates the selected entries and overwrites their reference results. Run it when a change is meant to move the results, and review the diff of results/ like any other code change.
  • suite freeze re-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 rewrites SHA256SUMS. Observations change only when the detector does. The images are read from the manifest's folder, or with --images <dir> from the cache suite fetch fills (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.

EntryDownloaded from
kuka_1The 30 images and the robot poses of Datasets/kuka_1 in the RWHE-Calib repository, at a pinned commit
ds8DS8.zip of the Ag Data Commons record; the images as published (RGB TIFF for the two JAI cameras, colour JPEG for the third)
opencv_stereoThe 26 images of samples/data in the OpenCV repository, at a pinned commit
stereo_left, stereo_right, stereo_rigNot 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_stereoThe 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]
          }
        }
      }
    ]
  }
}
  • problem names the problem type and input is its input, the same payload CalibrationSession::set_input takes in Rust and the run_* functions take in Python (see Data Types Quick Reference). Rig inputs hold num_cameras and, per view, one observation per camera in cameras order, null where that camera did not see the target.
  • cameras lists the cameras in input order with their image size [width, height] in pixels.
  • points_3d are target points in metres in the target frame; planar targets lie in z = 0. points_2d are 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_b maps coordinates in frame b to frame a, so base_se3_gripper takes gripper coordinates to robot-base coordinates — OpenCV's R_gripper2base, t_gripper2base.
  • version is 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 k holds fx, fy, cx and cy in pixels, in the same pixel convention as the observations.
  • Distortion dist holds the Brown-Conrady coefficients by name; OpenCV's five-element distCoeffs is (k1, k2, p1, p2, k3) of the same model. Most configs fix k3 = 0. A fisheye camera's KannalaBrandt4 coefficients k1 … k4 are the four elements of cv::fisheye's D.
  • Reprojection error: mean_reproj_error and per_cam_reproj_errors are the mean Euclidean distance in pixels between observed and reprojected points. OpenCV's calibrateCamera returns the root-mean-square distance, which is larger for the same fit.
  • Poses follow the a_se3_b convention above.
  • Scheimpflug tilt: tilt_x, tilt_y are OpenCV's τx, τy of CALIB_TILTED_MODEL, with the same sign and the same place in the projection (after distortion, before K).

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
ProblemOpenCV
planar_intrinsicscalibrateCameraExtended with CALIB_FIX_K3
planar_intrinsics with a Scheimpflug sensorthe same with CALIB_TILTED_MODEL and CALIB_ZERO_TANGENT_DIST, the parameters our Scheimpflug model estimates (higher terms fixed)
single_cam_handeyecalibrateCameraExtended, then calibrateRobotWorldHandEye (Shah)
rig_extrinsicscalibrateCameraExtended per camera, then stereoCalibrate with CALIB_FIX_INTRINSIC, or refining the intrinsics when the entry does
rig_handeyeper camera as for single_cam_handeye; the rig frame is camera 0's
planar_intrinsics with a fisheye camerafisheye.calibrate with CALIB_RECOMPUTE_EXTRINSIC, CALIB_CHECK_COND and CALIB_FIX_SKEW, the flags of OpenCV's own fisheye tests
rig_extrinsics with fisheye camerasfisheye.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 σ:

EntryLibraryParametersLargest differenceMedianσ ratio (ours / theirs)Mean reprojection (ours / theirs)Truth (ours / theirs)
kuka_1opencv 4.12.0207.32σ (base_se3_target.rz)1.35σ1.464–1.6550.1347 / 2.2545 px—
stereo_leftopencv 4.12.080.01σ (cy)0.01σ1.939–1.9390.2339 / 0.2339 px—
stereo_rightopencv 4.12.080.00σ (cy)0.00σ2.205–2.2050.2442 / 0.2442 px—
stereo_rigopencv 4.12.0223.74σ (cam1.cam_se3_rig.tz)0.60σ———
ds8opencv 4.12.0363.75σ (cam2.p2)0.40σ1.720–6.631——
opencv_fisheye_leftopencv 4.12.080.01σ (k3)0.00σ—0.2227 / 0.2227 px—
opencv_fisheye_rightopencv 4.12.080.00σ (k2)0.00σ—0.2366 / 0.2366 px—
opencv_fisheye_stereoopencv 4.12.0220.00σ (cam1.k3)0.00σ———
synth_planaropencv 4.12.080.00σ (cx)0.00σ1.592–1.5920.6570 / 0.6570 px1.17σ / 1.17σ
synth_scheimpflugopencv 4.12.080.01σ (cx)0.01σ1.000–1.0000.6568 / 0.6568 px1.52σ / 1.53σ
synth_handeyeopencv 4.12.02010.09σ (base_se3_target.tx)1.82σ0.303–0.6310.6645 / 0.7521 px1.20σ / 10.46σ
synth_rigopencv 4.12.0220.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 stereo cameras our standard deviations are 1.9–2.2 times OpenCV's. On synth_planar they 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 terms p1, p2 free, a tilt and a decentring distortion explain the same images, and OpenCV's fit wanders along that valley (its own σ of cy is 40 px).
  • Hand-eye results differ by design. Our hand-eye workflows refine the robot poses within their priors, so on kuka_1 the 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_rig OpenCV's stereoCalibrate refines 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.
  • ds8 is 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::fisheye reports 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:

  1. 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.
  2. Write a dataset manifest (dataset.toml, the file the desktop app's Run workspace reads; data/kuka_1/dataset.toml is an example) and an [[entry]] in suite.toml that points manifest at it.
  3. Run suite freeze --only <id>, then suite bless --only <id>, and commit the observation file, SHA256SUMS and the reference result.
  4. If the publisher serves the files without a login, give the entry a fetch source and list every file the manifest reads, with its SHA-256, in fetch/<id>.sha256 (the sha256sum format, paths relative to the manifest's folder). suite fetch --only <id> and suite freeze --only <id> --images target/suite-images must 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.