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.
# 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
"""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:
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/# 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.
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.
# Related
- Pipeline Validation — structural and behavioural checks
- Testing PDAL Pipelines with pytest — checking the pipeline rather than the data
- Counting Points per Class with PDAL — class histograms in depth
- Repairing Stale LAS Header Bounds and Counts — fixing what the check finds
- Retrying Failed Tiles in Airflow — acting on the exit code