Files
wadamesh/scripts/map-pack/build_pack.py
T

164 lines
6.1 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build an offline OpenStreetMap tile pack for the SD card.
Example:
python build_pack.py --bbox 55.70,37.40,55.95,37.90 --zoom 10-14 \
--out D:\\ --name Moscow
The device reads the standard layout:
<out>/maps/osm/<z>/<x>/<y>.png
Copy the resulting `maps` directory to the microSD card, then enable
"Tiles from SD card" in the map options.
Please respect the tile server's usage policy. This tool is for small,
personal packs (a city/route), not for bulk planet downloads. It sleeps
between requests and skips tiles that already exist.
"""
import argparse
import datetime as dt
import json
import math
import os
import re
import sys
import time
import urllib.error
import urllib.request
DEFAULT_SOURCE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
UA = "wadamesh-map-pack/1.0 (personal offline map; +https://meshinfo.ru)"
def deg2num(lat, lon, z):
lat_r = math.radians(lat)
n = 2.0 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
return max(0, min(int(n) - 1, x)), max(0, min(int(n) - 1, y))
def parse_zoom(s):
if "-" in s:
a, b = s.split("-", 1)
return list(range(int(a), int(b) + 1))
return [int(s)]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bbox", required=True,
help="south,west,north,east (decimal degrees)")
ap.add_argument("--zoom", required=True, help="12 or 10-14")
ap.add_argument("--out", required=True, help="destination root (SD card root)")
ap.add_argument("--name", default="offline")
ap.add_argument("--source", default=DEFAULT_SOURCE)
ap.add_argument("--min-interval", type=float, default=0.25,
help="seconds between network requests")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
try:
south, west, north, east = [float(x) for x in args.bbox.split(",")]
except ValueError:
raise SystemExit("--bbox must be south,west,north,east")
if not (south < north and west < east):
raise SystemExit("bbox must satisfy south<north and west<east")
zooms = parse_zoom(args.zoom)
root = os.path.join(os.path.abspath(args.out), "maps", "osm")
os.makedirs(root, exist_ok=True)
total = 0
bytes_total = 0
files = []
last = 0.0
for z in zooms:
x0, y0 = deg2num(north, west, z)
x1, y1 = deg2num(south, east, z)
for x in range(min(x0, x1), max(x0, x1) + 1):
d = os.path.join(root, str(z), str(x))
os.makedirs(d, exist_ok=True)
for y in range(min(y0, y1), max(y0, y1) + 1):
path = os.path.join(d, str(y) + ".png")
rel = "%d/%d/%d.png" % (z, x, y)
if os.path.exists(path) and os.path.getsize(path) > 0:
total += 1
bytes_total += os.path.getsize(path)
files.append(rel)
continue
if args.dry_run:
total += 1
files.append(rel)
continue
wait = args.min_interval - (time.time() - last)
if wait > 0:
time.sleep(wait)
url = args.source.format(z=z, x=x, y=y)
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=30) as r:
data = r.read()
if not data:
raise urllib.error.HTTPError(url, 0, "empty", {}, None)
with open(path, "wb") as f:
f.write(data)
total += 1
bytes_total += len(data)
files.append(rel)
last = time.time()
except urllib.error.HTTPError as e:
if e.code == 404:
continue # ocean / no tile
print("tile %s: HTTP %s" % (url, e.code), file=sys.stderr)
except Exception as e:
print("tile %s: %s" % (url, e), file=sys.stderr)
manifest_path = os.path.join(root, "packs.json")
try:
manifest = json.load(open(manifest_path, encoding="utf-8"))
except Exception:
manifest = {"packs": []}
manifest["packs"] = [p for p in manifest.get("packs", [])
if p.get("name") != args.name]
slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", args.name).strip("_") or "offline"
list_rel = "packs/%s.lst" % slug
manifest["packs"].append({
"name": args.name,
"bbox": [south, west, north, east],
"zoom": zooms,
"tiles": total,
"bytes": bytes_total,
"created": dt.datetime.now(dt.timezone.utc).isoformat(),
"list": list_rel,
})
manifest["generated"] = dt.datetime.now(dt.timezone.utc).isoformat()
pack_lines = [
"# wadamesh map packs",
"# count: %d" % len(manifest["packs"]),
"# name\tzooms\ttiles\tbytes\tcreated\tlist",
]
for p in manifest["packs"]:
pack_lines.append("%s\t%s\t%d\t%d\t%s\t%s" % (
p.get("name", "offline"),
"-".join(str(z) for z in p.get("zoom", [])),
int(p.get("tiles", 0)), int(p.get("bytes", 0)),
p.get("created", ""), p.get("list", "")))
if not args.dry_run:
pack_dir = os.path.join(root, "packs")
os.makedirs(pack_dir, exist_ok=True)
list_path = os.path.join(root, *list_rel.split("/"))
for path, data in (
(manifest_path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"),
(os.path.join(root, "packs.txt"), "\n".join(pack_lines) + "\n"),
(list_path, "\n".join(files) + ("\n" if files else "")),
):
with open(path + ".tmp", "w", encoding="utf-8", newline="\n") as f:
f.write(data)
os.replace(path + ".tmp", path)
print("pack %r: %d tiles, %.1f MB, %s" % (
args.name, total, bytes_total / (1024.0 * 1024.0), root))
if __name__ == "__main__":
main()