Euclidean Segmentation with filters.cluster
TL;DR: filters.cluster links any two points closer than tolerance and writes a ClusterID per connected group. Set tolerance to two to three times the median point spacing, min_points to the smallest object you care about, max_points to catch merged blocks, keep is3d: true unless you want plan-view footprints, and restrict it with a where clause so ground never links everything together.
# Context and Motivation
This guide is part of Point Cloud Segmentation with PDAL. Euclidean segmentation is the simplest grouping rule there is: points belong together if you can walk from one to the other in steps no longer than the tolerance. That simplicity is its strength. It is fast, deterministic, has essentially one parameter, and does exactly what you expect on well-separated objects — parked cars on an empty car park, buildings with gardens between them, poles along a road.
Its weakness is equally simple: any chain of points closer than the tolerance joins two objects, however thin. A hedge touching a house, a branch overhanging a roof, or a single noise return between two cars is enough. Most of the craft is in what you feed the stage, not in the stage itself.
# Prerequisites and Assumptions
- PDAL 2.x with
filters.cluster;max_pointsandis3dare available in current releases (pdal --options filters.clusterlists them). - A projected CRS in metres.
- An idea of median point spacing on the objects to segment — for airborne data, roughly
1/sqrt(density), so 0.3 m at 10 pts/m².
# Step-by-Step Implementation
# Step 1 — Restrict to candidate points
Drop ground and noise, and cut to a height band, so that the terrain cannot connect objects. Either filter them out, or use a where clause on the cluster stage to keep every point in the output.
# Step 2 — Set tolerance from spacing
Start at 2.5 × median spacing. Too small fragments objects along sparse edges; too large merges neighbours.
# Step 3 — Set size guards
min_points relabels small groups to 0; max_points relabels oversized groups to 0. Both keep the object table clean and make failures visible.
# Step 4 — Choose 2D or 3D
With is3d: true, distance includes Z. With false, only plan position matters, which merges stacked objects — useful for footprints, harmful for bridges over roads.
# Step 5 — Run and inspect the label distribution
Count points per ClusterID, check how many are 0, and look at the largest groups first.
# Complete Working Example
{
"pipeline": [
{ "type": "readers.las", "filename": "carpark_03.laz" },
{ "type": "filters.hag_nn", "count": 2 },
{
"type": "filters.cluster",
"tolerance": 0.6,
"min_points": 40,
"max_points": 20000,
"is3d": true,
"where": "HeightAboveGround > 0.4 && HeightAboveGround < 3.5 && Classification != 2"
},
{
"type": "writers.las",
"filename": "carpark_03_vehicles.laz",
"minor_version": 4,
"dataformat_id": 6,
"extra_dims": "ClusterID=int64,HeightAboveGround=float"
}
]
}And the Python that summarises the result:
"""Run the vehicle segmentation and summarise groups."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pandas as pd
import pdal
spec = json.loads(Path("cluster_vehicles.json").read_text())
p = pdal.Pipeline(json.dumps(spec))
p.execute()
a = p.arrays[0]
df = pd.DataFrame({"x": a["X"], "y": a["Y"], "h": a["HeightAboveGround"], "id": a["ClusterID"]})
unassigned = int((df.id == 0).sum())
groups = (df[df.id > 0].groupby("id")
.agg(n=("x", "size"), dx=("x", np.ptp), dy=("y", np.ptp), h=("h", "max")))
groups["length"] = groups[["dx", "dy"]].max(axis=1)
vehicles = groups[groups.length.between(3.0, 6.5) & groups.h.between(1.2, 2.2)]
print(f"{len(groups)} groups, {unassigned} unassigned points, {len(vehicles)} car-sized groups")The example segments parked cars: objects between 0.4 and 3.5 m above ground, then kept if they are 3 to 6.5 m long and 1.2 to 2.2 m tall. The same shape — restrict, cluster, filter by extent — works for any discrete object class.
# Key Parameter Table
| Option | Type | Default | Guidance |
|---|---|---|---|
tolerance |
float | 1.0 | 2–3 × median spacing; the single most important setting |
min_points |
int | 1 | Smallest meaningful object; always raise from the default |
max_points |
int | unlimited | Largest plausible object; oversized groups become 0 |
is3d |
bool | true | false for plan-view grouping only |
where |
expression | none | Segment a subset while keeping all points in the output |
# Verification
- Label accounting. Groups plus unassigned must equal the point count; segmentation never adds or removes points.
- Size histogram. Plot points per group on a log axis. A healthy result shows many small groups and a distinct population at the object size you expect.
- Stability test. Rerun at 0.8× and 1.2× the chosen tolerance. The number of object-sized groups should change little; if it swings, you are on a steep part of the curve above.
ids = a["ClusterID"].astype(np.int64)
assert (ids >= 0).all(), "filters.cluster never writes negative labels"
sizes = np.bincount(ids)[1:]
sizes = sizes[sizes > 0]
print(f"groups: {len(sizes)}, median size {np.median(sizes):.0f}, largest {sizes.max()}")# Gotchas and Edge Cases
Default min_points of 1. Every isolated point becomes its own group. The object table fills with thousands of one-point groups that slow every aggregation. Always set it.
Ground left in the input. One ground point between two objects connects them, and the whole tile becomes one group, which max_points then relabels to 0 — leaving you with apparently nothing. If almost everything is 0, check the restriction step first.
Overlap stripes. Where flightlines overlap, density doubles and spacing halves, so the same tolerance links more readily there. It rarely matters for well-separated objects, but it does for touching ones; use DBSCAN or a spacing-aware tolerance in such areas.
where versus removal. A where clause keeps excluded points in the output with ClusterID 0, which is what you want when writing classifications back. Removing points with filters.range is faster for analysis-only runs.
# Frequently Asked Questions
What tolerance should I use for filters.cluster?
Start at two to three times the median spacing between neighbouring points on your objects, then check that the number of object-sized groups is stable when you change the tolerance by twenty percent either way.
Why is ClusterID zero for many points?
Zero means unassigned: the point was in a group smaller than min_points or larger than max_points, or it was excluded by a where clause. Count the zeros and look at why they occur before tuning anything else.
Should is3d be true or false?
True in almost every case, because objects separated vertically, such as a bridge over a road, should stay apart. Use false when you deliberately want everything above one footprint merged, such as all storeys and balconies of one building.
Is filters.cluster deterministic?
Yes. Connected components do not depend on processing order, so the same input and tolerance always produce the same groups, although the numeric IDs assigned to them may differ between PDAL versions.
# Related
- Point Cloud Segmentation — choosing between Euclidean and density-based methods
- DBSCAN Segmentation with filters.dbscan — when thin bridges and clutter need handling
- Extracting Objects from Segment Labels — the object table and per-object files
- Building Extraction from LiDAR — Euclidean segmentation of roofs
- Filtering Points with filters.expression — writing the restriction expressions