Reviewing Point-Cloud Diffs with CloudCompare
Quantify how much geometry actually changed between two point-cloud versions using CloudCompare’s cloud-to-cloud and multiscale distance tools, then wire the numbers into an automatic pre-merge gate. Part of Point-Cloud Versioning and Branching Strategies.
Concept & Context
Per-tile hashing tells you that a tile changed, but not how much or where — a single reclassified point and a landslide both flip the hash. Before merging an acquisition branch into the trunk, a reviewer needs a geometric magnitude: is this a millimetre of registration noise, or a metre of real terrain movement that a downstream model must not silently absorb? A hash is a boolean; a merge decision needs a distribution.
CloudCompare is the standard open tool for that measurement. Its C2C (cloud-to-cloud) distance assigns every point in a candidate cloud the distance to its nearest neighbour in the baseline, and its M3C2 (Multiscale Model-to-Model Cloud Comparison) computes signed change along the local surface normal, distinguishing uplift from subsidence in a way nearest-point distance cannot. Both produce a per-point scalar field you can reduce to a handful of statistics and threshold. This turns the visual, subjective act of “eyeballing two clouds” into a repeatable gate that sits in front of the acquisition-branch merge — the same review-gate philosophy that manual review triggers for critical edits applies to high-stakes vector features.
Core Review Steps
- Pick the metric. C2C for a fast unsigned “how much moved” scan; M3C2 when you need signed, normal-aligned change on real surfaces.
- Run headless. Drive CloudCompare through its CLI or the
pyccbindings so the comparison runs in CI with no GUI. - Reduce to statistics. Export the distance scalar field and compute the fraction of points beyond a change threshold and the 95th-percentile distance.
- Gate the merge. Auto-pass when change is below threshold; flag for a human when it exceeds the limit, attaching the statistics to the review.
Working Implementation
The script below drives CloudCompare headlessly to compute a C2C distance between the baseline and candidate versions, then reads the resulting distances back with pycc and reduces them to a pass/fail verdict. The CLI call does the heavy geometry; Python does the thresholding and the gate.
#!/usr/bin/env python3
"""Compute a CloudCompare C2C distance between two point-cloud versions and
gate a merge on the result. Falls back to M3C2 for signed surface change."""
import subprocess
import sys
from pathlib import Path
import numpy as np
import pycc # CloudCompare's Python bindings
# Review policy — derive thresholds from your survey accuracy
CHANGE_THRESHOLD_M = 0.15 # a point is "changed" if it moved more than this
MAX_CHANGED_FRAC = 0.05 # >5% of points changed => human review
MAX_P95_M = 0.30 # or 95th-percentile distance over 30 cm => review
CLOUDCOMPARE = "CloudCompare" # or absolute path to the binary
def run_c2c(baseline: str, candidate: str, out_dir: str) -> str:
"""Headless C2C: distance from each candidate point to the baseline cloud.
Returns the path to the output cloud carrying the distance scalar field."""
Path(out_dir).mkdir(parents=True, exist_ok=True)
cmd = [
CLOUDCOMPARE,
"-SILENT", # no GUI, no dialogs
"-AUTO_SAVE", "OFF",
"-O", baseline, # reference is opened first
"-O", candidate, # compared cloud second
"-C2C_DIST", # cloud-to-cloud distance
"-SAVE_CLOUDS", "FILE",
f"{out_dir}/c2c_result.laz",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
sys.exit(f"CloudCompare failed:\n{result.stderr}")
return f"{out_dir}/c2c_result.laz"
def load_distances(result_path: str) -> np.ndarray:
"""Read the C2C distance scalar field from the result cloud via pycc."""
cc = pycc.GetInstance()
clouds = cc.loadFile(result_path)
cloud = clouds if isinstance(clouds, pycc.ccPointCloud) else clouds[0]
# C2C writes a scalar field literally named "C2C absolute distances"
sf_idx = cloud.getScalarFieldIndexByName("C2C absolute distances")
if sf_idx < 0:
sys.exit("No C2C distance scalar field found in result cloud")
sf = cloud.getScalarField(sf_idx)
return np.array([sf.getValue(i) for i in range(cloud.size())])
def review_gate(distances: np.ndarray) -> dict:
"""Reduce per-point distances to a merge verdict."""
changed_frac = float((distances > CHANGE_THRESHOLD_M).mean())
p95 = float(np.percentile(distances, 95))
needs_review = changed_frac > MAX_CHANGED_FRAC or p95 > MAX_P95_M
return {
"changed_fraction": round(changed_frac, 4),
"p95_distance_m": round(p95, 3),
"max_distance_m": round(float(distances.max()), 3),
"verdict": "REVIEW" if needs_review else "AUTO_PASS",
}
if __name__ == "__main__":
baseline, candidate = sys.argv[1], sys.argv[2]
result = run_c2c(baseline, candidate, "diff_out")
verdict = review_gate(load_distances(result))
print(verdict)
# Non-zero exit blocks the merge in CI when review is required
sys.exit(1 if verdict["verdict"] == "REVIEW" else 0)
For signed surface change — settlement, erosion, dredging — swap the C2C stage for M3C2, which needs a small parameter file specifying the normal and projection scales:
# m3c2_params.txt sets NormalScale and SearchScale to ~20x point spacing.
CloudCompare -SILENT -AUTO_SAVE OFF \
-O baseline.laz -O candidate.laz \
-M3C2 m3c2_params.txt \
-SAVE_CLOUDS FILE diff_out/m3c2_result.laz
Validation & Output Verification
Confirm the gate measures real change and not an artifact before you trust it. First, run the comparison of a cloud against itself: C2C distances should be zero everywhere (within float epsilon), and any non-zero result means the two files were not actually identical or a scalar field leaked from a prior run. Second, check that the point counts and CRS of the two inputs match what the version manifest records — comparing clouds in different coordinate systems produces enormous meaningless distances.
# Self-comparison sanity check: distances must collapse to ~0
python cc_gate.py baseline.laz baseline.laz
# Expect changed_fraction 0.0, p95 ~0.0, verdict AUTO_PASS
# Confirm both inputs share a CRS before trusting any distance
pdal info --metadata baseline.laz | grep -i srs
pdal info --metadata candidate.laz | grep -i srs
Finally, spot-check the statistics against expectation: if you know a survey captured a 40 cm subsidence in one corner, the M3C2 result should show a signed cluster of returns near −0.40 m there. If the gate reports AUTO_PASS on a change you can see by eye, the threshold or the metric choice is wrong — a rough or sloped surface usually means C2C under-reports and M3C2 is the correct tool.
Failure Modes
- Symptom: Distances are large everywhere even on unchanged ground. Root cause: The two versions are misregistered, or one is in a different CRS. Fix: Confirm both share the manifest CRS, and coarse-register with an ICP alignment before comparing so the gate measures change, not offset.
- Symptom: C2C reports near-zero change across a slope where terrain clearly moved. Root cause: Nearest-point distance slides along the surface and under-reports normal displacement. Fix: Switch to M3C2, which projects change onto the local normal and reports the true signed movement.
- Symptom: The CLI run hangs or pops a dialog in CI. Root cause:
-SILENTomitted, or an unsaved-changes prompt from-AUTO_SAVE. Fix: Always pass-SILENTand set-AUTO_SAVE OFF, saving explicitly with-SAVE_CLOUDS FILE. - Symptom:
pycccannot find the distance scalar field. Root cause: The scalar field name differs by CloudCompare version, or the compute stage failed silently. Fix: List scalar fields withgetScalarFieldName(i)and match the exact name; check the CLI return code and stderr before loading the result.
Related
- Point-Cloud Versioning and Branching Strategies — the parent guide whose acquisition-branch merges this gate protects
- Integrating PDAL Pipelines into a Versioning Workflow — the pinned transform that produces the versions you compare here
- Manual Review Triggers for Critical Edits — routing changes that exceed the distance threshold to a human reviewer