‹‹‹ Übersicht Kolumne

Statische Kacheln für interaktive Karten in statischen Webseiten
Wie man eigene Kacheln herstellt und als statischen Inhalt einbindet

Bibliotheken wie Leaflet haben es einfach gemacht, eigene interaktive Karten in Webseiten einzubinden. Aber woher kommen die Kacheln, auch als Tiles bezeichnet, welche die Grundlage dieser Karten bilden? Neben der direkten Einbindung von Kacheln des OpenStreetMap-Projektes oder diversen akademischen und kommerziellen Produkten kann man selbstverständlich auch seine eigenen Kacheln verwenden. Der typischerweise empfohlene, datenschutzkonforme Weg besteht im Aufsetzen eines Kachel-Servers. Es geht jedoch auch dem Paradigma statischer Webseiten folgend: Die Kacheln lokal rendern und als einfache Dateien auf den eigenen Webspace hochladen.

  • DSGVO
  • Datenschutz
  • Docker
  • Kacheln
  • Karte
  • Leaflet
  • Open Data
  • Statische Webseite
  • Tiles

Hintergrund

Statische Webseiten

Über das Konzept von statischen Webseiten ist die vergangenen Jahre einiges geschrieben worden. Sie erfreuen sich unter anderem dank ihres verhältnismäßig überschaubaren Wartungsaufwands, deutlich geringerer Sicherheitsrisiken und zum Dauerbetrieb praktisch minimaler notwendiger Ressourcen zunehmender Beliebtheit. Dort, wo man sonst Webseiten für jede einzelne Anfrage des Benutzers - zumeist durch komplexe Cache-Systeme entlastet - mittels teils spezieller Sprachen wie beispielsweise PHP oder diversen anderen, moderneren Sprachen und Software-Paketen, auf dem Server dynamisch generiert und ausliefert, wird das Rendern im Falle einer statischen Webseite nur einmal vorgenommen, bevor die Webseite in Form von fertigen, d.h. "statischen" HTML-, JavaScript- und CSS-Dateien ihren Weg zum Webspace findet. Dementsprechend erfreuen sich auch Systeme für die Generierung eben jener statischer Webseiten zunehmender Beliebtheit, sodass moderne statische Webseiten ihren dynamisch generierten Pendents hinsichtlich Fähigkeiten, Interaktivität und Effizienz in fast nichts mehr nachstehen - Online-Shops und dergleichen, die per Definition dynamische Komponenten auf dem Server erfordern, bewusst ausgenommen. Auch diese Webseite fällt unter die Kategorie der statischen Seiten und wird duch ein eigenes System gepflegt.

Der Bau interaktiver statischer Webseiten erfordert ausgehend von etablierten Strategien für dynamische Seiten einiges Umdenken. Karten sind eines jener Funktionsmerkmale, die neue technische Ansätze erfordern, um der Idee einer tatsächlich statischen Webseite gerecht zu werden.

Leaflet

Die wohl am meisten genutzte Bibliothek bzw. Technologie, um eine Karte in die eigene Webseite einzubetten, ist Leaflet. Gemäß des Quick Start Guide des Leaflet-Projektes lässt sich eine minimale Karte, leicht abgewandelt, wie folgt in die eigene Webseite einbetten:

<link rel="stylesheet" href="leaflet.css"/>
<script src="leaflet.js"></script>
<div id="karte"></div>
Quelltext 1

Es werden ein Stylesheet, eine Javascript-Bibliothek sowie ein div-Tag mit definiertem id-Attribut benötigt. Letzteres kann frei positioniert werden und dient später zur Anzeige der Karte.

Damit sich Leaflet auf Smartphones und Tablet-Computern, der heute statistisch gesehen dominanten Form der Internet-Nutzung, korrekt verhält, ist die zusätzliche Einbindung einer Erweiterung, Leaflet.GestureHandling, dringend anzuraten:

<link rel="stylesheet" href="leaflet-gesture-handling.min.css"/>
<script src="leaflet-gesture-handling.min.js"></script>
Quelltext 2

Sind alle Komponenten korrekt eingebunden, lässt sich die Karte mittels Javascript initialisieren:

var karte = L.map('karte', {
    center: [51.34384, 12.38076],
    zoom: 13,
    minZoom: 12,
    maxZoom: 14,
    gestureHandling: true,
    maxBounds: new L.latLngBounds(
        new L.latLng([51.44005, 12.14433]),
        new L.latLng([51.22321, 12.66059])
    ),
    maxBoundsViscosity: 0.7
});
Quelltext 3

Das Parameter center: [51.34384, 12.38076] repräsentiert die anfänglichen geographischen Koordinaten der Mitte der Karte in Form von (nördlicher) Breite und (östlicher) Länge gemäß WGS84, siehe EPSG:4326. zoom: 13 repräsentiert das anfängliche Zoom-Level. Mittels minZoom: 12 und maxZoom: 14 werden optional die zur Verfügung gestellten Zoom-Levels eingeschränkt. gestureHandling: true aktiviert optional die Leaflet.GestureHandling-Erweiterung. maxBounds: ... schränkt optional den sichtbaren Bereich ein, d.h. die Karte kann nicht über die angegebenen Grenzen hinaus seitlich bewegt bzw. verschoben werden. maxBoundsViscosity gibt optional an, wie "weich" die Karte "abbremst", wenn der Benutzer versucht, sie über die seitlichen Grenzen hinaus zu verschieben.

Bis hier her würde die Karte zwar angezeigt, aber leer bleiben:

Karte 1: Leere Karte.

Kacheln

Um tatsächlich etwas zu sehen, muss ein Layer, beispielsweise aus so genannten Kacheln bestehend, welche als Hintergrund dienen, hinzugefügt werden:

L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>-Beitragende'
}).addTo(karte);
Quelltext 4

Damit erhält man eine interaktive Karte ähnlich der folgenden:

Karte 2: Stadt Leipzig, Zoom-Levels 12 bis 14, Kacheln via karteleipzig.pleiszenburg.de.

Der am letzten Code-Beispiel wichtigste Teil ist die Quelle der Kacheln:

https://tile.openstreetmap.org/{z}/{x}/{y}.png
Quelltext 5

Leaflet wird, je nach Zoom-Level und Ort, die Platzhalter {z}, {x} und {y} ausfüllen und auf dieser Grundlage eine Kachel in Form einer 256x256 Pixel großen PNG-Datei von tile.openstreetmap.org anfordern. Genau genommen sind für die meisten Karten-Ansichten mehrere Kacheln notwendig. {z} repräsentiert dabei das Zoom-Level. {x} und {y} stehen im weitesten Sinne für Länge und Breite gemäß der Web-Mercator-Projektion, auch als Pseudo-Mercator bezeichnet, siehe EPSG:3857. Der Aufbau der URL wird insgesamt als slippy map tilenames bezeichnet.

Am einfachsten lässt sich das Prinzip von Kacheln verdeutlichen, wenn man sie durch einfache graue Flächen mit farblich gekennzeichnetem Rahmen ersetzt:

Karte 3: Quasi-Karte basierend auf grauen Kacheln mit dunklem Rand.

An Stelle der vielen einzelnen Kacheln könnte man auch wenige, deutlich größere Bilder je Zoom-Level verwenden. Kacheln sind hierbei als "Kompromiss" zu verstehen, um u.a. im Interesse des transferierten Datenvolumens nur diejenigen Teile der Karte an den Besucher der Webseite auszuliefern, die gerade zur Anzeige benötigt werden. Das hier angedeutete System der Kacheln hat sich dabei über die vergangenen Jahre zum Industrie-Standard entwickelt.

Genau genommen geht es jedoch nicht nur um Datenvolumen. So werden Kacheln üblicherweise dynamisch auf Anfrage des Benutzers auf dem Server aus den jeweils aktuellsten Vektor-Daten gerendert, bevor sie zumeist als Rastergrafiken ausliefert werden. OpenStreetMap speichert intern Knoten und sich aus diesen Knoten zusammensetzende Pfade nebst Metadaten, d.h. Vektor-Daten, die auf den ersten Blick noch nicht so einfach als Karte nutzbar sind:

Vorplatz des Leipziger Hauptbahnhofes, Rohdaten von OpenStreetMap, visualisiert mit JOSM.
Abbildung 1: Vorplatz des Leipziger Hauptbahnhofes, Rohdaten von OpenStreetMap, visualisiert mit JOSM.

Erst die Visualisierung, d.h. das Rendern, zu einer brauchbaren Karte in Form von Kacheln in verschiedenen Zoom-Levels mit unterschiedlichem Detailgrad macht die Daten praktisch nutzbar:

Abbildung 2: Vier Kacheln, jeweils 256x256 Pixel, von links nach rechts Zoom-Levels 15, 16, 17 und 18, jeweils einen Teil des Leipziger Zentrums mit Vorplatz des Leipziger Hauptbahnhofes zeigend.

Motivation für eigene statische Kacheln

Obwohl OpenStreetMap als offenes und gemeinnütziges Projekt bekannt ist, gilt die Einbettung von Kacheln in die eigene Webseite über die Server von OpenStreetMap ohne explizite Einwilligung des Benutzers unter dem Gesichtspunkt Datenschutz und DSGVO als mindestens kontroverses Thema. Darüber hinaus haben nicht wenige alternative Anbieter freier Kacheln ihren Sitz zumeist in Nordamerika und/oder betreiben dort ihre Server. Alleine dieses Thema kann im Zweifelsfall Motivation genug sein, für die eigene Webseite eigene Kacheln anzubieten.

Im Zusammenhang mit statischen Webseiten stellt sich jedoch eine weitere Frage: Wie stellt man diese Kacheln bereit, ohne selbst einen eigenen Kachel-Server zu betreiben? Kachel-Server sind nicht per se schlecht. Sie benötigen jedoch eine nennenswerte Menge an Server-Ressourcen und stellen als aktive Server-Komponente prinzipbedingt ein Sicherheitsrisiko da, welches ständige Überwachung und Pflege erfordert. Dies widerspricht der Idee statischer Webseiten, bei denen man versucht, auf eben diese aktiven Server-Komponenten im Interesse von minimalem Risiko und reduziertem Wartungsaufwand zu verzichten. An dieser Stelle bietet es sich an, die Kacheln eines gewünschten Kartenausschnittes einmal lokal zu rendern und in Form von PNG-Dateien oder einem anderen gebräuchlichen Rastergrafik-Format auf dem Server zu hinterlegen.

Erwähnenswert ist, dass die Nutzungsbedingungen von OpenStreetMap explizit das systematische Scraping seiner Kacheln untersagt, um die Auslastung der Server von OpenStreetMap in einem überschaubarem Rahmen zu halten.

Nachteile eigener statischer Kacheln

Einmal gerenderte Kacheln aktualisieren sich nicht von selbst. Falls in der kartierten Region nennenswerte Aktivitäten, beispielsweise der Bau neuer Straßen, zu verzeichnen sind, müssen die Kacheln im Zweifelsfall erneut gerendert und hochgeladen werden. Große Kartenausschnitte mit hohem maximalen Zoom-Level, also einem hohen Grad an Details, können schnell zu sehr großen Mengen an Dateien und damit spürbarem Speicherverbrauch führen.

Durchführung: Notwendige Schritte

Um zu eigenen Kacheln zu kommen, muss man die folgenden Schritte durchlaufen:

  1. Beschaffung von Roh- bzw. Vektor-Daten beispielsweise von OpenStreetMap selbst, welche man in Kacheln rendern kann.
  2. Aufsetzen eines einfachen (lokalen) Kachel-Servers. Sicherheit und Stabilität spielen hierbei untergeordnete Rollen, da dieser Server nur einmalig zum Rendern benötigt und niemals im Internet exponiert wird.
  3. Ausführung eines Programms bzw. einfaches Skriptes, welches die zu rendernden Kacheln vom Server anfordert und in geeigneter Form als PNG-Dateien abspeichert, die später als Teil der statischen Webseite hochgeladen werden können.

Schritt 1: Roh- bzw. Vektor-Daten beschaffen

Die freien Roh-Daten von OpenStreetMap stellen die unbestritten beste Datengrundlage für eigenen Kacheln dar. Die originalen Daten, welche man direkt von OpenStreetMap beziehen kann, enthalten jedoch eine Reihe an ungewünschten bzw. nicht benötigten Informationen, wie beispielsweise die Geschichte eines jeden Punktes der Karte sowie auch "gelöschte" Objekte. Man kann diese Daten also nicht ohne weitere Aufbereitung direkt verwenden, was nicht gänzlich unkompliziert ist. An dieser Stelle kommen eine Reihe an Anbietern ins Spiel, welche die Daten von OpenStreetMap in regelmäßigen Abständen selbst herunterladen und in Form von gefilterten, d.h. in geeigneter Form aufbereiteten Paketen wiederum frei zum Download anbieten. Ein bekanntes Beispiel ist die Geofabrik. Dort sind regelmäßig aktualisierte Momentaufnahmen von ganz Deutschland beispielsweise in Form von knapp 4 Gigabyte großen protobuf-Dateien erhältlich. Einzelne Bundesländer sind genauso wie andere Staaten und ganze Kontinente ebenfalls als entsprechend kleine bzw. große protobuf-Dateien verfügbar.

Im weiteren Verlauf dieses Artikels wird davon ausgegangen, dass eine Momentaufnahme von Deutschland genannt germany-latest.osm.pbf lokal vorliegt.

Schritt 2: Ein lokaler Kachel-Server

Dieser Schritt mag kompliziert klingen, ist jedoch unter dem Gesichtspunkt, dass es hier weder um Stabilität noch um Sicherheit geht, relativ einfach. Das openstreetmap-tile-server-Projekt bietet fertige Docker-Images an, welche mittels weniger Befehle lokal betrieben werden können.

Optional: Docker installieren

Eine Installation von Docker bzw. der Docker-Engine, idealerweise auf einem Linux-System, wird vorausgesetzt. Das benötigte Docker-Image funktioniert leider nicht fehlerfrei auf alternativen Container-Laufzeitumgebungen wie beispielsweise podman.

Optional: Docker-Wurzelverzeichnis verschieben

Vor der Inbetriebnahme von openstreetmap-tile-server sollte sichergestellt werden, dass dort, wo Docker Daten ablegt, genug Speicherplatz vorhanden ist. Typischerweise legt Docker Daten in /var/lib/docker ab, dem Standard für das Docker-Wurzelverzeichnis. Für dieses Beispiel werden mindestens 110 Gigabyte Festplattenkapazität benötigt. Sollte diese am Ablageort des Docker-Wurzelverzeichnisses nicht vorhanden sein, empfiehlt sich eine Verschiebung.

Zum Verschieben müssen zuerst alle Komponenten der Docker-Engine gestoppt werden. Auf einem Ubuntu-basierten System betrifft dies drei Dienste:

sudo systemctl stop docker
sudo systemctl stop docker.socket
sudo systemctl stop containerd
Quelltext 6

Unter der Annahme, dass in Docker in Zukunft /new/docker/root als neues Wurzelverzeichnis nutzen soll, muss dieses angelegt werden:

mkdir -p /new/docker/root
Quelltext 7

Unter der Annahme, dass Docker auf dem fraglichen System schon im Einsatz war und Container/Volumes/etc vorhanden sind, müssten diese ins neue Wurzelverzeichnis überführt werden. Gemessen an der Menge von Dateien, die Docker anhäufen kann, bietet sich in diesem Szenario rsync zum sicheren Kopieren an:

sudo rsync -aP /var/lib/docker/ /new/docker/root
Quelltext 8

Anschließend kann das alte Wurzelverzeichnis gelöscht oder einfach nur geleert werden.

Um Docker mitzuteilen, wo sich das neue Wurzelverzeichnis befindet, muss eine entsprechende Option in /etc/docker/daemon.json, einer Konfigurationsdatei von Docker, gesetzt werden. Falls sie noch nicht vorhanden ist, kann man sie schlicht mit folgendem Inhalt erstellen:

{
  "data-root": "/new/docker/root"
}
Quelltext 9

Falls sie vorhanden ist, ist sie als JSON-Datei zu editieren und um die im obigen Beispiel genannte Option zu ergänzen. Anschließend kann Docker wieder gestartet werden:

sudo systemctl start docker
Quelltext 10

Kachel-Server initialisieren und starten

Zuerst muss ein Docker-Volume erstellt werden:

docker volume create osm-data
Quelltext 11

Anschließend muss die vorhandene protobuf-Datei, germany-latest.osm.pbf in diesem Beispiel, importiert werden. Bei diesem Schritt wird das Docker-Image overv/openstreetmap-tile-server zum ersten mal gestartet und dafür, falls noch nicht lokal vorhanden, automatisch heruntergeladen und entpackt:

sudo docker run \
    -v /full/path/to/germany-latest.osm.pbf:/data/region.osm.pbf \
    -v osm-data:/data/database/ \
    overv/openstreetmap-tile-server \
    import
Quelltext 12

Hierbei ist /full/path/to/ durch den tatsächlichen absoluten Pfad zu germany-latest.osm.pbf zu ersetzen. Der Import dauert selbst auf einem modernen, leistungsfähigen Rechner ca. 3 bis 4 Stunden.

Wenn der Import abgeschlossen ist, kann der eigentliche Kachel-Server gestartet werden:

sudo docker run \
    -p 8081:80 \
    -e THREADS=36 \
    -e "OSM2PGSQL_EXTRA_ARGS=-C 131072" \
    -v osm-data:/data/database/ \
    -e ALLOW_CORS=enabled \
    -d overv/openstreetmap-tile-server \
    run
Quelltext 13

Im obigen Befehl können - und sollten - einige Parameter den örtlichen Gegebenheiten angepasst werden. THREADS=36 sagt aus, in wie vielen Threads gleichzeitig Kacheln gerendert werden können. Die Zahl sollte maximal der Anzahl der Kerne der jeweiligen CPU entsprechen, also beispielsweise 4 auf einem "Quad-Core"-Prozessor. -e "OSM2PGSQL_EXTRA_ARGS=-C 131072" spezifiziert, wie viel RAM die Datenbank des Kachel-Servers beanspruchen darf. Die Zahl wird in Megabyte angegeben. Ein Wert von 8 Gigabyte würde sich aus 81024=82108*1024 = 8*2^{10} ergeben, also 8192. Während mehr RAM prinzipiell immer besser ist, sollte man jedoch darauf achten, nicht 100% des verfügbaren RAMs nur für die Datenbank zu verwenden. 50% ist ein guter Anfangswert. -p 8081:80 macht den Server auf Port 8081 verfügbar. Sollte dieser Port schon anderweitig in Gebrauch sein, ist dieser Wert anzupassen.

Weitere mögliche Parameter und deren Wirkung werden in der Anleitung von openstreetmap-tile-server beschrieben. Nennenswert ist hier unter anderem die Verwendung eigener Styles via -e NAME_STYLE=eigenes.style, um der Karte einen eigenen, ggf. an die jeweilige Webseite angepassten Stil zu verleihen.

Anschließend sollte der Server im Hintergrund laufen und in der Liste laufender Docker-Prozesse auftauchen, die man wie folgt abfragen kann:

sudo docker ps
Quelltext 14

Kachel-Server testen

Man kann den jetzt laufenden Kachel-Server relativ einfach testen, indem man Leaflet Kacheln von http://localhost:8081/tile/{z}/{x}/{y}.png beziehen lässt. Sieht man tatsächlich eine Karte und kann man beim Zoomen oder Verschieben des Kartenausschnittes eine gewisse CPU-Auslastung beobachten, funktioniert der Kachel-Server ordnungsgemäß.

Kachel-Server später stoppen und löschen

Sollte der Server später einmal nicht mehr benötigt werden, so lässt er sich wie folgt beenden:

sudo docker container stop NAME
Quelltext 15

Der jeweilige Name ist wiederum der Liste der laufenden Docker-Container zu entnehmen:

sudo docker ps
Quelltext 16

Die Überreste von gestoppten Docker-Containern lassen sich mittels eines prune-Kommandos entfernen. Nicht mehr benötigte Volumes lassen sich daraufhin ähnlich mit einem weiteren prune-Kommando ebenfalls bereinigen:

sudo docker container prune
sudo docker volume prune
Quelltext 17

Schritt 3: Kacheln rendern

Im letzten Schritt müssen die gewünschten Kacheln vom Server angefordert werden. Dies geschieht über normale HTTP-Requests - so, als ob eine Webseite diese Kacheln zur Anzeige benötigen würde. Um das Problem überschaubar zu halten, kommt ein einfaches Python-Skript zum Einsatz.

Eine isolierte virtuelle Umgebung für Python

Aufgrund einiger Abhängigkeiten des Skriptes empfiehlt es sich, zuerst eine virtuelle Umgebung zu erzeugen, um die notwendigen Abhängigkeiten mit Benutzerrechten unabhängig vom unterliegenden Betriebssystem installieren und betreiben zu können. Die dafür notwendigen Werkzeuge lassen sich auf einem Ubuntu-basiertem System wie folgt installieren:

sudo apt install python3-venv python3-pip python3-dev build-essential
Quelltext 18

Auf dieser Grundlage lässt sich mittels venv eine virtuelle Umgebung erstellen und in Betrieb nehmen:

python3 -m venv umgebung
source umgebung/bin/activate
Quelltext 19

Am Anfang der nächsten Zeile der Shell sollte der Name der virtuellen Umgebung in Klammern, also (umgebung), erscheinen. Alle weiteren Shell-Befehle müssen in eben dieser Shell ausgeführt werden.

Eine Aktualisierung einiger Werkzeuge innerhalb der virtuellen Umgebung ist empfehlenswert, aber nicht unbedingt notwendig:

pip install -U pip setuptools
Quelltext 20

Jetzt können die Abhängigkeiten des eigentlichen Skriptes installiert werden:

pip install requests tqdm
Quelltext 21

Das eigentliche Python-Skript

Alle im folgenden beschriebenen Code-Fragmente sollten in einer Datei gespeichert werden, zum Beispiel rendern.py.

Zuallererst müssen alle notwendigen Abhängigkeiten importiert werden:

from concurrent.futures import ProcessPoolExecutor
import math
import os
from time import sleep
from typing import Any, Generator, Tuple
import requests
from tqdm import tqdm
Quelltext 22

ProcessPoolExecutor dient dem späteren parallelen Laden von Kacheln, was den Prozess deutlich beschleunigt. math gibt Zugriff auf einfache mathematische Funktionen, u.a. Trigonometrie. os erlaubt u.a. den Zugriff auf Funktionen zur Manipulation von Pfaden. sleep erlaubt es, einen Thread für eine definierte Zeitspanne "schlafen" zu lassen. Das typing-Modul exponiert Objekte, die im Interesse der Lesbarkeit zur Annotation von Code genutzt werden können. Mit requests lassen sich HTTP-Requests erzeugen und deren Antworten unkompliziert auswerten. Mittels tqdm bekommt man schnell und einfach Fortschrittsbalken, so dass man eine ungefähre Idee über die verbleibende Laufzeit des Prozesses bekommt.

Als nächstes sollte das Zielgebiet, d.h minimale und maximale Länge und Breite, sowie die gewünschten Zoom-Level eingegrenzt werden. Im Folgenden wird das Gebiet auf die Stadt Leipzig für Zoom-Level 12 bis 19 festgelegt:

LON_MIN, LON_MAX = 12.14433, 12.66059  # (Östliche) Länge, WGS84
LAT_MIN, LAT_MAX = 51.22321, 51.44005  # (Nördliche) Breite, WGS84
ZOOM_MIN, ZOOM_MAX = 12, 19
Quelltext 23

Zum Finden der richtigen Koordinaten bietet sich ein einfacher Trick an. Mann kann den gewünschten Punkt leicht direkt auf der OpenStreetMap-Webseite identifizieren und heranzoomen. Die für diesen Anwendungsfall ausreichend genauen Koordinaten lassen sich dann der URL in der Adressleiste des Browsers entnehmen:

Koordinaten des Vorplatzes des Leipziger Hauptbahnhofes, 51.3434° Nord und 12.38054° Ost gemäß WGS84
Abbildung 3: Koordinaten des Vorplatzes des Leipziger Hauptbahnhofes, 51.3434° Nord und 12.38054° Ost gemäß WGS84

Das Verzeichnis, in welches die gerenderten Kacheln gespeichert werden sollen, muss benannt werden:

DUMP_FLD = '/pfad/zu/kacheln'
Quelltext 24

Zum Abfragen bzw. Rendern von Kacheln sind deren Koordinaten gemäß Web-Mercator-Projektion (EPSG:3857) notwendig. Um aus dem in WGS84-Koordinaten (EPSG:4326) angegebenen Zielgebiet entsprechende Koordinaten der Web-Mercator-Projektion zu erhalten, müssen diese umgerechnet werden. Die mathematischen Grundlagen sowie eine Skizze für die folgende Funktion finden sich in der OpenStreetMap-Wiki:

def deg2num(lat_deg: float, lon_deg: float, zoom: int) -> Tuple[int, int]:
    lat_rad = math.radians(lat_deg)
    n = 2.0 ** zoom
    xtile = int((lon_deg + 180.0) / 360.0 * n)
    ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
    return xtile, ytile
Quelltext 25

Die Parameter lat_deg und lon_deg entsprechen Breite und Länge in Grad. Das Parameter zoom repräsentiert das spätere Zoom-Level. Zurückgegeben werden mit xtile und ytile die Werte für x und y gemäß Web-Mercator.

Der Prozess lässt sich auch umkehren, was zur Fehlersuche interessant sein kann:

def num2deg(xtile: int, ytile: int, zoom: int) -> Tuple[float, float]:
    n = 2.0 ** zoom
    lon_deg = xtile / n * 360.0 - 180.0
    lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
    lat_deg = math.degrees(lat_rad)
    return lat_deg, lon_deg
Quelltext 26

Da es für das gewünschte Zielgebiet eine Reihe an Kacheln jeweils für mehrere Zoom-Level zu rendern gibt, gilt es als nächstes eine Liste an Aufgaben oder anders ausgedrückt eine Liste an notwendigen Kombinationen von x, y und z gemäß Web-Mercator zu generieren:

def get_tasks(
    zoom_min: int, zoom_max: int,
    lat_min: float, lat_max: float,
    lon_min: float, lon_max: float,
) -> Generator:
    for z in range(zoom_min, zoom_max + 1):
        x_min, y_min = deg2num(lat_deg = lat_min, lon_deg = lon_min, zoom = z)
        x_max, y_max = deg2num(lat_deg = lat_max, lon_deg = lon_max, zoom = z)
        x_min, x_max = sorted([x_min, x_max])
        y_min, y_max = sorted([y_min, y_max])
        for x in range(x_min, x_max + 1):
            for y in range(y_min, y_max + 1):
                yield x, y, z
Quelltext 27

Die Parameter hier sind das minimale Zoom-Level zoom_min, das maximale Zoom-Level zoom_max, die minimale Breite in Grad lat_min, die maximale Breite in Grad lat_max, die minimale Länge in Grad lon_min sowie die maximale Länge in Grad lon_max.

Um eine individuelle Kachel vom Server abzufragen und zu speichern bedarf es wiederum einer eigenen Funktion, die man später auch mehrmals parallel betreiben kann:

def download_single(x: int, y: int, z: int) -> bool:

    target_fld = os.path.join(DUMP_FLD, f'{z:d}', f'{x:d}')
    os.makedirs(target_fld, exist_ok = True)
    target_fn = os.path.join(target_fld, f'{y:d}.png')
    if os.path.exists(target_fn):
        return True

    source_url = f'http://localhost:8081/tile/{z:d}/{x:d}/{y:d}.png'

    count = 0
    success = False
    while count < 3:
        try:
            rq = requests.get(source_url)
            success = True
            break
        except:
            sleep(1)
            count += 1
    if not success:
        raise ValueError('TILE FAIL', x, y, z)

    with open(target_fn, 'wb') as fd:
        for chunk in rq.iter_content(chunk_size = 128):
            fd.write(chunk)

    return True
Quelltext 28

Die Funktion akzeptiert x, y und z gemäß Web-Mercator als Parameter und gibt im Idealfall True zurück. Unterhalb von DUMP_FLD wird mittels os.makedirs eine Verzeichnisstruktur erzeugt. Falls die Kachel schon gerendert wurde, os.path.exists(target_fn), wird sie nicht noch einmal angefordert und die Funktion bricht ab. Die URL der gewünschten Kachel wird aus den Koordinaten konstruiert und in der Variable source_url abgelegt. Mittels requests.get werden bis zu drei Anfragen mit einer Verzögerung von einer Sekunde, sleep(1), gestartet, falls der Server nicht antwortet oder einen Fehler liefert. Falls die Abfrage der Kachel erfolgreich war, wird sie lokal als PNG-Datei abgespeichert, siehe open(target_fn, 'wb') und folgend.

Zum parallelen Anfordern von Kacheln, d.h. damit auch zum parallelen Rendern, muss die Funktion download_single mehrmals gleichzeitig mit jeweils unterschiedlichen Parametern aufgerufen werden:

def download_many(njobs: int = 72, **kwargs: Any):
    with ProcessPoolExecutor(njobs) as p:
        jobs = [p.submit(download_single, *task) for task in get_tasks(**kwargs)]
        _ = [job.result() for job in tqdm(jobs)]
Quelltext 29

njobs gibt hierbei die maximale Anzahl an gleichzeitigen Anfragen an den Kachel-Server an. Falls der Kachel-Server beispielsweise mit -e THREADS=36, also mit 36 Threads, gestartet wurde, kann man diesen Wert mit etwa dem Doppelten, also 72, ansetzen. An dieser Stelle ist etwas Experimentierarbeit gefragt. Ist njobs zu klein, dauert der Prozess sehr lange und der Kachel-Server wird nicht optimal ausgelastet. Ist njobs zu groß, zwingt man den Kachel-Server sprichwörtlich in die Knie, so dass die Abfrage einzelner Kacheln augenscheinlich zufällig fehlschlägt und wiederholt werden muss.

Zuletzt muss man definieren, wie das Skript gestartet wird:

if __name__ == '__main__':
    download_many(
        zoom_min = ZOOM_MIN,
        zoom_max = ZOOM_MAX,
        lat_min = LAT_MIN,
        lat_max = LAT_MAX,
        lon_min = LON_MIN,
        lon_max = LON_MAX,
    )
Quelltext 30

Zurück auf der Kommandozeile lässt sich der Prozess nun wie folgt starten:

python rendern.py
Quelltext 31

Die gewünschten Kacheln tauchen der Reihe nach in Unterverzeichnissen von /pfad/zu/kacheln auf.

Zeit, Speicherplatz, Hardware und Rechenleistung

Es ist zu beachten, dass dieser Prozess je nach Größe des Gebietes sowie der gewählten Zoom-Levels mehrere Stunden bis Tage in Anspruch nehmen kann. Gleichsam können die Kacheln sehr viel Platz auf der Festplatte belegen. Die in diesem Beispiel gewählten Werte für die Stadt Leipzig mit Zoom-Levels 12 bis 19 belegt rund 1,3 Gigabyte in Form von etwas über einer halben Million Dateien in etwas über 1.500 (Unter-) Verzeichnissen. Der Prozess des Renderns beispielsweise auf einem modernen AMD-Server-Prozessor mit 24 CPU-Kernen dauert je nach Konfiguration des Systems für die gewählten Werte ca. 5 bis 9 Stunden. Das Rendern skaliert sehr gut mit der Anzahl von CPU-Kernen, so dass man auf einer normalen Desktop-CPU mit beispielsweise 4 CPU-Kernen mit rund 40 bis 70 Stunden, also etwa 2 bis 3 Tagen, zu rechnen hat. Vom Einsatz von Laptops für diese Art von Arbeit ist abzuraten, da diese bei so langen und nahezu konstant intensiven Belastungen, falls ihre Kühlung nicht absolut perfekt funktioniert, schwere Verschleißerscheinungen bis hin zu irreversiblen Schäden am Gerät davontragen können. Vor diesem Hintergrund ist das temporäre Mieten eines leistungsstarken Servers in einem Rechenzentrum ein guter Weg, um das Rendern zu beschleunigen bzw. im Falle von Anpassungen schneller wiederholen zu können. Systeme mit rund 40 Kernen lassen sich beispielsweise momentan für in der Größenordnung von rund einem Euro brutto je Stunde mieten. Hierbei ist es denkbar, den Kachel-Server mittels eines SSH-Tunnels vom lokalen Rechner aus zugänglich zu machen, so dass das Python-Skript zum Anfordern der Kacheln, welches selbst kaum Ressourcen benötigt, lokal laufen kann, der Server "nur noch" rendern muss und ein späterer Transfer der fertigen Kacheln vom Server zum lokalen Rechner entfällt.

Hochladen der Kacheln auf den eigenen Webspace

Der Transfer der hier erstellten schieren Anzahl an Dateien hin zum gewünschten Webspace ist unter Umständen noch einmal ein "interessantes" Problem für sich.

Viele klassische grafische FTP-Programme können so viele Dateien und Verzeichnisse auf einmal schlicht nicht handhaben. Hier steht man vor der Wahl, entweder von Hand kleinere Mengen an Kacheln "manuell" Schritt für Schritt hochzuladen, oder auf deutlich stabilere Werkzeuge auf der Kommandozeile auszuweichen. scp sowie vor allem rsync funktionieren in diesem Szenario hervorragend, auch wenn deren korrekte Parametrisierung je nach verwendetem Hosting-Anbieter selten vollständig dokumentiert ist und dementsprechend teils durch mühsames Probieren ermittelt werden muss.

Ebenfalls relevant ist, dass viele Hosting-Anbieter zwar ein direktes Limit des verfügbaren Speicherplatzes als Teil ihrer Vertragsbedingungen kommunizieren - beispielsweise 100 Gigabyte pro Vertrag - jedoch auch implizite, oftmals nicht dokumentierte und sich in unregelmäßigen Abständen ändernde Obergrenzen für die reine Anzahl an Dateien und Verzeichnissen erzwingen, die im Webspace abgelegt werden können. Diese liegen nicht selten im Bereich von etwa 100.000 bis 1.600.000 bzw. 220=10485762^{20} = 1048576 bis 224=167772162^{24} = 16777216 Dateien plus Verzeichnissen und müssen ebenfalls teils durch mühsames Probieren ermittelt werden. Um die Anzahl der notwendigen Dateien zu reduzieren empfiehlt es sich, auf höhere Zoom-Level zu verzichten. So bringt Zoom-Level 19 als typischerweise höchstes Zoom-Level gegenüber Zoom-Level 18 beispielsweise oft keine nennenswerten Vorteile und kann falls notwendig entfernt werden. Jedes weitere, höhere Zoom-Level erhöht die Anzahl der Dateien um einen Faktor von rund 1,3.

Um Kacheln und Webseite unabhängig voneinander pflegen zu können empfiehlt es sich, die Kacheln auf einer separaten Subdomain abzulegen.

Reales Beispiel: Stadt Leipzig

Eine Karte mit statischen Kacheln für den in diesem Artikel als Beispiel verwendeten Ausschnitt 12,14433° bis 12.66059° östliche Länge sowie 51,22321° bis 51.44005° nördliche Breite, dem Gebiet der Stadt Leipzig, kommt für Zoom-Level 12 bis 19 auf thomas-elsner-praxis.de/#karte zum Einsatz.

‹‹‹ Übersicht Kolumne