Converting Ellipsoidal to Orthometric Heights in Bulk

The conversion itself is a transformer call. Applying it to a 300-million-point cloud and a 24,000-pixel-square elevation raster, without loading either into memory and without leaving the two products in different vertical datums, is the part that takes design.

This page covers doing that at survey scale: converting rasters window by window, point clouds in chunks, keeping the products consistent with each other, and asserting the result before anything is written. It is the production application of applying a geoid grid with pyproj transformers.

Why a raster is not a list of points

Converting a point cloud is conceptually simple: every point has a position, transform them all. A raster is different, because its cells have positions implied by a transform rather than stored, and the undulation must be evaluated at each cell’s own location.

The naive approach materialises a coordinate for every cell, which for a 24,000-square raster is 576 million coordinate triples — larger than the raster itself. The efficient approach exploits the geoid’s smoothness: the undulation varies by millimetres over tens of metres, so it can be evaluated on a coarse grid and interpolated, at an error far below anything else in the budget.

Evaluating the undulation per cell against on a coarse grid Two approaches to converting a raster. Evaluating the geoid undulation at every one of five hundred and seventy-six million cells requires materialising that many coordinates and takes about forty minutes. Evaluating it on a coarse grid of one hundred by one hundred nodes and interpolating between them takes under a second and introduces an error below one tenth of a millimetre, because the geoid surface varies smoothly. A note records that the interpolation error is three orders of magnitude below the survey's own accuracy. per cell coarse grid, interpolated 576 million evaluations ≈ 40 minutes 10 000 evaluations, interpolated under a second, error < 0.1 mm The interpolation error is three orders of magnitude below the survey's own accuracy.

Figure 1 — Why the geoid’s smoothness is the property that makes bulk conversion cheap.

Minimal reproducible solution for rasters

import numpy as np
import rasterio
from rasterio.windows import Window
from scipy.interpolate import RegularGridInterpolator


def undulation_interpolator(transformer, bounds, crs, *, nodes: int = 100):
    """Sample the undulation on a coarse grid and return an interpolator.

    Evaluating a smooth surface on a hundred-node grid and interpolating is
    indistinguishable from evaluating it everywhere, and is four orders of
    magnitude cheaper.
    """
    left, bottom, right, top = bounds
    xs = np.linspace(left, right, nodes)
    ys = np.linspace(bottom, top, nodes)
    gx, gy = np.meshgrid(xs, ys, indexing="ij")

    zeros = np.zeros_like(gx)
    _, _, converted = transformer.transform(gx.ravel(), gy.ravel(), zeros.ravel())
    undulation = -np.asarray(converted).reshape(gx.shape)

    return RegularGridInterpolator((xs, ys), undulation, bounds_error=False,
                                   fill_value=None)


def convert_raster(src_path: str, dst_path: str, interpolator,
                   *, block: int = 1024) -> dict:
    """Subtract the interpolated undulation from a DEM, window by window."""
    with rasterio.open(src_path) as src:
        profile = src.profile
        profile.update(dtype="float32", nodata=np.nan, compress="deflate",
                       predictor=3, tiled=True)
        stats = {"min": np.inf, "max": -np.inf, "cells": 0}

        with rasterio.open(dst_path, "w", **profile) as dst:
            for row in range(0, src.height, block):
                for col in range(0, src.width, block):
                    win = Window(col, row, min(block, src.width - col),
                                 min(block, src.height - row))
                    data = src.read(1, window=win, masked=True).filled(np.nan)

                    wt = src.window_transform(win)
                    cols, rows = np.meshgrid(np.arange(win.width), np.arange(win.height))
                    xs = wt.c + (cols + 0.5) * wt.a
                    ys = wt.f + (rows + 0.5) * wt.e

                    n = interpolator(np.column_stack([xs.ravel(), ys.ravel()]))
                    out = (data - n.reshape(data.shape)).astype("float32")
                    dst.write(out, 1, window=win)

                    finite = out[np.isfinite(out)]
                    if finite.size:
                        stats["min"] = min(stats["min"], float(finite.min()))
                        stats["max"] = max(stats["max"], float(finite.max()))
                        stats["cells"] += int(finite.size)
    return stats

Point clouds, in chunks

import json
import subprocess


def convert_point_cloud(src: str, dst: str, *, horizontal_epsg: int,
                        vertical_epsg: int) -> None:
    """Reproject a cloud into a compound CRS so heights convert with positions.

    PDAL's reprojection applies the same PROJ machinery, so the geoid grid is
    used per point without any code of ours — and the output declares the
    compound CRS, which a manual subtraction would not.
    """
    pipeline = {"pipeline": [
        src,
        {"type": "filters.reprojection",
         "in_srs": "EPSG:4979",
         "out_srs": f"EPSG:{horizontal_epsg}+{vertical_epsg}"},
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "forward": "all", "a_srs": f"EPSG:{horizontal_epsg}+{vertical_epsg}"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

Letting the point-cloud library do the reprojection rather than subtracting an undulation array is the better route wherever it is available, because the output then carries the compound CRS and cannot be mistaken for ellipsoidal later.

Keeping the products consistent

The failure that matters most at this stage is partial conversion: a DEM converted, a point cloud not, and an orthomosaic whose heights were never relevant so nobody checked. A single manifest of what was converted is the cheapest guard.

from pathlib import Path


def conversion_manifest(products: dict[str, str], *, vertical_epsg: int,
                        geoid_model: str) -> dict:
    """Record which products were converted, and refuse a partial set."""
    unconverted = [name for name, state in products.items() if state != "orthometric"]
    if unconverted:
        raise ValueError(f"these products are still ellipsoidal: {unconverted} — "
                         "a mixed delivery will be compared against itself")
    return {"vertical_epsg": vertical_epsg, "geoid_model": geoid_model,
            "products": sorted(products), "height_type": "orthometric"}
Converting a whole dataset once, safely A four-stage conversion. Stage one asserts the input's declared vertical datum, refusing to proceed on a dataset whose datum is unstated rather than assuming one. Stage two transforms in batches, keeping the original ellipsoidal values alongside the converted ones rather than overwriting them. Stage three checks the separation applied at each point against the expected regional range, since a separation outside it indicates the wrong grid or a coordinate outside its coverage. Stage four writes the output with the new datum declared explicitly in its metadata. 1. assert the input datum — refuse if unstated 2. transform in batches, keeping the originals alongside 3. range-check each separation against the regional range 4. declare the new datum in the output metadata Stage 3 catches a coordinate outside the grid's coverage, which otherwise returns silently.

Figure 3 — Four stages, two of which exist purely to refuse bad input.

Edge-case matrix

Situation Risk Handling
Per-cell evaluation Forty minutes and huge memory Coarse grid plus interpolation
Coarse grid too sparse Interpolation error grows 100 nodes is ample; check against direct
Raster larger than memory Load fails Windowed conversion
NoData as a sentinel Sentinel converted as a height Read masked, fill with NaN
Some products converted Mixed datums in one delivery Manifest and refuse partial sets
Point cloud converted manually CRS not updated Reproject through the library instead
Site spans a grid edge Step in the undulation Check coverage before converting
Integer DEM Conversion quantised Write float32

The integer-DEM row is easy to overlook. A DEM stored as int16 in metres cannot represent the fractional undulation, so subtracting it quantises the result to whole metres — which is a catastrophic loss that produces a plausible-looking raster.

Verification snippet

import numpy as np
import rasterio


def verify_conversion(src_path: str, dst_path: str, interpolator,
                      *, samples: int = 2000, tol_m: float = 0.001) -> dict:
    """Spot-check the converted raster against a direct evaluation.

    Comparing a sample against the transformer directly, rather than against
    the interpolator, is what validates the interpolation as well as the
    conversion.
    """
    rng = np.random.default_rng(0)
    with rasterio.open(src_path) as src, rasterio.open(dst_path) as dst:
        rows = rng.integers(0, src.height, samples)
        cols = rng.integers(0, src.width, samples)
        errors = []
        for r, c in zip(rows, cols):
            win = rasterio.windows.Window(int(c), int(r), 1, 1)
            before = float(src.read(1, window=win, masked=True).filled(np.nan)[0, 0])
            after = float(dst.read(1, window=win)[0, 0])
            if not (np.isfinite(before) and np.isfinite(after)):
                continue
            x, y = src.xy(int(r), int(c))
            expected = before - float(interpolator([[x, y]])[0])
            errors.append(abs(after - expected))

    e = np.asarray(errors)
    return {"samples": int(e.size), "max_error_m": float(e.max()) if e.size else 0.0,
            "ok": bool(e.size and e.max() < tol_m)}
Interpolation error against coarse-grid node count A curve of maximum interpolation error against the number of coarse grid nodes per axis, from five to two hundred, for a four kilometre site. At five nodes the error is about four millimetres. At twenty it is under half a millimetre. At one hundred it is under one twentieth of a millimetre, and flat thereafter. A horizontal line marks one millimetre, which every option above about twelve nodes clears comfortably. A note records that the survey's own accuracy is thirty millimetres, three orders of magnitude above the chosen value. 520 50100200 coarse grid nodes per axis interpolation error 1 mm 100 nodes: under 0.05 mm The survey's own accuracy is 30 mm — three orders of magnitude above the chosen value.

Figure 2 — Choosing the node count by measurement rather than by caution.

Where in the pipeline the conversion belongs

The conversion can happen at three points, and the choice affects how many products have to be converted and how many chances there are to get it wrong.

At ingest, on the camera positions and control points, before the reconstruction. The reconstruction then works entirely in the target vertical datum and every product it emits is already correct. This is the fewest conversions and the fewest opportunities for a mixed delivery, and it is the right default.

After the reconstruction, on the surfaces and clouds. More products to convert, more chances to miss one, and the reconstruction’s own reports are in a different datum from the deliverables — which confuses anybody comparing them.

At delivery, as a final step. Worst of the three: by then there are orthomosaics, DEMs, clouds, contours and reports, and any of them can be missed.

def conversion_stage_cost(stage: str) -> dict:
    """How many products a conversion at each stage has to touch."""
    return {
        "ingest": {"products": 2, "items": ["camera positions", "control points"],
                   "note": "everything downstream inherits the correct datum"},
        "post_reconstruction": {"products": 4,
                                "items": ["DEM", "DSM", "point cloud", "contours"],
                                "note": "reconstruction reports remain ellipsoidal"},
        "delivery": {"products": 7,
                     "items": ["DEM", "DSM", "point cloud", "contours", "orthomosaic",
                               "report", "checkpoints"],
                     "note": "most opportunities to miss one"},
    }[stage]

Converting at ingest also has a diagnostic benefit: the control residuals the reconstruction reports are then directly comparable with the deliverable’s accuracy statement, because both are in the same vertical frame.

When to escalate

  • The DEM is stored as an integer type. Convert to float before subtracting, or the result is quantised to the integer’s unit.
  • Some products were already converted. Establish which by reading their declared CRS rather than by asking; a manifest built from the files is trustworthy and one built from memory is not.
  • The site spans a geoid grid boundary. The undulation has a step, and converting across it produces a discontinuity in the deliverable. Split or choose one model.

Geoid Models and Vertical Datum Automation