Writing Cloud-Optimized GeoTIFFs to S3

TL;DR: Rasterise ground points with writers.gdal using gdaldriver: "COG" and creation options (COMPRESS=DEFLATE, BLOCKSIZE=512, OVERVIEWS=AUTO), write to local scratch, then push the finished file to s3://survey-deliverables/cog/tile.tif with a boto3 upload_file — validate with rio cogeo validate before and gdalinfo /vsis3/... after.

# Context and Motivation

This guide is part of S3 Cloud Storage I/O with PDAL, which covers the full range of reading and writing point-cloud data against Amazon S3.

A terrain raster that lives in a bucket is only useful to downstream web maps and tiling services if it is a Cloud-Optimized GeoTIFF: internally tiled, carrying overviews, and laid out so a client can pull a small window with an HTTP range request instead of downloading the whole file. PDAL’s writers.gdal stage rasterises a classified point cloud into exactly this kind of surface, and GDAL’s COG driver structures the bytes correctly. The remaining question — the one this guide answers — is how the finished raster gets into the bucket: a direct /vsis3/ write from the writer, or a two-step “write local, upload with boto3” that trades a little code for atomicity and resumability. The upstream rasterisation is the same one covered in Generating a DTM GeoTIFF with writers.gdal; here the focus is COG structure and the S3 handoff.

Writing a COG DTM to S3: direct /vsis3/ write versus local write plus boto3 upload writers.gdal rasterises points into a COG. Path A writes directly to a /vsis3/ path with a single PutObject on close. Path B writes to local scratch, validates, then uploads with a boto3 multipart transfer. Both land the same object in the bucket. writers.gdal COG driver Path A: direct write /vsis3/ path buffer, PutObject on close Path B: local then upload local scratch .tif validate then boto3 boto3 upload_file multipart S3 bucket cog/tile.tif

# Prerequisites and Assumptions

Requirement Detail
PDAL 2.5+ built against GDAL 3.4+ (COG driver requires GDAL 3.1+)
Python pdal bindings pip install pdal
boto3 for the upload path and verification
rio-cogeo (optional) pip install rio-cogeo for rio cogeo validate
Input a classified cloud with ground points (Classification 2)
IAM permissions s3:PutObject on the target bucket/prefix

The point cloud should already carry ground labels. If it does not, run filters.smrf first as shown below; the ground-classification background lives in the broader terrain-model material referenced under DTM Raster Generation.

# Step-by-Step Implementation

# Step 1 — Rasterise a DTM with the COG driver

The COG driver builds tiling and overviews in a single pass, so the writer output is a valid Cloud-Optimized GeoTIFF with no separate overview step:

json
{
  "type": "writers.gdal",
  "filename": "/tmp/dtm_tile_0421.tif",
  "resolution": 0.5,
  "output_type": "idw",
  "data_type": "float32",
  "nodata": -9999,
  "gdaldriver": "COG",
  "gdalopts": "COMPRESS=DEFLATE,BLOCKSIZE=512,OVERVIEWS=AUTO,RESAMPLING=BILINEAR"
}

output_type: "idw" interpolates across small gaps between ground points; BLOCKSIZE=512 sets the internal tile size the COG driver uses, and OVERVIEWS=AUTO lets GDAL pick the overview levels.

# Step 2 — Choose direct write or local-plus-upload

For a direct write, swap the local filename for a /vsis3/ path — GDAL buffers the raster and issues one PutObject on close:

json
"filename": "/vsis3/survey-deliverables/cog/dtm_tile_0421.tif"

For the more robust two-step path, keep the local filename, validate the file, then upload it. This is preferred for large rasters and unattended batch jobs because a mid-run crash never leaves a half-written object, and boto3’s multipart transfer resumes cleanly.

# Step 3 — Upload with boto3 (two-step path)

python
import boto3
from boto3.s3.transfer import TransferConfig

def upload_cog(local_path: str, bucket: str, key: str) -> None:
    """Multipart-upload a finished COG with the correct content type."""
    cfg = TransferConfig(multipart_threshold=8 * 1024 * 1024)  # 8 MB parts
    boto3.client("s3").upload_file(
        local_path, bucket, key,
        ExtraArgs={"ContentType": "image/tiff", "ServerSideEncryption": "AES256"},
        Config=cfg,
    )

# Step 4 — Validate the COG structure

Confirm the file is genuinely cloud-optimized before or after upload:

bash
rio cogeo validate /tmp/dtm_tile_0421.tif
# or, structurally:
gdalinfo /tmp/dtm_tile_0421.tif | grep -iE "block|overview|layout"

A valid COG reports an internally tiled layout with overviews present.

# Complete Working Example

Save as dtm_cog_to_s3.py. It classifies ground, rasterises a COG DTM to scratch, validates it, and multipart-uploads it to S3.

python
#!/usr/bin/env python3
"""
dtm_cog_to_s3.py
Rasterise a Cloud-Optimized GeoTIFF DTM with PDAL and upload it to S3.

Usage:
    python dtm_cog_to_s3.py input.laz survey-deliverables cog/dtm_tile_0421.tif

Requirements:
    conda install -c conda-forge pdal python-pdal   # GDAL 3.1+ for the COG driver
    pip install boto3 rio-cogeo
"""

import os
import sys
import json
import subprocess
import tempfile

import pdal
import boto3
from boto3.s3.transfer import TransferConfig


def build_cog_dtm(src: str, dst_local: str, resolution: float = 0.5) -> pdal.Pipeline:
    """Classify ground, keep it, and rasterise a COG DTM to a local path."""
    stages = [
        {"type": "readers.las", "filename": src},
        {"type": "filters.smrf", "slope": 0.2, "window": 16.0, "threshold": 0.45},
        {"type": "filters.range", "limits": "Classification[2:2]"},
        {
            "type": "writers.gdal",
            "filename": dst_local,
            "resolution": resolution,
            "output_type": "idw",
            "data_type": "float32",
            "nodata": -9999,
            "gdaldriver": "COG",
            "gdalopts": "COMPRESS=DEFLATE,BLOCKSIZE=512,OVERVIEWS=AUTO,RESAMPLING=BILINEAR",
        },
    ]
    return pdal.Pipeline(json.dumps({"pipeline": stages}))


def validate_cog(path: str) -> None:
    """Raise if the file is not a valid Cloud-Optimized GeoTIFF."""
    result = subprocess.run(
        ["rio", "cogeo", "validate", path],
        capture_output=True, text=True,
    )
    if "is a valid cloud optimized GeoTIFF" not in result.stdout:
        raise RuntimeError(f"COG validation failed:\n{result.stdout}\n{result.stderr}")
    print(result.stdout.strip())


def upload_cog(local_path: str, bucket: str, key: str) -> None:
    cfg = TransferConfig(multipart_threshold=8 * 1024 * 1024)
    boto3.client("s3").upload_file(
        local_path, bucket, key,
        ExtraArgs={"ContentType": "image/tiff", "ServerSideEncryption": "AES256"},
        Config=cfg,
    )
    print(f"Uploaded s3://{bucket}/{key}")


def confirm_remote(bucket: str, key: str) -> None:
    head = boto3.client("s3").head_object(Bucket=bucket, Key=key)
    print(f"Confirmed s3://{bucket}/{key}: {head['ContentLength']:,} bytes")


def main() -> None:
    if len(sys.argv) != 4:
        print("Usage: python dtm_cog_to_s3.py <input.laz> <bucket> <key>")
        sys.exit(1)

    src, bucket, key = sys.argv[1], sys.argv[2], sys.argv[3]
    os.environ.setdefault("AWS_REGION", "us-west-2")

    with tempfile.TemporaryDirectory() as tmp:
        local_cog = os.path.join(tmp, "dtm.tif")
        pipeline = build_cog_dtm(src, local_cog)
        pipeline.validate()
        n = pipeline.execute()
        print(f"Rasterised {n:,} ground points into {local_cog}")

        validate_cog(local_cog)
        upload_cog(local_cog, bucket, key)
        confirm_remote(bucket, key)


if __name__ == "__main__":
    main()

# Key Parameter Table

Option Where Example Notes
gdaldriver writers.gdal COG Dedicated COG driver; builds tiling + overviews in one pass
COMPRESS gdalopts DEFLATE Lossless; use LZW or ZSTD as alternatives
BLOCKSIZE gdalopts 512 Internal tile edge in pixels (COG driver option)
OVERVIEWS gdalopts AUTO Overview generation; AUTO lets GDAL choose levels
RESAMPLING gdalopts BILINEAR Resampling used when building overviews
output_type writers.gdal idw Interpolation across gaps; mean/min/max also valid
resolution writers.gdal 0.5 Grid spacing in CRS units (metres for UTM)
nodata writers.gdal -9999 Fill value for empty cells
multipart_threshold TransferConfig 8388608 Part size boundary for boto3 multipart upload

For the plain GTiff driver, the equivalent block option is BLOCKXSIZE/BLOCKYSIZE with TILED=YES, and you must add overviews separately with gdaladdo before the file counts as a COG.

# Verification

Validate the COG locally with rio cogeo validate, which checks tiling, overview presence, and IFD ordering:

bash
rio cogeo validate /tmp/dtm.tif
# -> ".../dtm.tif is a valid cloud optimized GeoTIFF"

Confirm the object landed with a head_object and check the size is plausible for the raster dimensions:

python
import boto3
head = boto3.client("s3").head_object(Bucket="survey-deliverables", Key="cog/dtm_tile_0421.tif")
assert head["ContentLength"] > 0
print(head["ContentLength"], head["ETag"])

Read it back through /vsis3/ to prove the uploaded COG opens remotely and reports the tiled structure — the ultimate proof that a client can range-read it:

bash
AWS_REGION=us-west-2 gdalinfo /vsis3/survey-deliverables/cog/dtm_tile_0421.tif | grep -iE "block|overview|epsg"

# Gotchas and Edge Cases

1. COG driver versus GTiff + manual overviews. The dedicated COG driver is the reliable choice: it emits tiling, overviews, and the metadata-before-data layout in one write. If you must use GTiff (for a driver-specific option the COG driver does not expose), you have to set TILED=YES and run gdaladdo -r bilinear file.tif 2 4 8 16 afterward, then re-validate. Skipping the overview step yields a tiled GeoTIFF that fails COG validation.

2. Direct /vsis3/ writes are not resumable. A direct write holds the whole raster in memory and PUTs it once on close. That is atomic from a reader’s perspective — no partial object appears — but if the process dies mid-run you lose the compute and get no object at all. For large tiles, the local-then-upload path lets boto3 resume a failed multipart transfer and lets you validate the file before it ever reaches the bucket.

3. Overviews inflate small tiles. For tiny rasters, overviews and internal tiling can add more overhead than they save. The COG structure pays off for rasters large enough that a client benefits from range-reading a window; for a 200×200 pixel tile the benefit is marginal. Consider your consumer’s access pattern before mandating COG on every output.

4. Content type and encryption on upload. boto3 does not infer ContentType from the extension, so without ExtraArgs the object may be served as binary/octet-stream, which some tile viewers reject. Set ContentType="image/tiff", and if the bucket policy denies unencrypted PUTs, pass ServerSideEncryption explicitly — a direct /vsis3/ write cannot easily attach these headers, which is another reason to prefer the boto3 path for governed buckets.

# Frequently Asked Questions

Should I use the GDAL COG driver or GTiff with COG options in writers.gdal?

Prefer the dedicated COG driver (gdaldriver: "COG"). It builds internal tiling, overviews, and the correct IFD ordering in one pass, so the output is a valid Cloud-Optimized GeoTIFF by construction. GTiff with TILED=YES plus a manual gdaladdo can produce an equivalent file but requires the extra overview step and careful option matching.

Is a direct /vsis3/ write atomic?

Effectively yes for the object itself — GDAL buffers the raster and issues the PutObject only on close, so readers never see a partial file. But a crash mid-run leaves no object at all and wastes the compute. Writing to local scratch then uploading with boto3 gives you a verifiable file plus multipart resumability.

Do Cloud-Optimized GeoTIFFs need internal overviews?

Yes. Overviews let a client fetch a low-resolution preview with a small range request instead of downloading the full raster. The COG driver generates them automatically; with the plain GTiff driver you must add them with gdaladdo or the OVERVIEWS creation option before the file qualifies as a COG.

How do I validate that my GeoTIFF is actually a valid COG?

Run rio cogeo validate on the file, or inspect it with gdalinfo and confirm it reports a tiled layout with overviews and the COG structure. A valid COG has internal tiles, overviews ordered for range reads, and the image data positioned after the metadata.