BREAKING CHANGE: satellite.mbtiles ist nun einheitlich JPEG (zuvor gemischte PNG/JPEG) Features: - Neue Scripts: convert-satellite-to-jpeg.py (batch-basiert, resume-fähig) - download-satellite.py jetzt mit on-the-fly PNG→JPEG-Konvertierung - docker-compose.download.yml: Pillow installiert, Qualität konfigurierbar Fixes: - 1.505.049 PNG-Tiles zu JPEG konvertiert (22.9 Minuten) - SQLite quick_check bestanden (png=0, quick_check=ok, total=5.6M) - Martin lädt satellite-Source ohne Format-Warnungen - Alle 3 Sources (osm, osm-europe, satellite) geladen und produktiv Documentation: - ADMIN.md: Satellit-Konvertierungs-Anleitung aktualisiert - PROJECT_CONTEXT.md: Finaler Status dokumentiert - Session-Log: 2026-06-15_satellite-fix-cleanup_session.md Cleanup: - Temporäre Dateien gelöscht - 17GB Backup gelöscht - __pycache__ gelöscht
177 lines
6.5 KiB
Python
Executable file
177 lines
6.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Sentinel-2 cloudless tiles zoom 0-10 -> MBTiles (resume-capable)
|
|
# Quelle: EOX s2cloudless-2021 CC BY 4.0 https://s2maps.eu
|
|
import io, os, sys, sqlite3, time, threading, urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
|
|
MAX_ZOOM = int(os.environ.get("SAT_MAX_ZOOM", "10"))
|
|
THREADS = int(os.environ.get("SAT_THREADS", "8"))
|
|
JPEG_QUALITY = int(os.environ.get("SAT_JPEG_QUALITY", "90"))
|
|
JPEG_OPTIMIZE = os.environ.get("SAT_JPEG_OPTIMIZE", "0") == "1"
|
|
DB_BATCH = int(os.environ.get("SAT_DB_BATCH", "500"))
|
|
CHUNK = int(os.environ.get("SAT_CHUNK", "500"))
|
|
# Schreibt in .part-Datei → Martin ignoriert diese während des Downloads
|
|
_FINAL = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/data/satellite.mbtiles")
|
|
OUTPUT = _FINAL.with_suffix(".mbtiles.part")
|
|
TILE_URL = ("https://tiles.maps.eox.at/wmts/1.0.0/"
|
|
"s2cloudless-2021_3857/default/GoogleMapsCompatible/{z}/{y}/{x}.jpg")
|
|
HEADERS = {"User-Agent": "rd13-tileserver/1.0 (self-hosted)", "Referer": "https://s2maps.eu"}
|
|
_lock = threading.Lock()
|
|
stats = {"ok": 0, "skip": 0, "err": 0, "conv": 0}
|
|
pending = []
|
|
PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
def init_db(path):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
c = sqlite3.connect(str(path), check_same_thread=False)
|
|
c.execute("PRAGMA journal_mode=WAL")
|
|
c.execute("PRAGMA synchronous=NORMAL")
|
|
c.execute("CREATE TABLE IF NOT EXISTS metadata(name TEXT PRIMARY KEY, value TEXT)")
|
|
c.execute("CREATE TABLE IF NOT EXISTS tiles("
|
|
"zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB,"
|
|
"PRIMARY KEY(zoom_level, tile_column, tile_row))")
|
|
c.executemany("INSERT OR REPLACE INTO metadata VALUES(?,?)", [
|
|
("name", "satellite"),
|
|
("format", "jpg"),
|
|
("type", "baselayer"),
|
|
("description", "Sentinel-2 cloudless 2021 (EOX CC BY 4.0)"),
|
|
("version", "1"),
|
|
("minzoom", "0"),
|
|
("maxzoom", str(MAX_ZOOM)),
|
|
("bounds", "-180,-85.051129,180,85.051129"),
|
|
("center", "0,0,2"),
|
|
])
|
|
c.commit()
|
|
return c
|
|
|
|
|
|
def fetch(url):
|
|
req = urllib.request.Request(url, headers=HEADERS)
|
|
for i in range(3):
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
return r.read() if r.status == 200 else None
|
|
except Exception:
|
|
if i < 2:
|
|
time.sleep(2 ** i)
|
|
return None
|
|
|
|
|
|
def normalize_tile(data):
|
|
if data.startswith(b"\xff\xd8"):
|
|
return data, False
|
|
if not data.startswith(PNG_SIG):
|
|
raise ValueError("unknown tile image format")
|
|
|
|
with Image.open(io.BytesIO(data)) as img:
|
|
if img.mode == "RGBA" or "transparency" in img.info:
|
|
rgba = img.convert("RGBA")
|
|
background = Image.new("RGBA", rgba.size, (0, 0, 0, 255))
|
|
background.alpha_composite(rgba)
|
|
rgb = background.convert("RGB")
|
|
else:
|
|
rgb = img.convert("RGB")
|
|
out = io.BytesIO()
|
|
rgb.save(out, format="JPEG", quality=JPEG_QUALITY, optimize=JPEG_OPTIMIZE)
|
|
return out.getvalue(), True
|
|
|
|
|
|
def do_tile(conn, z, x, y):
|
|
y_tms = (1 << z) - 1 - y
|
|
with _lock:
|
|
if conn.execute(
|
|
"SELECT 1 FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
|
|
(z, x, y_tms)
|
|
).fetchone():
|
|
stats["skip"] += 1
|
|
return
|
|
data = fetch(TILE_URL.format(z=z, y=y, x=x))
|
|
converted = False
|
|
if data:
|
|
try:
|
|
data, converted = normalize_tile(data)
|
|
except Exception as e:
|
|
log("[Sat] ERROR normalize z=%d x=%d y=%d: %s" % (z, x, y, e))
|
|
data = None
|
|
with _lock:
|
|
if data:
|
|
pending.append((z, x, y_tms, data))
|
|
if len(pending) >= DB_BATCH:
|
|
conn.executemany("INSERT OR REPLACE INTO tiles VALUES(?,?,?,?)", pending)
|
|
conn.commit()
|
|
pending.clear()
|
|
stats["ok"] += 1
|
|
if converted:
|
|
stats["conv"] += 1
|
|
else:
|
|
stats["err"] += 1
|
|
|
|
|
|
def log(msg):
|
|
print(msg, flush=True)
|
|
|
|
|
|
def tile_coords():
|
|
"""Generator: liefert alle (z,x,y)-Koordinaten ohne sie alle im RAM zu halten."""
|
|
for z in range(MAX_ZOOM + 1):
|
|
for x in range(1 << z):
|
|
for y in range(1 << z):
|
|
yield z, x, y
|
|
|
|
|
|
def main():
|
|
total = sum(4 ** z for z in range(MAX_ZOOM + 1))
|
|
log("[Sat] Output: %s" % OUTPUT)
|
|
log("[Sat] Zoom: 0-%d | Threads: %d | Tiles: %d" % (MAX_ZOOM, THREADS, total))
|
|
log("[Sat] Quelle: Sentinel-2 cloudless 2021 (EOX, CC BY 4.0)")
|
|
log("[Sat] Resume: bereits heruntergeladene Tiles werden übersprungen")
|
|
conn = init_db(OUTPUT)
|
|
t0 = time.time()
|
|
n = 0
|
|
|
|
coords = tile_coords()
|
|
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=THREADS) as ex:
|
|
while True:
|
|
chunk = []
|
|
for _ in range(CHUNK):
|
|
coord = next(coords, None)
|
|
if coord is None:
|
|
break
|
|
chunk.append(coord)
|
|
if not chunk:
|
|
break
|
|
futs = {ex.submit(do_tile, conn, z, x, y): 1 for z, x, y in chunk}
|
|
for f in as_completed(futs):
|
|
try:
|
|
f.result()
|
|
except Exception as e:
|
|
log("[Sat] ERROR in thread: %s" % e)
|
|
n += 1
|
|
if n % 1000 == 0 or n == total:
|
|
dt = time.time() - t0 or 0.001
|
|
eta = (total - n) / n * dt / 3600
|
|
log("[Sat] %5.1f%% %d/%d ok=%d skip=%d err=%d conv=%d %.0ft/s ETA=%.1fh" % (
|
|
n / total * 100, n, total,
|
|
stats["ok"], stats["skip"], stats["err"], stats["conv"],
|
|
n / dt, eta))
|
|
with _lock:
|
|
if pending:
|
|
conn.executemany("INSERT OR REPLACE INTO tiles VALUES(?,?,?,?)", pending)
|
|
conn.commit()
|
|
log("[Sat] Erstelle Index...")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx ON tiles(zoom_level, tile_column, tile_row)")
|
|
conn.execute("ANALYZE")
|
|
log("[Sat] Benenne um: %s → %s" % (OUTPUT, _FINAL))
|
|
OUTPUT.replace(_FINAL)
|
|
log("[Sat] Fertig %.1fh -- %s" % ((time.time() - t0) / 3600, _FINAL))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|