Skip to content

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 Z and --timeshift-error-sec for rotation and time errors;
  • --translation-error-m X Y Z for a lever-arm error;
  • --motion yaw_only for unobservable motion;
  • --no-accelerometer to 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_I are recorded as T_imu_lidar, and their inverse as T_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's dt).
  • 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.