├── .gitignore ├── LICENSE ├── README.md ├── config └── map_sources.json ├── documentation └── documentation.md ├── requirements.txt ├── setup.py ├── src └── TileDL.py ├── templates └── index.html └── utils └── dependency_installer.py /.gitignore: -------------------------------------------------------------------------------- 1 | ## MacOS 2 | **/.DS_Store 3 | 4 | ## Virtual Environment 5 | **/.venv 6 | .venv 7 | 8 | ## UV Virtual Environment 9 | .python-version 10 | pyproject.toml 11 | main.py 12 | uv.lock 13 | 14 | **/src/TileDL_002.py 15 | **/src/TileDL_002.ipynb 16 | **/templates/styles.css 17 | **/templates/index_copy.html -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Matthew Drummonds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Map Tile Downloader 2 | 3 | The Map Tile Downloader is a Flask-based web application designed to download map tiles from various sources. It allows users to select specific areas on a map, choose zoom levels, and download tiles for offline use. The application supports converting tiles to 8-bit color depth for compatibility with Meshtastic® UI Maps and provides options to view and manage cached tiles. 4 | 5 | image 6 | 7 | ## Features 8 | 9 | - **Custom Area Downloads**: Draw polygons on the map to select specific areas for tile downloading. 10 | - **World Basemap Downloads**: Download tiles for the entire world at zoom levels 0-7. 11 | - **8-bit Conversion**: Option to convert downloaded tiles to 8-bit color depth for Meshtastic® UI Maps. 12 | - **Cache Management**: View and delete cached tiles for different map styles. 13 | - **Progress Tracking**: Real-time progress bar showing downloaded, skipped, and failed tiles. 14 | - **Configurable Map Sources**: Easily add or modify map sources via a JSON configuration file. 15 | 16 | ## Prerequisites 17 | 18 | - Python 3.8 or higher (For Windows, make sure that Python in installed with the ADD TO PATH option selected) 19 | - A modern web browser (Chrome, Firefox, Edge, etc.) 20 | - Git (for cloning the repository) 21 | 22 | ## Installation 23 | 24 | 1. Clone the Repository (or download the zip file and extract to the location of your choice): 25 | 26 | git clone https://github.com/mattdrum/map-tile-downloader.git 27 | cd map-tile-downloader 28 | 29 | 2. Install Dependencies (Optional) : 30 | The application will automatically install required dependencies from requirements.txt on startup. However, you can manually install them using: 31 | 32 | pip install -r requirements.txt 33 | 34 | 3. Set Up Configuration (Optional, default sources are included) : 35 | 36 | Ensure the config/map_sources.json file is present and correctly formatted. See the Configuration section below for an example. 37 | 38 | 39 | ## Configuration 40 | The application uses a JSON configuration file (config/map_sources.json) to define available map sources. Each entry consists of a name and a URL template for the tiles. 41 | 42 | Example map_sources.json: 43 | 44 | "OpenStreetMap": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 45 | "OpenTopoMap": "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", 46 | "Stamen Terrain": "http://{s}.tile.stamen.com/terrain/{z}/{x}/{y}.png", 47 | "CartoDB Positron": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", 48 | "CartoDB Dark Matter": "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png" 49 | 50 | 51 | Adding a New Map Source: Simply add a new key-value pair to the JSON file with the map name and its tile URL template. 52 | 53 | 54 | ## Usage 55 | 1. Navigate to the application directory and Run the Application: 56 | 57 | python src/TileDL.py 58 | 59 | The application will start a local server at http://localhost:5000. 60 | - Alternatively you may create a Batch file "StartMap.bat" to launch from windows: 61 | - @echo off 62 | cd /d C:\(extractlocation)\map-tile-downloader 63 | python scr/TileDL.py 64 | pause 65 | 66 | 3. Access the Web Interface: 67 | 68 | Open your web browser and navigate to http://localhost:5000. 69 | 70 | 4. Select Map Style: 71 | 72 | Choose a map style from the dropdown menu. The available options are loaded from map_sources.json. 73 | 5. Draw Polygons: 74 | 75 | Use the drawing tools to select areas on the map for which you want to download tiles. 76 | 77 | 6. Set Zoom Levels: 78 | 79 | Specify the minimum and maximum zoom levels for the tiles you wish to download. 80 | 81 | 7. Download Tiles: 82 | 83 | Click "Download Tiles" to start downloading tiles for the selected areas and zoom levels. 84 | Alternatively, click "Download World Basemap" to download tiles for the entire world at zoom levels 0-7. 85 | 86 | 8. Monitor Progress: 87 | 88 | The progress bar will display the number of downloaded, skipped, and failed tiles. 89 | 90 | 9. Manage Cache: 91 | 92 | Check "View cached tiles" to see outlines of cached tiles on the map. 93 | 94 | Use "Delete Cache" to remove cached tiles for the selected map style. 95 | 96 | 97 | ## Contributing 98 | 99 | We welcome contributions to improve the Map Tile Downloader! To contribute: 100 | 101 | - Fork the Repository: Create your own fork of the project. 102 | - Create a Feature Branch: Work on your feature or bug fix in a separate branch. 103 | - Submit a Pull Request: Once your changes are ready, submit a pull request to the main repository. 104 | - Coding Standards: Follow PEP 8 for Python code and ensure your code is well-documented. 105 | - Testing: Test your changes locally before submitting a pull request. 106 | 107 | ## License 108 | 109 | This project is licensed under the MIT License. See the file for details. 110 | 111 | Contact Information 112 | For questions, suggestions, or support, please open an issue on the GitHub repository or contact k4mbd.ham@gmail.com 113 | 114 | ## Acknowledgements 115 | 116 | - Leaflet: For the interactive map interface. 117 | - Flask: For the web framework. 118 | - SocketIO: For real-time communication. 119 | - Mercantile: For tile calculations. 120 | - Shapely: For geometric operations. 121 | - Pillow: For image processing. 122 | 123 | Special thanks to all contributors and the open-source community! 124 | -------------------------------------------------------------------------------- /config/map_sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "Standard OSM": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 3 | "OpenTopoMap Outdoors": "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", 4 | "CartoDB Positron": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", 5 | "CartoDB Dark Matter": "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", 6 | "Esri World Imagery Satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", 7 | "Google Satellite": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}" 8 | } 9 | -------------------------------------------------------------------------------- /documentation/documentation.md: -------------------------------------------------------------------------------- 1 | # Map Tile Downloader - Documentation 2 | 3 | Documentation for Map Tile Downloader 4 | 5 | ## Table of Contents 6 | * [About](#about) 7 | * [Features](#features) 8 | * [Prerequisites](#prerequisites) 9 | * [Installation](#installation) 10 | * [Configuration](#configuration) 11 | * [Usage](#usage) 12 | * [Contributing](#contributing) 13 | * [License](#license) 14 | * [Acknowledgements](#acknowledgements) 15 | 16 | 17 | ## About 18 | The Map Tile Downloader is a Flask-based web application designed to download map tiles from various sources. It allows users to select specific areas on a map, choose zoom levels, and download tiles for offline use. The application supports converting tiles to 8-bit color depth for compatibility with Meshtastic® UI Maps and provides options to view and manage cached tiles. 19 | 20 | image 21 | 22 | 23 | ## Features 24 | 25 | - **Custom Area Downloads**: Draw polygons, or rectangles on the map to select specific areas for tile downloading. 26 | - **World Basemap Downloads**: Download tiles for the entire world at zoom levels 0-7. 27 | - **8-bit Conversion**: Option to convert downloaded tiles to 8-bit color depth for Meshtastic® UI Maps. 28 | - **Cache Management**: View and delete cached tiles for different map styles. 29 | - **Progress Tracking**: Real-time progress bar showing downloaded, skipped, and failed tiles. 30 | - **Configurable Map Sources**: Easily add or modify map sources via a JSON configuration file. 31 | 32 | ## Prerequisites 33 | 34 | - Python 3.8 or higher. 35 | - (For Windows, make sure that Python in installed with the ADD TO PATH option selected). 36 | - Note: While not immediately required, installing a current and supported Python version is recommended, such as Python 3.11, is recommended. Older versions may become deprecated in the future. 37 | - A Virtual Environment to install the required libraries and run the code. 38 | - A modern Web Browser (Chrome, Firefox, Edge, etc.). 39 | - Git (for cloning the repository). 40 | 41 | ## Virtual Environments 42 | 2. ### Create and Activate a Virtual Environment 43 | * Select your preferred way to create a virtual environment. There are many tools and methods to create them. 44 | 45 | 46 | ## Installation 47 | 0. Select the path on where the project will be created. 48 | 49 | 1. Clone the Repository (or download the zip file and extract to the location of your choice): 50 | 51 | git clone https://github.com/mattdrum/map-tile-downloader.git 52 | cd map-tile-downloader` 53 | 54 | 2. Install Dependencies 55 | 2.1) Activate virtual environment. 56 | 57 | 2.2.1) Install Dependencies using the dependency installer (Optional): 58 | 59 | The application `utils/dependency_installer.py` will automatically install required dependencies from requirements.txt after running the code. 60 | 61 | - Run the following code inside the terminal: 62 | `utils/dependency_installer.py` 63 | 64 | Note: 65 | Trying to run the code above might cause some errors when using a virtual environment created with UV from Astral. 66 | To solve this issue, make sure that pip is installed as a package inside the activated environment. 67 | 68 | **Only for virtual environments created with UV Astral** 69 | Run the following command to install pip inside the activated environment: 70 | `uv add pip` 71 | 72 | 2.2.2) Install all the libraries manually: 73 | 74 | `pip install -r requirements.txt` 75 | 76 | 2.2.3) Manually install libraries one by one: 77 | 78 | `pip install LibraryName` 79 | 80 | 81 | 3. Set Up Configuration (Optional, default sources are included) : 82 | 83 | Ensure the config/map_sources.json file is present and correctly formatted. See the Configuration section below for an example. 84 | 85 | 86 | ## Configuration 87 | The application uses a JSON configuration file (config/map_sources.json) to define available map sources. Each entry consists of a name and a URL template for the tiles. 88 | 89 | Example map_sources.json: 90 | 91 | "OpenStreetMap": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 92 | "OpenTopoMap": "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", 93 | "Stamen Terrain": "http://{s}.tile.stamen.com/terrain/{z}/{x}/{y}.png", 94 | "CartoDB Positron": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", 95 | "CartoDB Dark Matter": "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png" 96 | 97 | 98 | Adding a New Map Source: Simply add a new key-value pair to the JSON file with the map name and its tile URL template. 99 | 100 | 101 | ## Usage 102 | 0. Activate the virtual environment 103 | 104 | 1. Navigate to the application directory and Run the Application: 105 | 106 | python src/TileDL.py 107 | 108 | The application will start a local server at http://localhost:5000. 109 | - Alternatively you may create a Batch file "StartMap.bat" to launch from windows: 110 | - @echo off 111 | cd /d C:\(extractlocation)\map-tile-downloader 112 | python scr/TileDL.py 113 | pause 114 | 115 | 3. Access the Web Interface: 116 | 117 | Open your web browser and navigate to http://localhost:5000. 118 | 119 | 4. Select Map Style: 120 | 121 | Choose a map style from the dropdown menu. The available options are loaded from map_sources.json. 122 | 5. Draw Polygons: 123 | 124 | Use the drawing tools to select areas on the map for which you want to download tiles. 125 | 126 | 6. Set Zoom Levels: 127 | 128 | Specify the minimum and maximum zoom levels for the tiles you wish to download. 129 | 130 | 7. Download Tiles: 131 | 132 | Click "Download Tiles" to start downloading tiles for the selected areas and zoom levels. 133 | Alternatively, click "Download World Basemap" to download tiles for the entire world at zoom levels 0-7. 134 | 135 | 8. Monitor Progress: 136 | 137 | The progress bar will display the number of downloaded, skipped, and failed tiles. 138 | 139 | 9. Manage Cache: 140 | 141 | Check "View cached tiles" to see outlines of cached tiles on the map. 142 | 143 | Use "Delete Cache" to remove cached tiles for the selected map style. 144 | 145 | 146 | ## Contributing 147 | 148 | We welcome contributions to improve the Map Tile Downloader! To contribute: 149 | 150 | - Fork the Repository: Create your own fork of the project. 151 | - Create a Feature Branch: Work on your feature or bug fix in a separate branch. 152 | - Submit a Pull Request: Once your changes are ready, submit a pull request to the main repository. 153 | - Coding Standards: Follow PEP 8 for Python code and ensure your code is well-documented. 154 | - Testing: Test your changes locally before submitting a pull request. 155 | 156 | ## License 157 | 158 | This project is licensed under the MIT License. See the file for details. 159 | 160 | Contact Information 161 | For questions, suggestions, or support, please open an issue on the GitHub repository or contact k4mbd.ham@gmail.com 162 | 163 | ## Acknowledgements 164 | 165 | - Leaflet: For the interactive map interface. 166 | - Flask: For the web framework. 167 | - SocketIO: For real-time communication. 168 | - Mercantile: For tile calculations. 169 | - Shapely: For geometric operations. 170 | - Pillow: For image processing. 171 | 172 | Special thanks to all contributors and the open-source community! 173 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-socketio 3 | requests 4 | mercantile 5 | shapely 6 | pillow -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="map-tile-downloader", 5 | version="0.1", 6 | packages=find_packages(where="src"), 7 | package_dir={"": "src"}, 8 | install_requires=[ 9 | "flask", 10 | "flask-socketio", 11 | "requests", 12 | "mercantile", 13 | "shapely", 14 | "pillow" 15 | ], 16 | entry_points={ 17 | "console_scripts": [ 18 | "map-tile-downloader = tileDL:main" 19 | ] 20 | } 21 | ) -------------------------------------------------------------------------------- /src/TileDL.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import os 4 | from flask import Flask, render_template, request, send_file, jsonify 5 | from flask_socketio import SocketIO, emit 6 | import mercantile 7 | import requests 8 | from concurrent.futures import ThreadPoolExecutor, as_completed 9 | from pathlib import Path 10 | import zipfile 11 | import random 12 | import shutil 13 | import re 14 | import time 15 | import json 16 | from shapely.geometry import Polygon, box 17 | from shapely.ops import unary_union 18 | import threading 19 | from PIL import Image 20 | from pathlib import Path 21 | 22 | # Base directory for caching tiles, absolute path relative to script location 23 | BASE_DIR = Path(__file__).parent.parent # Root of map-tile-downloader 24 | CACHE_DIR = BASE_DIR / 'tile-cache' ## Note: Fixed referece location for cached tiles 25 | DOWNLOADS_DIR = BASE_DIR / 'downloads' 26 | CACHE_DIR.mkdir(exist_ok=True) 27 | DOWNLOADS_DIR.mkdir(exist_ok=True) 28 | 29 | ## Note: Moved to 'utils/dependency_installer.py' 30 | # Ensure dependencies are installed 31 | #def install_dependencies(): 32 | # try: 33 | # with open('requirements.txt', 'r') as f: 34 | # requirements = f.read().splitlines() 35 | # subprocess.check_call([sys.executable, '-m', 'pip', 'install', *requirements]) 36 | # except subprocess.CalledProcessError as e: 37 | # print(f"Failed to install dependencies: {e}") 38 | # sys.exit(1) 39 | # 40 | ## Install dependencies on startup if not already installed 41 | #install_dependencies() 42 | 43 | app = Flask(__name__, template_folder='../templates') 44 | socketio = SocketIO(app) 45 | 46 | 47 | # Load map sources from config file 48 | CONFIG_DIR = Path('config') 49 | MAP_SOURCES_FILE = CONFIG_DIR / 'map_sources.json' 50 | MAP_SOURCES = {} 51 | if MAP_SOURCES_FILE.exists(): 52 | with open(MAP_SOURCES_FILE, 'r') as f: 53 | MAP_SOURCES = json.load(f) 54 | else: 55 | print("Warning: map_sources.json not found. No map sources available.") 56 | sys.exit(1) 57 | 58 | # Global event for cancellation 59 | download_event = threading.Event() 60 | 61 | def sanitize_style_name(style_name): 62 | """Convert map style name to a filesystem-safe directory name.""" 63 | style_name = re.sub(r'\s+', '-', style_name) # Replace spaces with hyphens 64 | style_name = re.sub(r'[^a-zA-Z0-9-_]', '', style_name) # Remove non-alphanumeric except hyphens and underscores 65 | return style_name 66 | 67 | def get_style_cache_dir(style_name): 68 | """Get the cache directory path for a given map style name.""" 69 | sanitized_name = sanitize_style_name(style_name) 70 | return CACHE_DIR / sanitized_name 71 | 72 | def download_tile(tile, map_style, style_cache_dir, convert_to_8bit, max_retries=3): 73 | """Download a single tile with retries if not cancelled and not in cache, converting to 8-bit if specified.""" 74 | if not download_event.is_set(): 75 | return None 76 | tile_dir = style_cache_dir / str(tile.z) / str(tile.x) 77 | tile_path = tile_dir / f"{tile.y}.png" 78 | if tile_path.exists(): 79 | bounds = mercantile.bounds(tile) 80 | socketio.emit('tile_skipped', { 81 | 'west': bounds.west, 82 | 'south': bounds.south, 83 | 'east': bounds.east, 84 | 'north': bounds.north 85 | }) 86 | return tile_path 87 | subdomain = random.choice(['a', 'b', 'c']) if '{s}' in map_style else '' 88 | url = map_style.replace('{s}', subdomain).replace('{z}', str(tile.z)).replace('{x}', str(tile.x)).replace('{y}', str(tile.y)) 89 | headers = {'User-Agent': 'MapTileDownloader/1.0'} 90 | for attempt in range(max_retries): 91 | try: 92 | response = requests.get(url, headers=headers, timeout=10) 93 | if response.status_code == 200: 94 | tile_dir.mkdir(parents=True, exist_ok=True) 95 | with open(tile_path, 'wb') as f: 96 | f.write(response.content) 97 | if convert_to_8bit: 98 | with Image.open(tile_path) as img: 99 | if img.mode != 'P': # Only convert if not already 8-bit palette 100 | img = img.quantize(colors=256) 101 | img.save(tile_path) 102 | bounds = mercantile.bounds(tile) 103 | socketio.emit('tile_downloaded', { 104 | 'west': bounds.west, 105 | 'south': bounds.south, 106 | 'east': bounds.east, 107 | 'north': bounds.north 108 | }) 109 | return tile_path 110 | else: 111 | time.sleep(2 ** attempt) # Exponential backoff 112 | except requests.RequestException: 113 | time.sleep(2 ** attempt) # Exponential backoff 114 | socketio.emit('tile_failed', { 115 | 'tile': f"{tile.z}/{tile.x}/{tile.y}" 116 | }) 117 | return None 118 | 119 | def get_world_tiles(): 120 | """Generate list of tiles for zoom levels 0 to 7 for the entire world.""" 121 | tiles = [] 122 | for z in range(8): # 0 to 7 inclusive 123 | for x in range(2**z): 124 | for y in range(2**z): 125 | tiles.append(mercantile.Tile(x, y, z)) 126 | return tiles 127 | 128 | def get_tiles_for_polygons(polygons_data, min_zoom, max_zoom): 129 | """Generate list of tiles that intersect with the given polygons for the specified zoom range.""" 130 | polygons = [Polygon([(lng, lat) for lat, lng in poly]) for poly in polygons_data] 131 | overall_polygon = unary_union(polygons) 132 | west, south, east, north = overall_polygon.bounds 133 | all_tiles = [] 134 | for z in range(min_zoom, max_zoom + 1): 135 | tiles = mercantile.tiles(west, south, east, north, zooms=[z]) 136 | for tile in tiles: 137 | tile_bbox = mercantile.bounds(tile) 138 | tile_box = box(tile_bbox.west, tile_bbox.south, tile_bbox.east, tile_bbox.north) 139 | if any(tile_box.intersects(poly) for poly in polygons): 140 | all_tiles.append(tile) 141 | all_tiles.sort(key=lambda tile: (tile.z, -tile.x, tile.y)) 142 | return all_tiles 143 | 144 | def download_tiles_with_retries(tiles, map_style, style_cache_dir, convert_to_8bit): 145 | """Download tiles with efficient retries using parallelism and adaptive backoff.""" 146 | socketio.emit('download_started', {'total_tiles': len(tiles)}) 147 | retry_queue = [] 148 | max_workers = 5 149 | batch_size = 10 150 | 151 | def process_batch(batch): 152 | with ThreadPoolExecutor(max_workers=max_workers) as executor: 153 | futures = {executor.submit(download_tile, tile, map_style, style_cache_dir, convert_to_8bit): tile for tile in batch} 154 | for future in as_completed(futures): 155 | if future.result() is None and download_event.is_set(): 156 | retry_queue.append(futures[future]) 157 | 158 | while tiles and download_event.is_set(): 159 | for i in range(0, len(tiles), batch_size): 160 | if not download_event.is_set(): 161 | break 162 | batch = tiles[i:i + batch_size] 163 | process_batch(batch) 164 | tiles = retry_queue if retry_queue else [] 165 | retry_queue = [] 166 | if tiles: 167 | delay = min(2 ** len(retry_queue), 8) 168 | time.sleep(delay) 169 | 170 | if download_event.is_set(): 171 | socketio.emit('tiles_downloaded') 172 | 173 | def create_zip(style_cache_dir, style_name): 174 | """Create a zip file from the style-specific cache directory in the downloads folder.""" 175 | sanitized_name = sanitize_style_name(style_name) 176 | zip_path = DOWNLOADS_DIR / f'{sanitized_name}.zip' # Absolute path 177 | with zipfile.ZipFile(zip_path, 'w', compression=zipfile.ZIP_DEFLATED) as zipf: 178 | for root, _, files in os.walk(style_cache_dir): 179 | for file in files: 180 | file_path = Path(root) / file 181 | arcname = file_path.relative_to(style_cache_dir) 182 | zipf.write(file_path, arcname) 183 | return str(zip_path) # Return as string for send_file 184 | 185 | @app.route('/') 186 | def index(): 187 | """Render the main page.""" 188 | return render_template('index.html') 189 | 190 | @app.route('/get_map_sources') 191 | def get_map_sources(): 192 | """Return the list of map sources from the config file.""" 193 | return jsonify(MAP_SOURCES) 194 | 195 | @socketio.on('start_download') 196 | def handle_start_download(data): 197 | """Handle download request for tiles within polygons.""" 198 | try: 199 | polygons_data = data['polygons'] 200 | min_zoom = data['min_zoom'] 201 | max_zoom = data['max_zoom'] 202 | map_style_url = data['map_style'] 203 | convert_to_8bit = data.get('convert_to_8bit', False) 204 | style_name = next(name for name, url in MAP_SOURCES.items() if url == map_style_url) 205 | style_cache_dir = get_style_cache_dir(style_name) 206 | if min_zoom < 0 or max_zoom > 19 or min_zoom > max_zoom: 207 | emit('error', {'message': 'Invalid zoom range (must be 0-19, min <= max)'}) 208 | return 209 | if not polygons_data: 210 | emit('error', {'message': 'No polygons provided'}) 211 | return 212 | tiles = get_tiles_for_polygons(polygons_data, min_zoom, max_zoom) 213 | download_event.set() 214 | download_tiles_with_retries(tiles, map_style_url, style_cache_dir, convert_to_8bit) 215 | if download_event.is_set(): 216 | zip_path = create_zip(style_cache_dir, style_name) 217 | emit('download_complete', {'zip_url': f'/download_zip?path={zip_path}'}) 218 | except Exception as e: 219 | print(f"Error processing download: {e}") 220 | emit('error', {'message': 'An error occurred while processing your request'}) 221 | 222 | @socketio.on('start_world_download') 223 | def handle_start_world_download(data): 224 | """Handle download request for world basemap tiles (zoom 0-7).""" 225 | try: 226 | map_style_url = data['map_style'] 227 | convert_to_8bit = data.get('convert_to_8bit', False) 228 | style_name = next(name for name, url in MAP_SOURCES.items() if url == map_style_url) 229 | style_cache_dir = get_style_cache_dir(style_name) 230 | tiles = get_world_tiles() 231 | download_event.set() 232 | download_tiles_with_retries(tiles, map_style_url, style_cache_dir, convert_to_8bit) 233 | if download_event.is_set(): 234 | zip_path = create_zip(style_cache_dir, style_name) 235 | emit('download_complete', {'zip_url': f'/download_zip?path={zip_path}'}) 236 | except Exception as e: 237 | print(f"Error processing world download: {e}") 238 | emit('error', {'message': 'An error occurred while processing your request'}) 239 | 240 | @socketio.on('cancel_download') 241 | def handle_cancel_download(): 242 | """Handle cancellation of the download.""" 243 | download_event.clear() 244 | emit('download_cancelled') 245 | 246 | @app.route('/download_zip') 247 | def download_zip(): 248 | """Send the zip file to the user.""" 249 | zip_path = request.args.get('path') 250 | while not Path(zip_path).exists(): # Wait until the file is created 251 | time.sleep(0.5) 252 | return send_file(zip_path, as_attachment=True, download_name=Path(zip_path).name) 253 | 254 | @app.route('/tiles////.png') 255 | def serve_tile(style_name, z, x, y): 256 | """Serve a cached tile if it exists.""" 257 | style_cache_dir = get_style_cache_dir(style_name) 258 | tile_path = style_cache_dir / str(z) / str(x) / f"{y}.png" 259 | if tile_path.exists(): 260 | return send_file(tile_path) 261 | return '', 404 262 | 263 | @app.route('/delete_cache/', methods=['DELETE']) 264 | def delete_cache(style_name): 265 | """Delete the cache directory for a specific style.""" 266 | cache_dir = get_style_cache_dir(style_name) 267 | if cache_dir.exists(): 268 | shutil.rmtree(cache_dir) 269 | return '', 204 270 | return 'Cache not found', 404 271 | 272 | @app.route('/get_cached_tiles/') 273 | def get_cached_tiles_route(style_name): 274 | """Return a list of [z, x, y] for cached tiles of the given style.""" 275 | style_cache_dir = get_style_cache_dir(style_name) 276 | if not style_cache_dir.exists(): 277 | return jsonify([]) 278 | cached_tiles = [] 279 | for z_dir in style_cache_dir.iterdir(): 280 | if z_dir.is_dir(): 281 | try: 282 | z = int(z_dir.name) 283 | for x_dir in z_dir.iterdir(): 284 | if x_dir.is_dir(): 285 | try: 286 | x = int(x_dir.name) 287 | for y_file in x_dir.glob('*.png'): 288 | try: 289 | y = int(y_file.stem) 290 | cached_tiles.append([z, x, y]) 291 | except ValueError: 292 | pass 293 | except ValueError: 294 | pass 295 | except ValueError: 296 | pass 297 | return jsonify(cached_tiles) 298 | 299 | if __name__ == '__main__': 300 | CACHE_DIR.mkdir(exist_ok=True) 301 | CONFIG_DIR.mkdir(exist_ok=True) 302 | socketio.run(app, debug=True) 303 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Map Tile Downloader 6 | 7 | 8 | 9 | 10 | 11 | 42 | 43 | 44 |
45 |
46 |
47 |
48 | 49 |
50 | 51 |
52 | 53 |
54 | Use cached tiles
55 | Convert to 8 bit for Meshtastic UI Maps
56 | View cached tiles
57 | 58 | 59 | 60 | 61 |
62 |
63 |
Ready
64 |
65 | 66 | 320 | 321 | 322 | -------------------------------------------------------------------------------- /utils/dependency_installer.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | # Ensure dependencies are installed 5 | def install_dependencies(): 6 | """ 7 | Installs all libraries located inside the requirements.txt 8 | 9 | Args 10 | - The path is automatic no inputs for the user are required. 11 | - Path location for requirements.txt 12 | 13 | Returns 14 | - sys.executable for pip install requirements in requirements.txt 15 | - print statement for Successul installation 16 | """ 17 | try: 18 | with open('requirements.txt', 'r') as f: 19 | requirements = f.read().splitlines() 20 | subprocess.check_call([sys.executable, '-m', 'pip', 'install', *requirements]) 21 | print(f"\nDependencies Installed Successfully.") 22 | except subprocess.CalledProcessError as e: 23 | print(f"Failed to install dependencies: {e}") 24 | sys.exit(1) 25 | 26 | # Install dependencies on startup if not already installed 27 | install_dependencies() --------------------------------------------------------------------------------