Inspecting PROJ Transformations Before Reprojecting

TL;DR: Run projinfo -s <src> -t <dst> --spatial-test intersects -o PROJ for your area to list every candidate operation with its accuracy and the grids it needs. If the best one needs a grid you do not have, either enable PROJ_NETWORK=ON or install the grid with projsync. For reproducible production runs, compare PDAL’s output against the expected operation with pyproj, and pin the environment so the same grids exist everywhere.

# Context and Motivation

This guide is part of Spatial Reprojection. filters.reprojection delegates to PROJ, and PROJ chooses among several possible coordinate operations between two CRSs — a grid-based datum shift accurate to centimetres, a Helmert transform accurate to a metre, or a “ballpark” transformation that ignores the datum difference altogether. It picks the most accurate one whose grids are available on that machine. The same pipeline can therefore produce outputs that differ by a metre between a laptop with grids installed and a container without them, and nothing in PDAL’s output tells you which happened.

Inspecting the candidates before a production run, and checking which one was used, turns that silent variability into a documented choice.

The best operation you have installed Three candidate operations listed from most to least accurate: a grid-based shift accurate to 0.05 metres that needs a grid file, a Helmert transformation accurate to 1 metre, and a ballpark transformation of unknown accuracy. On a machine with the grid, PROJ uses the first. On a machine without it, PROJ silently falls to the second, shifting the output by up to a metre. 1 · grid shift, accuracy 0.05 m needs a datum-shift grid file 2 · Helmert, accuracy 1 m no grid needed 3 · ballpark, accuracy unknown ignores the datum difference laptop with grids → uses 1 container without grids → uses 2 same pipeline, same input, outputs up to a metre apart — and no warning in the PDAL log

# Prerequisites and Assumptions

  • PROJ 7+ with the projinfo and projsync utilities (bundled with PROJ in conda-forge and most Linux packages).
  • pyproj 3.x in Python, built against the same PROJ as PDAL where possible.
  • Source and target CRSs identified, ideally as EPSG codes, with a bounding box of the project in geographic coordinates.

# Step-by-Step Implementation

# Step 1 — List candidate operations for your area

projinfo with --spatial-test intersects and an area of use lists only operations valid where your data is.

bash
projinfo -s EPSG:4269 -t EPSG:6318 --spatial-test intersects \
  --bbox -76.0,39.5,-75.0,40.5 --summary

# Step 2 — Read accuracy and grid requirements

Each candidate shows its accuracy and whether it is usable with installed grids. projinfo ... -o PROJ prints the full pipeline, including +proj=hgridshift +grids=... steps.

# Step 3 — Make the grids available

Either allow PROJ to fetch grids on demand with PROJ_NETWORK=ON (it caches them locally), or install them ahead of time with projsync --bbox ... so containers work offline.

# Step 4 — Check what PDAL will use

Build the same transformation with pyproj’s TransformerGroup, confirm the best candidate is available, and compare a test point against PDAL’s output.

# Step 5 — Pin and record

Record the chosen operation’s description in your processing log or output metadata, and bake the grids into the container image so every worker uses the same one.

# Complete Working Example

python
"""Report PROJ candidates for a reprojection and verify PDAL used the best one."""
from __future__ import annotations

import json
import os

import numpy as np
import pdal
from pyproj.transformer import TransformerGroup

SRC, DST = "EPSG:6318+5703", "EPSG:6347+5703"      # NAD83(2011) geographic -> UTM 18N
AREA = (-76.0, 39.5, -75.0, 40.5)                  # west, south, east, north


def candidates() -> TransformerGroup:
    from pyproj.aoi import AreaOfInterest
    group = TransformerGroup(SRC, DST, always_xy=True,
                             area_of_interest=AreaOfInterest(*AREA))
    for i, t in enumerate(group.transformers):
        print(f"[{i}] acc={t.accuracy} m  {t.description}")
    for op in group.unavailable_operations:
        grids = [g.short_name for g in op.grids if not g.available]
        print(f"[unavailable] acc={op.accuracy} m  {op.name}  missing grids: {grids}")
    if not group.best_available:
        print("WARNING: the most accurate operation is not available on this machine")
    return group


def pdal_point(x: float, y: float, z: float) -> np.ndarray:
    arr = np.array([(x, y, z)], dtype=[("X", "f8"), ("Y", "f8"), ("Z", "f8")])
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "filters.reprojection", "in_srs": SRC, "out_srs": DST}]}), arrays=[arr])
    p.execute()
    out = p.arrays[0][0]
    return np.array([out["X"], out["Y"], out["Z"]])


if __name__ == "__main__":
    print("PROJ_NETWORK =", os.environ.get("PROJ_NETWORK", "OFF"))
    group = candidates()
    x, y, z = -75.5, 40.0, 120.0
    best = np.array(group.transformers[0].transform(x, y, z))
    got = pdal_point(x, y, z)
    diff = np.abs(best - got)
    print("pyproj best:", best.round(3), " PDAL:", got.round(3), " diff:", diff.round(3))
    assert diff.max() < 0.01, "PDAL did not use the best available operation"

Run it once with grids and once in your production container. If the outputs or the warning differ, the container is missing grids.

Where grids come from PROJ searches for a grid in order: the PROJ_DATA directories bundled with the installation, then the user cache directory, and finally, only when PROJ_NETWORK is ON, the PROJ content delivery network over HTTPS. A downloaded grid is written to the cache for next time. In an offline container, only the first location is available. PROJ_DATA dirs projsync, image build user cache earlier downloads PROJ CDN only if PROJ_NETWORK=ON downloaded grid cached search order; an offline container sees only the first box

# Key Parameter Table

Tool or setting Example Purpose
projinfo --summary projinfo -s A -t B --summary One line per candidate with accuracy
--spatial-test intersects with --bbox w,s,e,n Only operations valid in your area
-o PROJ projinfo -s A -t B -o PROJ Full pipeline, including grid names
PROJ_NETWORK=ON environment variable Fetch missing grids from the PROJ CDN
projsync projsync --bbox -76,39.5,-75,40.5 Pre-download grids for an area
TransformerGroup pyproj Candidates and availability from Python

# Verification

  • Best available equals best overall. group.best_available is true on every machine that runs production.
  • PDAL matches pyproj. The test point transformed by PDAL equals pyproj’s first candidate to within a millimetre.
  • Environment recorded. The output metadata or run log records the PROJ version and the operation description.

# Gotchas and Edge Cases

Ballpark transformations are silent. When no real datum transformation is known between two CRSs, PROJ may use a ballpark operation that treats different datums as identical. projinfo labels it clearly; PDAL does not. Always inspect when the source and target datums differ.

Network grids in batch jobs. PROJ_NETWORK=ON downloads grids on first use, which works on a laptop but can fail or be slow in a fleet of containers without internet egress. Bake grids into the image with projsync during the build instead.

Different PROJ builds for PDAL and pyproj. pip-installed pyproj wheels bundle their own PROJ. If PDAL uses a different PROJ with a different grid set, the check above can disagree for reasons unrelated to your data. Install both from conda-forge to share one PROJ.

One environment, two PROJs Left: PDAL linked to a system or conda PROJ with grids installed. Right: a pip pyproj wheel bundling its own PROJ with a separate, empty data directory. A comparison between them fails even though PDAL's output is correct. Installing both from conda-forge makes them share one PROJ and one grid directory. PDAL conda PROJ 9.x grids installed pyproj (pip wheel) bundled PROJ separate, empty grid dir install both from conda-forge so they share one PROJ and one set of grids

EPSG database updates. New PROJ releases add operations and change preferences. Pin the PROJ version in production and re-run the inspection when you upgrade it.

# Frequently Asked Questions

How do I know which transformation filters.reprojection used?

PDAL does not report it directly. Reproduce the transformation with pyproj using the same PROJ installation, list the candidates, and compare a test point: if PDAL matches the first candidate, it used the best available operation.

What does PROJ_NETWORK=ON do?

It lets PROJ download missing grid files from its content delivery network when an operation needs them, caching them locally. It is convenient interactively but should be replaced by pre-installed grids in reproducible batch environments.

Why do I get different results on different machines?

Almost always because different grids are installed, so PROJ selects different operations. Inspect the candidates on both machines and install the same grids everywhere.

Is a Helmert transformation good enough for LiDAR?

It depends on the accuracy you need. Helmert transformations between NAD83 and WGS84 realizations are typically accurate to about a metre, which is far worse than modern LiDAR’s vertical accuracy. Use grid-based operations when they exist.