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.outlieronly reclassifies to Class 7/18. If you forget thefilters.rangecleanup (or anignoreon the ground filter), the noise is still in the cloud and still poisons rasterization. - ELM cell too small. A tiny
cellinfilters.elmtreats normal micro-relief as pits and over-marks ground. Keepcellat 8–20 m so only genuine below-surface artifacts are caught. - Wrong negation syntax.
Classification[7:7]keeps only Class 7; you wantClassification![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.
Related
- LiDAR Point Cloud Preprocessing — the parent workflow this denoising step opens
- Normalizing LiDAR Point Clouds with PDAL — the height-above-ground step that assumes a denoised cloud
- Canopy Height Model Creation — where surviving noise would inflate canopy statistics
- Canopy Height Modeling & Terrain Extraction — the full point-cloud-to-canopy pipeline
Up: LiDAR Point Cloud Preprocessing · Canopy Height Modeling & Terrain Extraction