Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

calibsense

Your camera calibration reports a reprojection error of 0.2 px. That number does not tell you whether you can measure a part to half a millimetre.

calibsense computes what the reprojection RMS cannot: the full parameter covariance, the parameter combinations your capture does not determine at all, whether the residuals are structured rather than random, and what that means in the units you care about.

A 100 mm feature measured at 800 mm has an expected error of 0.199 mm, with a 95% interval of ±0.485 mm. Of that, 40% comes from the calibration and 60% from 0.119 px of pixel noise at measurement time.

It names what is wrong with the capture and what to do about it, and refuses to print a figure that cannot be trusted as though it could.

The problem

Six synthetic rigs, one true camera at fx = 900 px, the same 0.2 px corner noise, the same 18 views. Reproduce with python examples/degeneracy_demo.py:

rig reprojection RMS fx error sd(fx) identifiable
flat, 1 depth 0.2753 px +3334.2 px 708.0 no
flat, 3 depths 0.2761 px -139.1 px 0.336 no
5 deg tilt, 1 depth 0.2754 px +41.2 px 28.5 yes
20 deg tilt, 1 depth 0.2757 px +2.3 px 3.93 yes
40 deg tilt, 3 depths 0.2759 px +1.3 px 1.19 yes

Every row fits its own images to within a thousandth of a pixel of every other row, while the focal length ranges from correct to wrong by 3334 px. An engineer looking at the first row sees 0.275 px and ships it.

Tilt is what makes the focal length identifiable, not depth variation: on a frontoparallel target a global rescale of all focal lengths and all depths reproduces the images exactly, and a fourth working distance arrives with its own free translation, so only foreshortening within a single view pins fx.

Read identifiable before the standard deviation: row two reports sd(fx) = 0.336 px while being wrong by 139 px, because a pseudo-inverse assigns zero variance to a direction the data does not constrain. A rank-deficient fit reports false confidence, not a wide error bar.

Install

pip install .

Three runtime dependencies: numpy, opencv-contrib-python, pyyaml. Python 3.9 or newer. make binary produces dist/calibsense, a 64 MB single file that needs no Python present.

Usage

1. Detect the target

calibsense ingest images --images captures/ \
    --target checkerboard:9x6:25mm -o session.npz

Failures are named, because a detector that silently drops a third of your capture is the first thing you want to know:

  [   2/20] ... view001  no 9x6 checkerboard found
  ...
  target       9x6 checkerboard, 25 mm squares
  views        14
  points       756 total, 54-54 per view (median 54)
  detection    14/20 images (70%) via checkerboard

Target shorthand avoids a config file: checkerboard:9x6:25mm, charuco:8x11:20mm:15mm:DICT_5X5_1000, circles:4x11:0.75in:asymmetric.

Add --calibration shipped.yml to audit one you already ship; calibsense refit session.npz --no-refit then instruments it in place and reports whether it is even the optimum of its own detections.

2. Ask what the capture supports

calibsense diagnose session.npz
  1 warning(s): image coverage

  [WARNING ] Image coverage
               corners occupy 40% of an 8x6 grid, 12% of the border ring, and
               reach 56% of the way to the frame corner; the periphery is thin
               -> Add views with the board against the frame edges and corners.

  clean: Out-of-sample error, Pose diversity, Frontoparallel dominance, Depth
         variation, Target scale, Distortion model adequacy, Noise model

It exits 3 on a critical finding, so CI can gate on calibration quality without parsing anything.

3. Get the answer in millimetres

calibsense report session.npz --task length:800mm:100mm --task plane:800mm \
    --json audit.json --pdf audit.pdf --contact metrology@example.com
A 100 mm feature measured at 800 mm has an expected error of 0.199 mm, with a
95% interval of +/-0.485 mm.

The reported 0.1633 px reprojection error is an honest error estimate: held-out
views reprojected to 0.168 px, a ratio of 1.03x.

The dominant cause is image coverage: corners occupy 40% of an 8x6 grid, 12% of
the border ring, and reach 56% of the way to the frame corner.

That capture was rendered through a camera with fx = 700.0; the fit recovered 700.855 ± 1.121, 0.76 sigma from truth, with all nine intrinsics inside their reported intervals.

The JSON carries every number; the PDF is the two-page version a quality manager can sign. Both lead with the variance split, the line most people act on: calibration error and measurement-time pixel noise are independent, so their variances add, and splitting them says re-calibrating this camera can improve the answer by at most 40%. The rest is photons.

Four tasks are available as --task: length:DEPTH:SIZE for measuring a feature, plane:DEPTH[:TILT] for locating a plane, stereo:DEPTH:BASELINE for triangulated depth, and base:DEPTH for a point in the robot's base frame.

When a figure cannot be trusted the report opens with a banner and the JSON carries "trustworthy": false with the reason, for example THE CORNER NOISE IS CORRELATED - EVERY FIGURE BELOW IS TOO TIGHT BY ABOUT 3.0X. Pass --deterministic before the subcommand for reproducible output; without it results move in the last few bits, because cv2.calibrateCamera reduces across threads.

Measuring the noise instead of inferring it

Everything above infers the noise scale from fit residuals, which assumes the model is right. To take the model out of the loop, bolt the camera and target down, capture thirty or more frames, and measure how far the corners wander:

calibsense noise-floor --images static/ --target checkerboard:9x6:25mm
frames       40 static frames, 54 corners
scale        0.0116 px total, 0.0111 px irreducible (9% absorbable by pose)
correlation  length 0.0 px, 0.00 of the 22 px corner spacing (independent)
shape        anisotropy 1.60 against a 1.21 floor from 40 frames
drift        0.0052 px largest whole-frame shift

The correlation length decides whether the rest of the report can be trusted. σ² = cost / (m - p) measures the noise scale but asserts its shape, and breaking that assumption one way at a time showed most of the shape does not matter: anisotropy, per-view scale variation and a few per cent of mis-detected corners each leave the predicted deviation within about 15% of the truth. Spatial correlation is the exception, because a correlated field is partly absorbable by the pose and distortion parameters:

injected noise reported σ̂ predicted sd(fx) true sd(fx)
independent, 0.25 px 0.253 px 3.14 px 3.03 px
correlated, 60 px length 0.162 px 1.95 px 5.69 px
correlated, 200 px length 0.055 px 0.67 px 5.24 px

The RMS gets better as the answer gets worse, which is why no residual statistic can catch it, and why the covariance is also computed a second way with the view as the independent unit; their ratio is reported as a finding. Feed the measured value back with --noise-px 0.011 to move the pixel-noise half of the task-space error.

What it accepts

Targets checkerboard, ChArUco, circle grid (symmetric and asymmetric)
Camera models pinhole with Brown-Conrady (4, 5, 8, 12 or 14 coefficients), fisheye with Kannala-Brandt
Sources a folder of images, or an existing calibration plus its detections
Calibration formats OpenCV FileStorage, ROS camera_info, Kalibr camchain, calibsense JSON
Robot poses JSON or CSV; matrix, quaternion or rotation-vector form; either hand-eye direction; any length unit

Point ids are per view rather than a fixed grid, so a ChArUco board showing half of itself still contributes labelled points. A session is one .npz loaded with allow_pickle=False, so opening a result cannot execute code.

What it computes

Everything cv2.calibrateCamera works out internally and then throws away: the full parameter covariance in factored form, rather than the column of marginal deviations calibrateCameraExtended returns, because the off-diagonals are where corr(fx, tz) = 0.93 lives; identifiability from the null space of the Jacobi-scaled Schur complement; a second view-clustered covariance and the ratio between the two; per-view and per-corner residuals with a radial profile that separates radial from tangential; conditioning, raw and Jacobi-scaled; held-out error from K-fold over views; and whether the parameters sit at the optimum of their own objective, which is frequently false when auditing someone else's calibration.

The held-out ratio has a blind spot: on the frontoparallel rig above it reads 1.005 despite fx being wrong by 3334 px, because a held-out view solves its own pose and a wrong depth cancels a wrong focal length. So per-fold identifiability gates the verdict, and the across-fold parameter spread is reported beside the covariance's prediction.

Hand-eye

Eye-in-hand and eye-to-hand, with a covariance over all twelve parameters and pose-set sufficiency checks. The closed form is implemented here rather than delegated, because cv2.calibrateHandEye is absent from OpenCV 5's Python bindings while its CALIB_HAND_EYE_* flags are still exported.

Two things measurement caught. The residual-based covariance is 3.1x optimistic, giving 0.39 mm against an actual error of 1.29 mm, because it treats the target-in-camera poses as exact when they come from the calibration and their errors are correlated across views; resampling whole calibrations gives 1.22 mm and is the default. And hand-eye is determined by rotation and nothing else, so a degenerate pose set produces a small predicted deviation beside a large actual error: one rotation axis gives 88 mm of error against 1.5 mm predicted, and a shuffled robot pose gives 367 mm. Both are caught, the latter because conjugate rotations have equal angles, which no amount of optimisation would have shown.

Python API

from calibsense import load_session
from calibsense.report import run_audit, write_json, write_pdf
from calibsense.task import LengthAtDepth

session = load_session("session.npz")
audit = run_audit(session, tasks=[LengthAtDepth(depth_mm=800.0, length_mm=100.0)])

print(audit.trustworthy)            # read this first
print(audit.caveats())              # and this, when it is False
print(audit.headline())             # the paragraph the report leads with

write_pdf(audit, "audit.pdf")
write_json(audit, "audit.json")

Or a piece at a time with instrument, cross_validate, diagnose and propagate from the top-level package. An InstrumentedFit exposes conditioning.identifiable (read it first), covariance.intrinsic_std(), the view-clustered covariance.robust.std(), the ratio between them as covariance.worst_robust_inflation(), and covariance.correlation_with_poses("fx") for corr(fx, tz) per view.

calibsense.synthetic generates captures with known truth, which is how the test suite works. make docs runs pdoc over the docstrings into docs/api.

Scope

Two bounds worth knowing, because neither is going away soon.

One camera. No stereo or multi-camera calibration; a Kalibr chain is read one camera at a time and the extrinsics between cameras are ignored. The stereo task takes your baseline as exact, and on a real rig the baseline's own uncertainty usually dominates the triangulated depth.

Board geometry is treated as exact. A printed target's 50 to 200 µm of print scale and flatness error appears nowhere in the covariance, and it is a systematic on metric scale: 0.1% of board scale error goes straight into fx and therefore into millimetres, where it can dominate everything modelled. Order a target with a calibration certificate if the millimetres matter.

Author

Written by Abhishek Gola (abhishek-gola). Copyright (C) 2026 Abhishek Gola. Bugs, questions and licensing enquiries via the issue tracker.

Licence

AGPL-3.0-only. See LICENSE; every source file carries an SPDX-License-Identifier header.

Section 13 reaches network use: if you modify calibsense and let people interact with the modified version over a network, you must offer those users the corresponding source. Running it as a CLI, or importing it as a library in software you do not distribute, triggers nothing. A PyInstaller binary is a distribution of the program, so dist/calibsense must ship with the corresponding source or a written offer for it.

If AGPL does not suit your organisation, contact the author; the copyright is held by a single author, so other arrangements are possible.

Metadata

Release files for calibsense 0.1.0a1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for calibsense 0.1.0a1
File Size Uploaded
calibsense-0.1.0a1.tar.gz 275.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for calibsense 0.1.0a1
File Interpreter ABI Platform
calibsense-0.1.0a1-py3-none-any.whl Python 3 none any Details

Total release size: 530.2 kB

Release files / calibsense-0.1.0a1.tar.gz

Download URL calibsense-0.1.0a1.tar.gz
Size 275.6 kB
Tags Source
SHA-256 checksum
How to use checksums
830bf0d5b5a94437fffb9ca90e8c9dfe1870650fe7c7edb7c0fd16b81d9500eb
BLAKE2b-256 checksum
How to use checksums
b6714d56739ab1113d2979043bb79e61759df291ae06f6d0c5615f5e62339795
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release files / calibsense-0.1.0a1-py3-none-any.whl

Download URL calibsense-0.1.0a1-py3-none-any.whl
Size 254.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e12ee2c9ebbef6b848994453ac61c1f1f789148a6d113874f1f238c753b096b2
BLAKE2b-256 checksum
How to use checksums
ab2f2d7a3da7846c779d13131c15d25eb9af5d20516834fab0fb7732dce75cbf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release history Release notifications | RSS feed

This release

0.1.0a1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page