rigidity-cli 0.1.1

Headless CLI: registration with a conditioning report, output as JSON or Parquet.
Documentation

rigidity

crates.io docs.rs CI licence: AGPL-3.0 or commercial

Point-cloud registration that tells you which degrees of freedom the geometry actually determined — and which it did not.

Classical ICP returns a pose and a residual. On a long corridor, a bare wall or a weld seam it returns a confident-looking pose whose along-the-feature component is essentially arbitrary. Nothing in the output says so.

$ rigidity register corridor_source.ply corridor_target.ply --noise 0.01 --tolerance 0.001

Translation:  x = -0.0300 m   y = -0.0190 m   z = -0.0100 m

RMSE: 0.00120 m   Correspondences: 34361   Iterations: 50

Condition number: 45.5

σ₁  spread   6.590e-5 m  HIGH    ρ=[+0.99 +0.00 +0.00] φ=[+0.00 +0.01 -0.01]

σ₂  spread   6.730e-5 m  HIGH    ρ=[+0.04 +0.00 +0.00] φ=[+0.00 +0.00 +0.17]

σ₃  spread   9.356e-5 m  HIGH    ρ=[+0.00 -0.01 +0.99] φ=[-0.02 +0.00 +0.00]

σ₄  spread   9.517e-5 m  HIGH    ρ=[+0.00 +0.11 +0.12] φ=[+0.17 +0.00 +0.00]

σ₅  spread   5.911e-4 m  HIGH    ρ=[-0.18 +0.00 +0.00] φ=[+0.00 +0.17 +0.00]

σ₆  spread   3.000e-3 m  MEDIUM  ρ=[+0.00 +1.00 +0.00] φ=[+0.00 +0.00 +0.00]

The true offset was y = −0.0200. It came back as −0.0190 — a 1 mm error, while every other axis is accurate to 0.01 mm. σ₆ is exactly ρ = [0, 1, 0]: translation along the corridor. The RMSE is better than on a well-conditioned scene. Only the last line tells you not to trust that axis.


Install

cargo install rigidity-cli

As a library:

cargo add rigidity

rigidity is a facade over the crates below; default-features = false leaves the core alone, without the format parsers.

No system libraries. No Qt, no VTK, no Python. Builds from source on Linux, macOS and Windows with nothing but a Rust toolchain — which is most of the reason this exists in Rust rather than as another PCL module.

Three commands

# Build a synthetic scene whose degenerate directions are known analytically
rigidity scene --kind corridor --points 20000 --out target.ply
rigidity scene --kind corridor --points 20000 --shift 0.03,0.02,0.01 --out source.ply

# What would this surface determine, before you even scan?
rigidity analyse target.ply --noise 0.01 --tolerance 0.001

# Register, and report what the answer is worth
rigidity register source.ply target.ply --noise 0.01 --tolerance 0.001

Two numbers give the report its meaning. --noise is your sensor's standard deviation in metres. --tolerance is the accuracy your application needs. A degeneracy threshold without a required accuracy is meaningless: 5 mm of spread is excellent for a mobile robot and catastrophic for a welding cell.

How it works

The point-to-plane Jacobian row is [nᵀ | (p × n)ᵀ]. Its singular spectrum says how firmly the geometry pins each of the six rigid motions. Two details make that spectrum trustworthy:

The columns are made commensurate. Translation columns are dimensionless, rotation columns are metres, so the raw singular values cannot be compared and the singular vectors depend on the choice of units — a verdict of "rotation about Z is degenerate" can flip when you switch metres to millimetres. The substitution ξ' = [ρ; r_g·φ], with r_g the radius of gyration about the centroid of the correspondences, puts all six coordinates in metres. There is a test that fails if this invariance is lost.

JᵀJ is never formed. Squaring the matrix squares the condition number, and the small singular values are the entire point of the exercise. R comes from a tall-skinny QR built out of Givens rotations, and the spectrum from one-sided Jacobi, which gives relative accuracy on the small values where the QR algorithm gives only absolute accuracy.

Relative error of the smallest singular value

Going through J costs ε·κ; going through JᵀJ costs ε·κ² — slope 1 against slope 2. At the project's operating point (κ ≈ 10⁷, set by storing points as f32) the direct path errs by 3·10⁻¹¹ and the normal equations by 5 %, one order of magnitude away from losing the value entirely.

Every result is bit-for-bit reproducible regardless of thread count. The reduction tree is fixed by construction, not by however rayon happened to split the range — a floating λ_min would mean a floating detector.

Is the prediction any good?

On synthetic scenes, yes. 1000 registrations per scene across seven scenes with analytically known null spaces: the ratio of empirical spread to predicted spread has median 0.993, range 0.946–1.058.

Predicted versus empirical spread

On real data, it is optimistic by a factor of about 17. Measured on the ETH ASL Challenging Datasets against millimetre-accurate theodolite ground truth: 19× on the mountain plain, 15× in the ETH Hauptgebäude corridor. The formula assumes N independent measurements; real laser errors are correlated, and the effective count is some 300× smaller than the nominal one — of 25 000 points, roughly seventy do the work.

The factor is stable across an open outdoor plain and an enclosed indoor corridor, which matters more than its size: it is a systematic property of the model rather than of the scene. Pass --calibration 17 on real data.

What this does not do

It does not tell you whether you found the right minimum. Conditioning describes the local shape of the cost function. Inside a wrong local minimum the surfaces agree just as tightly and the report looks just as confident. On the plain, 11 of 30 scan pairs converged to a wrong basin, and their conditioning was no worse than that of the successful ones — RMSE separates them, the spectrum does not.

It is not a calibrated uncertainty. σ_noise/σ'ᵢ is a conditioning diagnostic. The closed-form ICP covariance is known to understate real spread by orders of magnitude (Landry, Pomerleau, Giguère, CELLO-3D, 2018), and the measurement above reproduces exactly that. Treat the numbers as a comparison between degrees of freedom — rank correlation with the truth is ≈ +0.33 on real data — not as an absolute error bar.

Rich geometry gains nothing. In a furnished room or a forest, plain ICP works and this only adds cost.

Prior art

Degeneracy-aware registration is not new, and the honest contribution here is a reproducible open implementation rather than the idea.

  • Zhang, Kaess, Singh. On Degeneracy of Optimization-based State Estimation Problems. ICRA 2016.
  • Gelfand, Ikemoto, Rusinkiewicz, Levoy. Geometrically Stable Sampling for the ICP Algorithm. 3DIM 2003.
  • Censi. An Accurate Closed-Form Estimate of ICP's Covariance. ICRA 2007.
  • Landry, Pomerleau, Giguère. CELLO-3D: Estimating the Covariance of ICP in the Real World. 2018.
  • Tuna, Nubert, Nava, Khattak, Hutter. X-ICP: Localizability-Aware LiDAR Registration. T-RO 2023.

Performance

Against the same input and the same accuracy target — the full pipeline, from reading the files to reaching 0.1 mm, on a million points:

iterations median error
rigidity 2 0.070 s 4.74·10⁻⁵ m
Open3D 0.19.0 2 0.108 s 9.54·10⁻⁵ m
PCL 1.15.1 2 0.239 s 4.58·10⁻⁵ m

Read this as a comparison of pipelines, not solvers: ICP itself is 12 % of that time, and voxel downsampling is half. Our downsampling is the single most expensive stage precisely because it is sort-based and therefore deterministic, where a hash grid would be O(n) — and the total is still the fastest of the three. Protocol and caveats: bench-external/.

Crates

crate what
rigidity the facade: one dependency that re-exports the rest, feature-gated
rigidity-core Lie groups, ICP, TSQR, conditioning. Depends on nalgebra, rayon, thiserror — and nothing else, enforced in CI
rigidity-spatial kd-tree over kiddo
rigidity-scenes synthetic scenes with analytically known null spaces
rigidity-io PLY and PCD (own parsers), LAS/LAZ, E57, CSV — read and write
rigidity-pipeline file → surface → registration → report; the sequence every front end must run in the same order
rigidity-viz Rerun logging, behind the rerun feature
rigidity-cli the binary

Building

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings

Recording a registration for the Rerun viewer:

cargo run -p rigidity-cli --features viz --release \
    --example record_registration -- corridor.rrd
rerun corridor.rrd

License

Dual-licensed: AGPL-3.0-only, or a commercial licence.

Free under the AGPL for students, universities, research, personal projects, evaluation and non-profits — and for anything else you are willing to publish the source of. Note that in Rust a crate that depends on rigidity-core is a derivative work, so the AGPL reaches the whole binary; running it internally without distributing the result asks nothing of you.

Shipping it inside a closed product, or hosting it as a service, needs the commercial licence. LICENSING.md has the boundary in a table, and the address to write to.