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.

Connected by short steps Two groups of points with edges drawn between every pair closer than the tolerance. Within each group, edges connect every point to the rest. Between the groups, the shortest gap is longer than the tolerance, so no edge crosses and the groups receive ClusterID 1 and 2. A scale bar shows the tolerance length. gap > tolerance: no link ClusterID 1 ClusterID 2 tolerance

# Prerequisites and Assumptions

  • PDAL 2.x with filters.cluster; max_points and is3d are available in current releases (pdal --options filters.cluster lists 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

json
{
  "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:

python
"""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
Tolerance on a car park Bars showing car-sized groups found at five tolerances on a car park with 64 parked cars. At 0.2 metres only 21 are found because cars fragment. At 0.4 metres 55. At 0.6 metres 63. At 1.0 metre 49 because adjacent cars merge. At 1.5 metres 22. 64 cars present 21 55 63 49 22 0.2 m 0.4 m 0.6 m 1.0 m 1.5 m illustrative, 25 pts/m² (spacing about 0.2 m)

# 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.
python
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.

One stray point is enough Two car-shaped point groups side by side, 0.9 metres apart. A single ground return left in the input sits between them, within the tolerance of both. Edges connect it to each car, so both cars and the ground point become one group. With ground excluded, the same cars form two groups. ground return inside tolerance of both car A car B result: one group — exclude ground and it becomes two

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.