Assigning Classification with Conditional filters.assign

TL;DR: {"type": "filters.assign", "value": ["Classification = 3 WHERE Classification == 1 && HeightAboveGround < 0.5", "Classification = 5 WHERE Classification == 1 && HeightAboveGround >= 2.0"]} applies each rule in order, only to points matching its WHERE expression. Rules run sequentially, so later rules see the results of earlier ones — write them so order does not matter, or order them deliberately.

# Context and Motivation

This guide is part of Attribute Mapping in PDAL Pipelines. Much of classification work is not detection but bookkeeping: split class 1 into vegetation classes by height, reset all ground to class 1 before running a better ground filter, move points inside water polygons to class 9, promote a model’s candidate label to the real classification only where confidence is high. Doing this in NumPy means reading the whole cloud into Python and writing it back. filters.assign does it inside the pipeline, streams, and records the rule in the pipeline JSON where reviewers can see it.

Since PDAL 2.3 the stage accepts a list of value rules in Dimension = expression WHERE condition form. Older pipelines use the assignment plus condition pair, which still works but allows only one rule per stage.

Three rules, three height bands A vertical height axis with three bands. Class 1 points below 0.5 metres above ground become class 3, low vegetation. Points from 0.5 to 2 metres become class 4, medium vegetation. Points at 2 metres and above become class 5, high vegetation. Points already in other classes, such as ground and buildings, are untouched because every rule requires Classification equal to 1. HAG < 0.5 m → Classification = 3 (low vegetation) 0.5 ≤ HAG < 2.0 m → Classification = 4 (medium vegetation) HAG ≥ 2.0 m → Classification = 5 (high vegetation) 0.5 2.0 every rule also requires Classification == 1

# Prerequisites and Assumptions

  • PDAL 2.3+ for the value / WHERE syntax; pdal --options filters.assign shows which form your build accepts.
  • The dimensions your conditions reference must exist before the stage — HeightAboveGround needs filters.hag_nn or filters.hag_delaunay upstream.
  • A clear specification of what each class means for the project; see understanding ASPRS classification codes.

# Step-by-Step Implementation

# Step 1 — Compute the dimensions the rules need

Add filters.hag_nn (or whatever computes the evidence) before the assign stage.

# Step 2 — Write each rule with an explicit source class

Always include the class you are changing from — WHERE Classification == 1 && ... — so a rule can never overwrite ground, buildings or anything else you did not intend.

# Step 3 — Make rules mutually exclusive

Non-overlapping conditions make rule order irrelevant, which makes the stage easy to reason about. The vegetation split uses half-open intervals (< 0.5, >= 0.5 && < 2.0, >= 2.0) for that reason.

# Step 4 — Use ordered rules only deliberately

When a rule must see an earlier rule’s result — reset then reassign — put them in one stage in that order and comment the intent in the surrounding code.

# Step 5 — Verify changes with a before-and-after histogram

Compare class counts on either side of the stage and check that only the intended classes moved.

# Complete Working Example

Height-based vegetation classes, water reassignment from an overlay, and a reset before a new ground run, in one pipeline:

json
{
  "pipeline": [
    "tiles/t_0431.laz",
    { "type": "filters.range", "limits": "Classification![7:7],Classification![18:18]" },
    { "type": "filters.hag_nn", "count": 2 },
    { "type": "filters.assign", "value": [
        "Classification = 3 WHERE Classification == 1 && HeightAboveGround >= -0.5 && HeightAboveGround < 0.5",
        "Classification = 4 WHERE Classification == 1 && HeightAboveGround >= 0.5 && HeightAboveGround < 2.0",
        "Classification = 5 WHERE Classification == 1 && HeightAboveGround >= 2.0"
    ]},
    { "type": "writers.las", "filename": "out/t_0431_veg.laz",
      "minor_version": 4, "dataformat_id": 6, "forward": "all" }
  ]
}

Resetting ground before running a different ground filter — two rules that must run in this order:

json
{ "type": "filters.assign", "value": [
    "Classification = 1 WHERE Classification == 2",
    "Classification = 1 WHERE Classification == 8"
]}

And a Python check of what changed:

python
"""Before/after class histograms around a filters.assign stage."""
import json

import numpy as np
import pdal

src = "tiles/t_0431.laz"
before = pdal.Pipeline(json.dumps({"pipeline": [src]})); before.execute()
after = pdal.Pipeline(json.dumps({"pipeline": ["out/t_0431_veg.laz"]})); after.execute()

hb = np.bincount(before.arrays[0]["Classification"], minlength=32)
ha = np.bincount(after.arrays[0]["Classification"], minlength=32)
for c in np.nonzero(hb + ha)[0]:
    delta = int(ha[c]) - int(hb[c])
    print(f"class {c:>2}: {hb[c]:>10,} -> {ha[c]:>10,}  ({delta:+,})")

Typical output shows class 1 draining into 3, 4 and 5, classes 7 and 18 removed by the range filter, and every other class unchanged:

text
class  1:  9,812,440 ->     61,203  (-9,751,237)
class  2: 14,208,991 -> 14,208,991  (+0)
class  3:          0 ->  2,114,780  (+2,114,780)
class  4:          0 ->  1,902,114  (+1,902,114)
class  5:          0 ->  5,734,343  (+5,734,343)
class  6:  3,301,522 ->  3,301,522  (+0)
class  7:     11,008 ->          0  (-11,008)

The 61,203 class-1 points left behind are those more than half a metre below the interpolated ground, which the first rule’s lower bound deliberately excludes — they are worth inspecting as possible low noise rather than silently calling them low vegetation.

When rules overlap, order decides Two orderings of two overlapping rules applied to a point with height above ground of 3 metres. In the first order, the rule for height above 2 metres runs first and sets class 5, then the rule for height above 1 metre only matches class 1 points, so class 5 stays. In the second order, the rule for height above 1 metre sets class 4 first, and the later rule that requires class 1 no longer matches, so the point ends as class 4. order A 1. = 5 WHERE C == 1 && HAG >= 2 2. = 4 WHERE C == 1 && HAG >= 1 point with HAG 3 m: ends as class 5 order B 1. = 4 WHERE C == 1 && HAG >= 1 2. = 5 WHERE C == 1 && HAG >= 2 point with HAG 3 m: ends as class 4

# Key Parameter Table

Form Example Notes
value rule "Classification = 3 WHERE ..." PDAL 2.3+; list of rules, applied in order
value expression "Z = Z - 0.12" Right side may be an expression, not just a constant
no WHERE "UserData = 0" Applies to every point
legacy assignment "Classification[:]=2" One rule per stage
legacy condition "HeightAboveGround[0:0.5]" Range syntax for the legacy form
stage where "where": "PointSourceId == 12" Restricts the whole stage, in addition to per-rule WHERE

# Verification

  • Histogram diff, as above: only intended source classes decrease and only intended target classes increase.
  • Left-behind points. Check for points still in the source class and understand why — negative HAG, missing dimensions, boundary values.
  • Idempotence. Running the stage twice should change nothing the second time; if it does, a rule’s target overlaps another rule’s source.

# Gotchas and Edge Cases

Half-open intervals. Writing < 2.0 in one rule and > 2.0 in the next leaves points at exactly 2.0 unassigned. Use < with >= consistently.

Gaps at the boundaries Two number lines of height above ground. On the first, rules use less-than 2.0 and greater-than 2.0, leaving the single value 2.0 uncovered, marked with an open circle and labelled unassigned. On the second, rules use less-than 2.0 and greater-than-or-equal 2.0, and the intervals meet exactly with no gap. < 2.0 and > 2.0 HAG = 2.0 matches neither rule < 2.0 and >= 2.0 intervals meet exactly; every value lands in one rule

Height values are doubles computed from interpolated ground, so an exact 2.0 is rare — but “rare” across forty million points means a handful of points left in class 1 on every tile, which then show up as speckle in a QA viewer and as an unexplained count in the histogram check.

Negative heights. Points just below the interpolated ground have negative HeightAboveGround. Decide whether they belong in the lowest band — HeightAboveGround < 0.5 without a lower bound includes them all — or, as in the example, bound the rule at −0.5 m so deeper points stay visible for review.

Legacy syntax in old pipelines. assignment and condition still work, but mixing them with value in one stage is confusing. Convert old pipelines when you touch them.

Assigning outside the dimension’s type. Classification is an 8-bit unsigned value in PDRF 6+. Assigning 300 wraps or fails depending on version. Keep classes within 0–255 and, for the ASPRS range, within the values your specification defines.

# Frequently Asked Questions

Can filters.assign apply several rules in one stage?

Yes. The value option takes a list of rules, each of the form Dimension = expression WHERE condition, applied in order. Later rules see the results of earlier ones.

What is the difference between value and the older assignment option?

value accepts multiple rules with full expression syntax in both the assigned value and the WHERE condition. The older assignment and condition pair accepts one rule per stage with range syntax. Both remain supported.

How do I split unclassified points into vegetation classes by height?

Compute HeightAboveGround first, then use three rules restricted to Classification equal to 1 with non-overlapping height intervals, assigning classes 3, 4 and 5.

Does filters.assign stream?

Yes. Each point is evaluated independently, so the stage works in streaming mode and adds almost no memory.