# simpleICP rejects correspondences two ways and admits its tests are rare

> Five implementations of point-to-plane ICP in C++, Julia, Matlab, Python and Rust, sharing one set of defaults: 1000 correspondences, 10 neighbours for normals, a 3 sigma median absolute deviation filter and a planarity floor. Exactly one of the five can observe rigid-body parameters, and the author says outright that tests are rather rare.

**pglira/simpleICP** — Implementations of a rather simple version of the Iterative Closest Point algorithm in various languages.

- Repository: https://github.com/pglira/simpleICP
- Stars: 353 · Forks: 65
- Language: Python
- License: MIT
- Published: 2026-09-17 · Updated: 2026-09-17 · Language: en
- Canonical page: https://hysenlabs.com/projects/pglira-simpleicp

## The error metric is a signed point-to-plane distance

The core choice is stated up front: every implementation uses the signed point-to-plane distance as the error metric rather than the point-to-point distance. Two reasons are given.

The first is convergence speed. The second is final alignment quality, under the explicit assumption that the two point clouds are differently sampled so that no real point-to-point correspondences exist. That assumption is the whole argument for point-to-plane, and it is worth checking against your data: if your two scans were sampled from the same surface with matching points, you are giving up a metric you did not need to.

The same assumption produces the project's one hard limitation. simpleICP is point-to-plane and supports 3D point clouds only. 2D data is not supported, and the obvious workaround does not work either, because adding a constant z column leaves the resulting normals degenerate.

## Two rejection tests, one statistical and one geometric

Correspondences are filtered automatically, and the two filters catch different failure modes.

The statistical filter uses the median of absolute deviations. A correspondence is rejected when the absolute difference between its distance and the median of all distances exceeds three times `sig_mad`, where `sig_mad` is 1.4826 times the median absolute deviation. The 1.4826 factor is what makes the estimator consistent for normally distributed data, so the threshold behaves like a three sigma cutoff rather than an arbitrary one.

The geometric filter rejects correspondences sitting on non planar neighbourhoods, because a normal estimated from a ragged patch produces a meaningless plane distance. Planarity is defined as `P = (ev2-ev3)/ev1` with the eigenvalues of the covariance matrix of the points used for the normal, ordered so that `ev1 >= ev2 >= ev3`. A correspondence is rejected when `P_i` falls below `min_planarity`, which defaults to 0.3. A small `P` means ev2 is close to ev3, which means the neighbourhood is line-like rather than sheet-like, and the normal there has no meaningful plane to be measured against.

## Convergence is judged on both the mean and the spread

After each iteration, two statistics of the point-to-plane distances are tracked: their mean and their standard deviation. If neither changes by more than `min_change` percent, the iteration stops. The default is `min_change = 1`.

Checking the spread as well as the location is the detail that matters. An ICP run can hold its mean residual steady while correspondences churn underneath it, and a mean-only test would call that converged. Requiring the standard deviation to settle too catches the case where the error is not reducing because the point set is unstable.

Two other defaults shape cost and quality together. Correspondences are a fixed count rather than an adaptive neighbourhood, defaulting to `correspondences = 1000`. Normals are estimated from the fixed cloud with a fixed neighbour count, defaulting to `neighbors = 10`. Both are deliberately small, which makes each iteration cheap at the cost of a more local view of the surface.

## max_overlap_distance is what makes partial overlap legal

The point clouds must not fully overlap. Partial overlap is allowed, and the Bunny dataset is named as the example of that case.

The mechanism is `max_overlap_distance`. Correspondences are only selected across points of the fixed cloud whose initial distance to the nearest neighbour in the movable cloud is less than or equal to that parameter. So the overlap region is defined once, up front, by proximity rather than by any index bookkeeping.

This has a consequence worth internalising before you run anything. Because the overlap is established from the initial configuration, a bad initial guess does not get corrected by iteration, it just shrinks or empties the correspondence set. If the two clouds start far apart, or start in the wrong orientation, the algorithm has no mechanism to recover, because the points it is allowed to match are decided before the first residual is computed.

## Observing transformation parameters exists in Python and nowhere else

The extended feature table has a single row, and it is worth reading closely. Observation of rigid-body transformation parameters: not in C++, not in Julia, not in Matlab, yes in Python, not in Rust. Everything else about the five implementations is meant to match.

The base result all five agree on is a rigid-body transformation for the movable cloud, returned as a homogeneous matrix:

```
H = [R(0,0) R(0,1) R(0,2)   tx]
    [R(1,0) R(1,1) R(1,2)   ty]
    [R(2,0) R(2,1) R(2,2)   tz]
    [     0      0      0    1]
```

applied to a 4 by n matrix of homogeneous coordinates with `Xt = H*X`, where `R` is the rotation matrix and `tx`, `ty` and `tz` are the translation components. In non-homogeneous coordinates the same relationship is written `Xt = RX + t`.

The observation feature is for the cases where you do not want six free parameters. You can weight individual parameters to infinity to hold some fixed, which is how you estimate a subset of the six. You can supply values observed another way, such as by manual measurement. Or you can seed from a previous run of simpleICP, in which case the observation weight is set following least squares adjustment theory as `w = 1/observation_error^2`, with `observation_error` defined as the standard deviation of the observed values. The reported observation error comes back in the logging output as est.uncertainty.

Two parameters carry it, `rbp_observed_values` and `rbp_observation_weights`, each with exactly six elements ordered alpha1, alpha2, alpha3, tx, ty, tz.

## Five dependency stacks, and only one command line

Each language directory brings its own stack. C++ uses nanoflann for nearest neighbour search, Eigen for linear algebra and cxxopts for option parsing. Julia uses NearestNeighbors.jl. Matlab needs the Statistics and Machine Learning Toolbox, which is a licensed add-on rather than base Matlab. Python uses NumPy, SciPy, lmfit and pandas. Rust uses nalgebra, kiddo and clap.

Only the C++ version is exposed through a command line interface. The others are libraries you call from a script or a session, which is the practical difference if you are automating a batch of registrations.

Distribution is uneven too. The Matlab version is published on the MathWorks File Exchange, and the Python version is on PyPI and pepy.tech. The C++, Julia and Rust versions are described only as being in the repository, so for those you are cloning a repository rather than installing a package.

## Readability was the goal, and the tests were the sacrifice

The author states the trade-off directly: the aim was to optimise the code for readability, with the code structure kept as simple as possible, and tests are rather rare. That sentence tells you what kind of artifact this is. It is a reference implementation meant to be read end to end, not a library with a regression suite behind it.

The repository layout supports that reading. There is one directory per language, `c++/`, `julia/`, `matlab/`, `python/` and `rust/`, alongside a `data/` directory holding the test data sets the Bunny example refers to and a `scripts/` directory. There is no shared test harness spanning the five, and no single package manifest tying the versions together, so the five implementations are five separate codebases that happen to agree on defaults.

Two directories at the root are worth noting as a sign of the repository's era: `.claude/` and a `.devcontainer/`, both editor and container tooling rather than anything to do with point clouds.

## Conclusion

Use simpleICP when you want to read how ICP is actually implemented, or when you need the same algorithm available in several languages with the same defaults, which is a genuine advantage when you are moving a pipeline between a Matlab prototype and a Rust service. Do not adopt it as a production point cloud library: the author optimised for readability and says tests are rather rare, the 2D case is explicitly unsupported, and five of the six feature behaviours are identical while the sixth exists only in Python. Four things to check before you rely on it. That your data really is 3D, since adding a constant z column does not work and produces degenerate normals. That your two clouds only partially overlap, because correspondences are drawn through `max_overlap_distance` and full overlap is called out as invalid input. Which language you need, since only the C++ version has a command line interface and the dependency stacks differ per language. And that you are on the `master` branch, which is what this repository uses. Licence is MIT, there are no GitHub releases, and the last push to master is dated 8 May 2026.

## FAQ

### How does the ICP algorithm work?

In simpleICP the loop estimates plane normals from the fixed cloud with a fixed number of neighbours, defaults to 10, selects a fixed number of correspondences between the two clouds, defaults to 1000, rejects bad ones statistically and geometrically, and solves for a rotation plus translation. It stops when the mean and standard deviation of the point-to-plane distances both change by less than min_change percent, default 1.

### What is the Iterative Closest Point algorithm?

ICP repeatedly matches points between two point clouds and solves for the rigid-body transform that best aligns them, refining the guess each iteration. simpleICP returns the result as a homogeneous 4 by 4 matrix H with rotation R and translation tx, ty, tz, applied to a 4 by n matrix of homogeneous coordinates as Xt = H*X.

### Which languages does simpleICP support, and what does each need?

C++ with nanoflann, Eigen and cxxopts; Julia with NearestNeighbors.jl; Matlab with the Statistics and Machine Learning Toolbox; Python with NumPy, SciPy, lmfit and pandas; and Rust with nalgebra, kiddo and clap. Only the C++ version has a command line interface.

### Can simpleICP handle 2D data?

No. It is point-to-plane ICP and supports 3D point clouds only. Adding a constant z column to make 2D data look 3D does not work, because the resulting normals are degenerate.

### How does simpleICP reject bad correspondences?

Two ways. A median absolute deviation filter rejects a correspondence when its distance deviates from the median by more than 3 times sig_mad, with sig_mad set to 1.4826 times the median absolute deviation. A planarity filter rejects one whose neighbourhood gives P = (ev2-ev3)/ev1 below min_planarity, default 0.3.

## Sources

- [Issues](https://github.com/pglira/simpleICP/issues)
- [License: MIT](https://github.com/pglira/simpleICP/blob/master/LICENSE)
- [pglira/simpleICP on GitHub](https://github.com/pglira/simpleICP)
- [README](https://github.com/pglira/simpleICP/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/pglira-simpleicp
