Remapping Vendor Classification Codes to ASPRS
TL;DR: Put the mapping in a JSON file next to the pipeline, apply it with filters.assign expressions ordered so no rule can undo an earlier one, and assert afterwards that the output contains only codes in your target schema.
# Context and Motivation
This guide is part of ASPRS Classification Codes, which covers the standard and its version differences. This page deals with the practical consequence of that standard being widely ignored: every delivery arrives with its own idea of what the numbers mean.
The failure this prevents is quiet. A pipeline that selects ground with Classification[2:2] runs perfectly against a delivery that codes ground as 11, produces an empty ground set, rasterizes nothing, and writes a DTM of NoData. Nothing errors. The remedy is to remap at ingest — to treat the vendor’s codes as a foreign vocabulary that gets translated once, at the boundary, rather than as something every downstream stage has to know about.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.4+ for filters.assign with value expressions |
| The delivery report | the only authoritative statement of what the vendor’s codes mean |
| A target schema | ASPRS LAS 1.4, unless you have a reason to differ |
| Output format | LAS 1.4, point format 6+, if any target code exceeds 31 |
The report row is not negotiable. Inferring a mapping from the data — “code 11 is probably ground because it is the most common” — is how a delivery’s water class becomes ground and a lake ends up in the terrain model.
# Step-by-Step Implementation
# Step 1 — Write the mapping down as data
{
"vendor": "acme-2026",
"source_schema": "ACME internal v3",
"map": {"11": 2, "20": 5, "21": 6, "30": 9, "31": 7}
}A JSON file beside the pipeline, versioned with it, is reviewable in a way that a chain of inline expressions is not.
# Step 2 — Order the rules so none can undo another
filters.assign applies its expressions in order, and each one sees the results of the previous. Mapping 11 to 2 and then 2 to 20 would send the first group through both rules. Two defences: process the mapping in an order where no target is also a source, or stage through a scratch dimension.
# Step 3 — Generate the assignments
{"type": "filters.assign",
"value": ["Classification = 2 WHERE Classification == 11",
"Classification = 5 WHERE Classification == 20",
"Classification = 6 WHERE Classification == 21"]}# Step 4 — Assert only known codes survive
The check is the point of the exercise. A code that no rule matched is a code nobody has thought about.
# Complete Working Example
"""Remap vendor classification codes to ASPRS, safely and verifiably."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import numpy as np
import pdal
LOG = logging.getLogger("remap")
ASPRS_ALLOWED = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}
def order_safely(mapping: dict[int, int]) -> list[tuple[int, int]]:
"""Order rules so no rule's target is a later rule's source.
When that is impossible the mapping has a cycle and must be staged through
a scratch dimension instead; we detect it rather than producing silent nonsense.
"""
remaining = dict(mapping)
ordered: list[tuple[int, int]] = []
while remaining:
safe = [s for s, t in remaining.items() if t not in remaining]
if not safe:
raise ValueError(f"cyclic mapping, cannot order safely: {remaining}")
for s in safe:
ordered.append((s, remaining.pop(s)))
return ordered
def remap(src: Path, dst: Path, mapping: dict[int, int]) -> dict:
rules = [f"Classification = {t} WHERE Classification == {s}"
for s, t in order_safely(mapping)]
spec = json.dumps({"pipeline": [
{"type": "readers.las", "filename": str(src)},
{"type": "filters.assign", "value": rules},
{"type": "writers.las", "filename": str(dst), "compression": "laszip",
"minor_version": 4, "dataformat_id": 6, "forward": "all"},
]})
written = pdal.Pipeline(spec).execute()
check = pdal.Pipeline(json.dumps({"pipeline": [
{"type": "readers.las", "filename": str(dst)}]}))
check.execute()
codes = np.unique(check.arrays[0]["Classification"])
unexpected = sorted(set(int(c) for c in codes) - ASPRS_ALLOWED)
if unexpected:
raise AssertionError(f"codes outside the target schema survived: {unexpected}")
LOG.info("%s → %s: %d points, codes %s", src.name, dst.name, written,
sorted(int(c) for c in codes))
return {"points": written, "codes": sorted(int(c) for c in codes)}
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
spec_file = json.loads(Path("acme_mapping.json").read_text())
table = {int(k): int(v) for k, v in spec_file["map"].items()}
print(json.dumps(remap(Path("delivery/tile_0431.laz"),
Path("ingest/tile_0431.laz"), table), indent=2))# Key Parameter Table
| Item | Choice | Why |
|---|---|---|
filters.assign value |
list of expressions | Applied in order; each sees the previous result |
| Rule ordering | targets never appear as later sources | Prevents a two-hop remap |
| Cyclic mappings | stage through a scratch dimension | Detected and refused by the example |
minor_version |
4 | Required for any target code above 31 |
forward |
all |
The delivery’s other records are still worth keeping |
# Verification
Only schema codes survive. The assertion above, and the reason the whole script exists.
Point counts are unchanged. Remapping relabels; it must never remove a point.
The class histogram is plausible. Ground between roughly 25 and 55 percent on suburban terrain; a ground class of 2 percent after a remap means a rule missed.
# Gotchas and Edge Cases
Codes above 31 need LAS 1.4. Writing a target of 64 into a point format 3 file loses the top bits into the classification flags — the layout described in understanding ASPRS classification codes.
An unmapped code is a decision, not an omission. Deciding to leave vendor code 44 alone is fine; leaving it alone because nobody noticed it is not. That is what the allow-list assertion is for.
Remapping does not fix a wrong classification. If the vendor labelled a bridge deck as ground, translating 11 to 2 faithfully preserves the error.
Keep the original. Store the delivery untouched and the remapped copy separately. The mapping will turn out to be wrong about something, and re-deriving it from a remapped file is impossible.
# Frequently Asked Questions
Why not just handle the vendor codes downstream?
Because every stage then has to know which supplier produced which tile, and one that forgets produces an empty result rather than an error. Translating once at ingest gives the pipeline a single vocabulary and one place to be wrong — a file you can read and review.
Why does rule order matter?
filters.assign applies expressions in sequence and each one sees the previous result. Mapping 11 to 2 and then 2 to 20 sends the originally-11 points through both rules. Ordering so that no rule target is a later rule source avoids it; a mapping where that is impossible has a cycle and needs a scratch dimension.
Can I infer the mapping from the data?
No. Guessing that the most common code is ground is how a delivery’s water class ends up in a terrain model. The delivery report is the only authoritative statement of what the numbers mean, and if it is missing the right move is to ask rather than to assume.
Does remapping fix a bad classification?
Not at all. It changes the labels, not the judgements behind them. If the supplier classified a bridge deck as ground, translating their ground code to 2 faithfully carries the error into your schema.
# Related
- ASPRS Classification Codes — the standard the remap targets
- Understanding ASPRS Classification Codes — the byte layout that limits codes to 31 in older formats
- Metadata and Header Sync — keeping the header consistent after a rewrite
- Reading and Writing LAS VLRs with PDAL — preserving the classification lookup record across the rewrite
- Point Cloud Data Standards and Fundamentals — the section overview