Filtering Noise and Outliers from Dense Clouds

The symptom is usually second-hand: a digital terrain model with a pit twelve metres deep in the middle of a flat yard, a volume that comes out negative, or a viewer whose auto-scaled elevation ramp renders the entire site in one flat colour because something is four hundred metres up. Open the cloud and the cause is a handful of points — sometimes a dozen out of three hundred million — sitting where nothing exists.

Those points are not noise in the statistical sense. They are the output of stereo matches that were simply wrong, and they are the reason a filtering pass belongs at the very head of the point cloud processing pipeline, before anything downstream tries to infer structure.

Why photogrammetric outliers are different

A lidar return that is 10 cm off represents a real surface measured imprecisely. Its error is small, roughly symmetric, and averages out across neighbours. A photogrammetric blunder represents a correspondence that never existed: the matcher paired a window reflection in one image with the window itself in another, triangulated the two rays, and wrote the intersection point. That point can be metres or hundreds of metres from any surface, and there is nothing in its attributes to distinguish it from a good one.

Three generators account for almost all of them. Repetitive texture — a car park of identical bays, a corrugated roof, a ploughed field — produces matches that are locally plausible and globally wrong. Reflection and transparency — water, glass, wet asphalt — produces points behind or below the real surface, which is why a pond in a photogrammetric cloud so often appears as a crater. Poor geometry at the survey edge — the first and last images in a flight line see their content from one direction only — produces a fringe of points with almost no depth constraint.

The practical consequence is that the filter must not assume a distribution centred on the true surface. It must ask a simpler and more robust question: does this point have neighbours where a real surface would put them?

Three generators of photogrammetric outliers in a site cross-section A cross-section through a site showing a ground surface with three outlier populations. Above a car park with repetitive markings, a scatter of points floats several metres up. Beneath a pond, a plume of points descends well below the water surface where reflections were triangulated. At the right-hand survey edge, a thin fringe of points spreads outward where only one viewing direction was available. A note records that all three look identical in the file and differ only in where they sit relative to real geometry. car park repetitive texture pond reflection, triangulated below the surface survey edge single-direction fringe None of the three carries a flag. All three are found by asking whether a point has the neighbours a surface would give it.

Figure 1 — Where the bad points come from. The generators differ; the detection rule does not.

Minimal reproducible solution

Two filters, in this order, cover the great majority of cases. The statistical filter catches points whose mean distance to their neighbours is anomalous; the radius filter catches points that have almost no neighbours at all.

import json
import subprocess


def denoise(src: str, dst: str, *, mean_k: int = 12, multiplier: float = 2.2,
            min_neighbors: int = 4, radius: float = 0.35) -> None:
    """Remove photogrammetric blunders from a dense cloud.

    Order matters. The statistical pass runs first because it is the one that
    catches loose clusters — several wrong points agreeing with each other,
    which a radius test happily accepts. The radius pass then sweeps up the
    genuinely isolated points the statistical pass rated as merely unusual.
    """
    pipeline = {"pipeline": [
        src,
        {"type": "filters.outlier", "method": "statistical",
         "mean_k": mean_k, "multiplier": multiplier},
        {"type": "filters.outlier", "method": "radius",
         "min_k": min_neighbors, "radius": radius},
        # Both filters only WRITE class 7. Nothing is deleted until here.
        {"type": "filters.range", "limits": "Classification![7:7]"},
        {"type": "writers.las", "filename": dst,
         "compression": "laszip", "forward": "all"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

The filters.range stage is the one people leave out, and leaving it out produces a file that is byte-for-byte as noisy as its input while the pipeline reports success. PDAL’s outlier filters are classifiers: they set Classification = 7 (low point / noise) and nothing more. The range expression Classification![7:7] means “everything except class 7” and is what actually drops the points.

The radius value is in the file’s horizontal unit and should be set from the cloud’s own point spacing, not guessed. A cloud with 2 cm spacing and a 35 cm radius is asking for hundreds of neighbours; the same radius on a 40 cm-spacing cloud asks for less than one.

import numpy as np
import pdal


def median_point_spacing(path: str, sample: int = 200_000) -> float:
    """Median nearest-neighbour distance, from a random sample of the cloud."""
    pipe = pdal.Pipeline(json.dumps({"pipeline": [
        path, {"type": "filters.sample", "radius": 0.0},
    ]}))
    pipe.execute()
    arr = pipe.arrays[0]
    idx = np.random.default_rng(0).choice(arr.size, size=min(sample, arr.size),
                                          replace=False)
    pts = np.column_stack([arr["X"][idx], arr["Y"][idx], arr["Z"][idx]])

    from scipy.spatial import cKDTree
    d, _ = cKDTree(pts).query(pts, k=2)      # k=2: self, then nearest other
    return float(np.median(d[:, 1]))

Set radius to roughly three times the median spacing and min_neighbors to four, and the filter behaves the same way on a 2 cm survey and a 15 cm one.

Edge-case matrix

Input variant What the naive filter does Correct handling
Isolated single points Removed correctly Radius pass, min_k ≥ 4
Clusters of 20–50 wrong points Kept — they are each other’s neighbours Statistical pass first, or a connected-component filter
Sparse but real edges of the survey Removed as noise Crop to the survey boundary before filtering
Points under a pond Kept — locally dense Mask water by extent, or accept and classify as noise later
Legitimately thin features (fences, masts) Removed Raise multiplier, exclude by height band
A cloud already containing class 7 Old flags reused Reset classification before filtering
Mixed-density cloud (varying texture) Over-filters the sparse areas Derive radius from local spacing, or tile and filter per tile
Fewer than mean_k points total Filter errors or passes everything Guard on point count before running

The thin-feature row is the one to weigh for utility surveys. A power line or a fence post is exactly what an aggressive radius filter deletes, and there is no parameter that keeps the fence and removes the reflection plume — they look identical to a neighbourhood test. Where thin structures are the deliverable, filter by height band and leave the near-ground volume alone.

What each filter pass catches, and why the order matters A two-row comparison. The top row shows a radius filter applied first: isolated points are removed, but a compact cluster of thirty wrong points survives because its members are each other's neighbours, and the surviving cluster is then indistinguishable from a real feature. The bottom row shows the statistical filter applied first: the cluster's mean neighbour distance is anomalous relative to the whole cloud, so it is marked, and the radius pass afterwards sweeps the remaining isolated points. A note states that a cluster is invisible to a neighbourhood count and visible to a distribution. radius first — the cluster survives kept: each point has neighbours becomes a "feature" statistical first — the cluster is marked mean distance is anomalous clean surface, thin features intact A neighbourhood count cannot see a cluster. A distribution over the whole cloud can.

Figure 2 — Order is not a preference. Reversing these two passes leaves the worst artefacts in place.

Verification snippet

Verification here is a before-and-after on quantities that a successful filter must move in a known direction.

import numpy as np
import pdal
import json


def filter_report(before: str, after: str) -> dict:
    """Quantify what the filter removed, and fail on implausible results."""
    def stats(path: str) -> dict:
        pipe = pdal.Pipeline(json.dumps({"pipeline": [path]}))
        pipe.execute()
        z = pipe.arrays[0]["Z"]
        return {"n": int(z.size), "z_min": float(z.min()), "z_max": float(z.max()),
                "z_p01": float(np.percentile(z, 1)),
                "z_p99": float(np.percentile(z, 99))}

    a, b = stats(before), stats(after)
    removed = (a["n"] - b["n"]) / a["n"]

    # A filter that removes almost nothing did not run; one that removes a
    # large share is eating the survey.
    assert 0.0001 < removed < 0.05, f"removed {removed:.2%} of points — check parameters"
    # The extremes should contract; the body of the distribution should not.
    assert b["z_max"] <= a["z_max"] and b["z_min"] >= a["z_min"]
    assert abs(b["z_p99"] - a["z_p99"]) < 0.05, "the 99th percentile moved — real surface was removed"
    return {"removed_fraction": removed, "before": a, "after": b}

The percentile assertion is what separates a filter from a trim. Removing outliers should collapse the min and max while leaving the first and ninety-ninth percentiles essentially untouched; if the percentiles move, the parameters are aggressive enough to be deleting surface.

Elevation range before and after filtering, with percentiles held fixed Two vertical elevation ranges side by side. Before filtering, the full range spans from minus fourteen metres to plus four hundred and six metres, while the first and ninety-ninth percentiles sit in a narrow band between ninety-four and one hundred and thirty-one metres. After filtering, the full range has collapsed onto the percentile band while the percentiles themselves are unchanged to within two centimetres. A note states that a percentile that moves indicates the filter is removing surface rather than blunders. before after max 406 m min −14 m p99 131 m p01 94 m max 132 m min 93 m p99 131 m p01 94 m The extremes collapse. The percentiles must not move. That single asymmetry is the whole acceptance test for an outlier filter.

Figure 3 — What a correct filter does to the elevation distribution, expressed as an assertion rather than a screenshot.

When to escalate

  • The filter removes more than a few percent of the cloud. That is not a noise population; it is a reconstruction problem. Look at the bundle adjustment and the matching before filtering harder — optimizing bundle adjustment with Python covers the upstream causes.
  • Outliers concentrate in one part of the site. A localised plume is a scene problem — water, glass, a repetitive surface — not a parameter problem. Mask the area by extent and record it, rather than tuning the whole-cloud filter around it.
  • Thin real structures are disappearing. No neighbourhood filter can distinguish a fence from a reflection plume. Restrict filtering to a height band above the structures, or run the classification in classifying point clouds with PDAL and Python first and filter within classes.

Classifying Point Clouds with PDAL and Python