This comprehensive guide updates and modernizes the original HOWTO install OpenTopoMap on Ubuntu 18.04 LTS. It walks you through system setup, database configuration, DEM preparation, and tile rendering.
Contents
Update and Clean Up Your System
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository main
sudo add-apt-repository restricted
sudo add-apt-repository universe
sudo add-apt-repository multiverse
sudo apt update && sudo apt full-upgrade
Clean up the system:
# 1. Ensure all official repos are enabled
sudo add-apt-repository -y main universe multiverse restricted
# 2. Fix partial installations
sudo dpkg --configure -a
sudo apt -f install
# 3. Refresh everything
sudo apt update
sudo apt full-upgrade -y
# 4. Remove orphaned / half-installed stuff
sudo apt autoremove --purge -y
sudo apt clean
Install Required Packages
sudo apt install git tar unzip wget bzip2 build-essential autoconf libtool pkg-config libxml2-dev libgeos-dev libgeos++-dev libpq-dev libbz2-dev libproj-dev proj-bin proj-data munin-node munin libprotobuf-c-dev protobuf-c-compiler libfreetype-dev libpng-dev libtiff-dev libicu-dev libgdal-dev libcairo2-dev libcairomm-1.0-dev apache2 apache2-dev libagg-dev lua5.3 liblua5.3-dev liblua5.1-0-dev fonts-unifont libgeotiff-dev cmake devscripts libjson-perl libipc-sharelite-perl libgd-perl debhelper gdal-bin python3-setuptools python3-matplotlib python3-bs4 python3-numpy python3-pip python3-lxml python3-gdal wget unzip pipx osm2pgsql python3-mapnik libmapnik-dev mapnik-utils osmctools bc aria2
Install some more useful packages:
sudo apt install vim git screen htop iptraf
Install PostgreSQL and PostGIS
sudo apt install -y postgresql postgresql-16 postgresql-contrib postgresql-16-postgis-3 postgresql-16-postgis-3-scripts
Start and enable the service:
sudo systemctl enable postgresql
sudo systemctl start postgresql
sudo systemctl status postgresql
Create a database user for yourself:
sudo -u postgres createuser -s $USER
Create a GIS database:
createdb -O $USER gis
psql -d gis -c "CREATE EXTENSION postgis;"
psql -d gis -c "CREATE EXTENSION hstore;"
PostgreSQL settings for the tested 32 GiB server
The defaults are too conservative for a continent-sized import. These values were used on a server with 32 GiB RAM. Scale them down on smaller machines; work_mem can be allocated more than once per query.
sudo -u postgres psql -v ON_ERROR_STOP=1 <<'SQL'
ALTER SYSTEM SET shared_buffers = '5GB';
ALTER SYSTEM SET effective_cache_size = '30GB';
ALTER SYSTEM SET maintenance_work_mem = '512MB';
ALTER SYSTEM SET work_mem = '32MB';
ALTER SYSTEM SET max_wal_size = '16GB';
ALTER SYSTEM SET checkpoint_timeout = '30min';
ALTER SYSTEM SET checkpoint_completion_target = '0.9';
ALTER SYSTEM SET wal_compression = 'pglz';
ALTER SYSTEM SET max_parallel_maintenance_workers = '4';
SQL
sudo systemctl restart postgresql
Clone the OpenTopoMap Repository
cd
git clone https://github.com/der-stefan/OpenTopoMap.git
cd OpenTopoMap/mapnik
Download Water Polygons
Get the generalized water polygons from http://openstreetmapdata.com/
mkdir data && cd data
# simplified polygons (replaces water‑polygons‑generalized‑3857.zip)
wget https://osmdata.openstreetmap.de/download/simplified-water-polygons-split-3857.zip
# unsimplified split polygons
wget https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip
# optional Antarctic datasets if you need polar coverage
# wget https://osmdata.openstreetmap.de/download/antarctica-icesheet-polygons-3857.zip
# wget https://osmdata.openstreetmap.de/download/antarctica-icesheet-outlines-3857.zip
# unpack the shapefiles
unzip water-polygons-split-3857.zip
unzip simplified-water-polygons-split-3857.zip
# unzip antarctica-icesheet-polygons-3857.zip
# unzip antarctica-icesheet-outlines-3857.zip
Prepare the DEM (Digital Elevation Model)
Indentify the elevation data you need: www.viewfinderpanoramas.org
Create an STRM download list:
mkdir ~/srtm
cd ~/srtm
sudo vi list.txt
Insert what you need:
http://viewfinderpanoramas.org/dem3/N31.zip http://viewfinderpanoramas.org/dem3/N32.zip http://viewfinderpanoramas.org/dem3/M31.zip http://viewfinderpanoramas.org/dem3/M32.zip http://viewfinderpanoramas.org/dem3/L31.zip http://viewfinderpanoramas.org/dem3/L32.zip
Download the data:
wget -i list.txt
Unzip:
for zipfile in *.zip;do unzip -j -o "$zipfile" -d unpacked; done
Fill the voids:
cd unpacked
for hgtfile in *.hgt;do gdal_fillnodata.py $hgtfile $hgtfile.tif; done
Merge TIFs:
gdal_merge.py -n 32767 -co BIGTIFF=YES -co TILED=YES -co COMPRESS=LZW -co PREDICTOR=2 -o ../raw.tif *.hgt.tif
rm *hgt.tif
gdalbuildvrt raw.vrt *.hgt.tif
gdal_translate raw.vrt ../raw.tif \
-co BIGTIFF=YES \
-co TILED=YES \
-co COMPRESS=ZSTD \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS
Re-project to Mercator, interpolate and shrink:
cd ..
gdalwarp -co BIGTIFF=YES -co TILED=YES -co COMPRESS=LZW -co PREDICTOR=2 -t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" -r bilinear -tr 5000 5000 raw.tif warp-5000.tif
gdalwarp -co BIGTIFF=YES -co TILED=YES -co COMPRESS=LZW -co PREDICTOR=2 -t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" -r bilinear -tr 1000 1000 raw.tif warp-1000.tif
gdalwarp -co BIGTIFF=YES -co TILED=YES -co COMPRESS=LZW -co PREDICTOR=2 -t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" -r bilinear -tr 700 700 raw.tif warp-700.tif
gdalwarp -co BIGTIFF=YES -co TILED=YES -co COMPRESS=LZW -co PREDICTOR=2 -t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" -r bilinear -tr 500 500 raw.tif warp-500.tif
gdalwarp -co BIGTIFF=YES -co TILED=YES -co COMPRESS=LZW -co PREDICTOR=2 -t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" -r bilinear -tr 90 90 raw.tif warp-90.tif
cd ..
gdalwarp -multi \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES \
-co TILED=YES \
-co BLOCKXSIZE=256 -co BLOCKYSIZE=256 \
-co COMPRESS=ZSTD \
-t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" \
-r bilinear -tr 5000 5000 \
raw.tif warp-5000.tif
gdalwarp -multi \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES -co TILED=YES \
-co BLOCKXSIZE=256 -co BLOCKYSIZE=256 \
-co COMPRESS=ZSTD \
-t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" \
-r bilinear -tr 1000 1000 \
raw.tif warp-1000.tif
gdalwarp -multi \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES -co TILED=YES \
-co BLOCKXSIZE=256 -co BLOCKYSIZE=256 \
-co COMPRESS=ZSTD \
-t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" \
-r bilinear -tr 700 700 \
raw.tif warp-700.tif
gdalwarp -multi \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES -co TILED=YES \
-co BLOCKXSIZE=256 -co BLOCKYSIZE=256 \
-co COMPRESS=ZSTD \
-t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" \
-r bilinear -tr 500 500 \
raw.tif warp-500.tif
gdalwarp -multi \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES -co TILED=YES \
-co BLOCKXSIZE=256 -co BLOCKYSIZE=256 \
-co COMPRESS=ZSTD \
-t_srs "+proj=merc +ellps=sphere +R=6378137 +a=6378137 +units=m" \
-r bilinear -tr 90 90 \
raw.tif warp-90.tif
Create color relief:
gdaldem color-relief -co COMPRESS=LZW -co PREDICTOR=2 -alpha warp-5000.tif ../OpenTopoMap/mapnik/relief_color_text_file.txt relief-5000.tif
gdaldem color-relief -co COMPRESS=LZW -co PREDICTOR=2 -alpha warp-500.tif ../OpenTopoMap/mapnik/relief_color_text_file.txt relief-500.tif
gdaldem color-relief \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES \
-co TILED=YES \
-co COMPRESS=ZSTD \
warp-5000.tif \
../OpenTopoMap/mapnik/relief_color_text_file.txt \
relief-5000.tif
gdaldem color-relief \
--config GDAL_NUM_THREADS ALL_CPUS \
-co NUM_THREADS=ALL_CPUS \
-co BIGTIFF=YES \
-co TILED=YES \
-co COMPRESS=ZSTD \
warp-500.tif \
../OpenTopoMap/mapnik/relief_color_text_file.txt \
relief-500.tif
Create hillshade:
gdaldem hillshade -z 7 -compute_edges -co COMPRESS=JPEG warp-5000.tif hillshade-5000.tif
gdaldem hillshade -z 7 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-1000.tif hillshade-1000.tif
gdaldem hillshade -z 4 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-700.tif hillshade-700.tif
gdaldem hillshade -z 4 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-500.tif hillshade-500.tif
gdaldem hillshade -z 2 -co compress=lzw -co predictor=2 -co bigtiff=yes -compute_edges warp-90.tif hillshade-90.tif && gdal_translate -co compress=JPEG -co bigtiff=yes -co tiled=yes hillshade-90.tif hillshade-90-jpeg.tif
gdaldem hillshade -z 5 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-90.tif hillshade-30m-jpeg.tif
gdaldem hillshade -z 7 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-5000.tif hillshade-5000.tif
gdaldem hillshade -z 7 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-1000.tif hillshade-1000.tif
gdaldem hillshade -z 4 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-700.tif hillshade-700.tif
gdaldem hillshade -z 4 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-500.tif hillshade-500.tif
gdaldem hillshade -z 2 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-90.tif hillshade-90.tif
gdaldem hillshade -z 5 -compute_edges -co BIGTIFF=YES -co TILED=YES -co COMPRESS=JPEG warp-90.tif hillshade-30m-jpeg.tif
Edit opentopomap.xml to use the dedicated contours table:
<Layer name="contours">
<StyleName>contours</StyleName>
<Datasource>
<Parameter name="table">(SELECT way,ele FROM contours) AS contours </Parameter>
<Parameter name="dbname">contours</Parameter>
&postgis-settings;
</Datasource>
</Layer>
Generate Contours
Option A: pyhgtmap for regional extracts
This method is suitable for countries and smaller regions. For a continent-sized DEM, use the GDAL/PostGIS method below.
Install pyhgtmap (fork of phyghtmap):
pipx ensurepath
pipx install --system-site-packages pyhgtmap
# pipx install --force pyhgtmap[geotiff]
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
Create contour lines:
pyhgtmap -o contour --max-nodes-per-tile=0 -s 10 -0 --pbf warp-90.tif
For larger regional jobs, smaller DEM mosaics can be processed in batches. A helper script is available in the repository:
process_batches.sh
Make sure the TIF files reside in mapnik/dem, e.g.:
mkdir ~/OpenTopoMap/mapnik/dem
mv *tif ~/OpenTopoMap/mapnik/dem/
Create the “contours” database:
sudo -u postgres psql -c "DROP DATABASE IF EXISTS contours;"
createdb -O $USER contours
psql -d contours -c "CREATE EXTENSION postgis;"
psql -d contours -c "CREATE EXTENSION hstore;"
Load the data:
osm2pgsql --slim -d contours --cache 5000 --style ../OpenTopoMap/mapnik/osm2pgsql/contours.style contour*.pbf
Option B: gdal_contour for Europe-sized DEMs
For a full European DEM, generate the contour lines directly in PostGIS. First create the database and explicitly assign EPSG:3857 to a VRT. This avoids legacy Mercator definitions being imported with a nonstandard SRID.
dropdb --if-exists contours
createdb -O "$USER" contours
psql -d contours -c "CREATE EXTENSION postgis;"
psql -d contours -c "CREATE EXTENSION hstore;"
cd ~/OpenTopoMap/mapnik/dem
gdal_translate -of VRT -a_srs EPSG:3857 \
warp-90.tif warp-90-epsg3857.vrt
Start a named and logged screen session:
screen -L -Logfile /home/$USER/contours-europe.log -S contours-europe
Run inside the screen session:
set -euo pipefail
export PG_USE_COPY=YES
cd ~/OpenTopoMap/mapnik/dem
psql -v ON_ERROR_STOP=1 -d contours -c \
'DROP TABLE IF EXISTS public.contours_build;'
time gdal_contour \
-b 1 \
-a ele \
-i 10 \
-snodata -32768 \
-f PostgreSQL \
-nln public.contours_build \
-lco GEOMETRY_NAME=way \
-lco FID=id \
-lco DIM=2 \
-lco SPATIAL_INDEX=NONE \
warp-90-epsg3857.vrt \
"PG:dbname=contours"
Detach with Ctrl-A, then D. Reattach with screen -r contours-europe.
When contour generation has finished, remove the zero-metre line, create the spatial index and activate the finished table:
psql -v ON_ERROR_STOP=1 -d contours <<'SQL'
DELETE FROM public.contours_build WHERE ele = 0;
ALTER TABLE public.contours_build
ALTER COLUMN ele TYPE integer USING ele::integer;
CREATE INDEX contours_build_way_gist
ON public.contours_build USING gist (way);
ANALYZE public.contours_build;
DROP TABLE IF EXISTS public.contours;
ALTER TABLE public.contours_build RENAME TO contours;
ALTER INDEX public.contours_build_pkey RENAME TO contours_pkey;
ALTER INDEX public.contours_build_way_gist RENAME TO contours_way_gist;
SQL
Verify the result:
psql -P pager=off -d contours -c "
SELECT COUNT(*) AS rows,
MIN(ele) AS minimum_elevation,
MAX(ele) AS maximum_elevation,
ST_SRID(way) AS srid,
GeometryType(way) AS geometry_type
FROM public.contours
GROUP BY ST_SRID(way), GeometryType(way);"
Subdivide continent-wide contour geometries
This step is essential for a continent-sized raster. gdal_contour can create continuous lines containing millions of points. Although the GiST index finds only a few matching records for a tile, Mapnik must still fetch and clip those complete geometries. In one measured European build, a single z14 tile selected 33 records containing 167 million points (about 2.5 GB in memory) and needed more than two minutes to render.
Build a spatially local replacement table in a named, logged screen session:
screen -L -Logfile /home/$USER/contours-subdivide.log -S contours-subdivide
Run inside the screen:
set -euo pipefail
time psql -X -v ON_ERROR_STOP=1 -d contours <<'SQL'
DROP TABLE IF EXISTS public.contours_subdivided;
CREATE TABLE public.contours_subdivided AS
SELECT
c.ele::integer AS ele,
s.geom::geometry(LineString,3857) AS way
FROM public.contours AS c
CROSS JOIN LATERAL ST_Subdivide(c.way, 256) AS s(geom);
SQL
time psql -X -v ON_ERROR_STOP=1 -d contours -c "
CREATE INDEX contours_subdivided_way_gist
ON public.contours_subdivided USING gist (way);"
time psql -X -v ON_ERROR_STOP=1 -d contours -c "
ANALYZE public.contours_subdivided;"
Validate before replacing the original table:
psql -X -P pager=off -d contours <<'SQL'
SELECT COUNT(*) AS rows,
MIN(ele) AS minimum_elevation,
MAX(ele) AS maximum_elevation,
COUNT(*) FILTER (WHERE way IS NULL OR ST_IsEmpty(way)) AS invalid_geometries,
ST_SRID(way) AS srid,
GeometryType(way) AS geometry_type
FROM public.contours_subdivided
GROUP BY ST_SRID(way), GeometryType(way);
SELECT COUNT(*) AS matching_geometries,
SUM(ST_NPoints(way)) AS total_points,
MAX(ST_NPoints(way)) AS largest_geometry_points,
pg_size_pretty(SUM(ST_MemSize(way))::bigint) AS geometry_memory
FROM public.contours_subdivided
WHERE way && ST_TileEnvelope(14, 8471, 5427);
SQL
largest_geometry_points must be at most 256. After validation, atomically activate the subdivided table while retaining the original as a rollback copy:
psql -X -v ON_ERROR_STOP=1 -d contours <<'SQL'
BEGIN;
ALTER TABLE public.contours RENAME TO contours_unsplit;
ALTER TABLE public.contours_subdivided RENAME TO contours;
ALTER INDEX public.contours_subdivided_way_gist RENAME TO contours_way_gist_subdivided;
COMMIT;
SQL
Render and inspect several tiles before dropping contours_unsplit.
Import OSM Data
mkdir ~/data
cd ~/data
aria2c -x 16 -s 16 -c -o europe-latest.osm.pbf \
https://download.geofabrik.de/europe-latest.osm.pbf
wget -O europe-latest.osm.pbf.md5 \
https://download.geofabrik.de/europe-latest.osm.pbf.md5
expected_md5="$(awk '{print $1}' europe-latest.osm.pbf.md5)"
printf '%s %s\n' "$expected_md5" europe-latest.osm.pbf | md5sum -c -
mkdir -p ~/flatnodes
screen -L -Logfile /home/$USER/osm2pgsql-import.log -S osm2pgsql-import
Run the import inside the screen session:
set -euo pipefail
time osm2pgsql \
--create \
--slim \
--drop \
--database gis \
--cache 12000 \
--number-processes 8 \
--flat-nodes ~/flatnodes/europe-flat-nodes.bin \
--style ~/OpenTopoMap/mapnik/osm2pgsql/opentopomap.style \
~/data/europe-latest.osm.pbf
Create indexes required by the preprocessing scripts
With osm2pgsql --drop, the rendering tables have spatial indexes but no B-tree indexes on osm_id. OpenTopoMap’s lowzoom and isolation scripts perform direct osm_id lookups. On continent-sized imports, omitting these indexes can turn preprocessing into repeated full-table scans.
Create the indexes before running any preprocessing scripts. For large imports, run this in another logged screen session:
screen -L -Logfile /home/$USER/osmid-indexes.log -S osmid-indexes
set -euo pipefail
time psql -X -v ON_ERROR_STOP=1 -d gis -c "
CREATE INDEX planet_osm_polygon_osm_id_idx
ON public.planet_osm_polygon USING btree (osm_id);"
time psql -X -v ON_ERROR_STOP=1 -d gis -c "
CREATE INDEX planet_osm_point_osm_id_idx
ON public.planet_osm_point USING btree (osm_id);"
psql -X -v ON_ERROR_STOP=1 -d gis -c "
ANALYZE public.planet_osm_polygon (osm_id);
ANALYZE public.planet_osm_point (osm_id);"
Preprocessing and Rendering
Compile helper tools:
cd ~/OpenTopoMap/mapnik/tools
cc -o saddledirection saddledirection.c -lm -lgdal
cc -Wall -o isolation isolation.c -lgdal -lm -O2
Install the database functions in single transactions:
cd ~/OpenTopoMap/mapnik
psql -X -v ON_ERROR_STOP=1 -1 -d gis -f tools/arealabel.sql
psql -X -v ON_ERROR_STOP=1 -1 -d gis -f tools/pitchicon.sql
psql -X -v ON_ERROR_STOP=1 -1 -d gis -f tools/viewpointdirection.sql
psql -X -v ON_ERROR_STOP=1 -1 -d gis -f tools/stationdirection.sql
The helper programs do not use the projected Mapnik DEM. The isolation and saddle-direction tools receive longitude/latitude coordinates and therefore require the original geographic EPSG:4326 raster, raw.tif. The projected warp-*.tif files are for Mapnik, hillshade, relief and contour generation. Pointing these helpers at warp-90.tif produces out-of-range raster requests and invalid values.
Verify the helper DEM:
gdalinfo ~/OpenTopoMap/mapnik/dem/raw.tif | \
grep -E 'Size is|ID\["EPSG"|Pixel Size|Upper Left|Lower Right|NoData'
The output must identify EPSG:4326. Make Europe-specific copies of the helper scripts:
cp ~/OpenTopoMap/mapnik/tools/update_isolations.sh ~/update_isolations_europe.sh
cp ~/OpenTopoMap/mapnik/tools/update_saddles.sh ~/update_saddles_europe.sh
In both copies, set:
demfile='/home/<user>/OpenTopoMap/mapnik/dem/raw.tif'
Also restrict both selection queries to the DEM coverage:
AND way && ST_MakeEnvelope(
-2115116.708,
3248874.716,
6679233.292,
11753334.716,
3857
)
Leave outside-DEM saddle directions null rather than inventing a direction. Validate both edited scripts:
bash -n ~/update_isolations_europe.sh
bash -n ~/update_saddles_europe.sh
Before running the parking script, replace any hard-coded installation path such as /home/otmuser/OpenTopoMap with the actual repository path or a path based on $HOME.
Run the preprocessing scripts one at a time in named, logged screen sessions. Do not run them concurrently: they place substantial load on PostgreSQL and the DEM storage.
screen -L -Logfile /home/$USER/lowzoom.log -S lowzoom
# Inside screen:
cd ~/OpenTopoMap/mapnik
time bash -euo pipefail tools/update_lowzoom.sh
screen -L -Logfile /home/$USER/isolation.log -S isolation
# Inside screen:
cd ~/OpenTopoMap
time bash -euo pipefail ~/update_isolations_europe.sh
screen -L -Logfile /home/$USER/parking.log -S parking
# Inside screen:
cd ~/OpenTopoMap/mapnik
time bash -euo pipefail tools/update_parking.sh
screen -L -Logfile /home/$USER/saddles.log -S saddles
# Inside screen:
cd ~/OpenTopoMap
time bash -euo pipefail ~/update_saddles_europe.sh
If Tirex is not installed, remove or comment out the GRANT ... TO tirex statement in update_lowzoom.sh before running it.
The stylesheet expects hiking and otm_isolation on both point and polygon tables. The preprocessing scripts normally create them. Verify the schema before loading Mapnik:
psql -X -v ON_ERROR_STOP=1 -d gis -c "
ALTER TABLE public.planet_osm_point
ADD COLUMN IF NOT EXISTS hiking text,
ADD COLUMN IF NOT EXISTS otm_isolation text;
ALTER TABLE public.planet_osm_polygon
ADD COLUMN IF NOT EXISTS hiking text,
ADD COLUMN IF NOT EXISTS otm_isolation text;"
Refresh planner statistics when preprocessing has finished:
psql -X -v ON_ERROR_STOP=1 -d gis -c "
ANALYZE public.planet_osm_point;
ANALYZE public.planet_osm_line;
ANALYZE public.planet_osm_polygon;
ANALYZE public.planet_osm_roads;"
Before rendering, verify that Mapnik can load every layer and datasource:
cd ~/OpenTopoMap/mapnik
python3 - <<'PY'
import mapnik
m = mapnik.Map(256, 256)
mapnik.load_map(m, "opentopomap.xml")
print("Map loaded successfully")
print("Layers:", len(m.layers))
PY
Create mapnik_render_tile.py for Python 3:
#!/usr/bin/env python3
"""
Render a single PNG tile with Mapnik to verify your DB/style setup.
Usage (example):
python3 mapnik_render_tile.py --style opentopomap.xml --out opentopomap_output.png \
--z 14 --x 8471 --y 5427
Defaults render one 256x256 tile near Kleve, Germany.
"""
import os
import sys
import argparse
import mapnik
def tile2prjbounds(settings, x, y, z):
"""
Compute projected bounds (EPSG:3857) for a Z/X/Y tile region.
:param settings: geometry settings dict
:param x: tile x
:param y: tile y
:param z: tile zoom
:return: (x0, y0, x1, y1)
"""
render_size_tx = 1
render_size_ty = 1
prj_width = settings['bound_x1'] - settings['bound_x0']
prj_height = settings['bound_y1'] - settings['bound_y0']
p0x = settings['bound_x0'] + prj_width * (float(x) / (settings['aspect_x'] * (1 << z)))
p0y = settings['bound_y1'] - prj_height * ((float(y) + render_size_ty) / (settings['aspect_y'] * (1 << z)))
p1x = settings['bound_x0'] + prj_width * ((float(x) + render_size_tx) / (settings['aspect_x'] * (1 << z)))
p1y = settings['bound_y1'] - prj_height * (float(y) / (settings['aspect_y'] * (1 << z)))
return p0x, p0y, p1x, p1y
def register_fonts(extra_dirs=None):
"""Register system fonts (and optional extra dirs) for Mapnik."""
try:
# Register distro/system fonts (works on Ubuntu 24.04)
mapnik.register_system_fonts()
except Exception:
# Older Mapniks may not have this helper; ignore if missing
pass
# Register additional directories (recursively)
for d in (extra_dirs or []):
if os.path.isdir(d):
try:
mapnik.register_fonts(d, True)
except Exception as e:
print(f"[warn] Could not register fonts in {d}: {e}", file=sys.stderr)
# Also register common locations explicitly (recursive)
for d in ("/usr/share/fonts",
"/usr/local/share/fonts",
os.path.expanduser("~/.local/share/fonts")):
if os.path.isdir(d):
try:
mapnik.register_fonts(d, True)
except Exception as e:
print(f"[warn] Could not register fonts in {d}: {e}", file=sys.stderr)
def main():
parser = argparse.ArgumentParser(description="Render a PNG tile with Mapnik.")
parser.add_argument("--style", "-s", default="opentopomap.xml", help="Mapnik XML stylesheet")
parser.add_argument("--out", "-o", default="opentopomap_output.png", help="Output PNG path")
parser.add_argument("--width", type=int, default=256, help="Image width in pixels")
parser.add_argument("--height", type=int, default=256, help="Image height in pixels")
parser.add_argument("--z", type=int, default=14, help="Zoom")
parser.add_argument("--x", type=int, default=8471, help="Tile X")
parser.add_argument("--y", type=int, default=5427, help="Tile Y")
args = parser.parse_args()
if not os.path.exists(args.style):
print(f"[error] Stylesheet not found: {args.style}", file=sys.stderr)
sys.exit(1)
register_fonts()
m = mapnik.Map(args.width, args.height)
mapnik.load_map(m, args.style)
# Web Mercator bounds in meters
geom_settings = {
'bound_x0': -20037508.3428,
'bound_x1': 20037508.3428,
'bound_y0': -20037508.3428,
'bound_y1': 20037508.3428,
'aspect_x': 1.0,
'aspect_y': 1.0,
}
p0x, p0y, p1x, p1y = tile2prjbounds(geom_settings, args.x, args.y, args.z)
bbox = mapnik.Box2d(p0x, p0y, p1x, p1y)
m.zoom_to_box(bbox)
print(f"[info] Envelope: {m.envelope()}")
print(f"[info] Scale: {m.scale()}")
mapnik.render_to_file(m, args.out)
print(f"[ok] Wrote {args.out}")
if __name__ == "__main__":
main()
Render a test tile:
cd ~/OpenTopoMap/mapnik/
python3 ./mapnik_render_tile.py
Here’s a tile for Kleve, Germany:
python3 mapnik_render_tile.py \
--style opentopomap.xml \
--out kleve_output.png \
--z 14 --x 8471 --y 5427
A first cold render may be slower, but a warm z14 tile should not take minutes. If it does, profile layers individually. Continent-wide unsplit contours are a known cause; complete the subdivision step above before configuring renderd.
Done!
Your OpenTopoMap rendering environment is ready. You can now generate tiles and explore global topographic rendering with contours, hillshades, and water layers—all powered by open data.