Checking Pipeline Output with pdal info --stats

TL;DR: After each tile, run pdal info --summary (header only, instant) to check point count, bounds and CRS, and pdal info --stats (reads every point) to check per-dimension minimum, maximum and mean against expected ranges. Parse the JSON in Python, compare against a small rule table, and exit non-zero on any violation so the batch system marks the tile as failed.

# Context and Motivation

This guide is part of Pipeline Validation. Validation and unit tests check the pipeline; they cannot check the data that flows through it. A pipeline that is correct can still produce a bad tile — a source file with a wrong CRS, a flightline with a Z offset, a sensor that recorded zero intensity, a reprojection that silently used a ballpark transformation. The cheapest place to catch those is right after each tile is written, before it is merged, published or handed to a client.

pdal info is already installed wherever PDAL is, needs no code to run, and returns JSON. Wrapping it in a short Python check gives every batch job an output gate for the cost of one extra read.

A gate after every tile A flow from the pipeline to a written tile, then to an output check running pdal info summary and stats. Tiles that pass continue to merging and delivery. Tiles that fail go to a quarantine folder with a report explaining which rule they broke, and the job exits non-zero. pipeline tile.laz output check summary + stats merge, deliver quarantine + report pass fail

# Prerequisites and Assumptions

  • The PDAL command-line tool on the worker that writes the tiles.
  • Python 3.10+ (standard library only for the check itself).
  • Expected ranges for the project: rough elevation limits, expected classes, minimum density, the CRS every tile must carry.

# Step-by-Step Implementation

# Step 1 — Check the header with --summary

pdal info --summary tile.laz returns summary.num_points, summary.bounds and summary.srs without reading points. It catches empty files, wrong CRS and absurd extents immediately.

# Step 2 — Compute statistics with --stats

pdal info --stats tile.laz reads every point and returns stats.statistic: one entry per dimension with minimum, maximum, average, stddev and count. Add --dimensions "X,Y,Z,Intensity,Classification" to limit the work to what you check.

# Step 3 — Enumerate classes

--enumerate Classification asks the stats filter to report the distinct values of a dimension, which is the simplest way to check that no unexpected class appears.

# Step 4 — Compare against rules

Keep rules in a small dictionary or YAML file: allowed classes, Z range, minimum points per square metre, required CRS substring.

# Step 5 — Exit non-zero on failure

Batch systems understand exit codes. A non-zero exit marks the task failed, triggers retries or alerts, and keeps the bad tile out of downstream steps.

# Complete Working Example

python
"""Post-write output check for a LAS/LAZ tile using pdal info."""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

RULES = {
    "crs_contains": "UTM zone 18N",
    "z_range": (-20.0, 900.0),
    "allowed_classes": {1, 2, 3, 4, 5, 6, 9, 17},
    "min_density": 8.0,               # points per square metre
}


def pdal_info(path: Path, *args: str) -> dict:
    out = subprocess.run(["pdal", "info", str(path), *args],
                         capture_output=True, text=True, check=True).stdout
    return json.loads(out)


def check(path: Path) -> list[str]:
    problems: list[str] = []
    summ = pdal_info(path, "--summary")["summary"]
    n = int(summ["num_points"])
    b = summ["bounds"]
    area = (b["maxx"] - b["minx"]) * (b["maxy"] - b["miny"])
    wkt = json.dumps(summ.get("srs", {}))
    if n == 0:
        return ["file contains no points"]
    if RULES["crs_contains"] not in wkt:
        problems.append("CRS does not match project CRS")
    if area > 0 and n / area < RULES["min_density"]:
        problems.append(f"density {n / area:.1f} pts/m² below {RULES['min_density']}")

    stats = pdal_info(path, "--stats", "--dimensions", "Z,Intensity,Classification",
                      "--enumerate", "Classification")["stats"]["statistic"]
    by_name = {s["name"]: s for s in stats}
    z = by_name["Z"]
    lo, hi = RULES["z_range"]
    if z["minimum"] < lo or z["maximum"] > hi:
        problems.append(f"Z range {z['minimum']:.1f}..{z['maximum']:.1f} outside {lo}..{hi}")
    if by_name["Intensity"]["maximum"] == 0:
        problems.append("Intensity is zero for every point")
    classes = {int(float(v)) for v in by_name["Classification"].get("values", [])}
    unexpected = classes - RULES["allowed_classes"]
    if unexpected:
        problems.append(f"unexpected classes {sorted(unexpected)}")
    return problems


if __name__ == "__main__":
    tile = Path(sys.argv[1])
    issues = check(tile)
    report = {"tile": tile.name, "ok": not issues, "issues": issues}
    print(json.dumps(report))
    sys.exit(1 if issues else 0)

Run after the pipeline in the same batch task:

bash
pdal pipeline dtm_and_laz.json --writers.las.filename=out/t_0431.laz \
  && python check_tile.py out/t_0431.laz \
  || mv out/t_0431.laz quarantine/
Header checks are free; statistics cost one read Bars of run time for a 40 million point LAZ tile. pdal info with summary takes 0.05 seconds because it reads only the header. pdal info with stats on three dimensions takes about 14 seconds. With all dimensions it takes about 22 seconds. A note recommends always running the summary and running stats on the dimensions that matter. --summary 0.05 s --stats, 3 dimensions 14 s --stats, all dimensions 22 s illustrative 40 M point LAZ tile

# Key Parameter Table

Option Reads Returns Use for
--summary header count, bounds, SRS, dimensions Empty files, CRS, extent, density
--stats all points min, max, mean, stddev per dimension Value ranges
--dimensions A,B all points, listed dims stats only for those Faster stats
--enumerate Dim all points distinct values of Dim Unexpected classes
--metadata header full LAS header metadata Version, PDRF, VLRs, GPS time type
--schema header dimension names and types Extra bytes present

# Verification

  • Break a tile on purpose. Reproject a copy to the wrong CRS, shift Z by 1,000 m, or write class 64 into a few points; each must fail the check with a clear message.
  • Exit codes. Confirm your batch system treats the non-zero exit as failure, not as a warning to be logged and ignored.
  • Report archive. Keep the JSON reports; a sudden change in, say, mean intensity across a batch is often the first sign of a sensor issue.

# Gotchas and Edge Cases

Enumerate output format. The exact JSON layout of enumerated values has varied between PDAL versions. Parse defensively — accept numbers or strings — and test the parser against your pinned version.

Stats read the whole file. On very large tiles, --stats costs a full read. Limit --dimensions, or compute statistics inside the production pipeline with filters.stats and read them from pipeline.metadata instead of re-reading the file.

Bounds from the header can be stale. A writer that did not update the header — or a tool other than PDAL — can leave wrong bounds. Compare --summary bounds with the --stats minima and maxima of X and Y; see repairing stale LAS header bounds and counts.

When the header and the points disagree Two overlapping rectangles in plan view. The header bounds rectangle is larger, left over from before a crop. The computed extent of the actual points, from statistics, is a smaller rectangle inside it. A note says a mismatch beyond one scale unit means the header was not updated. header bounds (stale) actual extent from --stats flag if they differ by more than scale

Rules that are too tight. A Z range copied from one tile fails on the next hill. Set limits from project-wide knowledge — lowest water surface, highest summit plus structures — not from a sample.

# Frequently Asked Questions

What is the difference between pdal info --summary and --stats?

The summary reads only the header and returns counts, bounds, the CRS and dimension names instantly. Stats reads every point and computes minimum, maximum, mean and standard deviation per dimension, costing one full pass over the file.

How do I list the classes present in a LAS file with PDAL?

Run pdal info with the stats flag and enumerate Classification. The output lists the distinct classification values found, which you can compare against the classes your specification allows.

How do I make a batch job fail on a bad tile?

Run the check script after the pipeline and exit with a non-zero status when any rule fails. Batch schedulers treat non-zero exits as task failures and can retry, alert or quarantine accordingly.

Can I compute these statistics without reading the file twice?

Yes. Add filters.stats to the production pipeline before the writer and read its results from the pipeline metadata. The check then costs nothing beyond the original run.