Classifying Ground with filters.csf

TL;DR: Remove noise, reset any existing class 2 to 1, run {"type": "filters.csf", "resolution": 1.0, "rigidness": 2, "threshold": 0.5}, then write the classified cloud and a 1 m IDW DTM from ground points. Check the ground share against neighbouring tiles and inspect a hillshade before trusting it.

# Context and Motivation

This guide is part of CSF Cloth Simulation Ground Filtering. The topic explains how the cloth settles; this page is the shortest path from a raw tile to a classified cloud and terrain model, with the few decisions that actually matter made explicit. It suits a first pass on a new project, a comparison against an existing vendor classification, or a batch job where CSF has already been chosen.

The run below targets rolling terrain with mixed land cover — farmland, woodland and scattered buildings — which is where CSF’s default behaviour is closest to right without tuning.

Five stages, two outputs A pipeline of stages: readers.las, a range filter removing noise, filters.assign resetting old ground to class 1, and filters.csf. From there one branch writes the classified LAZ and another keeps class 2 and writes a DTM GeoTIFF. readers.las drop 7, 18 reset 2 → 1 filters.csf classified LAZ class 2 → DTM

# Prerequisites and Assumptions

  • PDAL 2.1+ with filters.csf and the Python bindings.
  • A tile in a projected CRS in metres, ideally with a 30–50 m buffer from its neighbours.
  • Noise classified as 7 and 18, or an outlier step added before CSF.
  • Ground point spacing around 1 m or finer (roughly 4+ last returns per m²).

# Step-by-Step Implementation

# Step 1 — Remove noise

A single low outlier becomes the top of the inverted cloud and catches the cloth. Drop classes 7 and 18 immediately after reading.

# Step 2 — Reset previous ground

Move existing class 2 points to class 1 with filters.assign, so the output ground reflects CSF alone.

# Step 3 — Run filters.csf

Resolution near ground spacing, rigidness 2 for rolling terrain, threshold 0.5 m, slope smoothing on.

# Step 4 — Write both outputs

Tag the classified view, write it to LAZ, and branch a ground-only view to writers.gdal for the DTM.

# Step 5 — Check the result

Compare ground share with neighbouring tiles, count ground points that are early returns, and render a hillshade.

# Complete Working Example

json
{
  "pipeline": [
    { "type": "readers.las", "filename": "tiles/rolling_0417.laz" },
    { "type": "filters.range", "limits": "Classification![7:7],Classification![18:18]" },
    { "type": "filters.assign", "value": ["Classification = 1 WHERE Classification == 2"] },
    { "type": "filters.csf", "resolution": 1.0, "rigidness": 2, "threshold": 0.5,
      "smooth": true, "iterations": 500, "step": 0.65 },
    { "type": "writers.las", "filename": "out/rolling_0417_csf.laz",
      "minor_version": 4, "dataformat_id": 6, "forward": "all", "tag": "classified" },
    { "type": "filters.range", "inputs": ["classified"], "limits": "Classification[2:2]" },
    { "type": "writers.gdal", "filename": "out/rolling_0417_dtm.tif", "resolution": 1.0,
      "output_type": "idw", "window_size": 6, "data_type": "float32",
      "gdalopts": "COMPRESS=DEFLATE,TILED=YES" }
  ]
}

Run it and check the obvious things in Python:

python
"""Run the CSF pipeline and print quick quality indicators."""
import json
import subprocess
from pathlib import Path

import numpy as np
import pdal

subprocess.run(["pdal", "pipeline", "csf_rolling.json"], check=True)

p = pdal.Pipeline(json.dumps({"pipeline": ["out/rolling_0417_csf.laz"]}))
p.execute()
a = p.arrays[0]
g = a["Classification"] == 2
early = a["ReturnNumber"] < a["NumberOfReturns"]
print(f"ground share {g.mean():.1%}")
print(f"ground points that are early returns: {(g & early).sum():,} ({(g & early).sum() / g.sum():.2%})")

subprocess.run(["gdaldem", "hillshade", "-multidirectional", "out/rolling_0417_dtm.tif",
                "out/rolling_0417_hs.tif"], check=True)
print("hillshade written for visual inspection:", Path("out/rolling_0417_hs.tif").exists())
Is this tile in line with its neighbours? Bars of ground share for the processed tile and its eight neighbours. The neighbours range from 41 to 52 percent. The processed tile, highlighted, shows 47 percent, well inside the range, which is a quick sign the run behaved normally. 47 % this tile neighbours 41–52 % ground (illustrative)

# Running It Across a Project

The single-tile pipeline scales to a whole project with three additions. First, parameterize the input and output paths — command-line overrides are enough, --readers.las.filename=... --stage.classified.filename=... — so one validated JSON file serves every tile, as described in overriding stage options from the command line. Second, feed each tile its neighbours’ edge strips and add a crop before both writers, so every tile is classified with context and written without overlap. Third, keep the per-tile quality indicators — ground share, early-return ground count — in a CSV as each tile finishes.

That CSV is the most useful artefact of the batch. Sorted by ground share relative to the neighbourhood median, it tells you which few tiles to open in a viewer out of thousands. In practice most outliers are explained by land cover — a reservoir, a quarry, a dense town centre — and the handful that are not usually share a cause, such as a flight block with sparse ground returns where a coarser cloth helps.

CSF is single-threaded, so throughput comes from running tiles in parallel processes with OMP_NUM_THREADS=1; see load balancing uneven LiDAR tiles for keeping every core busy when tile sizes vary.

# Key Parameter Table

Option Value here When to change
resolution 1.0 m Finer for dense drone data, coarser (1.5–2) for sparse data
rigidness 2 3 on flat land and in towns, 1 in mountains
threshold 0.5 m 0.3 m if low vegetation creeps into ground
smooth true Keep on unless the terrain is entirely flat
iterations 500 200–300 on flat land to save time
DTM resolution 1.0 m Match ground density; see choosing a DTM resolution

# Verification

  • Ground share within the range of similar neighbouring tiles.
  • Early-return ground under about 1 percent of ground points; more means the cloth sagged into canopy.
  • Hillshade free of building-shaped bumps and without flattened ridge lines.
  • Checkpoints, if available: the DTM compared against surveyed ground as in vertical accuracy assessment.

# Gotchas and Edge Cases

Buffered input, cropped output. Run on a buffered tile and crop both outputs back to the nominal extent; otherwise edge effects appear as a frame around every tile in the mosaic.

Existing ground classes. Skipping the reset merges old and new ground. That can be intended — “accept vendor ground plus anything CSF finds” — but decide explicitly.

Water. Water returns few last returns, so the cloth spans lakes. The DTM then interpolates across water, which you will hydro-flatten later; see hydro-flattening water bodies in a DTM.

Buffer, then crop Two tile outlines. The unbuffered tile shows a band of unreliable ground classification along its edges. The buffered run processes a larger area whose unreliable band lies outside the nominal tile; cropping back keeps only the reliable interior. unbuffered: unreliable rim nominal tile: all reliable

Very steep terrain. Rolling-terrain settings cut off ridges and river banks in mountains. Move to rigidness 1 and consider slope-adaptive tuning, covered in tuning CSF cloth resolution and rigidness.

# Frequently Asked Questions

What is a good default configuration for filters.csf?

For rolling terrain at about 8 to 15 points per square metre: resolution 1.0, rigidness 2, threshold 0.5 and slope smoothing on. Adjust rigidness for flat or steep terrain and resolution for density.

Why reset existing ground before running CSF?

So the output reflects only CSF’s decisions. Without the reset, points the vendor called ground stay ground even if CSF would reject them, which makes comparisons and tuning misleading.

Can I write the classified cloud and DTM in one run?

Yes. Tag the stage after filters.csf, write it with writers.las, and start a second branch from that tag that keeps class 2 and ends in writers.gdal.

How do I know CSF worked on a tile?

Compare its ground share with neighbouring tiles, check that very few ground points are early returns of multi-return pulses, and inspect a hillshade of the DTM for bumps and flattened ridges.