Computing M3C2 Distances on Drone Point Clouds
A quarry face lost several cubic metres of rock between two flights. The difference raster shows almost nothing, because a vertical surface has no vertical difference: the material moved sideways, out of the face, and a top-down comparison is blind to it.
The standard answer is a cloud-to-cloud distance measured along the local surface normal, with an averaging radius that suppresses roughness and a per-point confidence interval that says whether each measurement is significant. This page implements that in Python for photogrammetric drone clouds, where the surfaces are dense, noisy and unevenly sampled — conditions that differ from the terrestrial laser scans the method was designed around.
It is the cloud-based half of change detection between survey epochs.
What the method actually computes
For each point in a reference cloud, the procedure is three steps. Estimate a surface normal from neighbours within a normal scale radius. Project a cylinder of a given projection radius along that normal into both clouds, and take the mean position of the points falling inside each. The signed distance between those two means is the measured change.
The averaging is what makes the method robust. A single nearest-neighbour distance between two noisy clouds measures noise; averaging tens or hundreds of points inside a cylinder reduces that noise by the square root of the count, and — importantly — the scatter inside each cylinder gives a direct estimate of how uncertain the result is at that point.
That per-point uncertainty is the property that distinguishes the method from a plain cloud-to-cloud distance. It means the output is not one map but two: the change, and whether the change is larger than that location’s own noise.
Figure 1 — Three radii, three jobs. Confusing the normal scale with the projection scale is the most common implementation error.
Minimal reproducible solution
import numpy as np
from scipy.spatial import cKDTree
def m3c2(core_pts: np.ndarray, cloud_a: np.ndarray, cloud_b: np.ndarray,
*, normal_scale: float = 1.0, projection_radius: float = 0.5,
max_depth: float = 5.0, min_points: int = 8,
registration_error: float = 0.02) -> dict:
"""Signed change along the local normal, with a per-point confidence.
core_pts are the locations to measure at — usually a subsample of
cloud_a, because computing at every point is wasteful and the result is
spatially smooth anyway.
"""
tree_a, tree_b = cKDTree(cloud_a), cKDTree(cloud_b)
tree_n = cKDTree(cloud_a)
n = len(core_pts)
change = np.full(n, np.nan)
uncert = np.full(n, np.nan)
counts = np.zeros((n, 2), dtype=int)
for i, p in enumerate(core_pts):
# 1. local normal from the normal-scale neighbourhood
idx = tree_n.query_ball_point(p, normal_scale)
if len(idx) < min_points:
continue
nb = cloud_a[idx] - cloud_a[idx].mean(axis=0)
normal = np.linalg.svd(nb, full_matrices=False)[2][-1]
# 2. points within the cylinder, in each cloud
stats = []
for tree, cloud in ((tree_a, cloud_a), (tree_b, cloud_b)):
cand = cloud[tree.query_ball_point(p, np.hypot(projection_radius,
max_depth))]
if len(cand) == 0:
stats.append(None)
continue
rel = cand - p
along = rel @ normal
radial = np.linalg.norm(rel - np.outer(along, normal), axis=1)
inside = (radial <= projection_radius) & (np.abs(along) <= max_depth)
if inside.sum() < min_points:
stats.append(None)
continue
stats.append((float(along[inside].mean()),
float(along[inside].std(ddof=1)),
int(inside.sum())))
if stats[0] is None or stats[1] is None:
continue
(ma, sa, na), (mb, sb, nb_) = stats
change[i] = mb - ma
uncert[i] = 1.96 * np.sqrt(sa ** 2 / na + sb ** 2 / nb_) + registration_error
counts[i] = (na, nb_)
return {"change": change, "uncertainty": uncert, "counts": counts,
"significant": np.abs(change) > uncert}
Adding registration_error to the confidence rather than folding it into the scatter is deliberate. The cylinder statistics capture how well each cloud’s local surface is determined; they say nothing about whether the two clouds are correctly aligned with each other, which is a separate and usually larger term derived from the co-registration in aligning two epochs with ICP before differencing.
Choosing the two scales
The normal scale should span several times the surface roughness and be small compared with the features being measured. On a blocky rock face with 20 cm relief, a normal scale of about 1 m gives stable orientations; at 20 cm the normals follow individual blocks and rotate wildly between neighbouring core points, which produces a noisy and asymmetric change map.
The projection radius trades resolution against precision. Larger radii average more points, so the uncertainty falls as the square root of the count, but a feature smaller than the radius is smoothed away. A useful starting point is a radius equal to the smallest feature that matters, with the normal scale two to three times larger.
import numpy as np
from scipy.spatial import cKDTree
def suggest_scales(cloud: np.ndarray, sample: int = 20_000) -> dict:
"""Data-driven starting values for the two scales.
Roughness is estimated as the median residual of a local plane fit at a
range of radii; the normal scale is taken where roughness stops growing,
which is the scale at which the surface stops looking like a plane.
"""
rng = np.random.default_rng(0)
pts = cloud[rng.choice(len(cloud), size=min(sample, len(cloud)), replace=False)]
tree = cKDTree(cloud)
out = {}
for radius in (0.2, 0.5, 1.0, 2.0, 4.0):
resid = []
for p in pts[:2000]:
idx = tree.query_ball_point(p, radius)
if len(idx) < 10:
continue
nb = cloud[idx] - cloud[idx].mean(axis=0)
normal = np.linalg.svd(nb, full_matrices=False)[2][-1]
resid.append(np.median(np.abs(nb @ normal)))
out[radius] = float(np.median(resid)) if resid else float("nan")
roughness = out
knee = max((r for r in roughness if np.isfinite(roughness[r])), default=1.0)
return {"roughness_by_radius": roughness,
"suggested_normal_scale": knee,
"suggested_projection_radius": knee / 2}
When the extra cost is justified
M3C2 is slower than differencing two rasters by a wide margin, and on a flat site it will give you the same answer.
Figure 3 — The choice follows the geometry, not the budget.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Normal scale too small | Noisy, sign-flipping change | Increase until normals stabilise |
| Normal scale too large | Real curvature smoothed, change biased | Reduce below the feature size |
| Projection radius too small | Few points, huge uncertainty | Increase, or subsample core points less |
| Projection radius too large | Small features vanish | Match to the smallest feature of interest |
| Thin vegetation on the face | Change dominated by leaves | Filter to ground and rock classes first |
| Overhanging geometry | Cylinder catches two surfaces | Reduce max_depth below the overhang separation |
| Very different point densities | Uncertainty asymmetric between epochs | Expected; the formula already handles it |
| Unregistered clouds | Uniform apparent change | Co-register first; M3C2 cannot fix alignment |
Verification snippet
import numpy as np
def verify_m3c2(result: dict, stable_core_mask: np.ndarray) -> dict:
"""Stable core points must come out insignificant and unbiased."""
ch = result["change"][stable_core_mask]
sig = result["significant"][stable_core_mask]
ch = ch[np.isfinite(ch)]
bias = float(np.median(ch)) if ch.size else float("nan")
false_rate = float(np.count_nonzero(sig) / max(sig.size, 1))
problems = []
if abs(bias) > 0.03:
problems.append(f"stable faces show {bias:+.3f} m — registration residual")
if false_rate > 0.10:
problems.append(f"{false_rate:.1%} of stable core points are significant — "
"the registration error term is understated")
return {"stable_bias_m": bias, "stable_significant_rate": false_rate,
"measured_points": int(ch.size), "problems": problems}
Figure 2 — The trade the projection radius makes, and where the answer usually sits for a rock face.
Making it fast enough to run on a whole face
The implementation above is a Python loop over core points, which is fine for a few thousand and unusable for a million. Three changes make it practical without leaving Python.
Subsample the core points aggressively. The change field is spatially smooth at the projection radius, so measuring at a spacing of roughly half that radius loses nothing. On a face sampled at 2 cm with a 40 cm projection radius, that is a twenty-fold reduction in core points before any other optimisation.
Batch the neighbour queries. cKDTree.query_ball_point accepts an array of points and a workers argument, so the tree lookups — which dominate the runtime — parallelise across cores with one call instead of one per point.
Keep the cylinder test vectorised. The radial and along-normal decomposition is pure array arithmetic; doing it once per core point over a candidate array is fast, and doing it per candidate point in Python is not.
import numpy as np
from scipy.spatial import cKDTree
def core_points(cloud: np.ndarray, spacing: float) -> np.ndarray:
"""Voxel-downsample a cloud to one representative point per cell."""
keys = np.floor(cloud / spacing).astype(np.int64)
_, idx = np.unique(keys, axis=0, return_index=True)
return cloud[np.sort(idx)]
def batched_neighbours(tree: cKDTree, pts: np.ndarray, radius: float,
workers: int = -1) -> list:
"""All neighbour lists in one parallel call rather than one call per point."""
return tree.query_ball_point(pts, radius, workers=workers)
With those three in place, a face of forty million points measured at a 20 cm core spacing runs in a few minutes on an ordinary workstation, which is comfortably inside the budget of a monthly monitoring job. The memory profile is modest too, because the trees hold indices rather than copies and the candidate arrays are transient.
One caution on subsampling: take the core points from the reference epoch only, and keep the same core set across every survey in a monitoring series. Re-deriving core points each month changes where the measurements are taken, which introduces a small apparent change of its own and makes the series harder to trend. Storing the core set with the site, alongside the stable polygon, removes the problem entirely.
When to escalate
- The face is too steep for the drone to have seen twice. No distance metric recovers geometry that only one flight captured. Add oblique passes to the flight plan.
- Significant change appears everywhere at similar magnitude. That is a registration residual, not movement, and the registration error term is understating it. Fix the alignment first.
- Results must be reconciled with a volume. M3C2 measures distances, not volumes. Converting requires an assumption about the affected area, and it is usually better to compute the volume from surfaces and quote the distances separately.