Applying Statistical Outlier Filters in PDAL

TL;DR: Insert filters.outlier with "method": "statistical" into your PDAL pipeline immediately after the reader to remove noise points; set mean_k between 10 and 30 and multiplier between 2.0 and 3.0 for typical airborne LiDAR.

This guide is part of Pipeline Filtering Logic, the parent reference for sequencing and combining filter stages in a PDAL execution model. Statistical outlier removal (SOR) is the most common first cleaning pass applied to raw LiDAR returns—before ground classification, rasterization, or any measurement that depends on surface continuity.

# Context and Motivation

Raw LiDAR acquisitions contain returns that no classification code can meaningfully represent: atmospheric scatter at apogee, bird strikes crossing the scan plane, multipath reflections off glass facades, and solar noise in single-photon systems. These isolated points have no spatial neighbors that resemble them; they appear as lone spikes well above or below the true surface.

Fixed-threshold Z-range filters catch gross spikes but miss noise at mid-elevation because they have no knowledge of local point density. Statistical outlier removal solves this by measuring each point’s relationship to its k nearest neighbors rather than checking an absolute bound. A point is an outlier only if it is unusually far from its local neighborhood—not because it exceeds a fixed threshold. This density-awareness makes SOR essential for mixed-resolution datasets where a single Z cutoff would destroy valid sparse returns in rural areas while leaving dense urban noise untouched.

Within a broader PDAL Pipeline Architecture & Execution workflow, SOR sits at the boundary between raw acquisition data and analytically meaningful point clouds. Everything downstream—ground models, tree height extraction, building footprints—benefits from a clean input. The pipeline validation stage you run before deployment will flag a missing or misnamed filter immediately, catching configuration errors before they propagate to expensive downstream steps.

SOR algorithm: each point queries k nearest neighbours; outliers exceed mean+multiplier×stddev Left panel: pipeline stage flow showing readers.las feeding into filters.outlier then into classification or writers.las. Right panel: scatter of points showing a normal point surrounded by neighbours within the sigma band, and an outlier point isolated far from its neighbours. readers.las raw returns filters.outlier method: statistical mean_k · multiplier classification / writers.las pipeline stage order k-d tree query (k=5) query pt mean dist within σ band → KEPT outlier dist > mean+n×σ → REMOVED

# Prerequisites and Assumptions

  • PDAL 2.4 or later — earlier releases have different parameter defaults for filters.outlier.
  • Python 3.10+ with pdal bindings installed via conda install -c conda-forge python-pdal or pip install pdal.
  • Input file format: LAS 1.2–1.4 or LAZ. The stage reads the X, Y, Z dimensions; it does not require Classification or any custom dimension.
  • numpy for in-memory array access after execution.
  • A test tile of 5–20 million points is recommended for initial parameter calibration before processing full flight lines.

Verify the setup:

python
import pdal
print(pdal.__version__)   # should be 2.4.x or higher

# Step-by-Step Implementation

# Step 1 — Build the minimal pipeline

The bare-minimum pipeline to apply statistical outlier removal reads a file, filters it, and writes a cleaned output:

json
{
  "pipeline": [
    {
      "type": "readers.las",
      "filename": "input_raw.laz"
    },
    {
      "type": "filters.outlier",
      "method": "statistical",
      "mean_k": 20,
      "multiplier": 3.0
    },
    {
      "type": "writers.las",
      "filename": "output_clean.laz",
      "forward": "all"
    }
  ]
}

"forward": "all" on the writer preserves every original point attribute (intensity, return number, scan angle, any custom dimensions) that the filter stage did not modify. Omitting it drops non-standard dimensions silently.

# Step 2 — Choose parameters for your acquisition type

The two parameters that control how aggressively points are removed:

Parameter Type Default Airborne range Terrestrial / mobile range Effect
mean_k int 8 1030 3060 Neighborhood size for the k-d tree query. Too low → noisy local mean; too high → smooths genuine edges.
multiplier float 2.0 2.53.5 2.03.0 Sigma threshold. Values below 1.5 will prune valid sparse features; above 4.0 rarely removes meaningful noise.

For a first pass on an unknown airborne dataset, mean_k=20 and multiplier=3.0 are safe starting values. Validate on a representative 5 km² tile before committing to full-project parameters.

# Step 3 — Position the stage correctly in a real pipeline

In any production pipeline that continues to PDAL stage chaining for classification or surface modelling, place filters.outlier immediately after the reader and any CRS-setting step, but before any classification or rasterization stage:

python
pipeline_def = [
    {"type": "readers.las", "filename": "survey.laz"},
    # Assign CRS if not already embedded in the header
    {"type": "filters.reprojection", "in_srs": "EPSG:32618", "out_srs": "EPSG:32618"},
    # Statistical outlier removal first — before SMRF or any feature extractor
    {"type": "filters.outlier", "method": "statistical", "mean_k": 20, "multiplier": 3.0},
    # Ground classification after cleaning
    {"type": "filters.smrf", "slope": 0.15, "window": 18.0, "threshold": 0.5, "scalar": 1.2},
    {"type": "writers.las", "filename": "classified_clean.laz", "forward": "all"},
]

Running SOR after filters.smrf risks removing valid ground points that SMRF has already classified as class 2, which corrupts DTM generation downstream.

# Step 4 — Execute and capture results

python
import pdal

pipeline = pdal.Pipeline(pipeline_def)
count = pipeline.execute()
print(f"Points retained: {count}")

pipeline.execute() returns the total number of points that passed through to the final stage. For typical airborne acquisitions you should see 0.1–2 % of points removed by SOR; more than 5 % warrants investigating whether multiplier is too aggressive for your data density.

# Complete Working Example

The following self-contained module can be copy-pasted and run against any LAS/LAZ file. It handles both file-output and in-memory array modes, logs progress, and validates results before returning:

python
import json
import sys
from typing import Any, Dict, Optional

import numpy as np
import pdal


def apply_statistical_filter(
    input_path: str,
    output_path: Optional[str] = None,
    mean_k: int = 20,
    multiplier: float = 3.0,
) -> Dict[str, Any]:
    """
    Apply PDAL's statistical outlier filter to a LAS/LAZ file.

    Args:
        input_path:  Path to source LAS/LAZ file.
        output_path: Optional path for the cleaned output. If None, results
                     are only available as an in-memory NumPy array.
        mean_k:      Number of nearest neighbours for the local mean distance.
        multiplier:  Standard-deviation threshold; points beyond this multiple
                     of the global mean distance are removed.

    Returns:
        Dict with 'points_kept' (int), 'points_removed' (int),
        and 'metadata' (dict).
    """
    # Count raw points before filtering for comparison
    count_pipeline = pdal.Pipeline(
        [{"type": "readers.las", "filename": input_path}]
    )
    raw_count = count_pipeline.execute()

    pipeline_stages: list = [
        {"type": "readers.las", "filename": input_path},
        {
            "type": "filters.outlier",
            "method": "statistical",
            "mean_k": mean_k,
            "multiplier": multiplier,
        },
    ]

    if output_path:
        pipeline_stages.append(
            {
                "type": "writers.las",
                "filename": output_path,
                "forward": "all",
            }
        )

    pipeline = pdal.Pipeline(pipeline_stages)

    try:
        kept = pipeline.execute()
    except RuntimeError as exc:
        print(f"Pipeline failed: {exc}", file=sys.stderr)
        sys.exit(1)

    removed = raw_count - kept
    pct_removed = (removed / raw_count * 100) if raw_count else 0
    print(f"Input points : {raw_count:,}")
    print(f"Points kept  : {kept:,}")
    print(f"Points removed: {removed:,} ({pct_removed:.2f}%)")

    if pct_removed > 5.0:
        print(
            "WARNING: >5% of points removed. Consider raising multiplier "
            "or increasing mean_k to reduce over-filtering.",
            file=sys.stderr,
        )

    return {
        "points_kept": kept,
        "points_removed": removed,
        "metadata": pipeline.metadata,
    }


def get_filtered_array(
    input_path: str,
    mean_k: int = 20,
    multiplier: float = 3.0,
) -> np.ndarray:
    """Return the filtered cloud as a NumPy structured array (no disk write)."""
    pipeline = pdal.Pipeline(
        [
            {"type": "readers.las", "filename": input_path},
            {
                "type": "filters.outlier",
                "method": "statistical",
                "mean_k": mean_k,
                "multiplier": multiplier,
            },
        ]
    )
    pipeline.execute()
    return pipeline.arrays[0]


if __name__ == "__main__":
    result = apply_statistical_filter(
        "input_raw.laz",
        "output_clean.laz",
        mean_k=20,
        multiplier=3.0,
    )
    # Confirm CRS survived the filter pass
    meta = json.loads(result["metadata"])
    reader_meta = meta["metadata"]["readers.las"][0]
    srs_wkt = reader_meta.get("srs", {}).get("wkt", "No CRS embedded")
    print(f"Output CRS: {srs_wkt[:80]}...")

# Key Parameter Table

Parameter Type Default Recommended range Tuning guidance
mean_k int 8 1060 Airborne: 10–30. Terrestrial/mobile: 30–60. Too low produces a noisy local mean; too high smooths genuine sharp edges like building corners.
multiplier float 2.0 1.54.5 Below 1.5 removes valid sparse features. Above 4.0 rarely catches real noise. Start at 3.0 for airborne, 2.5 for terrestrial.
method string "statistical" "statistical" or "radius" "statistical" uses k-NN density globally. "radius" removes points with fewer than min_k neighbours within a fixed radius—useful for removing isolated specks in mobile scans.
classify bool true When true, outliers are flagged as classification 7 (noise) rather than dropped, preserving the full point count while marking suspect returns for downstream inspection.
extract bool false When true, only the outlier points are passed to the next stage—useful for auditing what the filter would remove before committing to a destructive run.

# Verification

After execution, confirm three things:

1. Point count decreased as expected:

python
result = apply_statistical_filter("raw.laz", "clean.laz")
assert result["points_kept"] > 0, "Pipeline produced empty output"
assert result["points_removed"] > 0, "No points were removed — check parameters"
# For typical airborne data, SOR removes 0.1–2% of points
assert result["points_kept"] / (result["points_kept"] + result["points_removed"]) > 0.95, \
    "More than 5% removed — likely over-filtering"

2. Output dimensions are intact:

python
arr = get_filtered_array("raw.laz")
expected_dims = {"X", "Y", "Z", "Intensity", "ReturnNumber", "Classification"}
actual_dims = set(arr.dtype.names)
assert expected_dims.issubset(actual_dims), f"Missing dims: {expected_dims - actual_dims}"

3. CRS is preserved:

python
import json
meta = json.loads(result["metadata"])
reader_meta = meta["metadata"]["readers.las"][0]
print(reader_meta.get("srs", {}).get("wkt", "No CRS embedded"))

You can also run pdal info --stats output_clean.laz from the command line and compare the Z range and point count against the raw file to spot gross over-filtering quickly.

# Gotchas and Edge Cases

Stage name confusion. The correct PDAL stage is filters.outlier with "method": "statistical". There is no filters.statistical stage; using that name causes a RuntimeError: Couldn't create stage 'filters.statistical'. The pipeline validation workflow catches this before any data is read if you run pdal --validate on your JSON definition during CI.

Applying SOR after classification removes valid sparse classes. Transmission towers, isolated trees, and bridge decks have far fewer neighbors than the surrounding ground; SOR sees them as outliers. If your project requires preserving classified returns, run SOR on the raw, unclassified cloud first, or apply it selectively via filters.expression to subset only unclassified points before the cleaning pass.

Tile-edge artifacts. The k-d tree is built per pipeline invocation. Points at tile boundaries have artificially truncated neighborhoods because their true neighbors sit in the adjacent tile. Apply a 5–10 m overlap buffer when tiling with filters.splitter, then strip the buffer after SOR and before merging outputs. Alternatively, consult the memory management strategies for processing larger in-memory chunks that span tile boundaries without splitting.

k-d tree memory scaling. Memory consumption for the neighborhood index grows with point count at roughly O(n log n). For datasets above 50 million points per tile, monitor peak RAM with tracemalloc during development and budget at least 16 GB for 100 M-point tiles. If processing fails with an std::bad_alloc error, reduce tile size before adjusting filter parameters.

Pre-filtered sensor data. Some survey-grade scanners apply their own noise rejection before writing LAS. Running SOR on already-clean data with a low multiplier can strip valid but sparse features. Check pdal info --metadata input.laz for manufacturer-provided noise flags or pre-existing classification values before adding a redundant filter stage.