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.
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"}
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)}
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.