From 9fa252a6aff033e33a841d9069416d3892b6918c Mon Sep 17 00:00:00 2001 From: Shautvast Date: Tue, 31 Mar 2026 21:53:52 +0200 Subject: [PATCH] seed tiles --- backend/docker-compose.yml | 14 ++++ backend/scripts/seed_tiles.py | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 backend/scripts/seed_tiles.py diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index c75d332..ae1bb59 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -31,6 +31,20 @@ services: - osrm-walking - osrm-cycling + tile-seeder: + image: python:3-slim + networks: + - maps-net + volumes: + - ./scripts:/scripts:ro + environment: + BACKEND_URL: "http://backend:8080" + SEED_WORKERS: "4" + command: python3 /scripts/seed_tiles.py + restart: "no" + depends_on: + - backend + postgres: build: context: . diff --git a/backend/scripts/seed_tiles.py b/backend/scripts/seed_tiles.py new file mode 100644 index 0000000..2cfa2be --- /dev/null +++ b/backend/scripts/seed_tiles.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Pre-warm the Redis tile cache by fetching all tiles for a bounding box. + +Tiles are fetched through the backend, which caches them in Redis automatically. +Run this in the background after starting the stack: + + nohup python3 scripts/seed_tiles.py & + +Usage: + python3 seed_tiles.py [min_lon] [min_lat] [max_lon] [max_lat] [min_zoom] [max_zoom] + +Defaults to the Netherlands at zoom levels 0-12. +""" + +import math +import sys +import time +import urllib.request +import urllib.error +from concurrent.futures import ThreadPoolExecutor, as_completed + +import os + +BACKEND_URL = os.environ.get("BACKEND_URL", "http://localhost:8080") +LAYERS = ["planet_osm_polygon", "planet_osm_line", "planet_osm_point", "planet_osm_roads"] +WORKERS = int(os.environ.get("SEED_WORKERS", "4")) + + +def wait_for_backend(): + """Wait until the backend health endpoint responds.""" + url = f"{BACKEND_URL}/health" + print(f"Waiting for backend at {url}...") + while True: + try: + with urllib.request.urlopen(url, timeout=5) as resp: + if resp.status == 200: + print("Backend is up.") + return + except Exception: + pass + time.sleep(3) + + +def lon_to_x(lon, zoom): + return int((lon + 180.0) / 360.0 * (1 << zoom)) + + +def lat_to_y(lat, zoom): + lat_r = math.radians(lat) + return int((1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) / 2.0 * (1 << zoom)) + + +def tiles_for_bbox(min_lon, min_lat, max_lon, max_lat, zoom): + max_coord = (1 << zoom) - 1 + x_min = max(0, min(lon_to_x(min_lon, zoom), max_coord)) + x_max = max(0, min(lon_to_x(max_lon, zoom), max_coord)) + y_min = max(0, min(lat_to_y(max_lat, zoom), max_coord)) # max_lat → smaller y + y_max = max(0, min(lat_to_y(min_lat, zoom), max_coord)) # min_lat → larger y + for x in range(x_min, x_max + 1): + for y in range(y_min, y_max + 1): + yield zoom, x, y + + +def fetch_tile(layer, z, x, y): + url = f"{BACKEND_URL}/tiles/{layer}/{z}/{x}/{y}.pbf" + try: + with urllib.request.urlopen(url, timeout=120) as resp: + return resp.status == 200 + except urllib.error.HTTPError: + return False + except Exception: + return False + + +def main(): + wait_for_backend() + + min_lon = float(sys.argv[1]) if len(sys.argv) > 1 else 3.3 + min_lat = float(sys.argv[2]) if len(sys.argv) > 2 else 50.7 + max_lon = float(sys.argv[3]) if len(sys.argv) > 3 else 7.2 + max_lat = float(sys.argv[4]) if len(sys.argv) > 4 else 53.6 + min_zoom = int(sys.argv[5]) if len(sys.argv) > 5 else 0 + max_zoom = int(sys.argv[6]) if len(sys.argv) > 6 else 12 + + # Build full task list + tasks = [] + for zoom in range(min_zoom, max_zoom + 1): + coords = list(tiles_for_bbox(min_lon, min_lat, max_lon, max_lat, zoom)) + tile_count = len(coords) + print(f" z{zoom}: {tile_count} tiles × {len(LAYERS)} layers = {tile_count * len(LAYERS)} requests") + for z, x, y in coords: + for layer in LAYERS: + tasks.append((layer, z, x, y)) + + total = len(tasks) + print(f"\nTotal: {total} requests — starting with {WORKERS} workers...\n") + + done = 0 + errors = 0 + start = time.time() + + with ThreadPoolExecutor(max_workers=WORKERS) as executor: + futures = {executor.submit(fetch_tile, *t): t for t in tasks} + for future in as_completed(futures): + ok = future.result() + done += 1 + if not ok: + errors += 1 + if done % 50 == 0 or done == total: + elapsed = time.time() - start + rate = done / elapsed if elapsed > 0 else 0 + eta = (total - done) / rate if rate > 0 else 0 + pct = 100 * done // total + print(f" {done}/{total} ({pct}%) | {errors} errors | {rate:.1f} req/s | ETA {eta:.0f}s") + + elapsed = time.time() - start + print(f"\nDone: {total - errors}/{total} tiles seeded in {elapsed:.0f}s") + + +if __name__ == "__main__": + main()