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.
# Prerequisites and Assumptions
- PDAL 2.3+ for the
value/WHEREsyntax;pdal --options filters.assignshows which form your build accepts. - The dimensions your conditions reference must exist before the stage —
HeightAboveGroundneedsfilters.hag_nnorfilters.hag_delaunayupstream. - 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:
{
"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:
{ "type": "filters.assign", "value": [
"Classification = 1 WHERE Classification == 2",
"Classification = 1 WHERE Classification == 8"
]}And a Python check of what changed:
"""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:
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.
# 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.
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.
# Related
- Attribute Mapping in PDAL Pipelines — dimensions and their mapping
- Copying Dimensions with filters.ferry — keeping a copy before overwriting
- Remapping Vendor Classification Codes to ASPRS — bulk code translation
- Filtering Points with filters.expression — the expression language used in WHERE
- Counting Points per Class with PDAL — histograms for verification