Aligning a Point Cloud to a BIM Model with ICP
Iterative closest point registration refines an alignment that is already approximately correct, so the workflow is: georeference the cloud from survey control, sample both inputs to a comparable density, run a rigid ICP with scaling disabled and a decreasing correspondence distance, then measure the residual on surfaces the solve never saw. Using ICP to find an alignment rather than to refine one is the mistake this page exists to prevent. It belongs to Point Cloud and Reality Capture Integration.
How ICP Converges, and What It Cannot Do
ICP alternates two steps. It pairs each source point with the nearest point in the target, then solves for the rigid transform that minimises the summed squared distance between those pairs, and repeats. Both steps are local: correspondences are nearest neighbours, and the solve is a closed-form least squares over the current pairing.
That locality is the whole behaviour. If the initial placement pairs a scanned floor with the model’s floor, the algorithm converges on the right answer. If it pairs it with the floor above, the algorithm converges just as confidently on an answer that is one storey out — and reports a low residual, because floors genuinely do match floors.
It also has no notion of a coordinate reference system. It minimises distance, nothing else. Georeferencing comes from survey control or from the scanner’s own positioning; ICP takes up the residual between a georeferenced cloud and a model, which is typically decimetres, not the metres or kilometres that georeferencing spans.
Production-Ready Script
# open3d>=0.17, numpy>=1.24, Python 3.9+
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import open3d as o3d
@dataclass(frozen=True)
class Registration:
transform: np.ndarray # 4x4 rigid
inlier_rmse: float
fitness: float # fraction of source points with a correspondence
passes: tuple[float, ...] # the correspondence distances used
def _pcd(xyz: np.ndarray) -> o3d.geometry.PointCloud:
p = o3d.geometry.PointCloud()
p.points = o3d.utility.Vector3dVector(np.asarray(xyz, dtype=float))
return p
def register(
scan_xyz: np.ndarray,
model_xyz: np.ndarray,
*,
voxel_m: float = 0.05,
distances: tuple[float, ...] = (0.50, 0.20, 0.08),
initial: np.ndarray | None = None,
) -> Registration:
"""Rigid ICP refinement with a shrinking correspondence distance."""
# Work near the origin: single-precision kernels lose resolution at full
# projected coordinates, and the shift is exactly reversible.
origin = np.asarray(scan_xyz, dtype=float).mean(axis=0)
scan = _pcd(np.asarray(scan_xyz) - origin).voxel_down_sample(voxel_m)
model = _pcd(np.asarray(model_xyz) - origin).voxel_down_sample(voxel_m)
T = np.eye(4) if initial is None else np.array(initial, dtype=float)
estimator = o3d.pipelines.registration.TransformationEstimationPointToPoint(
with_scaling=False) # scale is survey truth, never a free parameter
result = None
for max_corr in distances:
result = o3d.pipelines.registration.registration_icp(
scan, model, max_corr, T, estimator,
o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=60),
)
T = result.transformation
# Undo the origin shift so the transform applies to the original coordinates.
shift = np.eye(4); shift[:3, 3] = origin
unshift = np.eye(4); unshift[:3, 3] = -origin
return Registration(
transform=shift @ np.asarray(T) @ unshift,
inlier_rmse=float(result.inlier_rmse),
fitness=float(result.fitness),
passes=distances,
)
def apply(transform: np.ndarray, xyz: np.ndarray) -> np.ndarray:
pts = np.asarray(xyz, dtype=float)
homogeneous = np.column_stack((pts, np.ones(len(pts))))
return (homogeneous @ np.asarray(transform).T)[:, :3]
def residual_on_holdout(transform, holdout_scan, holdout_model) -> dict:
"""Fit measured on surfaces the registration never saw."""
from scipy.spatial import cKDTree
moved = apply(transform, holdout_scan)
d, _ = cKDTree(np.asarray(holdout_model)).query(moved)
return {"rmse": float(np.sqrt((d ** 2).mean())),
"p95": float(np.percentile(d, 95)),
"max": float(d.max())}
Key implementation notes:
with_scaling=Falseis the single most consequential argument on the page.- The correspondence distance shrinks across passes. Starting tight risks discarding correct correspondences that are initially far apart; staying loose lets distant outliers pull the solution.
- Both inputs are shifted to a common local origin before the solve and the transform is un-shifted afterwards, so the returned matrix applies directly to projected coordinates without the precision loss of working there.
fitnessis reported alongside the RMSE. A very low RMSE with a fitness of 0.05 means an excellent fit to five per cent of the cloud, which is not an alignment.- The residual function takes explicitly held-out geometry. Measuring on the registration input reports how well the optimiser optimised.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
open3d |
>=0.17 |
registration_icp, voxel_down_sample |
numpy |
>=1.24 |
matrix composition and application |
scipy |
>=1.10 |
cKDTree for the residual measurement |
| Input | (n, 3) float64 arrays |
model sampled from surfaces, not vertices only |
| Initial alignment | within ~half the feature spacing | required — ICP does not georeference |
Fallback Strategies
1. Converged one storey out. The classic failure. Tighten the initial alignment, or constrain the vertical component by fixing Z from a known level and solving only in plan.
2. Fitness is very low. Little of the scan has a correspondence — usually because the model covers a subset of what was scanned, or vice versa. Clip both to their common extent before registering; see Clipping Point Clouds to CAD Boundaries in Python.
3. The result drifts between runs. Voxel downsampling is deterministic for a fixed voxel size but the sampling of model surfaces may not be. Seed or cache the model sampling so a re-run is comparable.
4. Residual is uniform and vertical. Not an alignment problem — a vertical datum problem. The cloud is on ellipsoidal height and the model is not. Resolve the height systems first.
5. Registration is slow. The cost is dominated by nearest-neighbour queries, which scale with point count. Downsample harder for the coarse passes and only tighten the density for the final one.
FAQ
Why must scaling be disabled?
Because scale between a scan and a design model is meaningful. Both are metric, so a genuine scale difference means something is wrong — a unit error, or a real dimensional discrepancy on site. Allowing the fit to scale lets it absorb that discrepancy and report an excellent alignment, which converts the finding you wanted into a number nobody sees.
How close does the initial alignment need to be?
Close enough that corresponding surfaces are nearer to each other than to non-corresponding ones — roughly, within half the spacing of repeating features. In a building with a 3 m storey height, an initial error above about 1.5 m risks converging one floor out, and the result looks superb because floors do resemble each other.
What residual should I expect?
It depends on what is being compared, not on the algorithm. Against as-built structure the residual reflects construction tolerance, typically 5–25 mm on cast elements. Against design geometry it also includes everything built differently from the model, which is usually the point of the exercise — a large localised residual is a finding rather than a failure.
Related Pages
- Point Cloud and Reality Capture Integration — parent reference on reading, decimating and georeferencing a cloud
- Scale and Rotation Synchronization — the closed-form similarity solve ICP refines the result of
- Reading LAS and LAZ Files with laspy — getting the cloud into memory before registering it