Files
MeshCore-Solo/tools/maps/fetch_tiles.py
T

153 lines
6.5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Offline raster map tiles for the ui-lvgl map screen (Wio Tracker L2 SD card).
Writes <out>/maps/{z}/{x}/{y}.png -- the same layout Meshtastic MUI uses, so a
card prepared for either works for both. Copy the `maps` folder to the root of
the SD card. The device can also download an area itself over WiFi; this is
the offline / bulk option.
tools/maps/fetch_tiles.py --preset krakow --url URL --out /Volumes/SDCARD
tools/maps/fetch_tiles.py --bbox 19.90,50.04,19.98,50.08 --zoom 10-16 --url URL --out ./card
tools/maps/fetch_tiles.py --preset krakow --dry-run # count + size estimate
Tile source: --url (or TILE_URL) with {z}/{x}/{y}. There is deliberately no
default. Check the provider's terms before downloading an area:
- tile.openstreetmap.org forbids offline use and answers with an
"access blocked" image;
- OpenTopoMap (https://a.tile.opentopomap.org/{z}/{x}/{y}.png) tolerates
moderate use -- a town or a trip, not a country;
- keyed providers (MapTiler, Thunderforest, Stadia, ...) put the key in the
URL; their plans say whether offline caching is allowed;
- your own render server (e.g. from an OSM extract) has no limits.
--attribution is written to maps/attribution.txt; the device shows it on the
map, as the data licences require.
The tool stops if the server answers every tile with the same image (a
"blocked" or "API key required" placeholder).
Resumable (existing files are skipped), single connection, throttled.
--manifest also writes maps/manifest.json (the list of tiles), which the
browser simulator (variants/sim/web/lvgl.html) uses to preload them.
"""
import argparse
import json
import math
import os
import sys
import time
import urllib.error
import urllib.request
PRESETS = {
# name: (lon_min, lat_min, lon_max, lat_max, z_min, z_max)
"krakow": (19.90, 50.04, 19.98, 50.08, 10, 16), # Old Town + Kazimierz; test set
"krakow-wide": (19.79, 49.97, 20.22, 50.13, 10, 15),
"tatry": (19.75, 49.15, 20.25, 49.32, 10, 15),
"warszawa": (20.85, 52.10, 21.20, 52.35, 10, 15),
"poland": (14.07, 49.00, 24.15, 54.84, 5, 9), # overview zooms only
}
USER_AGENT = "MeshCore-Solo-map-prep/1.0 (offline tiles for a LoRa handheld; throttled)"
def tile_xy(lon, lat, z):
n = 1 << z
x = int((lon + 180.0) / 360.0 * n)
lat_r = math.radians(max(min(lat, 85.0511), -85.0511))
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
return min(max(x, 0), n - 1), min(max(y, 0), n - 1)
def tiles_for(bbox, z_min, z_max):
lon_min, lat_min, lon_max, lat_max = bbox
for z in range(z_min, z_max + 1):
x0, y0 = tile_xy(lon_min, lat_max, z) # north-west corner
x1, y1 = tile_xy(lon_max, lat_min, z) # south-east corner
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
yield z, x, y
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--preset", choices=sorted(PRESETS))
ap.add_argument("--bbox", help="lon_min,lat_min,lon_max,lat_max")
ap.add_argument("--zoom", help="z_min-z_max (default from preset, else 10-15)")
ap.add_argument("--out", default=".", help="folder that gets maps/ (e.g. the SD card root)")
ap.add_argument("--url", default=os.environ.get("TILE_URL"), help="tile URL template with {z} {x} {y}")
ap.add_argument("--attribution", default="\u00a9 OpenStreetMap contributors",
help="credit line shown on the device's map")
ap.add_argument("--delay", type=float, default=1.0, help="seconds between requests (default 1)")
ap.add_argument("--manifest", action="store_true", help="also write maps/manifest.json")
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
if a.preset:
*bbox, z_min, z_max = PRESETS[a.preset]
elif a.bbox:
bbox = [float(v) for v in a.bbox.split(",")]
z_min, z_max = 10, 15
else:
ap.error("give --preset or --bbox")
if a.zoom:
z_min, z_max = (int(v) for v in a.zoom.split("-"))
tiles = list(tiles_for(bbox, z_min, z_max))
print(f"{len(tiles)} tiles, z{z_min}-{z_max}, ~{len(tiles) * 25 // 1024} MB (at ~25 KB/tile)")
if a.dry_run:
return
if not a.url:
ap.error("give --url (see the provider notes in --help)")
root = os.path.join(a.out, "maps")
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "attribution.txt"), "w", encoding="utf-8") as f:
f.write(a.attribution + "\n")
got = skipped = failed = 0
seen = {} # payload -> count, over the first downloads: a placeholder repeats
for i, (z, x, y) in enumerate(tiles, 1):
path = os.path.join(root, str(z), str(x), f"{y}.png")
if os.path.exists(path) and os.path.getsize(path) > 0:
skipped += 1
continue
os.makedirs(os.path.dirname(path), exist_ok=True)
url = a.url.format(z=z, x=x, y=y)
for attempt in range(3):
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=20) as r:
data = r.read()
if got < 8:
seen[data] = seen.get(data, 0) + 1
if seen[data] >= 4:
sys.exit("stopped: the server returns the same image for different tiles "
"(blocked or API key required?) -- check --url; delete what was written")
with open(path + ".tmp", "wb") as f:
f.write(data)
os.replace(path + ".tmp", path)
got += 1
break
except (urllib.error.URLError, TimeoutError) as e:
if attempt == 2:
failed += 1
print(f" failed {z}/{x}/{y}: {e}", file=sys.stderr)
time.sleep(2 * (attempt + 1))
if i % 50 == 0:
print(f" {i}/{len(tiles)}")
time.sleep(a.delay)
if a.manifest:
listed = []
for dirpath, _, files in os.walk(root):
for fn in files:
if fn.endswith(".png") or fn == "attribution.txt":
listed.append(os.path.relpath(os.path.join(dirpath, fn), root).replace(os.sep, "/"))
with open(os.path.join(root, "manifest.json"), "w") as f:
json.dump(sorted(listed), f)
print(f"done: {got} downloaded, {skipped} already there, {failed} failed -> {root}")
if __name__ == "__main__":
main()