├── TileDL.py ├── README.md └── KMLtoTiles.py /TileDL.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from math import log, tan, cos, pi 4 | from tqdm import tqdm 5 | 6 | # Define the bounding boxes and zoom levels. Below are random examples. 7 | regions = { 8 | "southern_ontario": (41.5, -83.5, 45.5, -75.0), 9 | "las_vegas": (35.5, -116.0, 37.5, -114.0), 10 | "grand_canyon": (35.5, -113.0, 37.0, -111.0) 11 | } 12 | zoom_levels = range(1, 15) # Focusing on zoom levels 1 to 14 13 | 14 | # mapstyle = "cycle" 15 | # mapstyle = "transport" 16 | # mapstyle = "landscape" 17 | # mapstyle = "outdoors" 18 | # mapstyle = "transport-dark" 19 | # mapstyle = "spinal-map" 20 | # mapstyle = "pioneer" 21 | mapstyle = "mobile-atlas" 22 | # mapstyle = "neighbourhood" 23 | # mapstyle = "atlas" 24 | 25 | # API Key and output directory 26 | api_key = "your_api_key_here" 27 | output_dir = os.path.join(os.path.expanduser("~"), "Desktop", "tiles") 28 | os.makedirs(output_dir, exist_ok=True) 29 | 30 | def lon2tilex(lon, zoom): 31 | return int((lon + 180.0) / 360.0 * (1 << zoom)) 32 | 33 | def lat2tiley(lat, zoom): 34 | return int((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * (1 << zoom)) 35 | 36 | def download_tile(zoom, x, y): 37 | url = f"https://tile.thunderforest.com/{mapstyle}/{zoom}/{x}/{y}.png?apikey={api_key}" 38 | tile_dir = os.path.join(output_dir, str(zoom), str(x)) 39 | tile_path = os.path.join(tile_dir, f"{y}.png") 40 | os.makedirs(tile_dir, exist_ok=True) 41 | 42 | if not os.path.exists(tile_path): 43 | response = requests.get(url) 44 | if response.status_code == 200: 45 | with open(tile_path, "wb") as file: 46 | file.write(response.content) 47 | else: 48 | print(f"Failed to download tile {zoom}/{x}/{y}: {response.status_code} {response.reason}") 49 | 50 | def main(): 51 | total_tiles = 0 52 | 53 | for zoom in zoom_levels: 54 | for min_lat, min_lon, max_lat, max_lon in regions.values(): 55 | start_x = lon2tilex(min_lon, zoom) 56 | end_x = lon2tilex(max_lon, zoom) 57 | start_y = lat2tiley(max_lat, zoom) 58 | end_y = lat2tiley(min_lat, zoom) 59 | 60 | total_tiles += (end_x - start_x + 1) * (end_y - start_y + 1) 61 | 62 | with tqdm(total=total_tiles, desc="Downloading tiles") as pbar: 63 | for zoom in zoom_levels: 64 | for min_lat, min_lon, max_lat, max_lon in regions.values(): 65 | start_x = lon2tilex(min_lon, zoom) 66 | end_x = lon2tilex(max_lon, zoom) 67 | start_y = lat2tiley(max_lat, zoom) 68 | end_y = lat2tiley(min_lat, zoom) 69 | 70 | for x in range(start_x, end_x + 1): 71 | for y in range(start_y, end_y + 1): 72 | download_tile(zoom, x, y) 73 | pbar.update(1) 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Map Tile Downloader 2 | 3 | This Python script downloads map tiles from Thunderforest's Mobile Atlas for specified regions at multiple zoom levels. It supports resuming downloads by skipping already downloaded files and provides a progress bar to track the download progress. This was designed to make map tiles that are compatible with the Lilygo T-Deck running Ripple. 4 | 5 | ## Features 6 | 7 | - Download map tiles from Thunderforest's Mobile Atlas 8 | - Specify multiple regions and zoom levels 9 | - Skip already downloaded files 10 | - Progress bar to track download progress 11 | - New utility (KMLtoTiles.py) supports gross parsing of a KML, 12 | then fetching tiles at waypoints and routes 13 | 14 | ## Requirements 15 | 16 | - Python 3.x 17 | - `requests` library 18 | - `tqdm` library 19 | - For KMLtoTiles: `fastkml` library 20 | 21 | ## Installation 22 | 23 | 1. Clone the repository: 24 | 25 | ```bash 26 | git clone https://github.com/fistulareffigy/MTD-Script.git 27 | cd MTD-Script 28 | ``` 29 | 30 | 2. Install the required Python packages: 31 | 32 | ```bash 33 | pip install requests tqdm fastkml 34 | ``` 35 | Note: fastkml is not required to use `TileDL.py` 36 | 37 | ## Configuration 38 | 39 | 1. Obtain an API key from [Thunderforest](https://www.thunderforest.com/docs/apikeys/). 40 | 41 | 2. Edit the script to include your API key: 42 | 43 | ```python 44 | api_key = "your_api_key_here" 45 | ``` 46 | 47 | 3. Specify the regions and zoom levels you want to download in the script: 48 | 49 | ```python 50 | # Define the bounding boxes and zoom levels 51 | regions = { 52 | "southern_ontario": (41.5, -83.5, 45.5, -75.0), 53 | "las_vegas": (35.5, -116.0, 37.5, -114.0), 54 | "grand_canyon": (35.5, -113.0, 37.0, -111.0) 55 | } 56 | zoom_levels = range(1, 15) # Focusing on zoom levels 1 to 14 57 | ``` 58 | 59 | 4. Choose map style 60 | 61 | ```python 62 | # mapstyle = "cycle" 63 | # mapstyle = "transport" 64 | # mapstyle = "landscape" 65 | # mapstyle = "outdoors" 66 | # mapstyle = "transport-dark" 67 | # mapstyle = "spinal-map" 68 | # mapstyle = "pioneer" 69 | mapstyle = "mobile-atlas" 70 | # mapstyle = "neighbourhood" 71 | # mapstyle = "atlas" 72 | ``` 73 | 74 | 75 | 76 | 77 | ## Usage (TileDL.py) 78 | 79 | The tiles will be saved in a folder named tiles on your desktop, organized by zoom level and tile coordinates. 80 | 81 | Run the script to start downloading tiles: 82 | 83 | ```bash 84 | python TileDL.py 85 | ``` 86 | 87 | ## Usage (KMLtoTiles.py) 88 | 89 | Run the script to start downloading tiles: 90 | 91 | ```bash 92 | python KMLtoTiles.py -k your_api_key_here -o ~/map -s outdoors -m 1000 93 | 94 | KMLtoTiles.py [-h] 95 | [-k APIKEY] 96 | [-s {outdoors,mobile-atlas,cycle,transport,landscape,transport-dark,spinal-map,pioneer,neighbourhood,atlas}] 97 | [-m MAXTILES] [-o OUTDIR] [--minzoom MINZOOM] [--maxzoom MAXZOOM] [--latrgn LATRGN] [--lonrgn LONRGN] 98 | kmlfile 99 | 100 | Process KML file into selected tiles 101 | 102 | positional arguments: 103 | kmlfile 104 | 105 | optional arguments: 106 | -h, --help show this help message and exit 107 | -k APIKEY, --apikey APIKEY 108 | -s {outdoors,mobile-atlas,cycle,transport,landscape,transport-dark,spinal-map,pioneer,neighbourhood,atlas}, --style {outdoors,mobile-atlas,cycle,transport,landscape,transport-dark,spinal-map,pioneer,neighbourhood,atlas} 109 | -m MAXTILES, --maxtiles MAXTILES 110 | -o OUTDIR, --outdir OUTDIR 111 | output directory (default: /home/ghn/maps) 112 | --minzoom MINZOOM minimum zoom level 113 | --maxzoom MAXZOOM maximum zoom level 114 | --latrgn LATRGN 115 | --lonrgn LONRGN 116 | 117 | ``` 118 | 119 | The map projection used for tilings is [EPSG:3857](https://epsg.io/3857), sometimes called the "Web Mapping System." It is a variation of the [Mercator Projection](https://en.wikipedia.org/wiki/Mercator_projection). It can be helpful to think of zoom levels as dividing the globe by powers-of-two. This is very straightforward for longitude, but more complex for latitude where the projection plays out. 120 | 121 | |Zoom Level|Deg Lon Per Tile|DMS Lon Per Tile|Width of Tile at 45°N| 122 | |---------:|---------------:|---------------:|------------:| 123 | | 1 | 180.00 | 180°00'00" | 10001km | 124 | | 2 | 90.00 | 90°00'00" | 6667km | 125 | | 3 | 45.00 | 45°00'00" | 3501km | 126 | | 4 | 22.50 | 22°30'00" | 1768km | 127 | | 5 | 11.25 | 11°15'00" | 886km | 128 | | 6 | 5.63 | 5°37'30" | 443km | 129 | | 7 | 2.81 | 2°48'45" | 222km | 130 | | 8 | 1.41 | 1°24'23" | 111km | 131 | | 9 | 0.70 | 0°42'11" | 5544m | 132 | | 10 | 0.35 | 0°21'06" | 2772m | 133 | | 11 | 0.18 | 0°10'28" | 1386m | 134 | | 12 | 0.09 | 0°05'16" | 693m | 135 | | 13 | 0.04 | 0°02'38" | 346m | 136 | | 14 | 0.02 | 0°01'19" | 173m | 137 | | 15 | 0.01 | 0°00'40" | 87m | 138 | 139 | ## Updates 140 | 2024-08-04 Thanks Scott Powell for suggesting and adding the map style selection function. 141 | 142 | 2025-04-23 Thanks Greg Nelson for creating the KML estension. 143 | 144 | ## Contributing 145 | If you find any issues or have suggestions for improvements, please feel free to create an issue or submit a pull request. 146 | 147 | ## License 148 | This project is licensed under the MIT License. See the LICENSE file for more details 149 | -------------------------------------------------------------------------------- /KMLtoTiles.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import argparse 4 | import warnings 5 | from pathlib import Path 6 | from math import log, tan, cos, pi 7 | from tqdm import tqdm 8 | with warnings.catch_warnings(): 9 | # This is ugly but suppresses a warning about pretty-printing we don't care about 10 | warnings.simplefilter('ignore') 11 | from fastkml import kml 12 | 13 | from fastkml import Placemark, Point, LineString 14 | from fastkml.utils import find_all as kml_find_all 15 | from typing import Dict, Tuple 16 | 17 | # Usage example: 18 | # python KMLtoTiles.py my_input.kml -k xx_yy_your_key_here -o ~/maps/outdoors -s outdoors -m 1000 19 | 20 | def lon2tilex(lon, zoom): 21 | return int((lon + 180.0) / 360.0 * (1 << zoom)) 22 | 23 | def lat2tiley(lat, zoom): 24 | return int((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * (1 << zoom)) 25 | 26 | def download_tile(outdir: Path, api_key: str, mapstyle: str, zoom: int, x:int, y:int): 27 | url = f"https://tile.thunderforest.com/{mapstyle}/{zoom}/{x}/{y}.png?apikey={api_key}" 28 | tile_dir = os.path.join(outdir, str(zoom), str(x)) 29 | tile_path = os.path.join(tile_dir, f"{y}.png") 30 | os.makedirs(tile_dir, exist_ok=True) 31 | 32 | if not os.path.exists(tile_path): 33 | response = requests.get(url) 34 | if response.status_code == 200: 35 | with open(tile_path, "wb") as file: 36 | file.write(response.content) 37 | else: 38 | print(f"Failed to download tile {zoom}/{x}/{y}: {response.status_code} {response.reason}") 39 | 40 | def expand_gps(lat, lon, latrgn, lonrgn) -> Tuple[float, float, float, float]: 41 | return (lat - latrgn, lon - lonrgn, lat + latrgn, lon + lonrgn) 42 | 43 | def kml_to_regions(kmlfile: Path, latrgn: float = 0.1, lonrgn: float = 0.1) -> Dict[str, Tuple]: 44 | resdict = dict() 45 | k = kml.KML.parse(kmlfile) 46 | pmarks = list(kml_find_all(k, of_type=Placemark)) 47 | for p in pmarks: 48 | pts = list(kml_find_all(p, of_type=Point)) 49 | if len(pts) == 1: 50 | coord = pts[0].kml_coordinates.coords[0] 51 | lon = coord[0] 52 | lat = coord[1] 53 | resdict[p.name] = expand_gps(lat, lon, latrgn, lonrgn) 54 | else: 55 | coord_idx = 0 56 | lstrs = list(kml_find_all(p, of_type=LineString)) 57 | for lstr in lstrs: 58 | for coord in lstr.kml_coordinates.coords: 59 | lon = coord[0] 60 | lat = coord[1] 61 | name = f'{p.name}_{coord_idx:06}' 62 | resdict[name] = expand_gps(lat, lon, latrgn, lonrgn) 63 | coord_idx += 1 64 | return resdict 65 | 66 | def download_tiles(regions: Dict[str, Tuple], 67 | outdir: Path, apikey: str, 68 | mapstyle: str, maxtiles: int, 69 | minzoom: int = 1, maxzoom: int = 12): 70 | fetch = set() 71 | nofetch = set() 72 | highest_zoom = None 73 | 74 | print('Iterating zoom levels: ', end='') 75 | for zoom in range(minzoom, maxzoom+1): 76 | print(f'{zoom}...', end='', flush=True) 77 | for min_lat, min_lon, max_lat, max_lon in regions.values(): 78 | start_x = lon2tilex(min_lon, zoom) 79 | end_x = lon2tilex(max_lon, zoom) 80 | start_y = lat2tiley(max_lat, zoom) 81 | end_y = lat2tiley(min_lat, zoom) 82 | 83 | tba = (end_x - start_x + 1) * (end_y - start_y + 1) 84 | if len(fetch) + tba < maxtiles: 85 | highest_zoom = zoom 86 | for x in range(start_x, end_x + 1): 87 | for y in range(start_y, end_y + 1): 88 | fetch.add((zoom, x, y)) 89 | else: 90 | for x in range(start_x, end_x + 1): 91 | for y in range(start_y, end_y + 1): 92 | nofetch.add((zoom, x, y)) 93 | 94 | print() 95 | fetch_count = len(fetch) 96 | total_count = len(fetch) + len(nofetch) 97 | print(f'Fetching {fetch_count} tiles out of requested {total_count}') 98 | print(f'Highest zoom level included is {highest_zoom}') 99 | 100 | with tqdm(total=fetch_count, desc="Downloading tiles") as pbar: 101 | for (zoom, x, y) in fetch: 102 | download_tile(outdir, apikey, mapstyle, zoom, x, y) 103 | pbar.update(1) 104 | 105 | if __name__ == "__main__": 106 | def_out = os.path.join(os.path.expanduser("~"), "maps") 107 | p = argparse.ArgumentParser( 108 | prog='KMLtoTiles.py', 109 | description='Process KML file into selected tiles', 110 | epilog=''' 111 | Note: zoom levels are specified as power-of-two fractions of the\n 112 | globe, i.e. zoom=3 means eight slices of latitude and longitude 113 | ''' 114 | ) 115 | p.add_argument('kmlfile', type=Path) 116 | p.add_argument('-k', '--apikey', type=str, required=True) 117 | p.add_argument('-s', '--style', type=str, default='outdoors', 118 | choices=['outdoors', 'mobile-atlas', 'cycle', 119 | 'transport', 'landscape', 'transport-dark', 120 | 'spinal-map', 'pioneer', 'neighbourhood', 'atlas']) 121 | p.add_argument('-m', '--maxtiles', type=int, default=1000) 122 | p.add_argument('-o', '--outdir', type=Path, 123 | default = def_out, 124 | help = 'output directory (default: %(default)s)') 125 | p.add_argument('--minzoom', type=int, default=1, 126 | help = 'minimum zoom level') 127 | p.add_argument('--maxzoom', type=int, default=15, 128 | help = 'maximum zoom level') 129 | p.add_argument('--latrgn', type=float, default=0.1) 130 | p.add_argument('--lonrgn', type=float, default=0.1) 131 | 132 | args = p.parse_args() 133 | regions = kml_to_regions(args.kmlfile, 134 | latrgn=args.latrgn, 135 | lonrgn=args.lonrgn, 136 | ) 137 | 138 | os.makedirs(args.outdir, exist_ok=True) 139 | download_tiles(regions, 140 | apikey=args.apikey, 141 | outdir=args.outdir, 142 | mapstyle=args.style, 143 | maxtiles=args.maxtiles, 144 | minzoom=args.minzoom, 145 | maxzoom=args.maxzoom, 146 | ) 147 | --------------------------------------------------------------------------------