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.

Translate once, at the boundary Above, three deliveries with three code sets flow directly into a pipeline, so every downstream stage needs to know which vendor produced which tile. Below, each delivery passes through a remap step at ingest and everything after it sees ASPRS codes only, so the pipeline has one vocabulary instead of three. without a remap step vendor A codes vendor B codes vendor C codes every stage must know which vendor each tile came from with a remap step vendor A codes vendor B codes remap at ingest everything downstream sees ASPRS codes only the second shape has one place to be wrong, and it is a file you can read

# 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

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

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

python
"""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
From a reviewable file to an audited result The mapping lives in a JSON file that a person can review. A script orders the rules so none can undo another and refuses cyclic mappings outright. filters.assign applies them. The output is then checked against the target schema, and any code that no rule matched fails the run rather than passing quietly. mapping.json reviewable safe ordering cycles refused filters.assign one rule per entry schema audit unknown code fails the only human decision is the first box, and it is a file in version control rather than a chain of inline expressions nobody can diff. Every other box is mechanical, which is what makes the last one trustworthy.

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

Why rule order is not cosmetic Two orderings of the same two rules. Applying 11 to 2 first and then 2 to 20 sends the originally-11 points through both rules and out as 20. Reversing the order maps 2 to 20 first, so the points that become 2 afterwards are no longer matched by any rule and the result is correct. rules applied 11→2 then 2→20 points coded 11 become 2 then become 20 ground lost entirely rules applied 2→20 then 11→2 points coded 11 no rule matches yet become 2, and stop correct order rules so that no rule’s target appears as a later rule’s source; when that is impossible the mapping has a cycle and needs a scratch dimension rather than a cleverer ordering.

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