164 lines
6.1 KiB
Python
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()
|