Regression Suite
The repository carries a versioned regression suite in regression/. Each
entry pairs a set of observations — the 2D–3D target correspondences, robot
poses and image sizes a calibration consumes, extracted once from the images —
with the reference result calibration-rs produces from them. Every change to
the library is checked against these results, and because the observations are
plain JSON, other calibration tools can run on exactly the same data.
Entries
| Id | Problem | Data | Licence |
|---|---|---|---|
kuka_1 | single_cam_handeye | 30 eye-in-hand views of a 17×28 chessboard (20 mm) from a KUKA arm | None stated |
stereo_left | planar_intrinsics | 20 views of a 7×11 chessboard (30 mm), left camera | None stated |
stereo_right | planar_intrinsics | The same 20 views, right camera | None stated |
stereo_rig | rig_extrinsics | Both cameras of the stereo pair together | None stated |
ds8 | rig_handeye | Three cameras on a Denso arm, 33 of 42 poses with a complete 10×14 chessboard (52 mm) | U.S. public domain |
opencv_stereo | rig_extrinsics | OpenCV's pinhole stereo sample: 13 views of a 6×9 chessboard (squares of 1) turned by 90° in most views, 640×480 | Apache-2.0 |
opencv_fisheye_left | planar_intrinsics | Kannala-Brandt fisheye: 34 views of an 8×6 chessboard (24.4 mm), 1280×800, left camera | None stated |
opencv_fisheye_right | planar_intrinsics | The same 34 views, right camera | None stated |
opencv_fisheye_stereo | rig_extrinsics | Both fisheye cameras together | None stated |
synth_planar | planar_intrinsics | Synthetic: 20 views of a 13×9 board, 0.5 px noise | CC0 |
synth_scheimpflug | planar_intrinsics | Synthetic: a tilted (Scheimpflug) sensor, 20 views, 0.5 px noise | CC0 |
synth_handeye | single_cam_handeye | Synthetic: eye-in-hand, 20 robot stations, 0.5 px noise | CC0 |
synth_rig | rig_extrinsics | Synthetic: two cameras, intrinsics refined with the rig, 20 views | CC0 |
synth_laserline | laserline_device | Synthetic: one camera and a laser plane, 20 views | CC0 |
synth_rig_handeye_laserline | rig_handeye_laserline | Synthetic: two cameras on a robot, each with a laser plane, solved jointly | CC0 |
The real-image entries come from their publishers:
kuka_1is datasetkuka_1of RWHE-Calib, from I. Ali, O. Suominen, A. Gotchev and E. Ruiz Morales, Methods for Simultaneous Robot-World-Hand–Eye Calibration: A Comparative Study, Sensors 19(12), 2837 (2019), https://doi.org/10.3390/s19122837. The images and robot poses are published at https://github.com/ihtishamaliktk/RWHE-Calib as open-access data; the authors state no licence and ask users to cite the paper.- The stereo entries use Chessboard Pictures for Stereocamera Calibration
by Daniel (Kaggle user
danielwe14), version 3, https://www.kaggle.com/datasets/danielwe14/stereocamera-chessboard-pictures, which states no licence. ds8is dataset 8 of A. Tabb and K. M. Ahmad Yousef, Solving the robot-world hand-eye(s) calibration problem with iterative methods, Machine Vision and Applications 28 (2017); the images are published at https://doi.org/10.15482/USDA.ADC/1340592 in the U.S. public domain.opencv_stereois the stereo calibration sample of OpenCV (samples/data/left*.jpg,right*.jpgin https://github.com/opencv/opencv), under OpenCV's Apache-2.0 licence. OpenCV does not publish the printed square size, so the board is declared with squares of 1.- The fisheye entries use
calib-3_stereo_from_JY, the stereo fisheye set of OpenCV's ownfisheyecalibration tests, from OpenCV's test data repository https://github.com/opencv/opencv_extra (testdata/cv/cameracalibration/fisheye/), which states no licence for it. The set ships the chessboard corners with the images, and the entries are frozen from those corners, so calibration-rs and OpenCV calibrate the same points (see Fisheye Calibration: OpenCV's Test Set).
regression/suite.toml records each entry's licence (an SPDX identifier,
NOASSERTION where the publisher states none), citation and source.
The synthetic entries come with their ground truth, so they check accuracy as well as stability, and the laser entries are the suite's laser coverage. Their data was generated once with fixed seeds and is frozen like the rest: a change to the generator cannot change it.
Layout
regression/
├── suite.toml entries: description, licence, config
├── SHA256SUMS checksums of the observation and truth files
├── observations/<id>.json the entry's observations
├── truth/<id>.json a synthetic entry's true parameters
├── results/<id>/calibration-rs.json the entry's reference result
├── results/<id>/<tool>.json another library's result, for comparison
├── fetch/<id>.sha256 the files `suite fetch` downloads, with checksums
└── schema/ JSON Schemas of the three file formats
SHA256SUMS is in the format of sha256sum, so the data can be verified
without any of our tools:
cd regression && sha256sum -c SHA256SUMS # shasum -a 256 -c on macOS
Running it
cargo run --release -p vision-calibration-bench --bin calib-bench -- suite check
suite check verifies each observation file against SHA256SUMS (a missing
or mismatched checksum fails the entry; it is never skipped), calibrates the
entry with its config and compares every number in the result with the
reference. Each entry reports PASS or FAIL with the values that moved, and
the largest change of an estimated parameter in units of the reference's
standard deviation (see Parameter Uncertainty); the command
exits non-zero when any entry fails. --only <id> selects entries.
--sigma <k> compares only the estimated parameters, each within k
standard deviations of the reference, for results that may legitimately
differ in their last digits. --backend factrs (or tiny_solver) runs every
solver stage on that backend:
calib-bench suite check --backend factrs --sigma 1
Two more subcommands maintain the suite:
suite blessrecalibrates the selected entries and overwrites their reference results. Run it when a change is meant to move the results, and review the diff ofresults/like any other code change.suite freezere-extracts an entry's observations from the images its dataset manifest names (built with--features tier-b), reads them from the corner files the publisher released, or regenerates a synthetic entry's observations and truth from its scene, and rewritesSHA256SUMS. Observations change only when the detector does. The images are read from the manifest's folder, or with--images <dir>from the cachesuite fetchfills (below); corner files only from that cache.
Fetching the images
The observations are all suite check needs. To re-extract them, or to run
another tool on the images themselves, suite fetch downloads each entry's
images from its publisher and checks every file against the entry's
fetch/<id>.sha256:
cargo run --release -p vision-calibration-bench --features tier-b \
--bin calib-bench -- suite fetch
cargo run --release -p vision-calibration-bench --features tier-b \
--bin calib-bench -- suite freeze --images target/suite-images
The files land in target/suite-images/<id>/ (--images <dir> picks
another folder), laid out as the dataset manifest expects. A file already
there that matches its checksum is not downloaded again, so the command is
cheap to repeat; a downloaded file that does not match fails the entry, as
it means the publisher's files changed. suite freeze --images <dir>
verifies the cached files again, freezes the entries suite fetch
downloads (or those named with --only) and rewrites SHA256SUMS. On
Apple-silicon macOS, where the observations are frozen, a clean checkout
reproduces them byte for byte. Corner detection is not bit-identical across
platforms: on x86-64 Linux the same images give corners up to about
0.001 px apart, so the files differ there while every view and corner is
the same.
| Entry | Downloaded from |
|---|---|
kuka_1 | The 30 images and the robot poses of Datasets/kuka_1 in the RWHE-Calib repository, at a pinned commit |
ds8 | DS8.zip of the Ag Data Commons record; the images as published (RGB TIFF for the two JAI cameras, colour JPEG for the third) |
opencv_stereo | The 26 images of samples/data in the OpenCV repository, at a pinned commit |
stereo_left, stereo_right, stereo_rig | Not fetched: Kaggle needs an account. suite fetch prints the dataset page; unpack the download into data/stereo/ |
opencv_fisheye_left, opencv_fisheye_right, opencv_fisheye_stereo | The corner files (left.xml, right.xml, object.xml) of calib-3_stereo_from_JY in opencv_extra, at a pinned commit |
suite.toml gives each fetchable entry a fetch source: files, each file
downloaded from a base_url followed by its path, or zip, one archive
whose root folder is the dataset's. An entry frozen from published corners
names them under corners: the file format (opencv_storage, the
cv::FileStorage XML of OpenCV's calibration tests, one node image_<i>
per view), the problem (planar_intrinsics or rig_extrinsics), the
object file of target points and, per camera, its points file and
image_size, which corner files do not record. A scheduled CI job fetches the images
from the publishers every week, re-freezes these entries and fails when the
observations no longer match SHA256SUMS.
The image entries are also the public datasets of the benchmark commands
that run detection: calib-bench run --dataset <id> and calib-bench feature-cov read each entry's dataset manifest, and --images <dir> reads
the fetchable entries from the suite fetch cache.
The observation format
{
"version": 1,
"cameras": [{ "id": "cam0", "image_size": [1928, 1208] }],
"problem": "single_cam_handeye",
"input": {
"views": [
{
"obs": {
"points_3d": [[0.0, 0.0, 0.0], [0.02, 0.0, 0.0]],
"points_2d": [[109.811, 92.798], [140.950, 91.702]]
},
"meta": {
"base_se3_gripper": {
"rotation": [0.0, 0.0, 0.0, 1.0],
"translation": [1.205, -0.808, 0.524]
}
}
}
]
}
}
problemnames the problem type andinputis its input, the same payloadCalibrationSession::set_inputtakes in Rust and therun_*functions take in Python (see Data Types Quick Reference). Rig inputs holdnum_camerasand, per view, one observation per camera incamerasorder,nullwhere that camera did not see the target.cameraslists the cameras in input order with their image size[width, height]in pixels.points_3dare target points in metres in the target frame; planar targets lie inz = 0.points_2dare the matching image points in pixels, with integer coordinates at pixel centres (the OpenCV convention).- A pose is
{"rotation": [qx, qy, qz, qw], "translation": [x, y, z]}: a unit quaternion, scalar last, and a translation in metres.a_se3_bmaps coordinates in framebto framea, sobase_se3_grippertakes gripper coordinates to robot-base coordinates — OpenCV'sR_gripper2base,t_gripper2base. versionis the format version; it changes only with an incompatible format change.
schema/observation_set.json is the format's JSON Schema, generated from the
types the library reads, with the input of every problem type; truth.json
and external_result.json describe the truth files and the results of other
libraries below.
Ground truth
truth/<id>.json names each true value by the label the result's
covariance uses (see Parameter Uncertainty):
{
"parameters": { "fx": 1000.0, "fy": 990.0, "k1": -0.12, "plane.nx": 0.287 },
"poses": {
"gripper_se3_camera": { "rotation": [0.05, -0.02, 0.01, 0.998], "translation": [0.05, -0.03, 0.08] }
}
}
Scalars sit under parameters: intrinsics, distortion coefficients, the
sensor tilt and laser-plane components, prefixed cam<i>. in a rig with more
than one camera. Poses sit under poses by their export names. suite check
compares every estimated parameter the truth names with its true value, in
units of the result's own standard deviation (a pose by the right
perturbation T_true⁻¹ · T), and fails the entry when one is 5σ or more
off. Each entry reports its largest error against the truth.
Comparing with other libraries
The reference results are the problem's export, minus the per-feature residual lists and the projected laser lines (they follow from the parameters and the observations; the residual histograms and the reprojection statistics stay). When comparing them with another tool's output:
- Intrinsics
kholdsfx,fy,cxandcyin pixels, in the same pixel convention as the observations. - Distortion
distholds the Brown-Conrady coefficients by name; OpenCV's five-elementdistCoeffsis(k1, k2, p1, p2, k3)of the same model. Most configs fixk3 = 0. A fisheye camera'sKannalaBrandt4coefficientsk1 … k4are the four elements ofcv::fisheye'sD. - Reprojection error:
mean_reproj_errorandper_cam_reproj_errorsare the mean Euclidean distance in pixels between observed and reprojected points. OpenCV'scalibrateCamerareturns the root-mean-square distance, which is larger for the same fit. - Poses follow the
a_se3_bconvention above. - Scheimpflug tilt:
tilt_x,tilt_yare OpenCV'sτx,τyofCALIB_TILTED_MODEL, with the same sign and the same place in the projection (after distortion, beforeK).
OpenCV
tools/compare/opencv/compare.py calibrates every entry OpenCV has a method
for and writes results/<id>/opencv.json: the parameters under the labels of
our results, the poses under our export names, OpenCV's standard deviations
and its mean reprojection error. The OpenCV version is pinned in the
harness's uv project:
uv run --project tools/compare/opencv tools/compare/opencv/compare.py
cargo run --release -p vision-calibration-bench --bin calib-bench -- suite compare
| Problem | OpenCV |
|---|---|
planar_intrinsics | calibrateCameraExtended with CALIB_FIX_K3 |
planar_intrinsics with a Scheimpflug sensor | the same with CALIB_TILTED_MODEL and CALIB_ZERO_TANGENT_DIST, the parameters our Scheimpflug model estimates (higher terms fixed) |
single_cam_handeye | calibrateCameraExtended, then calibrateRobotWorldHandEye (Shah) |
rig_extrinsics | calibrateCameraExtended per camera, then stereoCalibrate with CALIB_FIX_INTRINSIC, or refining the intrinsics when the entry does |
rig_handeye | per camera as for single_cam_handeye; the rig frame is camera 0's |
planar_intrinsics with a fisheye camera | fisheye.calibrate with CALIB_RECOMPUTE_EXTRINSIC, CALIB_CHECK_COND and CALIB_FIX_SKEW, the flags of OpenCV's own fisheye tests |
rig_extrinsics with fisheye cameras | fisheye.stereoCalibrate with the same flags, intrinsics refined with the rig |
OpenCV has no laser-plane calibration, so the laser entries have no
comparison. suite compare sets each result against our reference:
differences in units of our standard deviation, OpenCV's standard deviations
against ours, the mean reprojection error by the same definition, and for a
synthetic entry each library's largest error against the truth, in units of
our σ:
| Entry | Library | Parameters | Largest difference | Median | σ ratio (ours / theirs) | Mean reprojection (ours / theirs) | Truth (ours / theirs) |
|---|---|---|---|---|---|---|---|
kuka_1 | opencv 4.12.0 | 20 | 7.32σ (base_se3_target.rz) | 1.35σ | 1.464–1.655 | 0.1347 / 2.2545 px | — |
stereo_left | opencv 4.12.0 | 8 | 0.01σ (cy) | 0.01σ | 1.939–1.939 | 0.2339 / 0.2339 px | — |
stereo_right | opencv 4.12.0 | 8 | 0.00σ (cy) | 0.00σ | 2.205–2.205 | 0.2442 / 0.2442 px | — |
stereo_rig | opencv 4.12.0 | 22 | 3.74σ (cam1.cam_se3_rig.tz) | 0.60σ | — | — | — |
ds8 | opencv 4.12.0 | 36 | 3.75σ (cam2.p2) | 0.40σ | 1.720–6.631 | — | — |
opencv_fisheye_left | opencv 4.12.0 | 8 | 0.01σ (k3) | 0.00σ | — | 0.2227 / 0.2227 px | — |
opencv_fisheye_right | opencv 4.12.0 | 8 | 0.00σ (k2) | 0.00σ | — | 0.2366 / 0.2366 px | — |
opencv_fisheye_stereo | opencv 4.12.0 | 22 | 0.00σ (cam1.k3) | 0.00σ | — | — | — |
synth_planar | opencv 4.12.0 | 8 | 0.00σ (cx) | 0.00σ | 1.592–1.592 | 0.6570 / 0.6570 px | 1.17σ / 1.17σ |
synth_scheimpflug | opencv 4.12.0 | 8 | 0.01σ (cx) | 0.01σ | 1.000–1.000 | 0.6568 / 0.6568 px | 1.52σ / 1.53σ |
synth_handeye | opencv 4.12.0 | 20 | 10.09σ (base_se3_target.tx) | 1.82σ | 0.303–0.631 | 0.6645 / 0.7521 px | 1.20σ / 10.46σ |
synth_rig | opencv 4.12.0 | 22 | 0.09σ (cam0.cy) | 0.02σ | — | — | 2.07σ / 2.06σ |
Reading it:
- On plain and Scheimpflug intrinsics the two libraries agree within
0.02σ. Both start from the classical covariance; ours is scaled by its
view scatter when the views scatter significantly more than the noise
predicts, so on the real
stereocameras our standard deviations are 1.9–2.2 times OpenCV's. Onsynth_planarthey are 1.6 times: its views happen to pass that test (about one calibration in forty does on data that fit the noise model exactly). The Scheimpflug fit needs the same parameter set on both sides: with the tangential termsp1,p2free, a tilt and a decentring distortion explain the same images, and OpenCV's fit wanders along that valley (its own σ ofcyis 40 px). - Hand-eye results differ by design. Our hand-eye workflows refine the robot
poses within their priors, so on
kuka_1the reprojection error is the corner noise (0.13 px) rather than the robot's pose error (2.3 px with the measured poses). OpenCV takes the robot poses as exact for the hand-eye pose, but calibrates the intrinsics with a free pose per view, as the refined robot poses effectively are here: our intrinsics standard deviations are 1.5–1.7 times OpenCV's, the view scatter's scale. The synthetic hand-eye entry's robot poses are exact, and our solve estimates a robot error near zero from the corrections: it lands 1.2σ from the truth with standard deviations a third to two thirds of OpenCV's, whose hand-eye, from the same poses, is 10σ from the truth in our units. - Rig results refine each camera's intrinsics jointly with the rig, from
every view of every camera. On
stereo_rigOpenCV'sstereoCalibraterefines them too, but from the views both cameras see; the two estimates differ by up to 4σ, along the coupling between camera 0's focal length and camera 1's depth offset. On the synthetic rig the joint estimate lands within 2.1σ of the truth. ds8is a three-camera rig; OpenCV calibrates each camera's intrinsics alone and camera 0's hand-eye alone, while ours refines all three cameras with the rig and the hand-eye, so the difference includes what the joint solve moves.- On OpenCV's fisheye set the two libraries agree within 0.01σ, on each
camera alone and on the stereo pair, where both refine the intrinsics
with the rig; OpenCV's results are the values its own tests expect.
cv::fisheyereports no standard deviations, so there is no σ ratio.
Drift tolerance
A number passes when it is within abs + rel · max(|reference|, |result|) of
its reference, with rel = 1e-6 and abs = 1e-9 unless the entry sets its own
tolerance. That flags any change in the solution while allowing the last-bit
differences between platforms and compilers. Strings, flags and the shape of
the result must match exactly.
With --sigma k, a parameter passes when its change is within k of the
reference's standard deviations. A pose's change is measured as the right
perturbation T_ref⁻¹ · T in the coordinates of its covariance. The two
solver backends agree within 0.02σ on every entry.
Adding an entry
A synthetic entry names a scene of the solver benchmark's full matrix under
synthetic, by its id <problem>/<camera>/<scale>/n<σ>/<clean|huber|cauchy>
(as calib-bench solver run --preset full reports them), with the config the
scene needs, and carries the CC0 licence. Run suite freeze --only <id> and
suite bless --only <id> and commit the observation, truth and result files
with SHA256SUMS. For an entry with images:
- Check that the data's licence allows publishing observations derived from it, and record the licence (and citation, if the authors ask for one) in the entry.
- Write a dataset manifest (
dataset.toml, the file the desktop app's Run workspace reads;data/kuka_1/dataset.tomlis an example) and an[[entry]]insuite.tomlthat pointsmanifestat it. - Run
suite freeze --only <id>, thensuite bless --only <id>, and commit the observation file,SHA256SUMSand the reference result. - If the publisher serves the files without a login, give the entry a
fetchsource and list every file the manifest reads, with its SHA-256, infetch/<id>.sha256(thesha256sumformat, paths relative to the manifest's folder).suite fetch --only <id>andsuite freeze --only <id> --images target/suite-imagesmust then reproduce the observation file.
When the publisher releases the target corners with the images, as OpenCV
does for its test sets, the entry can name them under corners instead of a
dataset manifest; it then needs a fetch source whose list holds the corner
files.