Repairing Stale LAS Header Bounds and Counts
TL;DR: Read the file with laspy, compare header.point_count, header.number_of_points_by_return, header.mins and header.maxs against values computed from the points, and if any differ, call las.update_header() and write a new file — or simply pass the file through PDAL (pdal translate in.laz out.laz), which always writes a header computed from the data.
# Context and Motivation
This guide is part of Metadata and Header Sync. A LAS header summarizes its points: how many there are, how many are first, second and later returns, and the bounding box. Many tools trust those numbers instead of reading the data. A tile index built with pdal tindex or a GIS footprint layer uses the bounds; viewers use the count to allocate memory and the bounds to frame the view; QA scripts compare counts against expectations. When a file has been edited in place by a tool that did not update the header — a custom script that dropped points, a crop that left the old bounds, a merge that summed counts wrongly — every one of those consumers is quietly wrong.
Detecting a stale header costs one read; repairing it costs one write. It is worth doing on every file received from outside a trusted pipeline.
# Prerequisites and Assumptions
- laspy 2.x (with a LAZ backend) or PDAL.
- Files small enough to read, or a willingness to use chunked reading for very large files.
- Write access to a new location; repair by writing a new file, never by patching bytes in place.
# Step-by-Step Implementation
# Step 1 — Read the header values
laspy.open(path).header gives point_count, number_of_points_by_return, mins and maxs without reading points.
# Step 2 — Compute the true values
Read the points and compute the count, a histogram of return_number, and the minima and maxima of scaled X, Y, Z.
# Step 3 — Compare with tolerances
Counts must match exactly. Bounds should match to within one scale unit — the header stores doubles, the points are quantized.
# Step 4 — Repair
las.update_header() recomputes every summary field on the in-memory object; las.write(new_path) writes a consistent file. PDAL’s pdal translate achieves the same.
# Step 5 — Re-index
Rebuild any tile index or footprint layer that was made from the stale headers.
# Complete Working Example
"""Detect and repair stale LAS/LAZ headers for a folder of tiles."""
from __future__ import annotations
from pathlib import Path
import laspy
import numpy as np
def header_problems(path: Path) -> list[str]:
las = laspy.read(path)
h = las.header
probs = []
n = len(las.points)
if h.point_count != n:
probs.append(f"point_count {h.point_count} != {n}")
by_return = np.bincount(las.return_number, minlength=16)[1:len(h.number_of_points_by_return) + 1]
if not np.array_equal(np.asarray(h.number_of_points_by_return), by_return):
probs.append("number_of_points_by_return does not match data")
actual_min = np.array([las.x.min(), las.y.min(), las.z.min()])
actual_max = np.array([las.x.max(), las.y.max(), las.z.max()])
tol = np.asarray(h.scales)
if np.any(np.abs(np.asarray(h.mins) - actual_min) > tol) or \
np.any(np.abs(np.asarray(h.maxs) - actual_max) > tol):
probs.append(f"bounds differ: header {np.round(h.mins, 2)}–{np.round(h.maxs, 2)}, "
f"data {np.round(actual_min, 2)}–{np.round(actual_max, 2)}")
return probs
def repair(path: Path, out_dir: Path) -> Path:
las = laspy.read(path)
las.update_header()
out = out_dir / path.name
las.write(out)
return out
if __name__ == "__main__":
out_dir = Path("repaired")
out_dir.mkdir(exist_ok=True)
for tile in sorted(Path("received").glob("*.la[sz]")):
problems = header_problems(tile)
if problems:
fixed = repair(tile, out_dir)
print(f"REPAIRED {tile.name}: " + "; ".join(problems))
assert not header_problems(fixed), f"{fixed} still inconsistent"
else:
print(f"ok {tile.name}")The PDAL route, useful in shell scripts and for files too large to read whole with laspy:
pdal translate received/t_0431.laz repaired/t_0431.laz --writers.las.forward=allforward=all keeps the original scales, offsets, IDs and VLRs; PDAL still computes counts and bounds from the points it writes.
# Making the Check Part of Ingest
The cheapest place to catch a stale header is the moment data arrives, before anything downstream has trusted it. A small ingest step that runs header_problems on every received file, repairs what it can and logs the rest turns a class of subtle downstream bugs into a line in an ingest report.
Three habits make that step reliable. First, keep received files untouched in a raw area and write repaired copies elsewhere, so the original evidence is always available if a vendor disputes a finding. Second, record what was repaired — file, field, old value, new value — in a machine-readable log; a vendor whose headers are routinely stale is worth a conversation. Third, run the check again on your own outputs at the end of processing. Stale headers are not only a vendor problem: any in-house script that writes LAS with a hand-built header can produce them, and an ingest-style check on outputs catches your own mistakes before a client does.
For large deliveries, the check parallelizes trivially across files. Combined with the output checks in checking pipeline output with pdal info --stats, it gives a complete picture of file health in a few minutes per thousand tiles.
# Key Parameter Table
| Field | Where | Check | Repair |
|---|---|---|---|
point_count |
header | exact | update_header() / PDAL write |
number_of_points_by_return |
header | exact | same |
mins, maxs |
header | ± scale | same |
scales, offsets |
header | not a summary | keep (use forward) |
| legacy count fields (1.4) | header | consistent with extended | written by laspy and PDAL |
| VLR CRS | VLRs | not a summary | keep, or fix separately |
# Verification
- Re-check after repair, as the example does with an assertion.
- Rebuild the tile index and compare footprints before and after; repaired tiles typically shrink.
- Spot-check in a viewer: the view should frame the data tightly after repair.
# Gotchas and Edge Cases
Bounds from unscaled values. Computing bounds from raw integers (las.X) instead of scaled coordinates (las.x) gives nonsense comparisons. Use the lower-case fields.
Returns beyond five. LAS 1.4 formats support up to 15 returns per pulse; the extended by-return array has 15 entries. Compare the full array for PDRF 6–10.
Very large files. laspy’s full read may not fit. Compute counts and bounds with chunk_iterator, and repair with PDAL, which streams.
Do not patch bytes. Editing header fields in place with a binary patch is possible but fragile, especially for LAZ where the header precedes compressed chunks. Writing a new file is safer and cheap.
# Frequently Asked Questions
How do I fix the bounds in a LAS header?
Read the file with laspy, call update_header to recompute counts and bounds from the points, and write a new file. Alternatively pass the file through PDAL with pdal translate, which writes a header computed from the data.
Why does my tile index show the wrong footprint?
Tile indexes and footprint tools often use header bounds rather than reading points. If a file was cropped or edited without updating its header, the index shows the old extent. Repair the headers and rebuild the index.
Does PDAL always write correct header counts?
Yes. writers.las computes counts and bounds from the points it writes, even when forwarding other header fields from the source.
How much tolerance should I allow on bounds?
One scale unit per axis. The header stores bounds as doubles while points are stored as quantized integers, so a tiny difference is normal; anything larger means the header is stale.
# Related
- Metadata and Header Sync — keeping metadata consistent
- Converting GPS Week Time to Adjusted Standard Time — another header-level fix
- Building a Tile Index with pdal tindex — what stale headers break
- Checking Pipeline Output with pdal info --stats — catching mismatches automatically
- How to Parse LAS Headers with Python — header fields in detail