Running GDAL and PDAL Processing Jobs in GitHub Actions
This guide writes a GitHub Actions workflow that runs a PDAL point-cloud pipeline and a gdalwarp raster reprojection on every push and pull request, inside a container with pinned pdal and gdal, then caches the toolchain and uploads the processed tile as a build artifact. The processing is a PDAL pipeline JSON — readers.las → filters.range → filters.outlier → writers.las — followed by a gdalwarp reprojection with an explicit EPSG code.
You hit this the moment point-cloud processing needs to be reproducible: a teammate edits a filter threshold, and you want CI to re-run the exact same pdal build on the exact same fixture and prove the output still validates before the change can merge. This is the process stage of the broader CI/CD automation for spatial pipelines workflow.
Prerequisites
- A GitHub repository with a small
.lazfixture committed (a few thousand points is enough to exercise the pipeline in CI without a large checkout). - The source cloud in a known projected CRS — the examples use EPSG:32618 (WGS 84 / UTM zone 18N) — reprojected to EPSG:4979 (geographic 3D, WGS 84) so ellipsoidal height is carried explicitly for a downstream Cesium tiler.
- Familiarity with PDAL pipeline JSON: an array of stages where the reader, filters, and writer execute in order, each stage a JSON object with a
type. - The
osgeo/gdal:ubuntu-full-3.9.2container image, which shipsgdalwarpandlibgdal; PDAL is installed on top withapt. Pin the image by digest in production so PROJ grids never drift.
Step-by-Step
1. Write the PDAL pipeline JSON
The pipeline reads the LAS tile, clips implausible Z returns with filters.range (removing birds and low multipath before statistics run), removes statistical outliers with filters.outlier, and writes a clean LAS. filters.outlier only flags noise as classification 7; the trailing filters.range on Classification![7:7] is what actually drops it.
{
"pipeline": [
{
"type": "readers.las",
"filename": "tiles/tile_18_3312.laz",
"default_srs": "EPSG:32618"
},
{
"type": "filters.range",
"limits": "Z[-20:800]"
},
{
"type": "filters.outlier",
"method": "statistical",
"mean_k": 8,
"multiplier": 2.5
},
{
"type": "filters.range",
"limits": "Classification![7:7]"
},
{
"type": "writers.las",
"filename": "work/tile_18_3312_clean.laz",
"compression": "laszip",
"a_srs": "EPSG:32618"
}
]
}
2. Reproject the companion raster with gdalwarp
Terrain that accompanies the cloud must land in the same delivery CRS. Reproject with an explicit -t_srs, name the source CRS with -s_srs when the file lacks one, and pick a resampler suited to continuous elevation (bilinear), never the default nearest-neighbour which stair-steps a DEM.
gdalwarp \
-s_srs EPSG:32618 -t_srs EPSG:4979 \
-r bilinear -of GTiff \
-co TILED=YES -co COMPRESS=DEFLATE \
-overwrite \
terrain/tile_18_3312.tif work/tile_18_3312_4979.tif
3. Drive both tools from the workflow YAML
Run the job in the pinned container, install PDAL, execute the PDAL pipeline with pdal pipeline, then the gdalwarp step. Trigger on push and pull request so every change is exercised.
name: gdal-pdal-process
on:
push: { branches: [main] }
pull_request: { branches: [main] }
jobs:
process:
runs-on: ubuntu-24.04
container:
image: osgeo/gdal:ubuntu-full-3.9.2
steps:
- uses: actions/checkout@v4
- name: Cache apt + PDAL install marker
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-pdal-${{ runner.os }}-2.7
- name: Install PDAL
run: |
apt-get update
apt-get install -y --no-install-recommends pdal
pdal --version
- name: Run PDAL pipeline (filter + reproject-ready clean LAS)
run: |
mkdir -p work
pdal pipeline pipelines/filter_reproject.json
- name: Reproject terrain with gdalwarp to EPSG:4979
run: bash scripts/warp_terrain.sh
- name: Upload processed tile
uses: actions/upload-artifact@v4
with:
name: processed-tile_18_3312
path: work/
retention-days: 7
4. Override pipeline options from the command line
Committing one pipeline JSON and overriding its stage options at call time keeps a single reviewed pipeline while letting the workflow point it at different tiles or thresholds. pdal pipeline accepts --stage.option value overrides that patch the JSON in place, so the same filter_reproject.json drives every tile in a matrix without a templated file per tile.
TILE="tile_18_3312"
pdal pipeline pipelines/filter_reproject.json \
--readers.las.filename="tiles/${TILE}.laz" \
--writers.las.filename="work/${TILE}_clean.laz" \
--filters.outlier.multiplier=2.5 \
--filters.outlier.mean_k=8
Because the reader and writer filenames are injected here, the checked-in JSON can carry placeholder paths and the workflow stays the single source of truth for which tile runs. Keep the filter thresholds in the JSON, though — they are the reviewed parameters that a gate later asserts against, so they belong under version control rather than scattered across workflow steps.
5. Verify the PDAL result in the same job
Add a pdal info step so the job asserts the clean cloud is non-empty and carries the expected CRS before the artifact is uploaded. Piping through python3 turns the JSON summary into a hard exit code.
pdal info work/tile_18_3312_clean.laz --metadata \
| python3 -c '
import json, sys
meta = json.load(sys.stdin)["metadata"]
count = meta["count"]
srs = meta["srs"]["horizontal"]
assert count > 0, "clean cloud is empty"
assert "32618" in srs, f"unexpected CRS: {srs[:40]}"
print(f"PDAL OK: {count} points, EPSG:32618")
'
Expected Output & Verification
A successful run prints the PDAL version, the point count surviving the filters, and the reprojection summary. The outlier filter typically drops 1–4% of a terrestrial tile as noise; a much larger drop means multiplier is too aggressive.
PDAL 2.7.1 (git-version: ...)
pdal pipeline pipelines/filter_reproject.json
readers.las: 1 482 905 points in
filters.range (Z): 1 482 118 points
filters.outlier: flagged 31 774 as class 7
filters.range (!7): 1 450 344 points out
Creating output file that is 2048P x 2048L.
Processing terrain/tile_18_3312.tif [1/1] - done.
PDAL OK: 1450344 points, EPSG:32618
Confirm the reprojected raster carries the target CRS with gdalsrsinfo, which should report the EPSG:4979 authority code rather than the source UTM zone:
gdalsrsinfo -o epsg work/tile_18_3312_4979.tif # -> EPSG:4979
In the Actions run summary the Upload processed tile step lists processed-tile_18_3312 as a downloadable artifact, and re-running the job on an unchanged fixture restores the apt cache so the PDAL install step reports a cache hit.
Common Errors
PDAL: readers.las: Global encoding WKT flag not set for point format 6 - 10. The .laz is LAS 1.4 with a point format that requires a WKT CRS VLR, but the file only carries a legacy GeoTIFF key or none. PDAL cannot infer the CRS, so downstream reprojection is meaningless. Fix: set default_srs on readers.las (as in step 1) or repair the header with pdal translate --writers.las.a_srs=EPSG:32618, and never rely on an unstated CRS.
ERROR 1: PROJ: proj_create_from_database: crs not found from gdalwarp. The container’s PROJ database predates the EPSG code you passed, or a typo turned EPSG:4979 into a non-existent code. Fix: pin a GDAL image recent enough to know the code, verify with projinfo EPSG:4979, and pass the authority-qualified string (EPSG:4979) rather than a bare number.
filters.outlier removes almost everything, leaving a near-empty cloud. A multiplier set too low (for example 1.0) combined with a small mean_k on a sparse tile flags dense-edge points as outliers. Fix: raise multiplier toward 2.5–3.0 and mean_k to 8–16, and inspect the flagged fraction in the pdal info step — a healthy terrestrial tile loses single-digit percentages, not the bulk of its points.
Related Guides
- Schema Validation Gates for Spatial Data — turning the processed tile into a merge-blocking check
- Automated 3D Tiles Deployment to a CDN — promoting the validated artifact
- Removing Noise from Terrestrial LiDAR Scans — the filtering theory behind the PDAL stages here