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

  1. Pick the metric. C2C for a fast unsigned “how much moved” scan; M3C2 when you need signed, normal-aligned change on real surfaces.
  2. Run headless. Drive CloudCompare through its CLI or the pycc bindings so the comparison runs in CI with no GUI.
  3. Reduce to statistics. Export the distance scalar field and compute the fraction of points beyond a change threshold and the 95th-percentile distance.
  4. 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: -SILENT omitted, or an unsaved-changes prompt from -AUTO_SAVE. Fix: Always pass -SILENT and set -AUTO_SAVE OFF, saving explicitly with -SAVE_CLOUDS FILE.
  • Symptom: pycc cannot 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 with getScalarFieldName(i) and match the exact name; check the CLI return code and stderr before loading the result.

Back to Point-Cloud Versioning and Branching Strategies