External Calibration Adapters¶
Calibrex keeps external calibration implementations behind a ROS-independent,
subprocess/import boundary. It does not import UniCalib, Koide's direct
visual-LiDAR calibration implementation, model weights, ROS, CUDA, or GPL code
into src/calibrex.
The maintained commercial Koide handoff is pinned in
examples/official/koide_execution_lock.yaml.
Validate it and print the operator checklist with
calibrex camera-lidar koide-handoff --lock examples/official/koide_execution_lock.yaml.
The lock records the official source commit and published image digest, uses
the upstream manual-initialization command, excludes SuperGlue, and leaves the
upstream mutable base-image tag unresolved for any rebuild.
Every Koide or UniCalib adapter result includes an
external_calibration_run mapping. Set external_run_output to materialize
the same mapping as a standalone slac.external_calibration_run/v0.1
YAML/JSON artifact:
pipeline:
factors:
unicalib_lidar_camera:
enabled: true
options:
result_path: outputs/unicalib/transform.yaml
input_paths:
- inputs/fixed_split_manifest.yaml
expected_result_sha256: "<sha256>"
external_run_output: outputs/unicalib/external_run.yaml
tool_version: "wacv-2026"
source_repository: https://github.com/han-15/UniCalib
source_commit: "<full commit>"
license_spdx: MIT
training_isolation_declared: true
training_isolation_evidence: "checkpoint training split excludes evaluation sequence"
Commands may be a string or argv list. Placeholders are substituted per argv element and no shell is enabled:
command:
- /opt/unicalib/run
- --dataset
- "{dataset_path}"
- --output
- "{result_path}"
execute: true
timeout_sec: 600
Supported placeholders are {dataset_path}, {result_path},
{camera_streams}, and {lidar_streams}. Container execution should declare
execution_mode: container and a content-addressed container_digest.
The imported transform format is deliberately adapter-neutral:
transforms:
T_camera0_lidar0:
convention: T_parent_child
parent: camera0
child: lidar0
translation_m: [0.0, 0.0, 0.0]
rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0]
Koide native calib.json¶
direct_visual_lidar_calibration writes a native JSON result with
results.T_lidar_camera = [x, y, z, qx, qy, qz, qw]. The vector maps camera
coordinates into LiDAR coordinates. The adapter requires an explicit
lidar_frame and camera_frame (or a unique frame binding from configured
streams), stores the native estimate as T_<lidar>_<camera>, and emits the
strict inverse T_<camera>_<lidar>. No generic camera0/lidar0 convention is
guessed. Missing fields, wrong vector length, non-finite/non-numeric values,
and a zero quaternion become invalid_output with a concrete parse error.
For example:
pipeline:
factors:
koide_lidar_camera:
enabled: true
options:
result_path: outputs/external/calib.json
lidar_frame: lidar0
camera_frame: camera0
The result-level and typed external-run provenance retains the native format,
source path and SHA-256, declared T_lidar_camera convention, bound frame
names, and both generated transform names. The importer is available as
calibrex.importers.koide.import_koide_result; the strict lower-level parser
raises ValueError so external subprocess adapters cannot turn malformed
output into a usable transform.
The same boundary is available from the CLI:
calibrex external-run import-koide calib.json \
--output external-run.json \
--lidar-frame lidar0 \
--camera-frame camera0 \
--input-artifact recording.mcap \
--tool-version <koide-version> \
--source-commit <koide-commit> \
--training-isolation-declared \
--training-data-ids-sha256 <train-id-digest> \
--holdout-data-ids-sha256 <holdout-id-digest>
For the Koide pilot, repeat --input-artifact only for files used during
fitting. Do not import an artifact whose input inventory contains the frozen
holdout; the pilot verifies the combined input digest and rejects overlapping
files or whole-sequence directory mounts.
External objective values can be recorded under external_metrics, but the
schema labels them non-comparable with Calibrex holdout evidence by default.
The normal calibration pipeline recomputes its camera-LiDAR evidence after the
adapter transform is applied; an external training loss never substitutes for
those gates.
Typed Koide workflow runner¶
For a maintained execution path, use the typed Koide runner instead of a single shell command. Each stage is an argv list and is short-circuited on failure; the external-run artifact stores status, return code, duration, log tails, and stdout/stderr digests for every stage:
pipeline:
factors:
koide_lidar_camera:
enabled: true
options:
execution_mode: subprocess
profile: commercial # the default
dataset_path: captures/drive.mcap
result_path: outputs/koide/calib.json
lidar_frame: lidar_front
camera_frame: camera_front
stages:
- name: preprocess
argv: [python, tools/koide_preprocess.py, "{dataset_path}"]
- name: initial_guess
argv: [python, tools/manual_initial_guess.py, "{initial_guess_path}"]
- name: calibrate
argv: [python, tools/koide_calibrate.py, "{result_path}"]
initial_guess_mode: manual
The runner never invokes a shell. Legacy command options remain supported,
but new integrations should use stages so preprocess, initial guess, and
calibration are independently reproducible. A zero exit code is not enough
for success: an unreadable or frame-inconsistent native calib.json becomes
invalid_output.
Commercial mode rejects automatic initial guess and SuperGlue. A research
workflow must explicitly set profile: research-noncommercial and record the
provider, provider license, checkpoint path, and checkpoint SHA-256. Manual
or precomputed initial guesses are the supported fallback for commercial
deployments.
For ROS 2 Humble/Jazzy, keep ROS and Koide outside Calibrex and use an
immutable image digest. The container runner emits Docker/Podman argv with a
read-only input bind, output-only writable bind, --network none, dropped
capabilities, and optional CPU/memory limits:
execution_mode: container
container:
engine: docker
image_digest: ghcr.io/example/koide-ros2@sha256:<64-hex-digest>
input_dir: captures
output_dir: outputs/koide
network_mode: none
cpus: 4
memory: 8g
Run a configured workflow with one command:
calibrex external-run run-koide calibrex-koide.yaml \
--output outputs/koide/external-run.yaml
calibrex validate outputs/koide/external-run.yaml --kind external-run
The artifact records tool/source/container/config/input/output digests and the declared frame/time conventions. Docker/Podman and the ROS 2 Humble or Jazzy image remain optional runtime requirements; fake process runners cover the contract tests in environments without those tools.
Kalibr camchain and independent Camera--IMU evidence¶
calibrex external-run import-kalibr converts a Kalibr camchain-imucam.yaml
into a slac.external_calibration_run/v0.1 artifact without importing or
running Kalibr. Kalibr's own reprojection and gyro/accelerometer errors stay
non-comparable. Calibrex re-evaluates the imported T_cam_imu and
timeshift_cam_imu separately with
calibrex external-run evaluate-camera-imu, using a recording that Kalibr was
not fitted on:
calibrex external-run import-kalibr camchain-imucam.yaml \
--output outputs/kalibr/external-run.json \
--input-artifact kalibr-fitting.bag \
--tool-version "<kalibr version>" --source-commit "<full commit>" \
--training-isolation-declared \
--training-isolation-evidence "evaluation recording captured separately"
calibrex external-run evaluate-camera-imu outputs/kalibr/external-run.json \
--recording evaluation-recording.json --camera cam0 \
--output outputs/kalibr/camera-imu-evidence.json
calibrex validate outputs/kalibr/camera-imu-evidence.json
The recording is a slac.camera_imu_motion_recording/v0.1 artifact holding
camera orientations R_world_camera on the camera clock and body-frame gyro
samples on the IMU clock. The evaluator checks
omega_cam = R_cam_imu (omega_imu + b), with gyro samples taken at
t_imu = t_cam + timeshift_cam_imu. It fits only a constant gyro bias b,
and only on the training intervals. It then applies the following gates:
| Gate | Default | Consequence |
|---|---|---|
| The external run succeeded, the camera and IMU frames match, and the recording bytes are not a declared fitting input | required | blocked |
| Train/holdout interval counts (holdout is the middle 35–65% of the span, with a guard gap on each side) | ≥ 20 / ≥ 20 | inconclusive |
| Weakest-axis holdout rate excitation, and RMS angular acceleration | ≥ 0.15 rad/s, and ≥ 0.5 rad/s² | inconclusive |
| Holdout angular-rate RMSE | ≤ 0.05 rad/s | fail |
| Signed controls: ±2° about x/y/z and ±10 ms; each must raise holdout RMSE by ≥ 0.005 rad/s and ≥ 1.2× | detection ≥ 0.80 | fail |
| Training isolation declared, time shift declared, and a digest-bound fitting input | required for pass |
warn |
The evidence resolves errors about as large as the control magnitudes. A candidate that is wrong by less than 2° or 10 ms can still pass, so tighten the control magnitudes before the run if a program needs finer resolution.
Lever arm (T_cam_imu translation). The translation is checked against the
accelerometer when the recording also carries camera positions
(position_m, same world frame as the orientations) and imu_accel
specific-force samples. The result is reported in the evidence's lever_arm
block. Without that data the block reads not_evaluated and the overall status
is unchanged.
The IMU trajectory the candidate implies is
p_imu = p_cam + R_world_cam t_cam_imu. Over three camera samples
t - w, t, t + w, its second divided difference is the IMU acceleration averaged
with a triangular kernel. The accelerometer gives the same average independently:
a = R_world_imu (f - b_a) + g, integrated against that kernel. A constant
accelerometer bias b_a and the gravity vector g are fitted on training windows
only. The camera's world frame need not be level, because g is fitted in it.
Comparing positions with doubly integrated acceleration, instead of
differentiating positions twice, keeps millimetre-level pose noise from swamping
the signal.
| Gate | Default | Consequence |
|---|---|---|
| Camera positions and accelerometer samples present | required | not_evaluated |
Train/holdout windows (w = 0.25 s, same split and guard as above) |
≥ 20 / ≥ 20 | inconclusive |
| Weakest-axis lever-arm excitation (smallest singular value of the rotation's divided difference) | ≥ 0.5 s⁻² | inconclusive |
| Holdout specific-force RMSE | ≤ 0.3 m/s² | fail |
| A ±5 cm control on x/y/z that lowers the holdout RMSE by ≥ 0.02 m/s² (a translation 5 cm away fits better) | none allowed | fail |
| Each ±5 cm control raises holdout RMSE by ≥ 0.02 m/s² and ≥ 1.2× | detection ≥ 0.80 | inconclusive (the recording cannot test the translation) |
A failing lever arm makes the whole evidence fail. An inconclusive one adds a
warning, so a pass on rotation and time becomes warn.
calibrex external-run synthesize-camera-imu-fixture --output-dir <dir> writes a
nonphysical Kalibr camchain with separate fitting and evaluation recordings. The
recordings carry camera positions and a biased, noisy accelerometer in a tilted
world frame. Options produce wrong or unobservable candidates:
--rotation-error-deg X Y Zand--timeshift-error-secfor rotation and time errors;--translation-error-m X Y Zfor a lever-arm error;--motion yaw_onlyfor unobservable motion;--no-accelerometerto leave the translation unevaluated.
In the regression tests:
- The true candidate passes with all eight rotation/time controls and all six lever-arm controls detected. The train-only fit recovers gravity (9.81 m/s²) and the bias.
- 3° rotation errors and ±20 ms time errors fail.
- ±5 cm lever-arm errors on each axis fail.
- Yaw-only motion is inconclusive, and reusing the fitting recording is blocked.
These results prove the external-run contract with a Kalibr producer. They make no accuracy claim about Kalibr.
A public Kalibr run. UZH-FPV publishes the Kalibr camchain of its Snapdragon
forward camera, fitted on the indoor calibration recording. Evaluated on the
separately captured outdoor recording (poses from
tools/aprilgrid_camera_imu_recording.py), the published translation passes:
- holdout specific-force RMSE 0.102 m/s²;
- 6/6 lever-arm controls detected;
- every ±5 cm perturbation of it fails.
Rotation and time are not admissible on that recording. Frame-to-frame AprilGrid
poses make the camera rates too noisy for the 0.05 rad/s budget (holdout RMSE
0.171 rad/s). Details, license boundary and commands:
examples/public_datasets/uzh_fpv_kalibr_camera_imu.
LI-Init result import¶
LI-Init (hku-mars/LiDAR_IMU_Init) is GPL-2.0, so it runs only in the container
built from tools/external/li_init/Dockerfile (pinned commit 66b157a).
tools/external/li_init/convert_livox_ros2_to_ros1.py streams a Livox ROS 2
recording into the ROS 1 CustomMsg bag that LI-Init reads, and
run_li_init.sh replays it. calibrex.importers.li_init.import_li_init_result
parses the Initialization_result.txt that LI-Init writes and returns a
slac.external_calibration_run/v0.1 artifact:
- The refinement section wins over the initialization section. An import
without a refinement section carries a warning, and an empty file is
invalid_output. - LI-Init's
offset_R_L_I/offset_T_L_Iare recorded asT_imu_lidar, and their inverse asT_lidar_imu. - Its "Time Lag IMU to LiDAR" is recorded as
imu_minus_lidar_s(t_imu = t_lidar + lag, the same sign as Calibrex'sdt). - LI-Init's own metrics stay non-comparable. The MID360 comparison scores the imported rotation and clock offset on held-out data instead.
License and provenance gate¶
Release-ready runs pin tool version, source commit, SPDX identifier, adapter
version, input/output SHA-256 digests, frame/time conventions, execution mode,
and training-data isolation. Missing fields remain schema-valid but produce a
WARN provenance metric. Digest mismatch makes the external-run artifact
failed.
All allowed execution modes are out-of-process execution or result import. There is no in-process external-code mode. GPL tools therefore remain separate executables or containers and are never copied into the Calibrex core.
Frozen Camera-LiDAR baseline readiness audit¶
Before spending a frozen evaluation split, audit the Koide and UniCalib boundaries against the exact Camera-LiDAR problem and benchmark protocol:
calibrex camera-lidar audit-external-baselines \
problem.yaml six-dof-protocol.yaml \
--output-dir external-baseline-audit \
--koide-source-commit <40-hex-commit> \
--unicalib-source-commit <40-hex-commit>
The command verifies the problem/protocol digest and frame coverage, pins both
official repositories and their MIT licenses, and emits two
slac.external_calibration_run/v0.1 records. It intentionally reports
not_executed and exits with status 2: a repository identity is not a
benchmark result. A comparable result still requires a pinned command or
container, input/checkpoint lineage, a digest-bound transform, and independent
Calibrex evaluation under the same frozen protocol. Upstream objective names
or reported runtime values are retained as non-comparable metadata and cannot
open the release gate.
KITTI-family probabilistic correspondences¶
Learned correspondence providers for KITTI raw and KITTI-360 can export one
.npz per selected capture frame and a small YAML/JSON manifest. The manifest
is validated with camera_lidar_correspondence_export.schema.json and converted
to the provider-neutral probabilistic_correspondence artifact:
calibrex camera-lidar build-probabilistic-correspondence \
/path/to/kitti-correspondence-export.yaml \
--output /path/to/correspondence.yaml
Each NPZ must contain point_lidar_m (N×3) and image_mean_px (N×2);
image_covariance_px2 (N×4), outlier_probability (N),
reliability (N), and correspondence_id are optional and receive explicit
deterministic defaults when omitted. The manifest records the KITTI-family
sequence, dataset SPDX declaration, camera model/intrinsics, provider commit,
provider license, checkpoint lineage, and SHA-256 plus byte size for every
export. The resulting artifact repeats the provider identity and records the
manifest/export digests in its provenance.
The provider must use the same dataset_id, frame IDs, camera/LiDAR frame names,
and camera model as the corresponding build-kitti-problem or
build-kitti360-problem output. KITTI-360 fisheye exports declare the official
MEI parameters and radial-tangential coefficients; the core applies the
declared projection but does not import the KITTI-360 or provider runtime.
Development confidence calibrations from alternative provider/adaptor versions can be compared without opening an evaluation sequence:
calibrex camera-lidar compare-probabilistic-provider-support \
confidence-provider-a.yaml confidence-provider-b.yaml \
--output provider-support-comparison.yaml
Every input must be a geometric v0.2 confidence calibration for the exact same
development problem, threshold grid, split seeds, support gates, and excluded
evaluation datasets. The output ranks worst-split and frame-level geometric
support first, then uses non-geometric frame/accepted support to break exact
geometry ties. It also records Pareto dominance, but a diagnostic leader is
not automatically usable at runtime. If every source calibration is rejected,
the comparison also returns rejected, writes selected_candidate_id: null,
and exits with status 2.
I2PNet development adapter¶
tools/run_i2pnet_correspondence_provider.py keeps the I2PNet repository,
checkpoint, PyTorch, CUDA, and native extensions outside src/calibrex. The
adapter pins the official repository commit, MIT license, checkpoint digest,
adapter digest, optional checkpoint-archive digest, portability-patch digest,
camera/LiDAR input digests, and complete command. It requires a separate
schema-valid initial TransformResult whose role is initial; an input with
execution_mode: dataset_reference is rejected, and the Camera--LiDAR problem
or reference transform is not an accepted argument.
On Windows, use the default --neighbor-backend torch. It reproduces I2PNet's
local-grid KNN selection, including cylindrical width wrapping and nearest
padding, while avoiding the unstable fused-neighbor execution path. The
digest-pinned patch in
tools/provider_patches/i2pnet_windows_int64_indices.patch only changes the
external extension's Windows index ABI from long to int64_t; no I2PNet or
GPL source is copied into the Calibrex package. The external PointNet2
extension remains required by the official model.
The optional --pose-output writes a typed aggregate TransformResult using
the official convention
T_camera_lidar_output = T_correction @ T_camera_lidar_initial. Per-frame
translations are averaged, rotations use a quaternion eigenvector mean, and
the quality standard deviations are explicitly labeled frame-to-frame spread,
not calibrated model uncertainty. The coarse cost-volume correspondence export
is diagnostic: its manifest records a rejection warning whenever mean
reliability is below the fixed 0.01 development gate. A rejected diagnostic
artifact must not be passed to PnP or refinement merely because it is
schema-valid.
tools/run_i2pnet_pose_benchmark.py executes a
slac.camera_lidar_pose_initializer_protocol/v0.1 matrix without passing the
Camera--LiDAR problem or its reference transform to the provider subprocess.
The runner derives one explicit initial transform per frozen perturbation,
retains provider failures, verifies the returned manifest's dataset, frame,
adapter, checkpoint, backend, and initial-transform bindings, and emits the
generic schema-valid benchmark definition and aggregate benchmark artifacts.
Evaluation protocols are locked unless the caller explicitly supplies
--allow-evaluation after provider and threshold selection are frozen.
The first fixed development matrix uses three KITTI raw 0018 frames disjoint
from the two-frame exploratory diagnostic and four left-camera-frame SE(3)
perturbations. I2PNet completes 4/4 trials but reaches 0/4 strict hits at
<0.5 deg and <0.2 m. Mean rotation error changes from 8.1046 to
6.7043 deg; mean translation error changes from 0.40395 to 0.37994 m.
This is a completed provider execution, not a successful initializer gate.
The available independent KITTI-360 development/evaluation material uses the
image_03 MEI fisheye stream, which is incompatible with this rectified
pinhole KITTI-large checkpoint, so no evaluation partition was unlocked.
tools/analyze_i2pnet_pose_benchmark.py performs the next read-only diagnostic
step. It verifies the protocol/problem/definition lineage, every generated
initial/manifest/pose digest, and every frame NPZ digest before comparing
T_reference @ inverse(T_initial) with
T_output @ inverse(T_initial). The resulting
slac.camera_lidar_pose_initializer_failure_analysis/v0.1 artifact records
per-frame pose errors, signed correction gain along the required direction,
orthogonal correction leakage, and explicitly non-causal findings. It does not
retune a provider, threshold, or perturbation.