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.
# Prerequisites and Assumptions
- PDAL 2.1+ with
filters.csfand 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
{
"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:
"""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())# 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.
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.
# Related
- CSF Cloth Simulation Ground Filtering — how the method works
- Tuning CSF Cloth Resolution and Rigidness — systematic tuning
- CSF vs SMRF for Forested Ground — comparing methods
- Generating a DTM GeoTIFF with writers.gdal — the DTM branch
- Removing Noise Classes Before Processing — why step 1 matters