rcpd
Coherent point drift in Rust, with Python bindings: fit a transform between two point clouds when you do not know — and do not need to know — which point matches which.
The clouds may have different numbers of points. The fit is by EM over a Gaussian mixture (Myronenko & Song 2010), and what comes back is a closed-form transform, so it applies exactly to points that took no part in fitting it.
import rcpd
tr = rcpd.register_rigid(source, target) # rotation, translation, scale
tr = rcpd.register_deform(source, target) # a smooth non-rigid warp
moved = tr.apply(source)
use rcpd::{rigid_register, RigidOpts};
let fit = rigid_register(source.view(), target.view(), RigidOpts::default(), None)?;
let moved = fit.transform.apply(source.view())?;
What is here
- Rigid / similarity registration — rotation, translation and optionally one uniform scale, which can be confined to a range you consider plausible.
- Deformable registration — a smooth warp, regularised by the motion-coherence prior and solved through the low-rank formulation from the original paper.
Either as a single pair or a whole grid of pairs at once, one registration per core. Both return a function of position rather than a set of moved points, so either applies exactly to points that took no part in the fit — which is what makes fitting on a subsample free of approximation, and what saves a grid of registrations from being hundreds of gigabytes of displaced clouds.
Install
pip install rcpd
or, for the Rust crate,
cargo add rcpd
Rigid, then deformable
The two fits are not alternatives. Running both is the usual workflow: the rigid fit takes out the pose, and the deformable fit is asked only for what is left.
rigid = rcpd.register_rigid(source, target)
moved = rigid.apply(source)
deform = rcpd.register_deform(moved, target) # note: fitted on the *moved* points
final = deform.apply(moved)
rigid.nrms, deform.nrms # the two are on the same scale
Points that took no part in either fit go through both, in the same order —
deform.apply(rigid.apply(pts)).
Fitting the deformation on moved rather than on source is the load-bearing part. The
motion-coherence prior penalises displacement, so any pose offset left for the deformation to
undo is paid for out of the same budget as the warp — and past a large enough offset it is not
undone at all: the fit settles into a wrong local optimum whose residual reads like two
unrelated clouds. On a 400-point cloud rotated 70 degrees, shifted, and warped by something no
rigid transform can express, that is exactly what happens:
nrms |
|
|---|---|
| rigid alone | 0.053 |
| deformable alone | 0.25 — no better than two unrelated clouds |
| rigid, then deformable | 1.5e-4 — at the numerical floor |
Where the offset stops being recoverable is data-dependent; on that cloud it is somewhere between 45 and 70 degrees.
Judging a fit
Nearly every fit converges, and that is not the same as being right — coherent point drift will settle a cloud onto an unrelated one perfectly happily. So the transform carries the residual it left behind:
tr.rms # residual distance, in the units that went in
tr.nrms # the same, as a fraction of the target cloud's own radius
nrms is the one to compare between fits. It is calibrated rather than merely ordered —
displace every target point by 1% of the cloud's radius and it reads 0.01 — so a cutoff can
be picked by hand. Measured on five neuron skeletons:
nrms |
|
|---|---|
| 2e-5 | one cloud against a rotated copy of itself |
| 0.008 | one neuron against its own other half |
| 0.06 – 0.11 | two different neurons of the same type |
| ~0.2 | two unrelated point clouds |
A rigid fit's and a deformable fit's nrms are on the same scale, which is the point: run
both and the pair of numbers says whether the extra freedom bought anything. On two example
skeletons, rigid gives 0.087 and deformable 0.058.
Below ~1e-3 there is nothing left to measure: the E-step runs in f32, which resolves the
variance to ~1e-7 relative and hence the residual — its square root — to ~3e-4.
Why not pycpd?
pycpd is the reference Python implementation and has been unmaintained since 2021. It also
writes the E-step as
P = np.sum((X[None, :, :] - TY[:, None, :]) ** 2, axis=2)
which materialises an M x N x 3 array, squares it into a second, then builds three more
M x N temporaries — every iteration, of which there are typically 50-100. Two ~4,500-point
clouds:
| time | peak RSS | |
|---|---|---|
pycpd |
10.8 s | +1,759 MB |
rcpd, 1 core |
2.48 s | +0.4 MB |
rcpd, 14 cores |
0.29 s | +25 MB |
The memory is the more interesting column. The M-step only ever reads four reductions of
the correspondence matrix, and its normaliser is a reduction over rows — so a block of
columns is self-contained, and the whole E-step fits in one exp pass over a few hundred
KB. Peak memory stops depending on M x N at all, which is what makes the batch entry
point possible: at a gigabyte apiece you cannot hold fourteen registrations at once.
Across a grid the gap widens, because pycpd has no parallelism to give: a 12 x 12 pairwise
alignment of the same clouds takes 30 s here against ~26 min.
Deformable is where pycpd stops being usable at all, and for an algorithmic reason
rather than an implementation one: its M-step solves a dense M x M system once per
iteration, which is cubic in the point count in time and quadratic in memory. On the same
two ~4,500-point clouds, 50 iterations:
| time | peak RSS | |
|---|---|---|
pycpd |
36.6 s | +3,191 MB |
rcpd |
0.80 s | +95 MB |
The fix is the one in the original paper — approximate the kernel by its leading eigenpairs
and apply the Woodbury identity, so each iteration is a K x K solve. Those eigenpairs come
from a randomised subspace iteration that never forms the kernel, rather than the dense
np.linalg.eigh(G) that cycpd uses; see core/src/lowrank.rs for why, and for why there
is no fast Gauss transform here.
Held at the same iteration count — taking the deliberate convergence difference out of the comparison — the two implementations move the points to within 3e-6 of the cloud's radius at five iterations, and 3e-4 by sixty.
Deliberate differences from pycpd
Four, all fixes.
Convergence is relative. pycpd stops when its objective moves by less than tolerance
in absolute terms — on a quantity that scales with the data, so the same clouds converge
differently in nanometres and in microns. Here the test is on the relative change in the
fitted variance, so it is dimensionless.
A deformable fit gets a second stopping rule, because it has enough freedom to drive the
residual below what the f32 kernel can resolve — and there the variance stops descending and
rattles by a few percent an iteration around a value it has already reached, so tolerance is
never met however long it runs. Over a window of iterations the fit compares how far the
variance moved with how far it travelled, and stops when it is travelling without arriving. On
a well-matched pair that is 48 iterations rather than 200, for a residual 2% different; a fit
that is merely slow is untouched, since a descending variance has the two distances equal.
scale is honoured. pycpd.RigidRegistration takes no scale argument in any released
version, including master, so a caller that passes one has it land in **kwargs and a scale
fitted regardless. scale=False here holds it at exactly 1, and scale_bounds holds it inside
a range:
tr = rcpd.register_rigid(source, target, scale_bounds=(0.8, 1.25))
The limits are imposed at every EM step, so what comes back is the best alignment within
them — rotation and translation re-fitted against the limited scale — rather than a free fit
squashed into range afterwards. A pair that wants a scale outside the range comes back sitting
exactly on the nearer limit, which is how you tell that the constraint bound. This is worth
having on a grid of pairs: two clouds of the same shape at very different sizes will otherwise
be talked into a flattering nrms by a scale you know to be impossible.
w is scale-free. The outlier term weighs a Gaussian density against a uniform one over
some volume V; pycpd drops V, leaving a uniform density of 1, which is only right for
data spanning about one unit. On a cloud spanning 1e4 units every point is declared an
outlier and the fit collapses — at w=0.1 pycpd returns a scale of 0.19 against a true
1.0, and at w=0.01 it does not converge in 500 iterations. Here V is carried explicitly,
as the bounding volume of the fixed cloud.
At w=0 — the default — the two agree to the precision of the f32 kernel: on real
skeletons, transformed points match to 4e-8 of the cloud's extent.
beta is a fraction of the cloud, not a length. The motion-coherence kernel's width in
data units silently means something different for the same object in nanometres and in
microns, and the failure is quiet rather than loud: too wide a kernel makes the kernel matrix
nearly all-ones, the deformation collapses to a global translation, and what comes back looks
like a fit rather than like an error. Here beta is a fraction of the cloud's radius, so it
means the same thing whatever the units. Note it is coupled to num_modes — the kernel's
rank grows roughly as (1/beta)**3.
There is also no LinAlgError to work around. The 3x3 SVD here is a Jacobi eigensolve that
cannot fail to converge, and a rank-deficient system (collinear points) completes the
rotation basis rather than raising.
Development
cargo test --workspace # Rust core and bindings
cd py && maturin develop --release # build the extension in place
cd py && pytest tests/ # Python suite (checks against pycpd where installed)
Releasing
The version lives in the workspace Cargo.toml and nowhere else — the crate, the extension
and the Python package all inherit it. Bump it, land the changelog entry, then tag:
git tag v0.1.0 && git push origin v0.1.0
.github/workflows/release.yml builds wheels for macOS, Linux (glibc and musl, x86-64 and
aarch64) and Windows, plus an sdist that it builds back into a wheel to check it is not
broken, publishes those to PyPI, then publishes the crate to crates.io. It refuses to start if
the tag and the manifest disagree, since neither registry lets a version be replaced. Running
it from the Actions tab instead builds everything and publishes nothing, which is how to test
a change to it.
Licence
GPL-3.0-or-later.
Release files for rcpd 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| rcpd-0.1.1.tar.gz | 107.8 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| rcpd-0.1.1-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details |
| rcpd-0.1.1-cp39-abi3-musllinux_1_2_x86_64.whl | CPython 3.9 | abi3 | Linux musl 1.2+ x86-64 | Details |
| rcpd-0.1.1-cp39-abi3-musllinux_1_2_aarch64.whl | CPython 3.9 | abi3 | Linux musl 1.2+ ARM64 | Details |
| rcpd-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| rcpd-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| rcpd-0.1.1-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| rcpd-0.1.1-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 4.2 MB
Release files / rcpd-0.1.1.tar.gz
| Download URL | rcpd-0.1.1.tar.gz |
|---|---|
| Size | 107.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0e9930c9f993e97c69d8a946a2c2da7bb09f5ee7ed28b74959a8fa284269769f
|
|
BLAKE2b-256 checksum How to use checksums |
7537abea981b520ed2e23553bc2bec45bde5d4869348f101b00160429297cc66
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-win_amd64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 440.7 kB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
b3ddf0001a0b5432b664f35e8ca6bd0a7b584486a3f5ef9b8ef91c1b1bfaf3ea
|
|
BLAKE2b-256 checksum How to use checksums |
a11f27ec26d03461e8d84bfa9a44a7d68f37b0701416fa2910b46e44961d703c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-musllinux_1_2_x86_64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 779.2 kB |
| Tags | CPython 3.9 Linux musl 1.2+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
2a3e8abf44d678da041e6afb18a001efaec72fdc15e2d75ac79b6a0a3feb2599
|
|
BLAKE2b-256 checksum How to use checksums |
5911198ef5b2f27d299b660dc72c02aa158863d986268e43084a1030ced22019
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-musllinux_1_2_aarch64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-musllinux_1_2_aarch64.whl |
|---|---|
| Size | 731.0 kB |
| Tags | CPython 3.9 Linux musl 1.2+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
f6d3619e07f20124c0bbb772a47a787e805df5b51675ed92f587f373c3b4c9d0
|
|
BLAKE2b-256 checksum How to use checksums |
ceead344ae56ee2c70adc1e7e7dac54425cc5ba8477a6ba8f2e290f1bab755c0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 569.2 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
0c4dee9ff14f7ae01c2c60d89313595e6e752a8ee82c9ae3dde256973a79a303
|
|
BLAKE2b-256 checksum How to use checksums |
d0112fc285214cbf91defe3a7ef9e3f5c36a3efffab26e1bc1cdd0c9c22ec52f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 552.1 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
a716f612367af1792250973b3f3587d37654c0dedf799f0bb8c7d2bae31ecd4b
|
|
BLAKE2b-256 checksum How to use checksums |
b13af3837326faae6e387cf5cbff3996573506ac2116d056a46335b564ce0d50
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 519.5 kB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
b1d2d4b297861ae58b62ae7e90112ef97346c6b4bfe4a2a0d564ebe35f5f6917
|
|
BLAKE2b-256 checksum How to use checksums |
56bc09bb2cb1fdcc64572ad518a3b4e36d3b115cb2b3c02ef2b17fcaf2a62908
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency logRelease files / rcpd-0.1.1-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | rcpd-0.1.1-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 537.2 kB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
3910054d066b81fd70374c683d3eab49d6e037ea4ccb677991259b27f3475e77
|
|
BLAKE2b-256 checksum How to use checksums |
9b4dc47816a91ab6ee33f25192254c002ca3e9811e602e12da6765bfa539f232
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.
Transparency log