Filtering Noise and Outliers from LiDAR with PDAL

Airborne LiDAR arrives contaminated with returns that never touched the forest: high points from birds, aircraft, and atmospheric scatter, and low points from multipath reflections and sensor artifacts that sit metres below the true ground. Left in place, a single high-noise return inflates a canopy height percentile and a single low-noise pit anchors a ground filter onto empty air. Removing this noise is the first substantive step in LiDAR Point Cloud Preprocessing, part of the wider Canopy Height Modeling & Terrain Extraction workflow, and it must run before ground classification so that noise is never a candidate ground point. A clean cloud here is what makes the downstream Normalizing LiDAR Point Clouds with PDAL step and the eventual Canopy Height Model Creation trustworthy.

PDAL offers three complementary noise tools. filters.outlier flags statistically or geometrically isolated points and tags them as ASPRS Class 7 (low noise) or 18 (high noise). filters.elm (Extended Local Minimum) specifically hunts the low pits that morphological ground filters are most vulnerable to. filters.range then physically drops the flagged points, or restricts the cloud to any classification range you specify. Used together they turn a raw tile into a noise-free cloud without deleting a single legitimate return.

When to use each tool

Reach for the tool that matches the noise geometry. Most forestry tiles need filters.outlier in statistical mode plus a filters.range cleanup; add filters.elm when low pits survive.

Situation Recommended tool Why
Scattered high birds / atmospheric returns, isolated in 3D filters.outlier method=statistical Distance-to-neighbours test catches points that are globally isolated regardless of local density
Dense survey with variable point density filters.outlier method=radius A fixed search radius with min_k neighbours adapts better than a global mean-distance test
Low pits that will anchor a morphological ground filter filters.elm Purpose-built to detect and mark local-minimum noise below the surface
Points already tagged Class 7/18 by vendor or a prior stage filters.range on Classification Physically removes noise classes without re-running detection
You must keep noise flagged but not deleted (audit trail) filters.outlier alone (no range) Marks Class 7/18 in place; downstream stages can ignore them

filters.outlier does not delete points — it reclassifies them. To actually remove noise before ground classification, pair it with a filters.range stage that excludes Class 7 and 18, or set filters.csf/filters.smrf to ignore those classes.

Minimal reproducible example

The pipeline below is the production pattern for forested tiles: strip low pits with ELM, flag statistically isolated high and low noise with the outlier filter, then physically drop both noise classes with a range filter so only clean returns survive into ground classification.

{
  "pipeline": [
    "input_tile.laz",
    {
      "type": "filters.elm",
      "cell": 10.0,
      "threshold": 1.0
    },
    {
      "type": "filters.outlier",
      "method": "statistical",
      "mean_k": 12,
      "multiplier": 2.2
    },
    {
      "type": "filters.range",
      "limits": "Classification![7:7],Classification![18:18]"
    },
    {
      "type": "writers.las",
      "filename": "denoised_tile.laz",
      "forward": "all"
    }
  ]
}

filters.elm marks extended-local-minimum points as Class 7. filters.outlier in statistical mode computes each point’s mean distance to its mean_k nearest neighbours and flags any point beyond multiplier standard deviations of the global mean distance — high birds as Class 18, low outliers as Class 7. The filters.range stage then keeps only points whose classification is not 7 and not 18 (the ! negates the range), physically removing them from the cloud.

For dense surveys where point density varies across the tile, radius mode adapts better than the global statistical test:

{
  "type": "filters.outlier",
  "method": "radius",
  "radius": 1.0,
  "min_k": 4
}

Radius mode flags any point with fewer than min_k neighbours inside radius metres — a purely geometric isolation test that does not assume uniform density. Run either variant from Python so parameters stay version-controlled:

import json
import pdal

def denoise(in_laz: str, out_laz: str, mean_k: int = 12, multiplier: float = 2.2) -> int:
    """Flag and remove high/low noise; return the number of points kept."""
    pipeline = {
        "pipeline": [
            in_laz,
            {"type": "filters.elm", "cell": 10.0, "threshold": 1.0},
            {
                "type": "filters.outlier",
                "method": "statistical",
                "mean_k": mean_k,
                "multiplier": multiplier,
            },
            {"type": "filters.range",
             "limits": "Classification![7:7],Classification![18:18]"},
            {"type": "writers.las", "filename": out_laz, "forward": "all"},
        ]
    }
    p = pdal.Pipeline(json.dumps(pipeline))
    kept = p.execute()
    return kept

Parameter reference

The arguments that control detection sensitivity, with dense-forest starting values and the reason each matters.

Parameter Stage Type Default Recommended range Rationale
method filters.outlier str statistical statistical or radius Statistical suits uniform density; radius suits variable-density surveys
mean_k filters.outlier int 8 8–16 Neighbours in the mean-distance test; raise in dense clouds so real noise is not averaged away
multiplier filters.outlier float 2.0 2.0–2.5 Standard-deviation cutoff; lower removes more aggressively but risks erasing steep-slope returns
radius filters.outlier float (m) 1.0 0.5–1.5 Search radius for the geometric test (radius method only)
min_k filters.outlier int 2 3–5 Minimum neighbours within radius; points with fewer are flagged as noise
cell filters.elm float (m) 10.0 8–20 Grid cell for the local-minimum search; larger cells find broader pits
threshold filters.elm float (m) 1.0 0.5–1.5 Elevation gap below the next-lowest point that marks a return as a pit
limits filters.range str Classification![7:7],Classification![18:18] Range expression; ! negates so noise classes are excluded

Expected output and verification

A correct run writes a LAZ with fewer points than the input — the difference is the removed noise — and no returns remaining in Class 7 or 18. The vertical extent should also tighten: the implausible maximum and minimum Z values from birds and pits disappear. Verify both the point-count drop and the surviving Z range:

import numpy as np
import laspy

def verify_denoise(raw_laz: str, clean_laz: str) -> dict:
    """Confirm noise was removed and no Class 7/18 remains in the clean cloud."""
    with laspy.open(raw_laz) as fh:
        raw = fh.read()
    with laspy.open(clean_laz) as fh:
        clean = fh.read()
    clean_cls = np.asarray(clean.classification)
    z = np.asarray(clean.z)
    report = {
        "removed": len(raw.points) - len(clean.points),
        "residual_noise": int(((clean_cls == 7) | (clean_cls == 18)).sum()),
        "z_min": float(z.min()),
        "z_max": float(z.max()),
    }
    assert report["residual_noise"] == 0, "noise classes still present — check filters.range limits"
    assert report["removed"] > 0, "nothing removed — multiplier likely too high"
    return report

print(verify_denoise("input_tile.laz", "denoised_tile.laz"))

Running pdal info --stats denoised_tile.laz reports the same Z minimum and maximum from the command line; both should now sit within physically plausible bounds for the terrain and canopy. If the maximum is still hundreds of metres above the ground, a high bird survived — lower multiplier toward 2.0 or add a radius-mode pass.

Common pitfalls

  • Removing too much on steep slopes. A low multiplier (below 2.0) flags legitimate returns on steep ravines and ridgelines as outliers, thinning true terrain. Start at 2.2 and only tighten if noise survives.
  • Flagging without removing. filters.outlier only reclassifies to Class 7/18. If you forget the filters.range cleanup (or an ignore on the ground filter), the noise is still in the cloud and still poisons rasterization.
  • ELM cell too small. A tiny cell in filters.elm treats normal micro-relief as pits and over-marks ground. Keep cell at 8–20 m so only genuine below-surface artifacts are caught.
  • Wrong negation syntax. Classification[7:7] keeps only Class 7; you want Classification![7:7] to exclude it. Mixing these up silently deletes every return except the noise.

Frequently Asked Questions

Should I run filters.elm before or after filters.outlier?

Run filters.elm first. It targets low pits that the statistical outlier test can miss when several noise points sit close together and mutually raise their neighbour counts. Marking those pits as Class 7 up front means the subsequent statistical pass and the ground filter both see a cleaner low surface.

Does filters.outlier delete points or just tag them?

It only tags them — Class 7 for low noise, Class 18 for high noise — and leaves them in the cloud. To physically remove them, add a filters.range stage that excludes those classes, or configure the downstream ground filter to ignore Class 7 and 18 so they cannot be classified as ground.

Up: LiDAR Point Cloud Preprocessing · Canopy Height Modeling & Terrain Extraction