Building a Tile Index with pdal tindex
TL;DR: pdal tindex create index.gpkg -f GPKG --lyr_name tiles --fast_boundary tiles/*.laz — then immediately check that the feature count equals the file count, that one CRS is present, and that no polygon is much larger than a grid cell.
# Context and Motivation
This guide is part of Tile Indexing, Buffering and Merging. The command is one line; the value is in what you do with the result in the next five minutes, because a tile index is the cheapest opportunity you will get to find out what is actually in a delivery.
pdal tindex reads headers rather than points, so indexing a thousand tiles takes seconds. Each file becomes one feature whose geometry is either the header bounding box — fast, and enough for buffered reads — or a computed boundary that follows irregular coverage, which is slower and far more informative when the flight lines do not fill their tiles.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.4+ with the tindex application |
| GDAL/OGR | for the output driver and for querying afterwards |
| One CRS | tiles in mixed coordinate systems produce a meaningless layer |
| Write access | tindex create appends, so the target must not already exist |
# Step-by-Step Implementation
# Step 1 — Build the index
rm -f index/tiles.gpkg
pdal tindex create index/tiles.gpkg -f GPKG --lyr_name tiles \
--fast_boundary tiles/*.lazThe rm is not defensive tidiness — create appends, so without it a rebuild doubles every feature.
# Step 2 — Count features against files
ls tiles/*.laz | wc -l
ogrinfo -so index/tiles.gpkg tiles | grep "Feature Count"# Step 3 — Check the coordinate systems agree
ogrinfo -so index/tiles.gpkg tiles | grep -A3 "Layer SRS"# Step 4 — Look for oversized polygons
A feature whose area is much larger than the nominal grid cell holds points that belong somewhere else.
# Step 5 — Add the attributes you will filter on later
tindex writes the path and geometry; point counts, acquisition dates and processing status are worth joining in from a header pass.
# Complete Working Example
"""Build a tile index and immediately audit it."""
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
LOG = logging.getLogger("tindex_build")
def build(tiles: list[Path], out: Path, layer: str = "tiles") -> None:
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
out.unlink()
subprocess.run(["pdal", "tindex", "create", str(out), "-f", "GPKG",
"--lyr_name", layer, "--fast_boundary",
*[str(t) for t in tiles]], check=True)
def audit(index: Path, expected: int, cell_m: float, layer: str = "tiles") -> dict:
info = subprocess.run(["ogrinfo", "-so", str(index), layer],
check=True, capture_output=True, text=True).stdout
count = int([l for l in info.splitlines() if "Feature Count" in l][0].split(":")[1])
sql = (f"SELECT COUNT(*) AS n FROM {layer} "
f"WHERE (ST_MaxX(geom)-ST_MinX(geom)) > {cell_m * 1.5}")
oversized = subprocess.run(
["ogrinfo", "-q", "-dialect", "SQLITE", "-sql", sql, str(index)],
check=True, capture_output=True, text=True).stdout
result = {
"features": count,
"files": expected,
"duplicated": count > expected,
"oversized_report": oversized.strip().splitlines()[-1:] or ["n = 0"],
}
if count != expected:
raise AssertionError(
f"index holds {count} features for {expected} files — "
"the index was appended to, or the glob missed a subdirectory"
)
return result
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
tiles = sorted(Path("tiles").glob("*.laz"))
build(tiles, Path("index/tiles.gpkg"))
print(json.dumps(audit(Path("index/tiles.gpkg"), len(tiles), cell_m=1000.0), indent=2))# Key Parameter Table
| Option | Effect |
|---|---|
-f GPKG |
Output driver; GeoPackage avoids shapefile’s ten-character field names |
--lyr_name |
Layer name, needed by every later SQL query |
--fast_boundary |
Header bounding box instead of a computed hull |
-t_srs |
Reproject the index geometry only; the tiles are untouched |
--stdin |
Read the file list from standard input, for very large campaigns |
--write_absolute_path |
Store absolute rather than relative paths; usually the wrong choice |
# Verification
Feature count equals file count. Asserted in the example; catches both the double-append and a glob that missed a directory.
One CRS. Two in an index means the campaign cannot be merged as it stands.
No wildly oversized polygons. A tile whose bounding box spans several grid cells will break every neighbour query that follows.
Paths resolve. Open two at random and confirm they exist from the directory the index will be read from.
# Gotchas and Edge Cases
create appends. The most common tile-index bug, and it produces an index that works for lookups and reports twice the coverage.
Relative paths are relative to the working directory, not the index. Build the index from the directory it will be read from, or resolve paths explicitly at read time.
Shapefile truncates field names. Ten characters, silently — the DBF limit that also bites tile-attribute exports.
A file that fails to open is skipped. tindex reports it and continues, so the feature count check is what tells you a tile is unreadable.
The index describes the moment it was built. It records what each header said at build time, which is exactly what makes it fast and exactly why it goes stale. Rebuild it whenever the delivery changes rather than patching individual features, and keep the build script in version control beside the pipelines that read it — a tile index nobody can regenerate is a liability the first time somebody asks whether it is still correct.
# Frequently Asked Questions
Why does my index have twice as many features as files?
Because pdal tindex create appends to an existing layer rather than replacing it. The resulting index works for lookups and reports double the coverage, which is why the build script should delete the output first.
Should I use fast boundaries or computed ones?
Fast for anything you rebuild. On 1,240 tiles the header-only form takes eleven seconds and the computed form takes over four hours, because the latter reads every point. Compute boundaries once, for a coverage map you will show someone.
Why GeoPackage rather than shapefile?
Field names. Shapefile truncates them to ten characters silently, so acquisition_date and acquisition_sensor become the same field. GeoPackage also carries a spatial index by default, which neighbour queries on a large campaign need.
What happens to a file that cannot be opened?
It is reported and skipped, and the index is built from the rest. That is reasonable behaviour and it is why comparing the feature count with the file count matters — the skipped file is otherwise invisible.
# Related
- Tile Indexing, Buffering and Merging — the parent guide to what an index is for
- Buffered Tiling to Avoid Edge Artefacts — the neighbour queries this index makes possible
- Merging Processed Tiles into One LAZ — putting results back together afterwards
- Syncing Metadata Between LAS and Shapefiles — the field-name limit that decides the output format
- Batch Automation and Cloud Integration for PDAL — the section overview