
In this tutorial, we’ll create a tile service for GeoTIFF files using Python, FastAPI, and various libraries such as xarray, rioxarray, rasterio, and Pillow. This service will dynamically generate map tiles on-the-fly and cache them for efficient retrieval.
Prerequisites
Before starting, ensure you have the following installed:
- Python (3.7 or later)
- FastAPI (
pip install fastapi) - Uvicorn (
pip install uvicorn) - xarray (
pip install xarray) - rioxarray (
pip install rioxarray) - shapely (
pip install shapely) - rasterio (
pip install rasterio) - Pillow (
pip install pillow)
Overview
- Setup FastAPI: Initialize a FastAPI application.
- Load GeoTIFF Data: Load the GeoTIFF file using
xarrayandrioxarray. - Calculate Tile Extent: Define a function to calculate the geographic extent of a tile.
- Define Tile Generation Functions: Implement functions to generate map tiles, interpolation etc.
- Serving and Caching Tiles: Create an API endpoint to serve map tiles.
- Runnig TileServer
- Rendering raster tiles in OpenLayers map
To get full source code for this tutorial, click here.
To check the live demo for this tutorial, click here.
Let’s dive into the implementation.
Step 1: Setup FastAPI
|
1 2 3 4 5 |
from fastapi import FastAPI, Response from starlette.responses import FileResponse app = FastAPI() |
We initialize a FastAPI application to serve our tile service.
Step 2: Load GeoTIFF Data
|
1 2 3 4 5 6 7 8 9 |
import xarray as xr import rioxarray as riox # Load GeoTIFF file ds = xr.open_dataset('raster/dem_hp.tif') ds = ds.rio.reproject("EPSG:3857") # Reproject to Web Mercator ds = ds.band_data # Extract band data ds = ds.astype(np.int32) # Convert data type to int32 |
Here, we load the GeoTIFF file using xarray and reproject it to the Web Mercator coordinate system (EPSG:3857) using rioxarray. We then extract band data and convert it to int32 for efficient processing.
Step 3: Calculate Tile Extent
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
def ST_TileEnvelope(zoom, x, y, bounds, margin=0): worldTileSize = 2 ** min(zoom, 31) # Extract bounding box coordinates xmin, ymin, xmax, ymax = bounds # Calculate width and height of the bounding box boundsWidth = xmax - xmin boundsHeight = ymax - ymin if boundsWidth <= 0 or boundsHeight <= 0: raise ValueError("Geometric bounds are too small") if zoom < 0 or zoom >= 32: raise ValueError("Invalid tile zoom value") if x < 0 or x >= worldTileSize: raise ValueError("Invalid tile x value") if y < 0 or y >= worldTileSize: raise ValueError("Invalid tile y value") # Calculate tile geographic size tileGeoSizeX = boundsWidth / worldTileSize tileGeoSizeY = boundsHeight / worldTileSize # Calculate margins if margin < -0.5: raise ValueError("Margin must not be less than -50%") margin = max(-0.5, margin) margin = min(0.5, margin) # Calculate tile bounds x1 = xmin + tileGeoSizeX * (x - margin) x2 = xmin + tileGeoSizeX * (x + 1 + margin) y1 = ymax - tileGeoSizeY * (y + 1 + margin) y2 = ymax - tileGeoSizeY * (y - margin) # Clip y-axis to the given bounds y1 = max(y1, ymin) y2 = min(y2, ymax) return [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)] |
We define a function ST_TileEnvelope to calculate the geographic extent of a tile based on its zoom level, x, and y coordinates. This function calculates the bounding box coordinates of the tile in the Web Mercator projection. This function is directly inspired from postgis function ST_TileEnvelope
Step 4: Define Tile Generation Functions
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
def normalize_array(arr, min_value, max_value): # Function to normalize array values # ... def convert_black_to_transparent(image): # Function to convert black pixels to transparent # ... def generate_tile(zoom, x, y, bounds, margin, min_value, max_value): tile_envelope = ST_TileEnvelope(zoom, x, y, bounds, margin) xmin, ymin = tile_envelope[0] xmax, ymax = tile_envelope[2] try: data = ds.rio.clip_box(minx=xmin, miny=ymin, maxx=xmax, maxy=ymax, auto_expand=True) data = data.rio.reproject(data.rio.crs, shape=(256, 256)) data = data.rio.pad_box(xmin, ymin, xmax, ymax, 0) except riox.exceptions.NoDataInBounds: # Return empty tile if no data found in bounds return None arr = normalize_array(data.values[0], min_value, max_value) arr = arr.astype(np.int32) img = Image.fromarray(arr) img = img.convert('RGBA') img = convert_black_to_transparent(img) img_bytes = io.BytesIO() img.save(img_bytes, format='PNG') img_bytes.seek(0) return img_bytes.getvalue() |
We define helper functions to facilitate tile generation. normalize_array normalizes array values to the 0-255 range. convert_black_to_transparent converts black pixels to transparent in an image. generate_tile generates a map tile based on zoom level, tile coordinates, and other parameters.
This step is basically interpolation of raster data based on extent.
The interpolation and resizing steps in the tile generation process involve two main components:
- Interpolation using
rio.reproject():- This step resizes the raster data to match the desired shape of the tile, typically 256×256 pixels.
- It ensures that the raster data covers the exact spatial extent of the tile.
- Interpolation helps maintain the resolution and accuracy of the raster data when resizing to fit the tile shape.
- Resizing based on Tile Extent using
rio.pad_box()- After interpolation, additional resizing is performed to ensure that the tile covers the specified geographic extent.
- This step adjusts the size of the tile to match its bounding box coordinates, ensuring accurate representation of the geographic area.
- Resizing based on the tile extent helps generate map tiles that accurately represent the specified area while maintaining consistency in tile dimensions.
- This resizing prevents the tile distortion
Step 5: Serving and Caching Tiles
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@app.get("/tiles/{z}/{x}/{y}.png") async def get_tile(z: int, x: int, y: int, response: Response): response.headers["Content-Type"] = "image/png" tile_path = f"tiles/{z}/{x}/{y}.png" if (os.path.exists(tile_path) is False): # Render tile if not available in cache, then store in cache directory os.makedirs(f"tiles/{z}/{x}", exist_ok=True) with open(tile_path, "wb") as tile: # Generate tile tile_data = generate_tile( z, x, y, bounds, margin, min_value, max_value) if tile_data is not None: tile.write(tile_data) return Response(content=tile_data) else: # Return tile from cache return FileResponse(tile_path) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) |
Here, we define an API endpoint /tiles/{z}/{x}/{y}.png to serve map tiles. When a request is made for a tile at a specific zoom level (z), x-coordinate (x), and y-coordinate (y), the server checks if the tile is available in the cache directory. If not, it generates the tile using the generate_tile function and saves it to the cache directory. Finally, it returns the tile as a PNG image.
Cache Directory structure looks like below where path of tile is maintained based on {z}/{x}/{y}.png

Step 6: Running the Application
To run the tile service, execute the following command:
|
1 2 3 4 5 |
(datascience-dev.guru) geoknight@pop-os:~/tileserver$ python tileserver.py INFO: Started server process [201433] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) |
Step 7: Rendering Raster Tiles in OpenLayers Map
The provided HTML code sets up an OpenLayers map with two layers:
- OSM Layer: This layer displays a base map using OpenStreetMap tiles.
- Custom Raster Tile Layer: This layer is configured to display raster tiles using the specified URL pattern (
tiles/{z}/{x}/{y}.png), where{z},{x}, and{y}represent the zoom level, tile’s column index, and tile’s row index respectively.
The JavaScript code initializes the map, sets its view to a specific center and zoom level, and adds the OSM and custom raster tile layers to the map. The raster tile layer will dynamically fetch tiles based on the provided URL pattern and display them on the map.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<html> <head> <title>Raster Tile</title> <script src="cdn/ol.js"></script> <link rel="stylesheet" href="cdn/ol.css"> <link rel="stylesheet" href="cdn/style.css"> </head> <body> <div id="map"> <div id="popup"></div> </div> </body> <script> let osmLayer = new ol.layer.Tile({ source: new ol.source.OSM() }) var map = new ol.Map({ target: 'map', view: new ol.View({ center: [ 8542295.27212957, 3595868.3527581077], zoom: 6, // minZoom: 10, maxZoom: 13 }), layers: [ osmLayer, new ol.layer.Tile({ source: new ol.source.XYZ({ url: 'tiles/{z}/{x}/{y}.png', }), }) ] }); </script> </html> |
I hope this tutorial will create a good foundation for you. If you want tutorials on another GIS topic or you have any queries, please send an mail at contact@spatial-dev.guru.

Hi,
Did you by any chance link to the wrong demo? For me the link goes to https://iamgeoknight.github.io/Animating-a-3D-point-in-arcgis/
You are correct. Many Thanks for notifying me. I have corrected the repo and demo hyper link.