Life and Shell

Writing · drones

From drone photos and GeoTIFFs to web maps: how Ollebo's map-maker works

By Mattias Hemmingsson · · 5 min read

At Ollebo we turn drone flights into maps, 3D models and live video. This post walks through the map part: how a drone flight or an existing GeoTIFF becomes a map you can zoom around in the browser.

The pipeline at a glance

drone photos ──► OpenDroneMap ──► orthophoto GeoTIFF
                                        │ upload
                                        ▼
                         API ──► object storage (S3)
                          │
                          └──► NATS JetStream "maps" ──► map-maker (worker)
                                                            │ GDAL
                                                            ▼
                                       Cloud-Optimized GeoTIFFs in S3
                                                            │ register
                                                            ▼
                               Terracotta (tile server) ──► OpenLayers web map

Everything runs in Kubernetes, deployed with ArgoCD.

Step 1: photos to orthophoto

A typical flight is a grid over a field or a building with a small DJI drone, a few hundred overlapping photos. OpenDroneMap (ODM) stitches them into an orthophoto: one large, georeferenced GeoTIFF where every pixel has a real-world position.

One trap: ODM writes both odm_orthophoto.tif and odm_orthophoto_render.tif. The render file has no georeference. Upload it by mistake and the job fails with "CRS invalid". We check that early and say so in the error.

Step 2: upload and queue

The user uploads the GeoTIFF in the web app. The API:

  1. stores the file in object storage,
  2. creates a map row with status pending (and skips duplicates),
  3. publishes a message on the NATS JetStream subject maps.

A queue between the upload and the processing matters because processing takes minutes, and the API should answer in milliseconds. It also means a crashed worker doesn't lose the job; JetStream redelivers it.

Step 3: map-maker builds Cloud-Optimized GeoTIFFs

map-maker is a Python worker that listens on maps. For each message it downloads the TIFF, works out what kind of data it is, and builds a set of variants:

Input Variants
RGB (normal camera) rgb (split into one COG per band), grayscale
Multispectral rgb, grayscale, ndvi (vegetation health)
DEM (elevation) hillshade, color-relief, slope

Every variant is a Cloud-Optimized GeoTIFF (COG): a normal GeoTIFF with internal tiles and overviews, laid out so a reader can fetch just the part it needs with HTTP range requests. All of it is GDAL:

COG_CO = [
    "-co", "COMPRESS=DEFLATE",
    "-co", "PREDICTOR=2",       # better compression for imagery
    "-co", "OVERVIEWS=AUTO",    # zoomed-out levels built in
    "-co", "BLOCKSIZE=512",
]

# RGB: one single-band COG per channel
gdal_translate -of COG -ot Byte -b 1 -scale  $COG_CO  ortho.tif  map__r.tif

# NDVI from a multispectral image
gdal_calc.py --calc="(NIR.astype(float)-R)/(NIR+R+1e-6)" \
  -R ortho.tif --R_band=<red> --NIR ortho.tif --NIR_band=<nir> \
  --type=Float32 --NoDataValue=-9999 --outfile=ndvi.tif

# Elevation products
gdaldem hillshade -of COG ... dem.tif hillshade.tif
gdaldem slope     -of COG ... dem.tif slope.tif

Why split RGB into three files? The tile server's /rgb endpoint composites three single-band datasets on the fly. That also lets you do false-color combinations later without reprocessing.

Public and private maps

Every map is private unless it's explicitly marked public. Unknown or missing means private, so a bug can never leak a customer's map into the public bucket.

Public maps are also downsampled to about zoom level 19 (roughly 30 cm per pixel) before the COGs are built. The public version is a good preview; the full drone resolution stays in the private bucket, behind login.

Step 4: Terracotta serves the tiles

Terracotta is a small Python tile server that reads COGs directly from S3. map-maker registers each COG in Terracotta's catalog (a MySQL database) with its metadata, then tells the API the map is ready.

There's no tile cache to build. No gdal2tiles, no millions of small PNGs in a bucket. When the browser asks for tile z/x/y, Terracotta reads the matching window from the COG (using the built-in overviews when zoomed out) and renders a PNG.

/rgb/<map>/{z}/{x}/{y}.png?r=r&g=g&b=b     true color
/singleband/<map>/ndvi/{z}/{x}/{y}.png?colormap=rdylgn

Private maps go through a small auth proxy in front. In Ollebo, a "space" (a team) is a Keycloak group, and the proxy checks that the user is in the right group before passing the request on.

Step 5: the web map

The front end is React with OpenLayers. A drone map is just another XYZ layer on top of base maps (OpenStreetMap, Lantmäteriet's Swedish maps, Copernicus satellite data). Switching between RGB, NDVI and hillshade is switching the URL template.

Bonus: maps without a drone

The same COG builders work for any GeoTIFF. A separate tool called geodata imports a source described in one YAML file and runs it as a Kubernetes Job:

id: my-elevation
name: Elevation model
type: raster          # GeoTIFF/DEM → map-maker's COG builders → Terracotta
source:
  url: https://example.org/dem.tif
attribution: "© Data owner"
license: CC-BY-4.0

Vector data (shapefiles, GeoPackages, WFS, OpenStreetMap extracts) takes another path, through ogr2ogr or osm2pgsql into PostGIS, served as vector tiles with martin. Adding a layer never touches a Kubernetes manifest; the new URL works as soon as the import finishes.

Lessons from running it

  • Match ack time to job time. JetStream's default ack wait is 30 seconds. A map takes minutes. So JetStream decided the worker had died and redelivered the message while it was still working, to the same worker. Now the ack wait is 30 minutes.
  • Cap redeliveries. A file that always fails (wrong CRS, broken TIFF) was redelivered forever. Now a message is retried a few times for temporary S3 or network errors, then terminated, and the map is marked failed with a reason.
  • Statistics on a sample. Terracotta computes value ranges when you register a dataset. By default it read the whole raster, and on a big orthophoto it ran out of memory. Computing them on a downsampled 1024 × 1024 view is just as good for coloring and uses almost no memory.
  • One subscriber per push consumer. Our consumer is a push consumer, which only allows one subscriber. During a rolling update the old and new pod both tried to subscribe, so the Deployment uses strategy: Recreate.
  • Trust the code, not the old README. The first version made tiles with gdal2tiles. The docs said so long after the code moved to COGs. When you change the pipeline, change the doc in the same commit.

Why this design?

  • COGs + Terracotta means one file per variant instead of millions of tiles. Reprocessing is fast and storage is cheap.
  • A queue keeps the API fast and the workers replaceable.
  • Private by default and a downsampled public copy means you can share a map without giving away the full-resolution data.

If you have drone imagery or GeoTIFFs and want to see them on a map, try Ollebo.

Filed underdrones · gis · geotiff · gdal · kubernetes · ollebo