How to Filter a Point Cloud by Class and Return Number

Problem statement

Almost every point cloud operation starts with a selection, and the two columns that drive it β€” classification and return_number β€” are related in ways that are easy to get backwards.

Measured on a real 3DEP survey of 12,968,770 points:

ground points that are last returns:      100.0%
last returns that are classified ground:   38.4%

Every ground point is a last return, and fewer than two in five last returns are ground. Filtering by "last return" gives you a set two and a half times too large.

And the classes you expect may not exist. This survey is 77.1% class 1 (unclassified) and 22.9% class 2 (ground), with two noise points. There are no vegetation or building classes at all.

Quick answer

Check what is there before filtering:

import numpy as np

codes, counts = np.unique(classification, return_counts=True)
for code, count in zip(codes, counts):
    print(f"  class {code:2d}: {count:11,}  {count / len(classification):6.2%}")

ground = classification == 2
first = return_number == 1
last = return_number == number_of_returns
single = number_of_returns == 1
intermediate = (return_number > 1) & (return_number < number_of_returns)

print(f"  ground {ground.mean():.2%}, first {first.mean():.2%}, "
      f"last {last.mean():.2%}, single {single.mean():.2%}")
  class  1:  10,003,338  77.13%
  class  2:   2,965,430  22.87%
  class  7:           2   0.00%
  ground 22.87%, first 59.47%, last 59.47%, single 23.48%
Nested sets showing ground points entirely inside last returns, which are much larger, with single returns overlapping both.
Ground is a strict subset of last returns. The gap is what a ground classifier exists to resolve.

Step-by-step solution

1. Print the class histogram, always

The LAS standard defines classes 0–18. It does not require a survey to populate them. Vendors classify what they were paid to classify, and ground-only deliveries are common.

Code that filters classification == 5 on this survey returns an empty array and everything downstream fails with a confusing error about zero-length input.

2. Get the return predicates right

first        = return_number == 1
last         = return_number == number_of_returns
single       = number_of_returns == 1
intermediate = (return_number > 1) & (return_number < number_of_returns)

Two things people get wrong. last is not return_number == number_of_returns.max() β€” the maximum is a property of the file, not the pulse. And a single return is both first and last, which is correct and occasionally surprising in a Venn diagram.

Measured distribution:

return 1: 7,712,998  59.47%
return 2: 4,675,883  36.05%
return 3:   568,150   4.38%
return 4:    11,679   0.09%
return 5:        60   0.00%

76.5% of points came from a pulse that produced more than one return β€” a high figure, indicating well-vegetated ground and a sensor that resolves closely spaced echoes.

3. Choose the filter from what the product needs

product filter
DTM classification == 2
DSM everything, or first
canopy structure everything except noise
building detection classification == 6, if it exists
first-return intensity image first & (point_source_id == one_line)

4. Exclude noise explicitly

Classes 7 (low noise) and 18 (high noise) exist to be dropped. This survey has two class-7 points β€” the easy case. Unclassified low points are the ones that ruin a minimum-based DTM, and no class flags them.

usable = ~np.isin(classification, [7, 18])

5. Use last returns as a ground proxy only knowingly

If the survey has no ground class, last returns are the available approximation. Measured against the classified ground on this survey:

DTM from last returns vs from classified ground
  holes    7.48% vs 10.93%
  bias    -0.040 m mean, p1 -0.33 m, p99 +0.00 m

Fewer holes, and never higher β€” because a per-cell minimum over a superset can only fall. The 4 cm downward bias comes from low returns the classifier had excluded.

Return number distribution with 59.5 percent first returns, 36.1 percent second, 4.4 percent third and 0.1 percent fourth.
Three quarters of the points came from multi-return pulses β€” the vertical structure lidar is bought for.

Code examples

Example 1 β€” a filter builder that fails loudly

import numpy as np

CLASS_NAMES = {0: "never classified", 1: "unclassified", 2: "ground",
               3: "low vegetation", 4: "medium vegetation",
               5: "high vegetation", 6: "building", 7: "low noise",
               9: "water", 11: "snow", 18: "high noise"}
NOISE = (7, 18)


def build_filter(classification, return_number, number_of_returns,
                 classes=None, returns=None, drop_noise=True,
                 min_points=1000):
    """A boolean mask, with a clear error when a requested class is absent."""
    present = set(np.unique(classification).tolist())

    if classes is not None:
        missing = set(classes) - present
        if missing:
            names = ", ".join(f"{c} ({CLASS_NAMES.get(c, '?')})"
                              for c in sorted(missing))
            raise ValueError(
                f"requested class(es) {names} are not in this file. "
                f"Present: {sorted(present)}. Derive the property from the "
                "returns instead of relying on a classification."
            )
        mask = np.isin(classification, list(classes))
    else:
        mask = np.ones(len(classification), bool)

    if returns == "first":
        mask &= return_number == 1
    elif returns == "last":
        mask &= return_number == number_of_returns
    elif returns == "single":
        mask &= number_of_returns == 1
    elif returns == "intermediate":
        mask &= (return_number > 1) & (return_number < number_of_returns)
    elif returns is not None:
        raise ValueError(f"unknown return filter {returns!r}")

    if drop_noise:
        mask &= ~np.isin(classification, NOISE)

    print(f"  {int(mask.sum()):,} of {len(mask):,} points "
          f"({mask.mean():.2%}) after classes={classes} returns={returns}")
    if mask.sum() < min_points:
        raise ValueError(f"filter left only {int(mask.sum()):,} points β€” "
                         "check the class histogram before proceeding")
    return mask

The explicit error on a missing class is worth more than any other line here. Silently returning an empty array is how a batch job produces a hundred empty rasters.

Example 2 β€” the class-versus-return cross-tabulation

import numpy as np
import pandas as pd


def class_return_table(classification, return_number, number_of_returns):
    """Which classes appear in which returns? Usually revealing."""
    kind = np.full(len(classification), "intermediate", dtype=object)
    kind[number_of_returns == 1] = "single"
    kind[(return_number == 1) & (number_of_returns > 1)] = "first of many"
    kind[(return_number == number_of_returns) &
         (number_of_returns > 1)] = "last of many"

    table = pd.crosstab(pd.Series(classification, name="class"),
                        pd.Series(kind, name="return"), normalize="index")
    print(table.round(3).to_string())
    return table
return  first of many  intermediate  last of many  single
class
1               0.467         0.058         0.270   0.206
2               0.000         0.000         0.667   0.333
7               0.000         0.000         0.500   0.500

The ground row is the useful one: class 2 is 66.7% last-of-many and 33.3% single, and zero first-of-many or intermediate. That is exactly what physics predicts and a good check that the classification is sane.

A ground class with first-of-many returns in it would mean the classifier had accepted canopy tops as ground.

Example 3 β€” deriving height classes when the survey has none

import numpy as np


def height_classes(x, y, z, classification, dtm, transform, cell,
                   breaks=(0.5, 2.0, 5.0)):
    """Assign vegetation classes from height above ground."""
    left, top = transform.c, transform.f
    height, width = dtm.shape
    col = np.clip(((x - left) / cell).astype(int), 0, width - 1)
    row = np.clip(((top - y) / cell).astype(int), 0, height - 1)
    above = z - dtm[row, col]

    derived = np.array(classification, copy=True)
    unclassified = classification == 1
    ok = unclassified & np.isfinite(above)

    derived[ok & (above < breaks[0])] = 2                    # ground-ish
    derived[ok & (above >= breaks[0]) & (above < breaks[1])] = 3
    derived[ok & (above >= breaks[1]) & (above < breaks[2])] = 4
    derived[ok & (above >= breaks[2])] = 5

    codes, counts = np.unique(derived[ok], return_counts=True)
    for code, count in zip(codes.tolist(), counts.tolist()):
        print(f"    -> {code} {CLASS_NAMES.get(code, '?'):18} "
              f"{count:11,}  {count / ok.sum():6.2%}")
    print(f"  {int((~np.isfinite(above)).sum()):,} points had no ground "
          f"beneath them and keep their original class")
    return derived

This is height stratification, not classification: it says nothing about whether a point is vegetation or a wall. It is enough for canopy metrics and not enough for anything that needs to distinguish a tree from a building.

Explanation

Why every ground point is a last return

A pulse stops when it hits something opaque. Ground is opaque, so nothing beyond it returns, so a ground echo is necessarily the final one from that pulse.

Measured: 100.0% of class-2 points satisfy return_number == number_of_returns. That is not a coincidence, it is geometry, and it is a useful invariant to assert as a data-quality check.

Why so few last returns are ground

The converse fails badly β€” 38.4% here β€” because a pulse can stop on anything opaque. A roof, a dense branch cluster, a wall and a car all produce a last return well above the ground.

That gap is the whole reason ground classification exists as a separate, difficult step. Filtering by last return gives you a superset that includes every hard surface in the scene.

Under closed canopy the gap widens: many pulses never reach the ground at all, so their last return is a branch, and no filter on return number can tell.

Why "unclassified" does not mean "not ground"

Class 1 is "unclassified" β€” the classifier looked and did not assign a class, or was never asked. It is not a statement that the point is vegetation.

In a ground-only delivery, class 1 contains everything that is not ground: canopy, buildings, wires, vehicles and any ground point the filter missed. Treating class 1 as vegetation will include roofs, and treating it as noise will discard the entire above-ground scene.

Why single returns matter

A single return means the pulse hit something opaque with nothing in front of it β€” open ground, a roof, water, a road.

Measured, 23.5% of points overall are single returns and 33.3% of ground points are. The other two thirds of ground points are the last of several returns β€” the pulse passed through vegetation and still reached the ground.

That ratio is a useful summary of how obstructed the site is. Over genuinely open terrain the ground would be mostly single returns; here two thirds of ground echoes came through something first.

A cross-tabulation showing ground points only ever being last-of-many or single returns.
Zero in the first two columns is what a correct ground classification looks like.

Edge cases or notes

  • Print the class histogram before filtering. Many surveys classify only ground.
  • last is return_number == number_of_returns, per pulse β€” not the file maximum.
  • A single return is both first and last.
  • Class 1 is "unclassified", not "vegetation".
  • Drop classes 7 and 18 before any minimum-based reduction.
  • Ground points must all be last returns. If they are not, the classification is suspect.
  • Point formats below 6 cap returns at 5 and classes at 31.
  • Check withheld and synthetic flags; they mark points the vendor excluded.

FAQ

How do I select ground points from a point cloud?

classification == 2, after checking the class histogram β€” many surveys classify only ground, and some classify nothing.

Can I use last returns instead of ground classification?

Only knowingly. Every ground point is a last return, but only 38.4% of last returns are ground, so you get a superset that includes roofs and dense canopy.

What does classification 1 mean?

Unclassified β€” the classifier did not assign a class. It is not a statement that the point is vegetation, and in a ground-only delivery it contains the entire above-ground scene.

How do I select first and last returns?

return_number == 1 for first, return_number == number_of_returns for last. A single return satisfies both.

Why does my class filter return nothing?

The class is not in the file. Print np.unique(classification) first; vegetation and building classes are frequently absent.

Should I drop noise classes?

Yes, before any minimum- or maximum-based reduction. Classes 7 and 18 exist for exactly that purpose.

What if I need vegetation classes and the survey has none?

Derive height above ground from a DTM and stratify by height. That is height stratification, not classification β€” it cannot tell a tree from a wall.