Public Datasets¶
Calibrex examples should prefer public datasets over synthetic-only workflows. Large datasets are not committed to the repository. Instead, Calibrex keeps small manifests, configs, and source metadata.
List known public datasets:
calibrex public-datasets list
calibrex public-datasets show tum_rgbd_freiburg1_xyz
RGB-D / Open3D SLAC:
python3 tools/download_public_dataset.py tum_rgbd_freiburg1_xyz --output-dir data/public
calibrex calibrate examples/public_datasets/tum_rgbd_freiburg1_xyz/config.yaml
Multi-plane LiDAR-camera calibration on ACFR's public real VLP-16 target poses:
python3 tools/download_public_dataset.py acfr_vlp_plane_poses --output-dir data/public
calibrex calibrate examples/public_datasets/acfr_vlp_plane_poses/config.yaml
calibrex verify outputs/acfr_vlp_plane_poses/bundle.json
The run evaluates two independent native baselines on the same capture split: Zhang-Pless plane-only alignment and a Verma-style board-centre/normal robust Procrustes solver. The latter reports centre/normal/offset holdout closure, a six-DoF joint spectrum, and twelve known-bad controls. On the pinned real VLP-16 poses its gates PASS (1.17 cm centre RMSE, 1.68° normal RMSE, 12/12 controls), while the 1.76 cm / 0.219° inter-method transform delta remains an ungated WARN diagnostic. The upstream ROS/PCL feature extractor stays outside the core and its pinned commit, Apache-2.0 license, input digest, and source URL are recorded as provenance.
Seven native hand-eye baselines plus Zhuang-Roth-Sudhakar, Shah, Li-Wang-Wu, and the closed-form and nonlinear Dornaika-Horaud robot-world/hand-eye estimators on ETHZ ASL's real robot-arm pose streams:
python3 tools/download_public_dataset.py ethz_hand_eye_robot_arm_real \
--output-dir data/public
calibrex calibrate \
examples/public_datasets/ethz_hand_eye_robot_arm_real/config.yaml
calibrex verify outputs/ethz_hand_eye_robot_arm_real/bundle.json
The comparison runs Park-Martin, Tsai-Lenz, Daniilidis, Horaud-Dornaika
closed-form/nonlinear, Chou-Kamel, and the incremental Andreff-Horaud-Espiau
method with one disjoint relative-motion protocol. Zhuang-Roth-Sudhakar, Shah,
Li-Wang-Wu, and both Dornaika-Horaud methods solve absolute-pose AX=YB
(Z in the latter papers)
for both transforms on one common leakage-safe split. The Zhuang implementation
reports its paper-specific a0/z0 failure margins; Dornaika-Horaud reports
quaternion double-cover sign synchronization and the nonlinear estimator's full
24-column local spectrum. Closure and all minimum-width gates pass on the public
sample, while the default known-bad gate remains INCONCLUSIVE; the documented
negative result is not converted into a PASS by threshold tuning.
Solid-state LiDAR-to-LiDAR evidence demo:
The Livox workflow now carries a schema-validated solid-state profile through the result. See solid-state LiDAR calibration for point-time/integration semantics, temperature declarations, distance/FOV coverage, and the independent-holdout limitation of this two-PCD sample.
calibrex demo livox-evidence --output-dir outputs/livox_horizon_horizon_pcd_sample
The demo command downloads the public Livox PCD sample when needed, writes a
materialized demo_config.yaml, recomputes result.yaml, renders
evidence.json, protocol.json, transforms.json, assessment.json, and
policy.json, and verifies bundle.json.
The same flow can be run step by step:
python3 tools/download_public_dataset.py livox_horizon_horizon_pcd_sample --output-dir data/public
python3 tools/generate_calibration_evidence_gif.py --readme-gallery
calibrex public-datasets show livox_horizon_horizon_pcd_sample --json
calibrex inspect data/public/livox_horizon_horizon_pair --type livox-pcd --json
calibrex calibrate examples/public_datasets/livox_horizon_horizon_pcd_sample/config.yaml
calibrex evidence outputs/livox_horizon_horizon_pcd_sample/result.yaml \
--output outputs/livox_horizon_horizon_pcd_sample/evidence.json
calibrex render examples/public_datasets/livox_horizon_horizon_pcd_sample/cached_evidence_result.yaml \
--output-dir outputs/livox_horizon_horizon_pcd_sample
calibrex validate outputs/livox_horizon_horizon_pcd_sample/evidence.json --kind report-evidence
calibrex validate outputs/livox_horizon_horizon_pcd_sample/assessment.json --kind assessment
calibrex validate outputs/livox_horizon_horizon_pcd_sample/protocol.json --kind protocol
calibrex validate outputs/livox_horizon_horizon_pcd_sample/transforms.json --kind transforms
calibrex validate outputs/livox_horizon_horizon_pcd_sample/policy.json --kind policy
calibrex assess outputs/livox_horizon_horizon_pcd_sample/evidence.json \
--policy outputs/livox_horizon_horizon_pcd_sample/policy.json \
--output outputs/livox_horizon_horizon_pcd_sample/reassessment.json
calibrex assess outputs/livox_horizon_horizon_pcd_sample/evidence.json \
--policy outputs/livox_horizon_horizon_pcd_sample/policy.json \
--enforce
calibrex verify outputs/livox_horizon_horizon_pcd_sample/bundle.json \
--output outputs/livox_horizon_horizon_pcd_sample/verification.json
TIERS LidarsCali ROS 1 bag (Livox Horizon + Avia)¶
The TIERS LidarsCali sequence records a Livox Horizon
(/livox/lidar) and a Livox Avia (/avia/livox/lidar) in one ROS 1 bag.
Calibrex reads the bag directly with a pure-Python rosbag v2.0 parser
(slac.data.rosbag1); no ROS installation is required. Decoding
sensor_msgs/PointCloud2 payloads to arrays requires numpy
(pip install "Calibrex[rosbag1]"); bags with lz4 chunk compression
additionally need pip install "Calibrex[rosbag1-lz4]" (none and bz2
compression work out of the box).
The bag is multiple gigabytes, so Calibrex never downloads it automatically. Fetch it manually from the upstream dataset:
- Open the TIERS dataset repository:
https://github.com/TIERS/tiers-lidars-dataset and follow its download
table to the
LidarsCalisequence (hosted on the University of Turku SharePoint; the direct link is recorded inexamples/public_datasets/tiers_livox_lidars_cali/manifest.yamlunderprovenance.download_url). - Save the bag as
data/public/tiers_lidars_cali/LidarsCali.bag(the path declared in the example config).
Continuous-time VLP-16 ↔ Livox Horizon quickstart¶
The timed path profiles the target extrinsic and a scalar clock offset jointly
against fixed supplied VRPN odometry. It accepts both ROS 1 and ROS 2 through
the same ROS-independent solver; this TIERS example uses the ROS 1 bag and
Livox offset_time values. Run the inspection before the solver so missing
point-time or odometry coverage is visible before a long replay:
python -m pip install -e ".[rosbag1-lz4]"
calibrex public-datasets show tiers_livox_lidars_cali --json
calibrex inspect data/public/tiers_lidars_cali/LidarsCali.bag \
--type rosbag1 --json
calibrex continuous-time-lidar-pair \
examples/public_datasets/tiers_livox_lidars_cali/online_continuous_time_config.yaml \
--output outputs/tiers_livox_lidars_cali_continuous_time/continuous_time_lidar_pair.yaml \
--json
calibrex validate \
outputs/tiers_livox_lidars_cali_continuous_time/continuous_time_lidar_pair.yaml \
--kind continuous-time-lidar-pair
The output is a schema-valid continuous-time artifact rather than a claim of
absolute clock truth. Its provenance.source_sha256 binds the config and the
full bag; iterations records every candidate probe and its train/holdout
RMSE; and time_offset_sign_convention defines the positive direction. The
current checked run selected +40 ms, but the public sequence has no
independent clock reference, so treat that value as an algorithmic estimate
under fixed odometry and the declared temporal holdout.
Then inspect and run the LiDAR-pair evidence config:
calibrex public-datasets show tiers_livox_lidars_cali --json
calibrex inspect data/public/tiers_lidars_cali/LidarsCali.bag --type rosbag1 --json
calibrex calibrate examples/public_datasets/tiers_livox_lidars_cali/config.yaml
The offline config uses the native ROS-independent point-to-plane adapter. It
reserves later target capture windows for holdout and records whether the
source map was cut at the holdout time boundary. With
require_independent_holdout: true, too few target windows or an unverifiable
Livox/ROS clock relationship is an inconclusive evidence outcome, not a
successful calibration claim.
In the downloaded TIERS bag, Livox timebase values are in a since-boot-like
domain while ROS bag records are in the recording epoch. The ROS 1 adapter
therefore uses the bag record timestamp as the replay clock and applies
capture_ns = bag_record_timestamp_ns + offset_time_ns; it does not substitute
the Livox timebase with a message header stamp. Results record
point_time_clock_mapping_by_sensor, including the residual and stable/unstable
decision. The current TIERS run measured approximately 1 ms residuals and
classified both Livox mappings as stable.
For the point-time ablation, materialize signed timing controls and both deskew modes from the online mixed-sensor config:
python tools/run_livox_time_ablation.py \
examples/public_datasets/tiers_livox_lidars_cali/online_mixed_config.yaml \
--output-dir outputs/tiers_livox_time_ablation \
--include-no-deskew
calibrex validate outputs/tiers_livox_time_ablation/ablation_manifest.yaml \
--kind livox-time-ablation
The manifest binds every materialized config/result by SHA-256. A nonzero
inject_time_offset_s is a validation-only known-bad timing control; it is not
reported as a recovered clock estimate. The deskew_off variants still
decode and report offset_time, but do not consume it for pose interpolation.
Camera-LiDAR measured capture-time evidence¶
The same bag contains RealSense color images, Velodyne VLP-16 PointCloud2
messages with a measured time field, and a VRPN pose stream. Run the native,
ROS-independent capture-time adapter with:
calibrex calibrate \
examples/public_datasets/tiers_livox_lidars_cali/camera_lidar_capture_time_config.yaml
calibrex validate outputs/tiers_camera_lidar_capture_time/result.yaml --kind result
calibrex verify outputs/tiers_camera_lidar_capture_time/bundle.json
This example deliberately does not estimate an extrinsic or clock offset. The
bag has no declared camera-to-LiDAR point identities, and the constant transform
between the VRPN UWBTest rigid body and Velodyne is unpublished. It instead
tests whether measured per-point firing times can be applied at real camera
exposures and whether the sequence makes a scalar clock perturbation observable.
| Evidence | Train | Holdout | Verdict |
|---|---|---|---|
| Paired captures / points | 18 / 2,304 | 5 / 640 | disjoint split |
| Mean point-time span | 100.82 ms | 100.81 ms | expected VLP-16 scan duration |
| Mean camera/LiDAR stamp delta | 11.52 ms | 8.48 ms | within 60 ms pairing gate |
| Deskew displacement RMS | 0.91 mm | 1.90 mm | diagnostic only |
| Time sensitivity | 0.01562 m/s | shared train diagnostic | rank 0; below 0.05 m/s gate |
| Signed ±5/10/20 ms controls | — | 0 / 6 detected | below 1 mm margin |
The result is INCONCLUSIVE, not PASS: the firing-time data are present and
authenticated, but this short sequence has insufficient motion excitation for
the declared timing gate. camera_lidar_time_offset_estimated=0 records the
separate correspondence limitation. Bundle verification recomputes the full
7.18 GB bag SHA-256 and reports zero issues.
Online/streaming Horizon-to-Avia calibration replays the same bag without loading
it into memory. Early Horizon messages build the fixed source voxel map; Avia
messages stream in time order as target batches. The bounded replay window and
message budgets are declared in the example config factor options and recorded in
result.yaml provenance (rosbag1_* keys).
pip install -e ".[dev,rosbag1-lz4]"
calibrex calibrate examples/public_datasets/tiers_livox_lidars_cali/online_config.yaml \
--online \
--batch-size 500 \
--accumulation-batches 3 \
--max-accumulated-train-points 8000
outputs/tiers_livox_lidars_cali_online/result.yaml and timeline.json capture
per-batch gate status, batch-only vs accumulated observability rank, holdout RMSE,
and rolling RMSE. Gate thresholds are declared under
pipeline.factors.lidar_rig_point_to_plane.options (online_gate_* keys) and
recorded in result.yaml provenance. The example config uses a 60 s Avia replay
slice (max_replay_duration_s) so a multi-gigabyte bag stays stream-bounded.
Livox point-time and deskew ablation¶
Livox CustomMsg carries a timebase for the first point and an integer
nanosecond offset_time for each point. The online adapter decodes and records
both. With use_point_time_offsets: true it uses those offsets for pose
interpolation when /vrpn_client_node/UWBTest/pose is available; with the flag
false it still records the offsets but deliberately uses message-level timing.
Signed inject_time_offset_s values are validation-only perturbations.
Materialize and run the comparison set with provenance and SHA-256 digests for every variant:
python tools/run_livox_time_ablation.py \
examples/public_datasets/tiers_livox_lidars_cali/online_config.yaml \
--output-dir outputs/tiers_livox_time_ablation \
--include-no-deskew
ablation_manifest.yaml compares deskew on/off at zero shift and the requested
signed timing controls. The default tool set is 0, ±5, ±10, and ±20 ms; a
bounded replay can request only --offset-s 0 -0.005 as in the checked
TIERS evidence bundle. A shifted case is a negative control and must not be
reported as an estimated clock offset. Check each result's
point_time_reference_by_sensor, clock-domain note, and
point_time_clock_mapping_by_sensor and
rosbag1_source_map_temporal_boundary_applied before interpreting it. The
tool reuses a completed result when its config SHA still matches, so a long
bag experiment can be resumed without silently changing completed variants.
When report.html is generated for an online run, an Online timeline
section visualizes the same timeline.json data: a per-batch gate verdict strip
(pass / fail / inconclusive), inline SVG charts of holdout and rolling RMSE with
threshold lines, a per-batch observability table (batch-only vs accumulated rank,
condition number, retention), and a session summary (batch counts, final gate
status, final estimate, thresholds). Offline runs omit this section.
On a bounded replay of the same bag (12 source messages / 8k source points, 36
target messages / 54k target points, 60 s budget,
--batch-size 500 --accumulation-batches 3 --max-accumulated-train-points 8000),
the Horizon→Avia run produced 108 target batches (500 points each) from 36 Avia
messages. All 108 batches passed the gates with full rank 6, per-batch holdout
RMSE between 0.05 m and 0.23 m (mean ~0.14 m), and final rolling RMSE near
0.14 m.
Checked TIERS LidarsCali.bag result (2026-08-02)¶
The full 14-variant check (deskew on/off × 0, ±5, ±10, and ±20 ms validation
shift) completed and is recorded in
ablation_manifest.yaml.
Every variant passed the final online calibration gate and detected all known
bad controls (known_bad_detectable_fraction: 1.0). The independent holdout
RMSE range was 0.15778–0.15857 m and the final rolling RMSE range was
0.17377–0.17444 m. At zero shift, deskew on/off was 0.158479/0.173897 m vs
0.158435/0.174256 m (holdout/rolling), so this sequence does not support a
deskew benefit claim: the difference is below 1 mm and is smaller than the
trajectory-quality uncertainty.
The Livox point-time mapping was stable for all variants, with a maximum
clock-mapping residual of 1.089 ms and a decoded point-time span of 99.905 ms.
The result quality grade remains fail because the recorded trajectory exceeds
the current kinematic gate (3.383 m/s maximum linear speed and
2897.130 deg/s maximum angular speed) and no cross-segment source scans were
available. The solid-state coverage was 10.20 m in distance, 81.7° in azimuth,
and 28.1° in elevation; only one of four distance bins was supported, and the
bag has no integration-window or temperature profile. These are evidence gaps,
not reasons to relax the trajectory gate.
AgRob Modular-e ROS 2 smoke check¶
The 2023-06-14T174658Z sequence from the public
AgRob Modular-e dataset was downloaded
and checksum-verified (f8e52a9ee09f4013773f56893fc67b8b). It contains 3855
Livox messages over 386.15 s and 7539 /odom messages, but /livox/lidar is
sensor_msgs/msg/PointCloud2 with only x, y, z, intensity, tag, and
line fields. No per-point time field is present. Its odometry also contains
large outliers (maximum linear speed 98.69 m/s), so this sequence is retained
as an adapter smoke fixture only and is not used for the point-time or deskew
calibration evidence.
The larger AgRob sequences expose the ROS 2 Livox topic as
livox_interfaces/msg/CustomMsg alongside /rslidar_points. Calibrex's
ROS-independent rosbag2 adapter decodes that message directly: it preserves
timebase, offset_time, reflectivity, tag, and line, and maps the Livox clock
to the rosbag2 record clock for online deskew. A ROS installation or vendor
driver is not required. Configure the Livox sensor with
point_time_field: offset_time; leave the RS-LiDAR PointCloud2 sensor's time
field unset unless its fields provide one.
The reproducible paired-sequence entry point is
agrob_modular_e/manifest.yaml
and
agrob_modular_e/online_config.yaml.
The manifest pins the 2023-06-13T170852Z.zip archive (2,548,542,803 bytes,
MD5 fa0cea9ea4aafd5e7a0d75676e8e064d), which contains 2455 Livox
CustomMsg messages, 2470 /rslidar_points messages, and 4932 /odom
messages according to the Zenodo record. The downloader resumes exact byte
ranges and promotes the archive only after the MD5 check succeeds:
python tools/download_public_dataset.py agrob_modular_e \
--agrob-workers 256 --agrob-chunk-kib 256
calibrex validate examples/public_datasets/agrob_modular_e/manifest.yaml --kind dataset-manifest
calibrex calibrate examples/public_datasets/agrob_modular_e/online_config.yaml --online
The parallel options are useful when the public Zenodo endpoint throttles each
individual Range response. They write a size-shaped, but explicitly
unverified, sidecar plus a resumable state ledger; only the final MD5 check
creates 2023-06-13T170852Z.zip. A partial file must never be treated as a
valid bag or extracted.
This is the first public paired run in the project that exercises the ROS 2
Livox CustomMsg adapter against an independent RS-LiDAR stream. A successful
solver exit is not by itself an accuracy claim: retain the generated result's
holdout, known-bad, trajectory-quality, point-time mapping, and provenance
gates when interpreting it.
For the checked three-window joint replay, the standalone
outputs/agrob_modular_e_livox_rslidar_online_joint/trajectory_window_drift.yaml
artifact reports full-span RMSE 0.349 m (FAIL) and local RMSEs 0.361 m (FAIL),
0.288 m (PASS), and 0.330 m (FAIL). Its
local_inconsistency_suspected interpretation means the joint failure cannot
be attributed to long-span accumulation alone; it remains a ground-truth-free
diagnostic rather than an absolute extrinsic-accuracy claim.
The deskew-off control is preserved in
outputs/agrob_modular_e_livox_rslidar_online_joint/trajectory_window_drift_deskew_off.yaml:
full-span RMSE rises to 0.358 m and the three windows become 0.364/0.310/0.352
m (all FAIL). The odometry stream has 4932 raw and retained poses with zero
sub-millisecond burst removals, so preserve, keep_first, and keep_last
are equivalent for this recording. Window 0/2 also have much less rotational
excitation than window 1 (endpoint rotation 6.1°/1.8° versus 170.5°), which
is a stronger next suspect than burst preprocessing alone.
AIST GLIM versatile Livox trajectory fixture¶
The official GLIM cross-sensor evaluation dataset
provides a smaller, reproducible Livox Avia fixture. The downloaded
livox.bag is 613,350,674 bytes with MD5
fb17a6d4e0bc1765e276f0dcca8a5063, and gt/livox.txt is the accompanying
trajectory file. calibrex inspect --type rosbag1 found 618 messages each on
/livox/lidar (livox_ros_driver/CustomMsg) and /livox/points
(sensor_msgs/PointCloud2); both expose point time over approximately 99.85 ms
per message. The bag span is 61.693 s, while the 618 GT poses span 61.700 s.
The GT trajectory has a maximum linear speed of 0.749 m/s and maximum angular
speed of 56.6 deg/s under the current kinematic policy, so it is suitable for
ROS 1 CustomMsg/PointCloud2 decoding, point-time mapping, and deskew replay
regression. It is not a LiDAR-to-LiDAR absolute-extrinsic reference: this small
fixture supplies one Livox sensor and a trajectory, not a second calibrated
LiDAR. The raw bag remains an external input under data/public/glim_versatile/.
The checked stream contract is
glim_versatile/manifest.yaml
and validates with calibrex validate --kind dataset-manifest.
The ROS 1 PointCloud2 header carries the relative Livox clock (about 110 s),
while bag records use epoch time. The reproducible conversion therefore
restamps /livox/points with the measured median record/header offset and
maps the external GT with the same explicit offset. This is a clock-domain
normalization, not a recovered time-offset estimate:
python tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/glim_versatile/livox.bag \
--dst data/public/glim_versatile/livox_rosbag2_gt_restamped \
--topic /livox/points \
--odom-source external-trajectory \
--trajectory-file data/public/glim_versatile/gt/livox.txt \
--trajectory-time-offset-s 1655977530.260883 \
--restamp-topic /livox/points \
--duplicate-topic /livox/points:/livox/points_copy
The duplicate topic is an identity self-consistency control. The checked
zero-shift ablation is recorded in
outputs/glim_versatile_livox_time_ablation/ablation_manifest.yaml:
| Mode | Holdout RMSE (m) | Final rolling RMSE (m) | Final gate | Trajectory gate |
|---|---|---|---|---|
| deskew on | 0.164552 | 0.161424 | pass | pass |
| deskew off | 0.153536 | 0.149489 | pass | pass |
Both variants detect the declared known-bad controls (1.0), but the off-mode
is slightly better on this identity control. This does not establish a deskew
benefit or absolute extrinsic accuracy; it establishes that the external-GT
adapter, clock normalization, PointCloud2 t decoding, and trajectory gates
are executable on a public solid-state fixture.
Spinning × solid-state (Velodyne VLP-16 → Livox Horizon)¶
The same bag also carries spinning-LiDAR sensor_msgs/PointCloud2 topics
(/velodyne_points, /os_cloud_node/points, /os_cloud_nodee/points) beside
the Livox CustomMsg streams. calibrex inspect --type rosbag1 reports message
counts and sampled decoded point counts for every LiDAR topic without loading the
full bag.
Online mixed-pair calibration uses the Velodyne as the fixed 360° source map and streams Livox Horizon target batches—the same source/target split recommended for heterogeneous pairs where the denser scanner should anchor the voxel map:
calibrex calibrate examples/public_datasets/tiers_livox_lidars_cali/online_mixed_config.yaml \
--online \
--batch-size 500 \
--accumulation-batches 3 \
--max-accumulated-train-points 8000
The mixed config seeds frames.livox_horizon.transform.initial from the TIERS
dataset README GICP extrinsics (velo_sensor and hori_frame relative to
base_link in section 5.4); it is not hand-tuned to the online estimate. The
rig baseline is small (~0.30 m translation magnitude in that nominal seed).
On the same bounded replay budgets as the Horizon→Avia run above, a Velodyne→Horizon
replay produced 108 Horizon batches (500 points each) over ~3.6 s of bag time
(36 target messages consumed). All 108 batches passed (online_gate_min_rank: 6,
online_gate_max_holdout_rmse_m: 0.40, online_gate_max_rolling_regression_m:
0.15). Batch-only and accumulated observability stayed at rank 6 with condition
number ~8–14 and no weak DoF warnings. Per-batch holdout RMSE ranged 0.09–0.23 m
(mean ~0.16 m); rolling RMSE settled near 0.16 m. Gate thresholds match the
Horizon→Avia example because measured residuals sit in the same band—there was no
reason to relax them for the heterogeneous pair.
Compared with solid-state→solid-state (Horizon→Avia), the spinning×solid-state pair
shows slightly higher and more variable batch holdout RMSE (ring-sampled Velodyne
planes vs non-repetitive Horizon stripes) but still passes the same gates. FOV
overlap is sufficient in the static room sequence: the Velodyne 360° map covers
structure that reappears in forward-facing Horizon scans. Final estimate
T_velodyne_vlp16_livox_horizon (translation ~[0.12, 0.11, −0.16] m) sits about
0.16 m from the TIERS nominal GICP seed — a shift on the order of the measured
holdout residuals, so the two references agree only to that residual level rather
than confirming each other tightly. It is recorded in
outputs/tiers_livox_lidars_cali_online_mixed/result.yaml with full rosbag replay
provenance.
The mixed offline config follows the same bounded native adapter protocol, with
Velodyne as the source map and Horizon as the target. Its PointCloud2 time
field is recorded as a spinning-LiDAR control; it is not substituted for
Livox offset_time. KITTI/A2D2 and Indoor02 remain comparison controls: they
test spinning-LiDAR geometry, public-data reproducibility, and temporal
holdout machinery, but cannot establish solid-state temperature or internal
beam-calibration accuracy.
calibrex inspect --type rosbag1 lists every supported PointCloud2 or Livox
CustomMsg topic with message counts, and samples a few messages per topic to
report decoded point counts, intensity presence, spatial bounds, range,
azimuth/elevation FOV, point-time availability/range, and first/last ROS
timestamps (normalized to integer nanoseconds). Other message types are
ignored by design; the reader remains ROS-independent and point-cloud scoped.
ROS 2 bag (rosbag2) reader¶
Calibrex reads rosbag2 recordings directly with a pure-Python parser
(slac.data.rosbag2); no ROS installation is required. Supported storage
backends are sqlite3 (.db3, the default through Humble) and mcap
(.mcap, the default from Iron). A bag directory with metadata.yaml or a
bare .db3 / .mcap file path both work.
Decoding sensor_msgs/msg/PointCloud2 and nav_msgs/msg/Odometry payloads
requires numpy (pip install "Calibrex[rosbag2]"). MCAP bags with chunked
lz4 or zstd compression additionally need
pip install "Calibrex[rosbag2-compression]" (none / uncompressed chunks
work out of the box).
calibrex inspect path/to/bag --type rosbag2 --json
calibrex inspect --type rosbag2 reports per-topic message counts, decoded
frame_id, sampled point counts/bounds, Livox offset_time availability and
timebase range, plus an all-message Odometry motion audit (time span, path
length, linear/angular speed p95/max, duplicate timestamps, and the default replay
kinematic gate). This separates a valid message decode from a trustworthy
motion-compensation premise.
For ROS 2 online calibration, the replay applies a deterministic temporal
boundary: source-map messages at or after the final 20% target-capture boundary
are excluded from the source map, while the target stream retains per-batch
point holdouts. The result records the boundary timestamp, train/holdout window
counts, and whether the clock-domain comparison was actually applied. Livox
CustomMsg is also included in the independent first-half/second-half
trajectory drift evidence; no ROS vendor driver is needed.
For a joint replay over several time ranges, replace the legacy single-window options with a factor option such as:
capture_windows:
- start_timestamp_ns: 1686676331234969730
end_timestamp_ns: 1686676371234969729
- start_timestamp_ns: 1686676371234969730
end_timestamp_ns: 1686676411234969729
capture_window_sampling_policy: evenly_spaced
capture_windows is an inclusive union. With evenly_spaced, source and
target records are selected per window using decoded LiDAR capture timestamps;
the legacy max_source_messages, max_target_messages, and source-point cap
are applied per window. The result provenance records each boundary and the
adopted source/target message and point counts. The default first policy
preserves the historical single-window behavior. Set max_replay_duration_s
to null when windows span more than that duration.
Moving platform (TIERS Indoor02, rosbag2 + odometry)¶
The TIERS Indoor02 sequence records a Velodyne VLP-16 (/velodyne_points),
an Ouster OS1 (/os_cloud_nodee/points, note the upstream typo), and a VRPN
MOCAP geometry_msgs/PoseStamped stream (/vrpn_client_node/UWBTest/pose) on
a moving office platform (~42 s). The ROS 1 bag is multiple gigabytes; fetch it
manually from the upstream dataset:
- Open the TIERS dataset repository:
https://github.com/TIERS/tiers-lidars-dataset and follow its download table
to the
Indoor02sequence (direct link recorded inexamples/public_datasets/tiers_lidars_dataset_indoor02/manifest.yamlunderprovenance.download_url). - Save the bag as
data/public/tiers_lidars_dataset/indoor02.bag.
The repository helper performs the public SharePoint cookie handshake and resumable Range download automatically:
python tools/download_public_dataset.py tiers_lidars_dataset_indoor02 \
--output-dir data/public
Reproduction audit (2026-08-02): a cookie-preserving public SharePoint session
retrieved the raw bag to an external data volume (the 17,974,826,130-byte file
is intentionally not committed). Its first-MiB SHA-256 matches the manifest:
164c2f8dcfd42463ad4981ba25a97668f015ced776db2781fc1e4211fd8fd574.
calibrex inspect --type rosbag1 then reported 420 Velodyne messages, 423
Ouster messages, and Livox/other LiDAR streams. The pure-Python ROS 1 reader
also had to be made tolerant of the official bag's padding between top-level
records; the regression test and the raw-bag replay now cover that path.
The generated Indoor02 results below are therefore a fresh, schema-validated
run from the public archive. The archive itself remains an external input.
Convert the ROS 1 bag to rosbag2 with synthesized odometry (the pose trajectory
is real MOCAP; only the nav_msgs/msg/Odometry envelope is authored). The
upstream ROS 1 bag mixes clock domains: /velodyne_points and the VRPN pose use
the recording epoch, while /os_cloud_nodee/points stamps with the Ouster
since-boot clock (~753 s). Add --restamp-topic /os_cloud_nodee/points so the
converter shifts each Ouster header stamp by the per-topic median
bag_receive_time − header_stamp (measured ≈ 1.645×10⁹ s on Indoor02), moving
it into the recording clock domain while preserving the sensor's own relative
timing. The constant offset includes mean transport/assembly latency, so
restamped absolute stamps carry a bias of that order; residual error is roughly
latency × platform speed.
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2 \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--pose-topic /vrpn_client_node/UWBTest/pose \
--odom-source pose-topic \
--odom-topic /odom \
--child-frame-id base_link \
--storage sqlite3 \
--compress none
Rig-frame odometry from KISS-ICP on the source Velodyne (no external body
alignment; poses are T_world_velo_sensor with T_base_source = identity):
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2_kissicp \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--odom-source kiss-icp \
--kiss-icp-topic /velodyne_points \
--kiss-icp-max-range 30.0 \
--child-frame-id base_link \
--storage sqlite3 \
--compress none
An MCAP variant with per-message zstd compression exercises the message-mode decompression path on real independently-encoded CDR:
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2_mcap \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--pose-topic /vrpn_client_node/UWBTest/pose \
--odom-source pose-topic \
--odom-topic /odom \
--child-frame-id base_link \
--storage mcap \
--compress zstd
Inspect the converted bags (topics, counts, odometry pose sanity):
calibrex inspect data/public/tiers_lidars_dataset/indoor02_rosbag2 --type rosbag2 --json
calibrex inspect data/public/tiers_lidars_dataset/indoor02_rosbag2_kissicp --type rosbag2 --json
calibrex inspect data/public/tiers_lidars_dataset/indoor02_rosbag2_mcap --type rosbag2 --json
Online motion-compensated calibration with MOCAP odometry (A), the static-rig control (B), and the optional KISS-ICP rig-frame odometry (C):
pip install -e ".[dev,rosbag1-lz4,rosbag2,rosbag2-compression]"
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_motion_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_static_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_kissicp_config.yaml --online
On the fresh bounded replay (12 Velodyne source messages / 8k source points,
36 Ouster target messages / 1.5k target points, 60 s budget), the MOCAP run (A)
and static control (B) each produced 108 target batches. Before restamping,
Ouster header stamps were in a different clock domain from /odom; the
converter's measured per-topic offset was 1645462364594977536 ns. After
restamping, A had zero odometry clamping and zero extrapolation.
The recomputed evidence is:
| Run | Odometry | Batches adopted | Holdout RMSE (m) | Final rolling RMSE (m) | Trajectory/evidence verdict |
|---|---|---|---|---|---|
| A motion | MOCAP /odom |
108 / 108 | 0.319675 | 0.281411 | kinematic gate fail; max 240.387 m/s and 115546.218 deg/s |
| B static | none | 107 / 108 | 0.302346 | 0.286878 | trajectory skipped; static evidence warn |
Both runs detected all six declared known-bad extrinsic perturbations (6/6),
and both final online gates passed. The result, timeline, trajectory (A),
assessment, and evidence bundle validate against their schemas; calibrex
verify reports 10/10 artifacts and zero failed claims for each run. The
artifacts are under outputs/tiers_lidars_dataset_indoor02_online_motion/ and
outputs/tiers_lidars_dataset_indoor02_online_static/.
The trajectory failure in A is intentional evidence, not a relaxed threshold: the VRPN pose stream contains kinematic outliers under the current 3 m/s and 120 deg/s policy. A's LiDAR holdout and interpolation numbers are retained, but A is not an absolute-accuracy certification. B is a useful static control, not ground truth. The TIERS README GICP seed (inter-sensor distance about 0.37 m) remains a nominal reference rather than an independently verified truth; the current estimates differ from that seed by about 2.853 m / 45.4° (A) and 0.862 m / 17.6° (B). These gaps prevent claiming absolute extrinsic accuracy.
Run C (KISS-ICP rig-frame odometry) remains a reproducible optional experiment via the command above, but its numeric result is not included in this audit until that conversion and bundle are rerun from the same raw archive.
Indoor02 pose-burst and frame-semantics audit¶
The official TIERS OptiTrack CSV is available at
https://github.com/TIERS/tiers-lidars-dataset/blob/main/data/ground_truth/indoor02_optitrack.csv.
An audit of that file and the converted /odom stream found 854 consecutive
pose intervals shorter than 1 ms. The burst is present in the official source
trajectory itself; it is not introduced by the rosbag2 conversion. The raw
track remains the input of record.
Two schema-validated controls make the handling explicit:
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_motion_pose_burst_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_motion_baseframe_config.yaml --online
Both set dataset.odometry_preprocessing.burst_policy: keep_last and
min_interval_s: 0.001. This retains the latest pose in each sub-millisecond
burst, without modifying the bag. The result provenance records 5,065 raw
poses, 4,211 retained poses, and 854 removed poses.
| Run | Frame graph | Batches adopted | Final rolling RMSE (m) | Linear max (m/s) | Angular max (deg/s) | Trajectory verdict |
|---|---|---|---|---|---|---|
| A raw baseline | Velodyne root | 108 / 108 | 0.281411 | 240.387 | 115546.218 | FAIL |
| A1 pose-burst control | Velodyne root | 108 / 108 | 0.309474 | 1.745 | 600.717 | FAIL |
| A2 pose-burst + explicit base frame | base_link → velodyne → ouster |
106 / 108 | 0.285919 | 1.745 | 600.717 | FAIL |
The burst policy removes the artificial linear-speed failure, but the official
orientation stream still exceeds the declared 120 deg/s angular gate. No
orientation smoothing or threshold relaxation is applied, so A1/A2 are
improved reproducible analyses rather than absolute-accuracy certification.
The A2 output keeps the documented fixed T_base_link_velodyne_vlp16 edge and
estimates T_velodyne_vlp16_ouster_os1; the online pair selector is covered by
a regression test for chained non-root LiDAR frames.
The corrected A2 bundle is under
outputs/tiers_lidars_dataset_indoor02_online_motion_baseframe_pose_burst/.
It has a passing final online gate, a passing cross-segment RMSE of 0.2973 m,
and a failing overall quality grade solely because of the angular kinematic
gate. Verify it with:
calibrex verify outputs/tiers_lidars_dataset_indoor02_online_motion_baseframe_pose_burst/bundle.json
calibrex validate outputs/tiers_lidars_dataset_indoor02_online_motion_baseframe_pose_burst/trajectory.json --kind trajectory
This validation established that the public archive exercised three real-input
reader paths that synthetic mirror-image tests did not: ROS 1 top-level record
padding between chunks, CDR encapsulation endianness keyed off the wrong header
byte, and message-mode zstd compression declared in metadata.yaml being
ignored. With those fixes, calibrex inspect decodes the raw bag and the
regenerated sqlite3/MCAP bags with standard CDR headers and per-message
decompression.
| Run | Odometry | Batches adopted | Holdout RMSE (m) | Final Δ vs TIERS seed |
|---|---|---|---|---|
| A motion | MOCAP /odom |
108 / 108 | 0.319675 | 2.853 m, 45.4°; kinematic gate fail |
| B static | none | 107 / 108 | 0.302346 | 0.862 m, 17.6°; warn |
| C kiss-icp | KISS-ICP /odom |
not rerun in this audit | — | optional, no current claim |
The identity/KISS-ICP and deskew subsections below are historical optional evidence records from earlier Indoor02 conversions. They are retained as reproduction targets, but their numeric claims are not part of the fresh 2026-08-02 A/B audit above because those KISS-ICP bundles were not regenerated in this run. Re-run the commands and verify the resulting bundles before using any of those numbers as current release evidence.
Identity self-consistency control¶
Duplicate the source Velodyne on the kiss-icp rosbag2 variant so ground truth
is exactly identity: calibrate /velodyne_points (root) vs
/velodyne_points_copy with an identity initial transform. Same factor
options and gates as the A/B/C runs above (voxel 0.5 m, correspondence gate
1.5 m, 12 source messages / 8k source points, 36 target messages / 1.5k target
points, holdout 0.2, prior_sigma 0.3 m / 15°).
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2_selftest \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--odom-source kiss-icp \
--kiss-icp-topic /velodyne_points \
--kiss-icp-max-range 30.0 \
--child-frame-id base_link \
--storage sqlite3 \
--compress none \
--duplicate-topic /velodyne_points:/velodyne_points_copy
calibrex inspect data/public/tiers_lidars_dataset/indoor02_rosbag2_selftest --type rosbag2 --json
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_motion_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_static_config.yaml --online
Two additional odometry modes improve the synthesized /odom track at bag
conversion time (before calibration). Both use the Velodyne per-point time
field (float32 seconds, ~99% negative offsets, span ≈ 0.10 s on Indoor02).
Native deskew (--kiss-icp-native-deskew with --odom-source kiss-icp):
single-pass KISS-ICP with cfg.data.deskew = True and per-point timestamps
normalized to [0, 1] within each scan before register_frame.
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2_selftest_kissdeskew \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--odom-source kiss-icp \
--kiss-icp-topic /velodyne_points \
--kiss-icp-max-range 30.0 \
--kiss-icp-native-deskew \
--kiss-icp-time-field time \
--child-frame-id base_link \
--storage sqlite3 \
--compress none \
--duplicate-topic /velodyne_points:/velodyne_points_copy
Two-pass (--odom-source kiss-icp-two-pass): pass 1 runs KISS-ICP on raw
scans; each scan is rigidified to its message stamp with the pass-1 track
(p_rigid = T(t_msg)⁻¹ T(t_msg + dt_i) p_i, pose interpolation with clamping
at track ends); pass 2 runs a fresh KISS-ICP instance on the deskewed scans.
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2_selftest_twopass \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--odom-source kiss-icp-two-pass \
--kiss-icp-topic /velodyne_points \
--kiss-icp-max-range 30.0 \
--kiss-icp-time-field time \
--child-frame-id base_link \
--storage sqlite3 \
--compress none \
--duplicate-topic /velodyne_points:/velodyne_points_copy
Example configs for the new bags:
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_kissdeskew_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_twopass_config.yaml --online
On the bounded replay budget above, motion-compensated selftest (KISS-ICP
/odom) adopted all 104 batches with zero odometry clamping and zero
extrapolation and recovered identity to ~9.6 cm / ~1.2°. The static control
(no odometry) also adopted all 104 batches and passed every internal gate —
yet its final estimate landed ~98 cm / ~37° from the known identity truth.
Motion compensation cut the extrinsic error roughly 10× versus the static
run; the residual ~10 cm / ~1.2° was attributed to no per-point deskew and
LiDAR-odometry drift on a moving platform.
Per-point deskew is now available for rosbag2 online runs via
sensors.<name>.point_time_field (for example time on Velodyne VLP-16,
t on Ouster OS1). When dataset.odometry_topic is set, capture time is
header_stamp + offset with offsets decoded as float seconds or integer
nanoseconds from the named PointCloud2 field. Replay provenance records
deskew_applied, deskew_time_field, and deskew_span_s (max − min offset
seen). Without odometry, point_time_field is ignored
(deskew_ignored_no_odometry: true).
On the Velodyne selftest bag, per-point time offsets are ~99% negative
(span ≈ 0.10 s, consistent with a VLP-16 sweep where the header stamp sits
near the end of rotation). A deskew replay with point_time_field: time on
both Velodyne sensors adopted 104 / 104 batches and landed ~10.5 cm / ~1.5°
from identity — not a meaningful improvement over the ~9.5 cm / ~1.2°
per-message motion-compensated baseline on this control. The modest regression
is consistent with KISS-ICP odometry drift dominating the residual once
intra-scan timing is corrected.
| Run | Odometry | Deskew | Batches adopted | Final Δ vs identity (truth) |
|---|---|---|---|---|
| Selftest motion | KISS-ICP /odom |
no | 104 / 104 | ~9.5 cm, ~1.2° |
| Selftest deskew | KISS-ICP /odom |
velodyne time |
104 / 104 | ~10.5 cm, ~1.5° |
| Selftest static | none | — | 104 / 104 | ~98 cm, ~37° |
| Ground truth | — | — | — | 0 cm, 0° |
Odometry deskew variants (bag conversion, identity selftest): on the same bounded replay budget, all three motion-compensated variants adopted 104 / 104 batches. Native-deskew KISS-ICP odometry tightened translation recovery meaningfully versus the v0.2 baseline (~4.3 cm vs ~9.5 cm) at the cost of a modest rotation increase (~2.0° vs ~1.2°). Two-pass odometry also beat the baseline on translation (~6.2 cm) but did not improve rotation and degraded cross-segment trajectory consistency (RMSE 0.366 m, trajectory verdict FAIL). Native deskew wins on identity translation recovery (~5 cm below baseline); two-pass is intermediate on translation but worse on trajectory evidence.
| Variant | Odometry mode | Batches adopted | Final Δ vs identity | Holdout RMSE (m) | Cross-segment RMSE (m) | Trajectory verdict |
|---|---|---|---|---|---|---|
| Baseline | kiss-icp raw | 104 / 104 | 9.54 cm, 1.17° | 0.059 – 0.184 | 0.143 (PASS) | PASS |
| Native deskew | kiss-icp + --kiss-icp-native-deskew |
104 / 104 | 4.34 cm, 1.96° | 0.062 – 0.165 | 0.160 (PASS) | PASS |
| Two-pass | kiss-icp-two-pass | 104 / 104 | 6.16 cm, 2.01° | 0.060 – 0.176 | 0.366 (FAIL) | FAIL |
KISS-ICP rig-frame Velodyne→Ouster run C with deskew (time + t) adopted
106 / 108 batches and landed ~76 cm / ~28° from the TIERS GICP seed versus
~85 cm / ~28° without deskew — a modest translation improvement with rotation
unchanged, still well above the holdout residual band and not confirmation of
the nominal seed.
| Run | Odometry | Deskew | Batches adopted | Final Δ vs TIERS seed |
|---|---|---|---|---|
| C kiss-icp | KISS-ICP /odom |
no | 106 / 108 | ~85 cm, ~28° |
| C kiss-icp deskew | KISS-ICP /odom |
time + t |
106 / 108 | ~76 cm, ~28° |
Example configs:
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_deskew_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_kissicp_deskew_config.yaml --online
This is the first genuine absolute-accuracy validation on real moving-platform data with exact ground truth: motion compensation reduces extrinsic error from ~0.98 m / ~37° to ~9.6 cm / ~1.2°. The honest negative finding is that the static run passed all internal gates (holdout RMSE 0.40 m, rolling regression, rank 6) while being ~1 m / ~37° wrong — on this scene the online gates measure internal consistency of a self-consistently wrong map, not absolute accuracy. Per-point deskew is implemented but did not materially tighten the identity selftest residual on this sequence when applied only during online replay; remaining error was attributed primarily to LiDAR-odometry drift once intra-scan timing is handled. Native-deskew odometry at bag conversion does materially reduce translation drift on the same control (~4.3 cm vs ~9.5 cm baseline).
Reconciling the earlier A/B/C discussion: the Velodyne→Ouster ~85 cm residual in run C is now attributable primarily to cross-sensor effects (Ouster stamp semantics and restamp bias, LiDAR-odometry drift, nominal GICP seed), not a failure of the motion-compensation pipeline itself. Deskew narrows the C translation gap modestly (~9 cm) but does not establish absolute accuracy against the TIERS reference.
Temporal evidence (time-offset probes and estimator)¶
After a motion-compensated rosbag2 online run completes, optional post-run temporal evidence re-evaluates holdout point-to-plane RMSE while shifting target capture timestamps. This is evaluation-only — it never re-solves the extrinsic.
Sign convention: positive delta_t_s evaluates
T_world_source(t_capture + delta_t_s); positive offset means target capture
timestamps are treated as lagging the odometry clock.
Factor options under lidar_rig_point_to_plane:
| Option | Default | Role |
|---|---|---|
time_offset_probe_s |
[] |
Declared probe offsets (empty = probes off) |
time_offset_probe_min_rmse_increase |
0.10 |
Relative RMSE increase for a probe to count as detected |
online_gate_min_time_offset_probe_detection_ratio |
1.0 |
Fraction of declared probes that must be detected |
estimate_time_offset |
false |
Run 1D holdout-RMSE search (±time_offset_search_bound_s, ~1 ms resolution) |
time_offset_search_bound_s |
0.20 |
Search half-width in seconds |
online_gate_max_abs_time_offset_s |
0.05 |
Pass when |δt̂| ≤ gate; fail when above with significant RMSE improvement |
time_offset_anchor |
final |
final = evaluate at the adopted online estimate (v0.2); initial = hold extrinsic at the configured initial transform (Pillar 2 anchored mode) |
inject_time_offset_s |
0.0 |
Validation-only: shift target capture timestamps at load time (requires dataset.odometry_topic; recorded loudly in provenance) |
When temporal options are set, evidence is computed in both modes every run:
adapted (final adopted extrinsic) and anchored (configured initial). Top-level
metrics and gate verdicts follow time_offset_anchor (final by default).
run.provenance.temporal_evidence always records adapted and anchored
sub-blocks, time_offset_anchor, anchored_transform, optional
time_offset_injected_s, and a separability joint-observability verdict.
Separability verdict (online_temporal_separability, pass for
separable/consistent, warn for degenerate):
| Verdict | Condition |
|---|---|
separable |
Anchored curve localizes δt while adapted curve is flat or has absorbed the offset (anchored |δt̂| ≫ adapted |δt̂| ≈ 0) — v0.2 degeneracy signature reversed |
degenerate |
Both holdout-RMSE curves are flat (motion cannot excite δt) |
consistent |
Both curves localize a minimum |
Reports also emit a temporal evidence-summary row:
Extrinsic/Temporal Separability (metric ids include
online_temporal_separability).
When temporal options are set but dataset.odometry_topic is unset, provenance
records temporal_evidence_skipped_no_odometry: true and no temporal verdict is
emitted. Non-zero inject_time_offset_s without odometry raises ConfigError.
Example configs and commands:
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_kissicp_temporal_anchored_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_motion_temporal_anchored_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_motion_temporal_anchored_injected_config.yaml --online
On the bounded Indoor02 replay budget (12 source messages / 8k source points, 36
target messages / 1.5k target points, 60 s, probes [0.05, -0.05, 0.10],
estimate_time_offset: true, gate 0.05 s, time_offset_anchor: initial):
Pillar 2 acceptance read (honest): Anchored mode restores more probe power than v0.2 adapted-only runs on the injected selftest (+0.10 s probe detects; δt̂ moves toward the injected bias), but ±0.05 s probes still miss on real Indoor02 and the anchored estimator does not yet meet the ±10 ms recovery target on injection. Cross-sensor KISS-ICP anchored curves remain below the 10 % flatness margin (separability degenerate); δt̂ ≈ −140 ms is in the Ouster restamp bias order of magnitude but estimate gate stays INCONCLUSIVE. The machinery, dual-mode provenance, and separability row are in place; full Pillar 2 acceptance on real Indoor02 is not yet met.
| Run | Anchor δt̂ | Anchor flatness | Anchor probes | Adapted δt̂ | Adapted flatness | Adapted probes | Separability |
|---|---|---|---|---|---|---|---|
| Selftest clean (anchored) | −25 ms | 0.31 | 0 / 3 | −71 ms | 0.34 | 1 / 3 | consistent |
| Selftest injected +50 ms | −75 ms | 0.44 | 1 / 3 | −44 ms | 0.32 | 1 / 3 | consistent |
| KISS-ICP cross-sensor (anchored) | −140 ms | 0.032 | 0 / 3 | −18 ms | 0.012 | 0 / 3 | degenerate |
The differential response is the strongest signal in this table: injecting
+50 ms moved the anchored estimate from −25 ms to −75 ms — a shift of exactly
−50 ms. The anchored estimator therefore tracks injected offsets precisely on
real data; it is the absolute baseline (−25 ms on a shared-clock selftest)
that stays offset. Interpretation (not a measurement): VLP-16 per-point
time offsets on this bag span ≈ [−0.10, 0] s with the header stamp at sweep
end, so the effective mean capture time sits ≈ 50 ms before the stamp; a
residual-minimizing δt̂ between 0 and −50 ms is consistent with that scan-time
semantics rather than with estimator error. By contrast the adapted-mode
estimate did not track the injection (−71 ms → −44 ms, a +27 ms shift against
a −50 ms truth change) — the adapted extrinsic partially absorbs the offset,
which is exactly the degeneracy the anchor mode was built to expose.
v0.2 adapted-only reference (same replay budget, time_offset_anchor: final):
| Run | δt̂ (adapted) | Curve flatness | Probe detection | Temporal verdict |
|---|---|---|---|---|
| KISS-ICP temporal | −18 ms | 0.012 | 0 / 3 | FAIL (probe) |
| Selftest temporal | −71 ms | 0.34 | 1 / 3 | FAIL (probe) |
Synthetic moving-rig fixtures (unit tests) recover injected biases to ±5 ms in anchored mode with FREE solver while adapted δt̂ ≈ 0 (separability separable); zero-offset and static-odometry controls assert consistent and degenerate respectively.
Trajectory evidence (motion-compensated online runs)¶
When dataset.odometry_topic is set, motion-compensated online runs emit a
schema-validated trajectory.json artifact (slac.trajectory/v0.1) beside
timeline.json. The artifact records subsampled T_world_base poses (default
budget 500 samples, evenly spaced in time), odometry source metadata, frame
semantics, interpolation health (interpolation_count, clamp_count,
max_extrapolation_s), and a quality block with graded metrics and recorded
gate thresholds. Provenance registers trajectory_path; runs without odometry
set trajectory_evidence_skipped_no_odometry: true.
Three ground-truth-free metric families feed trajectory_gate_verdict (worst of
the three: pass < inconclusive < fail):
| Option | Default | Gate |
|---|---|---|
trajectory_gate_max_speed_mps |
3.0 |
Kinematic: max single-step linear speed (m/s) from consecutive odometry poses |
trajectory_gate_max_angular_speed_dps |
120.0 |
Kinematic: max single-step angular speed (deg/s) |
trajectory_gate_max_clamp_fraction |
0.05 |
Interpolation: clamp_count / interpolation_count |
trajectory_gate_max_cross_segment_rmse_m |
0.30 |
Cross-segment drift proxy: evaluation-only stream of the source PointCloud2 topic over the full odometry track span (independent of calibration replay budgets). The track midpoint splits two ~21 s windows; up to 40 scans per half are chosen with evenly_spaced_time sampling, up to 4000 world-frame points per half after per-scan subsampling. First-half points build a voxel plane map; second-half points are scored point-to-plane with identity extrinsic. Provenance and trajectory.json record per-half scan/point counts, time windows, correspondence count, RMSE, and cross_segment_sampling_policy. |
Fewer than 10 odometry samples → kinematic INCONCLUSIVE; fewer than 50 cross-segment correspondences → cross-segment INCONCLUSIVE.
Before starting a solid-state LiDAR calibration, run the capture-readiness
wizard. It checks window-local motion excitation, voxel-plane normal diversity,
and a six-DoF point-to-plane observability proxy, then emits a concise
PROCEED, REVIEW, or RECAPTURE recommendation:
calibrex calibration-readiness \
examples/public_datasets/agrob_modular_e/online_joint_config.yaml \
--output outputs/agrob_modular_e_livox_rslidar_online_joint/capture_readiness.yaml
calibrex validate \
outputs/agrob_modular_e_livox_rslidar_online_joint/capture_readiness.yaml
For the AgRob Modular-e three-window replay, the readiness artifact reports
window 0: RECAPTURE (6.1° endpoint rotation), window 1: PROCEED
(170.5°), and window 2: RECAPTURE (1.8°). All three windows have full
local geometry evidence in this recording (normal rank 3, estimated observable
DoF 6, and 3567–3713 proxy correspondences), so the actionable problem is
motion excitation rather than missing planes. The artifact is
slac.capture_readiness/v0.1 and includes the config/bag digests, thresholds,
deskew policy, and recapture instructions.
When the readiness result is usable, the continuous-time LiDAR-pair baseline can profile a scalar clock offset while re-optimizing the extrinsic at every candidate offset:
calibrex continuous-time-lidar-pair \
examples/public_datasets/agrob_modular_e/online_joint_config.yaml \
--output outputs/agrob_modular_e_livox_rslidar_continuous_time_ablation_hybrid_support6_iter10/adaptive_mad/continuous_time_lidar_pair.yaml
This artifact uses the latest target capture frame group as holdout and records
the sign convention, offset profile, train/holdout RMSE, local rank, robust
rejection count, map strategy, and input digests. The checked AgRob run uses a
range-adaptive voxel map plus a robust uniform fallback, deterministic MAD
rejection, and one correspondence-refinement pass. It uses caps of 6,000
source records and 6,000 target points per split, a six-point minimum adaptive
voxel support, and ten clock-profile iterations; it converged at +37.5 ms,
with train/holdout RMSE 0.2507/0.2246 m, rank 6, and 491 rejected
correspondences. These are ground-truth-free fit diagnostics, not an absolute
accuracy claim. It is a fixed-odometry continuous-time refinement baseline:
the piecewise-SE(3) trajectory is interpolated but its knots are not estimated.
That boundary is explicit so it is not confused with iKalibr-style joint
trajectory estimation. Set continuous_time_voxel_strategy: uniform and
continuous_time_outlier_policy: none for a declared baseline comparison.
To materialize the fair public-data comparison in one user-facing command, run the uniform/no-rejection baseline and the adaptive/MAD variant with identical capture windows, temporal holdout, and solver budgets:
python tools/run_continuous_time_lidar_ablation.py \
examples/public_datasets/agrob_modular_e/online_joint_config.yaml \
--output-dir outputs/agrob_modular_e_livox_rslidar_continuous_time_ablation_hybrid_support6_iter10
calibrex validate \
outputs/agrob_modular_e_livox_rslidar_continuous_time_ablation_hybrid_support6_iter10/ablation_manifest.yaml \
--kind continuous-time-lidar-ablation
The checked manifest records both config/result SHA-256 digests. On a later
rerun, add --reuse-only to verify the materialized evidence without
recomputing the solver. On this AgRob recording, adaptive_mad converged at
+37.5 ms with 0.2507/0.2246 m train/holdout RMSE and 491 rejected
correspondences; uniform_none converged at +10 ms with
0.3113/0.2907 m and no deterministic rejection. The adaptive holdout RMSE
is 22.7% lower on this recording. This is a ground-truth-free ablation result,
not evidence that either policy is universally superior.
Cross-dataset solid-state benchmark¶
The same comparison can be aggregated across the locally available public recordings. The declaration labels whether each dataset is a real sensor pair, an identity control, or only a trajectory reference; the output does not turn temporal holdout into absolute extrinsic ground truth.
calibrex validate examples/public_datasets/solid_state_cross_dataset_benchmark.yaml \
--kind solid-state-cross-dataset-benchmark-config
python tools/run_solid_state_benchmark_replicates.py \
examples/public_datasets/solid_state_cross_dataset_benchmark.yaml \
--output-root outputs/solid_state_benchmark_v02_replicates \
--output-spec examples/public_datasets/solid_state_cross_dataset_benchmark_v02.yaml \
--split-id middle_holdout --split-id late_holdout \
--seed 0 --seed 17
python tools/run_solid_state_benchmark_replicates.py \
examples/public_datasets/solid_state_cross_dataset_benchmark.yaml \
--output-root outputs/solid_state_benchmark_v03_replicates \
--output-spec examples/public_datasets/solid_state_cross_dataset_benchmark_v03.yaml \
--append-spec examples/public_datasets/solid_state_cross_dataset_benchmark_v02.yaml \
--split-id early_holdout --split-id middle_holdout --split-id late_holdout \
--seed 0 --seed 17 --seed 42
python tools/run_solid_state_cross_dataset_benchmark.py \
examples/public_datasets/solid_state_cross_dataset_benchmark_v03.yaml \
--output outputs/solid_state_cross_dataset_benchmark_v03.yaml \
--markdown-output outputs/solid_state_cross_dataset_benchmark_v03.md \
--html-output outputs/solid_state_cross_dataset_benchmark_v03.html
calibrex validate outputs/solid_state_cross_dataset_benchmark_v03.yaml \
--kind solid-state-cross-dataset-benchmark
The checked v0.3 run uses nine paired replicates per dataset (three temporal holdout boundaries × three sampling seeds). Lower holdout RMSE is better:
| Dataset | Replicates | Adaptive win rate | Mean improvement (95% CI) | Winner | Reference label |
|---|---|---|---|---|---|
| AgRob Modular-e | 9 | 0.556 | -1.34% (-10.16, 6.68) | mixed (5/9) | real pair, trajectory-only |
| TIERS LidarsCali | 9 | 1.00 | 78.76% (76.00, 81.73) | adaptive | real pair, trajectory-only |
| AIST GLIM | 9 | 1.00 | 65.67% (64.18, 66.95) | adaptive | identity control |
Across 27 scored replicates, adaptive won 23/27 (win rate 0.852), with mean
improvement 47.70% and bootstrap 95% CI [34.43, 59.99]%. AgRob is a useful
counterexample: its adaptive result is not stable across the tested splits and
seeds. TIERS and GLIM still expose max_iterations in some artifacts, so the
failure classification remains visible even when the holdout comparison is
scored. These results are temporal-holdout evidence, not a universal SOTA
claim; independently surveyed extrinsic ground truth and unseen public
sequences remain the next validation gate. The compact checked report is
solid-state-cross-dataset-benchmark-v03.md.
To inspect the AgRob counterexample without tuning on its holdout, generate a digest-bound diagnostic artifact:
python tools/run_solid_state_failure_analysis.py \
outputs/solid_state_cross_dataset_benchmark_v03.yaml \
--dataset-id agrob_modular_e \
--output outputs/agrob_solid_state_failure_analysis_v03.yaml \
--markdown-output outputs/agrob_solid_state_failure_analysis_v03.md \
--html-output outputs/agrob_solid_state_failure_analysis_v03.html
calibrex validate outputs/agrob_solid_state_failure_analysis_v03.yaml \
--kind solid-state-failure-analysis
The current analysis identifies four uniform-winning AgRob replicates where adaptive has lower train RMSE but higher holdout RMSE, and records sampling-seed and clock-profile variation as follow-up hypotheses. It also confirms that all 18 variants are rank 6 and converged, so the counterexample is not explained by a simple rank or termination failure. Residual histograms and per-iteration correspondence counts are not yet present in the v0.1 result artifact; the findings are therefore explicitly labelled candidate causes.
As a controlled semantic ablation, --adaptive-no-uniform-fallback was also
run over the same 12 split/seed replicates. It was not adopted: AgRob mean
improvement changed from -3.63% to -41.72%, and the GLIM identity control
changed from +65.17% to -82.22%. The current hybrid fallback remains the
declared default while the next investigation targets adaptive plane support
and clock-profile selection.
For a multi-window capture, recompute the same map/odometry evidence per window without rerunning the extrinsic solver:
calibrex trajectory-window-drift \
examples/public_datasets/agrob_modular_e/online_joint_config.yaml \
--reference-result outputs/agrob_modular_e_livox_rslidar_online_joint/result.yaml \
--output outputs/agrob_modular_e_livox_rslidar_online_joint/trajectory_window_drift.yaml
calibrex validate \
outputs/agrob_modular_e_livox_rslidar_online_joint/trajectory_window_drift.yaml
The slac.trajectory_window_drift/v0.1 artifact records odometry coverage and
motion for every configured window, its local cross-segment RMSE and
correspondence count, the full-span comparison, calculation parameters, and
input digests. Use --deskew on|off and
--odometry-burst-policy preserve|keep_first|keep_last for controlled
diagnostic variants; the selected values are recorded in the artifact.
local_inconsistency_suspected means at least one window fails;
long_span_inconsistency_suspected is reserved for a full-span-only failure.
Both are ground-truth-free hypotheses and do not uniquely identify whether
odometry, map geometry, or sensor timing caused the residual.
The cross-segment pass is a separate bounded bag decode (~80 source messages on
Indoor02) and does not reuse the calibration replay's max_source_messages
budget.
Example (same bounded KISS-ICP replay budget as run C above):
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_kissicp_config.yaml --online
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/online_selftest_motion_config.yaml --online
Indoor02 KISS-ICP Velodyne→Ouster: trajectory.json — 420 poses recorded
(42.26 s span, 1 clamp / 39 interpolations, max extrapolation 0.024 s).
Kinematic PASS (p95 linear 0.96 m/s, max 1.07 m/s; p95 angular 34.8 deg/s,
max 50.9 deg/s). Interpolation PASS (clamp fraction 0.026). Cross-segment
PASS — 40 + 40 scans, 4000 + 4000 points (evenly_spaced_time over
21.13 s halves), 3799 correspondences, RMSE 0.143 m (gate 0.30 m). Overall
trajectory verdict PASS. The proxy now spans the full KISS-ICP track and
produces a real RMSE; at voxel size 0.5 m the first-half map and second-half
returns are consistent within the 0.30 m gate. That does not contradict the
documented KISS-ICP drift narrative for absolute extrinsic accuracy (~85 cm
vs the TIERS seed in run C): cross-segment measures leave-segment-out map
consistency under the estimated odometry frame, not agreement with an external
reference.
Velodyne selftest (duplicate topic): same odometry track and source topic geometry — kinematic PASS, interpolation PASS (zero clamps), cross-segment PASS (3799 correspondences, RMSE 0.143 m). Verdict PASS.
| Run | Kinematic | Interpolation | Cross-segment | Trajectory verdict |
|---|---|---|---|---|
| KISS-ICP | PASS | PASS (clamp 0.026) | PASS (0.143 m, 3799 corr.) | PASS |
| Selftest motion | PASS | PASS (clamp 0) | PASS (0.143 m, 3799 corr.) | PASS |
LiDAR-IMU rotation evidence (ADR 0007 Pillar 3)¶
The Indoor02 source ROS 1 bag carries /os_cloud_nodee/imu (OS1 internal IMU,
~100 Hz, 4231 messages). Convert a kiss-icp replay subset with IMU passthrough
and restamp both Ouster points and IMU (they share the Ouster since-boot clock):
uv run tools/rosbag1_to_rosbag2_online_pair.py \
--src data/public/tiers_lidars_dataset/indoor02.bag \
--dst data/public/tiers_lidars_dataset/indoor02_rosbag2_kissicp_imu \
--topic /velodyne_points \
--topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/points \
--restamp-topic /os_cloud_nodee/imu \
--imu-topic /os_cloud_nodee/imu \
--odom-source kiss-icp \
--kiss-icp-topic /velodyne_points \
--kiss-icp-max-range 30.0 \
--child-frame-id base_link \
--storage sqlite3 \
--compress none
Clock-domain check (measured on the source bag): median
bag_receive_time − header.stamp for /os_cloud_nodee/imu is
1645462364.473 s (p05–p95 span 0.015 s, σ ≈ 0.004 s) — the same
since-boot epoch as /os_cloud_nodee/points (~753 s offset from the recording
clock). Decision: restamp /os_cloud_nodee/imu alongside the points topic.
Evaluate an externally supplied R_velo_imu candidate (not IMU calibration):
calibrex calibrate examples/public_datasets/tiers_lidars_dataset_indoor02/imu_rotation_evidence_config.yaml
Channel semantics
| Channel | Role | Gate |
|---|---|---|
| Rotation-rate consistency (primary) | ω_odo = log(R_i⁻¹ R_{i+1})/Δt and interval-mean ω_imu, each symmetrically low-pass filtered with the same ±N pose-interval centered moving average (imu_rotation_rate_smoothing_intervals, default 2 ≈ 0.5 s at 10 Hz) before holdout RMSE |
imu_gate_max_holdout_rotation_rate_rmse_dps (default 5.0 deg/s) on contiguous-block holdout (every 5th block) |
| Known-bad rotation probes | ±5° / ±10° about roll, pitch, yaw (12 cases); detectable when holdout RMSE increases ≥ 20% relative | fraction ≥ 0.5 pass, ≥ 0.1 warn |
| Gravity support (supporting-only) | 0.5 s low-pass linear acceleration rotated to world vs upward gravity; never fails the decision boundary | warn if angle > 10° |
Approximation (provenance): the evaluated candidate is the documented TIERS
GICP seed R_velo_os1 applied as R_velo_imu. Ouster documents a small lever
arm; the OS1 internal IMU is approximately rotation-aligned with the os1 sensor
frame. This is an honest engineering approximation, not metrology ground truth.
Measured clean run (imu_rotation_evidence_config.yaml, seed quat
[-0.004, 0.021, 0.357, 0.934], symmetric MA ±2 intervals,
method pose_interval_mean_imu_vs_odometry_log_symmetric_ma2):
| Evidence row | Status | Values (threshold) |
|---|---|---|
| Candidate Support | pass | 4231 IMU samples, 419 pose intervals, excitation p95 36.8 deg/s (floor 5); per-axis p95 |ω| x 12.3 y 11.6 z 35.9 deg/s — dominant z (yaw probes weak) |
| Holdout Rotation Consistency | pass | holdout RMSE 3.56 deg/s (gate ≤ 5), mean |Δω| 2.64 deg/s |
| Known-Bad Controls | warn | detectable fraction 2 / 12 (margin 0.20 relative); roll −10° +34%, pitch +10° +40% detected; all yaw probes ≈ 0% (z-dominated excitation) |
| Gravity Support | pass | angle 4.42 deg (warn > 10) |
| Decision Boundary | warn | holdout pass; known-bad fraction below 0.5 pass threshold |
10° probe table (clean baseline RMSE 3.56 deg/s):
| Axis | Angle | Perturbed RMSE | Relative Δ |
|---|---|---|---|
| roll | −10° | 4.77 deg/s | +34% |
| roll | +10° | 4.25 deg/s | +19% |
| pitch | −10° | 3.98 deg/s | +12% |
| pitch | +10° | 5.00 deg/s | +40% |
| yaw | −10° | 3.56 deg/s | ≈ 0% |
| yaw | +10° | 3.56 deg/s | ≈ 0% |
Known-bad control (imu_rotation_evidence_known_bad_config.yaml, seed +10°
yaw → quat [-0.006, 0.020, 0.437, 0.899]):
| Evidence row | Status | Values |
|---|---|---|
| Holdout Rotation Consistency | pass | holdout RMSE 3.56 deg/s (Δ ≈ 0 vs clean — seed yaw error is weakly observable under z-dominated motion) |
| Decision Boundary | warn | same as clean (known-bad seed not separated by holdout RMSE) |
Honest read: Symmetric low-pass filtering of both rate series drops the
differentiation noise floor from ~8 deg/s to ~3.6 deg/s, confirming the
pose-differentiation theory. The GICP seed R_velo_os1 ≈ R_velo_imu now passes
the 5 deg/s holdout gate. Roll/pitch probes regain falsification power; yaw
probes remain uninformative because excitation is z-dominated (p95 |ω_z| ≈ 36
deg/s vs |ω_x|, |ω_y| ≈ 12 deg/s). The +10° yaw known-bad seed is not
detectable — consistent with axis observability, not a metric-power failure.
Online calibration with odometry motion compensation¶
run_online_calibration accepts dataset.type: rosbag2 with the same bounded
replay budgets as rosbag1 (max_source_messages, max_source_points,
max_target_messages, max_target_points, max_replay_duration_s under the
lidar_rig_point_to_plane factor options). Set dataset.odometry_topic to a
nav_msgs/msg/Odometry topic to enable motion compensation during replay.
Formulation with optional per-point deskew (ROS 1/ROS 2, when
sensors.<name>.point_time_field is set and dataset.odometry_topic is
configured):
T_world_base(t)— interpolated odometry pose (linear translation, quaternion slerp on the shortest arc; out-of-range queries clamp to the nearest pose).- Source map points use capture time
t_i = message_stamp + offset_ifor ROS 2 andt_i = bag_record_timestamp + offset_ifor ROS 1 when deskew is active; otherwise the corresponding message/record time. Points transform to the world frame asp_world = T_world_base(t_i) * T_base_source * p_sensorbefore voxel-plane map construction. - Target points keep sensor-frame coordinates and record per-point
T_world_source(t_j_point)when deskew is active, else per-messageT_world_source(t_msg)for correspondence (T_world_source * T_hat_source_target * pagainst world-frame planes). The solver still estimates one constantT_source_target. - When
dataset.odometry_topicis unset, the static-rig path is unchanged (identity frame poses) andpoint_time_fieldis ignored.
Replay provenance records the odometry topic, message count, time coverage,
interpolation method, clamp count, total interpolation count,
odometry_interpolation_max_extrapolation_s, a motion_compensated flag, and
when dataset.odometry_preprocessing is configured, the raw/retained pose
counts, selected burst policy, interval, and removed count. The raw bag is
never rewritten by this preprocessing stage. It also records
when configured deskew_applied, deskew_time_field, deskew_span_s, or
deskew_ignored_no_odometry.
Batches whose target timestamps extrapolate beyond
online_gate_max_odometry_extrapolation_s (default 0.25 s) outside the odometry
track are excluded with gate reason odometry_extrapolation; source messages
beyond the same tolerance raise DatasetError because the map premise is broken
globally. The online timeline artifact (slac.online_timeline/v0.3) records
gate reasons as free-form strings on each batch snapshot (no schema change).
dataset:
type: rosbag2
path: path/to/bag_dir_or.db3
odometry_topic: /odom
sensors:
lidar_map:
type: lidar
topic: /source/lidar
lidar_stream:
type: lidar
topic: /target/lidar
--readme-gallery regenerates the Livox and A2D2 README GIF assets from public
raw samples. It downloads the small A2D2 range sample when needed and refuses to
use built-in fallback geometry unless --allow-metadata-fallback is passed.
The README gallery includes an online-style A2D2 replay that shows live point
batches, a converging extrinsic estimate, rolling residuals, holdout checks, and
provenance-backed policy gates. To render only that hero asset, run:
python3 tools/generate_calibration_evidence_gif.py \
--source a2d2 \
--a2d2-source-id 0 \
--a2d2-target-id 3 \
--visual online \
--output docs/assets/online-calibration-loop.gif
The cached Livox evidence result is a report-rendering fixture, not a claim
that raw observations were reread and recomputed.
calibrex evidence materializes an evidence.json sidecar from an existing
result and does not recompute metrics from raw observations.
Its calibrex render --json payload includes render_only: true,
recomputed_metrics: false, and evidence_case_count; the cached fixture keeps
representative roll/pitch/yaw/x/y/z cases, while raw
calibrex calibrate recomputes the full configured perturbation set when the
public PCD files are available locally.
Generated summary.json and evidence.json include a materialization block
with metrics_origin, data_verified, computed_at, and
report_generated_at. Cached fixtures show metrics_origin: cached and
data_verified: false, and the HTML report displays a cached-evidence banner.
Raw Livox recomputation also writes evidence.json.input_files with the PCD
paths, SHA-256 digests, byte sizes, and known public source URLs.
The built-in falsification policy only passes the raw recomputation gate when
metrics_origin: recomputed, data_verified: true, and SHA-backed
input_files are all present.
Use calibrex verify --require-raw-recomputed bundle.json when that same
requirement should be enforced as an integrity gate instead of only reported as
assessment evidence.
The Livox public demo applies this gate before reporting bundle_valid: true.
It also writes verification.json beside bundle.json so the gate result can
be reviewed without rerunning verify.
Generated report directories also include bundle.json, which records SHA-256
digests for the HTML report and machine-readable sidecars, and
verification.json, which materializes the default bundle integrity check.
protocol.json records the declared evidence protocol. policy.json records
the falsification gates and thresholds, while assessment.json records the
applied policy result. transforms.json records candidate, reference, and
output transform estimate sets as a standalone artifact. Cached or
non-independent evidence can score useful known-bad controls while still
returning INCONCLUSIVE because raw observations were not recomputed or the
holdout split is not independent.
calibrex assess --policy policy.json reapplies the declared policy to an
existing evidence.json, so reviewers can verify that the verdict came from a
versioned artifact rather than hidden CLI defaults. By default, assess
returns success when it writes a valid assessment artifact, even if the
assessment status is FAIL or INCONCLUSIVE; add --enforce when a non-pass
assessment should fail a shell pipeline.
calibrex verify checks those digests and catches stale or mixed report
artifacts. For raw recomputation artifacts with input_files, it also checks
the referenced raw file sizes and SHA-256 digests. The verify JSON includes
input_file_count, checked_input_file_count, and checked_input_files;
cached report fixtures normally report zero input files. It also includes
verification_summary with total, ok, failed, skipped, and per-scope claim
counts, so automated review can fail quickly without parsing every claim.
Verification also checks semantic links between sidecars: protocol.json must
match the protocols embedded in evidence.json, and policy.json must match
the policy and gate fingerprints recorded by assessment.json.
The detailed record remains in
verification_claims, where each claim records a scope, status, method,
expected values, observed values, and scoped issues for artifact digests,
run consistency, assessment source links, and raw input digests.
Without --json, calibrex verify prints the same rollup as a compact
review summary before any issue list.
The saved verification.json is schema-versioned and can be validated or
stored as a review artifact. It records the verified bundle path, SHA-256,
size, schema version, and run id under source_bundle, plus the primary
evidence metrics_origin / data_verified values under
primary_evidence_materialization. You can also pass the saved
verification.json back to calibrex verify; Calibrex recomputes the source
bundle check and reports verification_record claims if the saved record is
stale or edited. Saved verification artifacts use relative source_bundle
paths when the bundle is colocated with, or near, the verification file.
If --require-raw-recomputed is added while checking a saved verification,
Calibrex applies that stricter raw-input gate as an additional claim without
treating the saved default verification record as stale.
Derived sidecars such as summary.json, metrics.json, observability.json,
and degeneracy.json also include source_evidence so reviewers can verify
which immutable evidence.json they summarize.
Use calibrex calibrate or future dataset-backed evaluate flows when metrics
must be recomputed from raw observations.
When comparing two results, calibrex compare reports
protocol_compatibility as compatible, warning, or not_comparable, and
prints left_materialization / right_materialization in non-JSON output.
This keeps cached evidence, recomputed evidence, and different holdout
protocols from being silently ranked as if they were produced under the same
conditions. By default, comparison artifact generation succeeds even when the
protocols are not comparable; add --enforce-compatible when protocol warnings
or non-comparable inputs should fail a shell pipeline.
Autonomous driving:
calibrex inspect examples/public_datasets/kitti_raw_2011_09_26_drive_0005 --type kitti-raw
Calibrex compile examples/public_datasets/kitti_raw_2011_09_26_drive_0005/config.yaml
calibrex inspect data/public/nuscenes --type nuscenes
KITTI raw data and nuScenes require their official download flows and terms. After downloading, point the dataset manifest paths at the local extracted dataset.
KITTI raw camera-LiDAR evaluated projection evidence demo:
calibrex calibrate examples/public_datasets/kitti_lidar_camera_evidence/config.yaml
calibrex demo kitti-lidar-camera-evidence \
--output-dir outputs/kitti_lidar_camera_evidence \
--strict-assessment
Because KITTI raw data requires the official login-gated download flow,
Calibrex cannot fetch it automatically the way it does for the Livox pair
demo. This demo instead defaults to a small synthetic fixture bundled at
examples/public_datasets/kitti_lidar_camera_evidence/ that mirrors the
KITTI raw directory layout, so the command is runnable end to end with no
setup. Point it at a real, locally downloaded KITTI raw sequence with
--dataset-path /path/to/2011_09_26/2011_09_26_drive_0005_sync to evaluate
real data instead. The demo writes a materialized demo_config.yaml,
recomputes result.yaml using the dataset's calib_velo_to_cam.txt extrinsic
as the reference/output transform (unless
evaluation.kitti.use_frame_graph_candidate: true), renders evidence.json,
assessment.json, protocol.json, policy.json, and transforms.json, and
verifies bundle.json.
Before running the full-scale benchmark, lock the prespecified raw inputs:
calibrex kitti lock-benchmark-input \
/path/to/2011_09_26/2011_09_26_drive_0005_sync \
--output outputs/kitti_0005/kitti-benchmark-input.json
calibrex validate outputs/kitti_0005/kitti-benchmark-input.json
The lock uses 20 frame pairs fixed in advance at indices 0:154:8, spanning
the complete drive. It records each selected image and Velodyne file, both
timestamp files, both required calibration files, their byte sizes and
SHA-256 digests, plus a deterministic aggregate input digest. The command
rejects the bundled two-frame synthetic fixture and incomplete official
downloads rather than silently changing the selected frames. Raw KITTI data
is never copied into the output artifact.
Compare the scalar-reference I2I optimizer with the vectorized, safety-gated deterministic coarse-to-fine path on the same locked frames and perturbations:
calibrex kitti benchmark-i2i \
outputs/kitti_0005/kitti-benchmark-input.json \
--definition-output outputs/kitti_0005/i2i-benchmark.definition.json \
--output outputs/kitti_0005/i2i-benchmark.json
calibrex validate outputs/kitti_0005/i2i-benchmark.json
Both methods receive the same six prespecified +0.10 m or +10 deg
single-axis initial errors and the same seeded frame holdout. The resulting
standard benchmark artifacts retain failures, runtime, translation/rotation
error to the KITTI dataset reference, held-out normalized MI, the exact input
digest, executed source digest, and producer/execution provenance. The command
also reports accuracy_non_degraded, runtime_improved, and
performance_gate_passed; non-degradation is checked for every paired trial,
not only for aggregate means. KITTI calibration is treated as a dataset
reference rather than independent metrology.
The demo reports the lidar_camera_projection_*,
lidar_camera_edge_alignment_score, and lidar_camera_perturbation_*
projection evidence metrics described below, plus koide_lidar_camera_*
adapter readiness metrics — the demo does not execute the external Koide-style
adapter, so those metrics reflect availability, not a computed result. This
is an evaluation layer for externally produced camera-LiDAR candidates, not a
standalone camera calibration algorithm.
lidar_camera_baseline_comparison validates a structured LiDAR capture-time
policy and records camera exposure as the reference instant. KITTI .bin
payloads have no per-point firing offsets, so the demo deliberately reports
lidar_camera_capture_time_deskew_applied=0 (WARN) and does not infer offsets
from point order. Every non-reference candidate is also compared with
kitti_dataset_reference in SE(3): translation/rotation distance and
candidate-minus-reference holdout edge evidence are stored together. These are
diagnostics, not PASS thresholds; a small evidence delta can reflect weak
observability rather than transform agreement.
Evidence rows (protocol kitti_lidar_camera_projection_edge_holdout/v0.1) use
recorded thresholds from evaluation.kitti.evidence_gate_* (defaults mirror
metric grading: holdout edge-alignment >= 0.20, depth-edge holdout
>= 0.20, perturbation detectable fraction >= 0.50, mandatory probe
detections >= 8 of the declared ±1 deg / ±0.10 m cases). On the committed
deterministic 300x300 synthetic fixture with the dataset reference extrinsic:
| Check | Measured (holdout where applicable) | Verdict |
|---|---|---|
| Candidate Support | 12 projected points, ratio 1.0, h/v coverage 0.2 | PASS |
| Holdout Edge Alignment | edge 1.0, depth-edge 1.0 (threshold >= 0.2) | PASS |
| Known-Bad Controls | detectable fraction 0.667, mandatory 16/24 (threshold >= 0.5 / >= 8) | PASS |
| Decision Boundary | support + holdout + controls pass | PASS |
| Falsification assessment | all 6 policy gates pass | PASS |
--strict-assessment enforces this Camera-LiDAR falsification assessment. The
generic result metric rollup still flags the fixture's intentionally tiny
two-frame, 24-point coverage and limited vehicle motion; those warnings must not
be interpreted as real-dataset calibration evidence. The fixture demonstrates
that the declared controls can reject wrong projections, not that any physical
rig has been calibrated accurately.
Known-bad candidate probe: set evaluation.kitti.use_frame_graph_candidate:
true and perturb the frame-graph candidate. Set this option whenever the
candidate under test comes from the config rather than
calib_velo_to_cam.txt; keep --strict-assessment enabled so unsupported
candidates fail the shell command.
A cached example result is available for report rendering without rerunning the pipeline:
calibrex render examples/public_datasets/kitti_lidar_camera_evidence/cached_evidence_result.yaml \
--output-dir outputs/kitti_lidar_camera_evidence
nuScenes inspection reads the JSON metadata tables directly, without requiring
the nuScenes SDK. It discovers sensor streams from sensor.json,
calibrated_sensor.json, and sample_data.json, then reports channel counts,
modality counts, keyframe counts, missing local sample files, ego pose table
coverage, and calibrated sensor transforms normalized to Calibrex xyzw
quaternions. This is the first nuScenes step for validating that Camera, LiDAR,
Radar, and ego-pose metadata fit the same Calibrex dataset inspection model
used by KITTI.
nuScenes mini Radar yaw evidence¶
nuScenes requires registration and acceptance of its current dataset terms. Download the mini split manually from the official nuScenes page, review the terms of use, and do not commit or redistribute the downloaded archives through slac. Extract the data so the local tree contains:
data/public/nuscenes/
├── v1.0-mini/
│ ├── calibrated_sensor.json
│ ├── ego_pose.json
│ ├── sample_data.json
│ └── sensor.json
├── samples/
│ └── RADAR_FRONT/
└── sweeps/
Then run the preflight and evidence workflow:
calibrex inspect data/public/nuscenes --type nuscenes --json
calibrex calibrate examples/public_datasets/nuscenes_mini/config.yaml \
--output-dir outputs/nuscenes_mini_radar --seed 0
calibrex evidence outputs/nuscenes_mini_radar/result.yaml \
--output outputs/nuscenes_mini_radar/evidence.json \
--sidecars-dir outputs/nuscenes_mini_radar
calibrex validate outputs/nuscenes_mini_radar/evidence.json --json
calibrex assess outputs/nuscenes_mini_radar/evidence.json \
--policy outputs/nuscenes_mini_radar/policy.json \
--output outputs/nuscenes_mini_radar/reassessment.json
calibrex verify outputs/nuscenes_mini_radar/bundle.json
The example evaluates the dataset-provided T_ego_radar reference, uses a
seeded frame-disjoint 20% holdout, and applies the mandatory -10, -5, +5, and
+10 degree yaw controls across RADAR_FRONT, RADAR_FRONT_LEFT,
RADAR_FRONT_RIGHT, RADAR_BACK_LEFT, and RADAR_BACK_RIGHT. Frame IDs are
prefixed by the configured sensor name, so simultaneous acquisitions from
different Radar units cannot leak across the split. The absolute residual limits remain deliberately
unset, so the decision boundary stays INCONCLUSIVE even when support,
observability, and controls pass. Do not tune those limits on the reported
holdout. Freeze them on predeclared development scenes, then evaluate once on
untouched validation scenes and record negative results unchanged.
The same config also runs radar_spatiotemporal_velocity for RADAR_FRONT.
It recomputes planar scan ego velocity from static raw vx/vy Doppler
returns, differentiates the corresponding ego_pose trajectory, and passes
those timestamped signals to the native lever-arm/clock-offset solver. The
factor declares radar_time + dt_radar = reference_time, a ±0.10 s search,
and a 2 ms grid resolution. Full 3D translation is accepted only when the
stacked angular-rate cross-product matrix has rank three. Normal road driving
often supplies yaw-only motion and should therefore produce an honest
INCONCLUSIVE lever-arm result rather than an invented vertical translation.
The accepted solution must also have rank four in the probe-scaled joint
(tx, ty, tz, dt) Jacobian. Its condition number and the fraction of the time
column explained by the translation subspace expose lever-arm/clock
compensation that a low residual or positive one-dimensional curvature can
miss. All consumed PCD and metadata files are recorded with SHA-256 digests;
the supplied Radar rotation and solver options are stored in provenance.
The same timestamped velocities also run an independent three-axis rotation
stage from Wise et al. (ICRA 2021), using the configured translation for the
lever-arm term. Its holdout RMSE, three-axis information rank/condition, and
roll/pitch/yaw ±5 degree controls are reported. The estimated rotation is not
fed into the lever-arm/clock stage in that run. Ordinary road motion can still
be poorly conditioned; such data remain INCONCLUSIVE with the weak spectrum
preserved instead of being reduced to a yaw-only success.
A third, independent path jointly refines all six Radar extrinsic components
and the bounded clock offset with the backend-neutral LM optimizer. It uses
the same eligible scan IDs and train/holdout split as the staged solver, which
is checked by radar_joint_spatiotemporal_common_split_consistent. Rank must
be 7/7 and all fourteen signed spatial/temporal controls remain visible; the
joint estimate is diagnostic and is not automatically applied on weak road
motion. Its configured initial SE(3)/clock state and optimized state are both
scored on the unchanged holdout IDs. The absolute and fractional RMSE
improvements therefore expose a converged estimate that fails to outperform
the supplied calibration without feeding holdout evidence back into fitting.
input_diagnostics records missing extrinsics, insufficient records, missing
ego poses, low-motion exclusions, missing or malformed PCD payloads, and frames
without eligible static returns. Counts are complete, while example events are
capped at 100. Stored payload references are dataset-relative; exception text
and personal absolute paths are intentionally omitted.
For reproducibility, retain the downloaded archive name and SHA-256 locally, the access date and terms URL, the exact config and commit, environment lock, selected frame IDs, split seed, calibrated-sensor tokens, all four probe results, and the generated bundle verification. Avoid publishing personal absolute paths or licensed raw point data.
When calibrex calibrate runs on dataset.type: nuscenes, Calibrex imports
those calibrated_sensor entries into result reference_extrinsics with
T_parent_child convention, ego as parent, and channel-derived child frame
names such as lidar_top, cam_front, and radar_front. These are dataset
reference values, not optimized Calibrex estimates, so they stay separate from
transforms and future optimized_extrinsics outputs.
Calibrex also stores config-derived initial transforms in candidate_extrinsics.
When a candidate and reference share the same parent/child edge, the report
adds extrinsic_reference_* metrics for pair count, maximum translation delta,
and maximum rotation delta. This is the first comparison path for evaluating
dataset calibration, manual candidate files, and future external baseline
outputs without mixing their meanings in one field.
External candidates can be supplied without editing the dataset config:
calibrex calibrate config.yaml --candidate-extrinsics candidates/manual.yaml
The candidate file may contain a top-level candidate_extrinsics mapping, a
transforms mapping from an existing Calibrex-style result, or a direct mapping
from transform name to T_parent_child transform fields. Imported candidates
override config-derived candidates with the same transform name and are recorded
under run.provenance.external_candidate_extrinsics.
For downloaded KITTI sequences, calibrex inspect --type kitti-raw --json
includes sampled Velodyne diagnostics: frame count, sampled point count, XYZ
bounds, intensity range, local planarity, roughness, map sharpness proxy, and
malformed file warnings.
Without --json, the same command prints a human-readable summary with streams,
warnings, LiDAR quality, point-to-plane metrics, and collection recommendations.
When KITTI OXTS files are present, inspect also reports motion excitation
diagnostics such as duration, mean speed, speed range, and yaw/pitch/roll
excitation. KITTI timestamp files are also compared to report Camera-LiDAR and
LiDAR-OXTS nearest timestamp deltas.
When image and Velodyne files are present, inspect also records concrete
camera-LiDAR frame pairs selected by nearest timestamp. These pairs include the
image path, .bin path, signed lidar_time - camera_time delta, and LiDAR point
count so overlay artifacts can reference real public-dataset frames.
When KITTI calib_velo_to_cam.txt and calib_cam_to_cam.txt are available,
calibrex calibrate and calibrex visualize --export-html project a sampled
Velodyne frame into the selected camera image inside
artifacts/camera_lidar_overlay.html.
The same projection feeds report metrics for projected point count, projection
ratio, median projected depth, depth span, and normalized horizontal/vertical
image coverage. A lightweight edge-alignment proxy also checks how many
projected LiDAR points land near image intensity edges.
For depth-aware checking, Calibrex also marks projected LiDAR points with
nearby depth jumps and scores whether those depth discontinuities land near
image edges. When more than one inspected camera-LiDAR frame pair is available,
these projection metrics are reported with train/holdout values instead of
judging the fixed-LiDAR calibration from a single frame.
Use evaluation.kitti.max_projection_pairs to increase the number of public
dataset frames used for those metrics. For quick checks, 3 frames is enough; for
fixed vehicle LiDAR validation, use 10 to 50 frames when the sequence is
available locally.
KITTI evaluation can also run known perturbation sweeps around the dataset
reference calibration:
evaluation:
kitti:
perturbation_rotation_deg: [0.5, 1.0]
perturbation_translation_m: [0.05, 0.10]
The resulting lidar_camera_perturbation_* metrics show whether roll, pitch,
yaw, x, y, and z offsets make projection and edge diagnostics worse. Treat this
as sensitivity and ranking evidence, not as absolute ground truth.
Those diagnostics feed the first LiDAR quality metrics in result files:
lidar_frame_coverage, lidar_point_coverage, and
lidar_spatial_coverage_m, plus geometry proxies such as
lidar_local_planarity, lidar_map_roughness_m, and lidar_map_sharpness.
The same local voxel planes also provide the alpha
lidar_point_to_plane_rmse_m metric with train/holdout values.
When KITTI OXTS packets are available, Calibrex additionally builds an
OXTS-projected LiDAR train map and scores holdout frames against that map. The
resulting lidar_world_map_point_to_plane_rmse_m,
lidar_world_map_point_to_plane_median_holdout_m, and
lidar_world_map_point_to_plane_p95_holdout_m metrics are the first
fixed-rig LiDAR map-consistency signals for vehicle extrinsic validation.
The matching lidar_world_map_perturbation_* metrics rerun that OXTS-projected
map consistency check after known extrinsic perturbations, so the report can
say whether a sequence has enough signal to rank bad roll, pitch, yaw, x, y, or
z candidates worse than the reference.
Calibrex also reports lidar_world_map_sensitivity_* and
lidar_world_map_weak_dof_count. A weak DoF warning means the evaluated public
dataset segment did not move the world-map holdout metric enough for that
direction, so the result should be treated as under-observed rather than
trusted.
The HTML report includes a dedicated LiDAR World-Map Diagnostics section with
the summary metrics and a DoF sensitivity table, so weak directions are visible
without digging through the full metric list.
For fixed vehicle LiDAR ranking, Calibrex also reports lidar_perturbation_*
metrics. These rerun the LiDAR voxel-plane proxy after known roll, pitch, yaw,
x, y, and z perturbations, then report train/holdout RMSE deltas. Positive
deltas mean the dataset reference calibration ranked better than the perturbed
candidate under the current LiDAR-only proxy.
Calibrex also turns these diagnostics into provisional degeneracy warnings, for
example when KITTI Velodyne samples contain too few planar neighborhoods, weak
vertical structure, or holdout point-to-plane degradation.
The report recommendations then say what to recollect, such as a longer
fixed-LiDAR sequence, more vertical structure, static planar surfaces, or
cleaner holdout passes with less dynamic traffic. Weak OXTS motion excitation
adds recommendations for longer driving logs, acceleration/braking, and turns.
Large timestamp deltas add a recommendation to verify synchronization or enable
time-offset estimation.
The KITTI raw example models the Velodyne LiDAR as a fixed-mounted sensor on
the vehicle and compiles a fixed_lidar_mount_prior
factor together with camera reprojection, LiDAR surfel, LiDAR-camera alignment,
and IMU preintegration descriptors.
KITTI is also the first public dataset target for SceneCalib/SST-Calib-inspired development: targetless camera-LiDAR calibration, time-offset estimation, and fixed-rig LiDAR quality gates should be exercised here before adding private or synthetic-only workflows.
Import KITTI fixed-LiDAR calibration initial values:
Calibrex kitti import-calib /path/to/2011_09_26 --output /tmp/kitti_transforms.yaml
This reads calib_velo_to_cam.txt and exports Calibrex T_parent_child
transforms such as T_camera0_lidar0.
Official sources:
- TUM RGB-D dataset: https://cvg.cit.tum.de/data/datasets/rgbd-dataset/download
- KITTI raw data: https://www.cvlibs.net/datasets/kitti/raw_data.php
- nuScenes: https://www.nuscenes.org/nuscenes
Research references:
- SceneCalib: https://arxiv.org/abs/2304.05530
- SST-Calib: https://arxiv.org/abs/2207.03704
- Decentralized multi-LiDAR SLAC: https://arxiv.org/abs/2007.01483
- M-LOAM: https://arxiv.org/abs/2010.14294
- Koide et al. LiDAR-camera calibration: https://arxiv.org/abs/2302.05094