Sur une petite machine (Raspberry Pi), LIDAR_TILE_CACHE_ONLY sert les tuiles depuis le cache uniquement (tuile absente = transparente non mémorisable, X-Tile-Pending) et LIDAR_TILE_BACKGROUND fait surveiller les dalles par un sondeur : chaque dalle nouvelle ou régénérée par le worker remet sa pyramide en file, rendue à basse priorité et au ralenti. Scan et état pilotables via /api/map/background, pré-calcul exhaustif toujours via /api/map/warm.
863 lines
34 KiB
Python
863 lines
34 KiB
Python
"""Serveur de tuiles XYZ + carte « slippy » (image lidar-maps).
|
||
|
||
Sert la pyramide de tuiles décrite dans `tiles.py` selon le schéma
|
||
OpenStreetMap/Google Maps, donc directement utilisable comme fond d'imagerie
|
||
dans JOSM, iD, QGIS, uMap, MapLibre ou OsmAnd :
|
||
|
||
GET /tiles/{couche}/{z}/{x}/{y}.png 256 px, PNG RGBA (canonique)
|
||
GET /tiles/{couche}/{z}/{x}/{y}@2x.webp 512 px, WebP (interface interne)
|
||
GET /tiles/{couche}.json TileJSON 3.0.0
|
||
GET /tiles/wmts.xml capacités WMTS 1.0.0 (QGIS…)
|
||
GET /tiles/josm.imagery.xml fichier d'imagerie JOSM
|
||
GET /api/map/meta couches, zooms, emprise, version
|
||
GET /api/map/tile?lat&lng dalle sous le point (fiche info)
|
||
POST /api/map/warm pré-calcul d'une emprise
|
||
GET / et /assets/* interface de consultation
|
||
|
||
Les tuiles sont rendues à la demande puis mises en cache sur disque
|
||
(`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un
|
||
serveur amont : une tuile absente localement y est rapatriée (quelques dizaines
|
||
de Ko) au lieu de rapatrier les dalles entières.
|
||
|
||
Sur une petite machine (Raspberry Pi), deux réglages inversent la charge :
|
||
|
||
- `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile
|
||
absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par
|
||
le navigateur) et apparaît après passage de la maintenance ;
|
||
- `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles
|
||
(nouvelles ou régénérées) et met leur pyramide à jour en file d'attente,
|
||
un rendu à la fois, à basse priorité (`os.nice`) et cadencé
|
||
(`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de
|
||
toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà
|
||
fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan,
|
||
`POST /api/map/warm` reste le pré-calcul manuel exhaustif.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
import xml.sax.saxutils as _xml
|
||
from collections import deque
|
||
from contextlib import asynccontextmanager
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||
from pydantic import BaseModel, Field
|
||
from starlette.concurrency import run_in_threadpool
|
||
|
||
from . import tiles as tiles_mod
|
||
from .mapui import ASSETS_DIRNAME, render_html, ui_version, write_map_assets
|
||
|
||
OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output"))
|
||
PORT = int(os.environ.get("LIDAR_PORT", "8975"))
|
||
# Serveur de tuiles amont (machine de traitement) — mode deux machines.
|
||
MAPS_URL = (os.environ.get("LIDAR_MAPS_URL") or "").rstrip("/")
|
||
ATTRIBUTION = os.environ.get(
|
||
"LIDAR_ATTRIBUTION",
|
||
'LiDAR HD © <a href="https://geoservices.ign.fr/lidarhd">IGN</a> '
|
||
'— Licence Ouverte 2.0')
|
||
ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").replace("—", "—")
|
||
|
||
# Rendus simultanés : plafond bas par défaut — une carte se consulte souvent
|
||
# depuis une petite machine (Raspberry Pi), et un rendu décode une dalle AVIF
|
||
# de plusieurs dizaines de Mo. Les requêtes en excès attendent le sémaphore
|
||
# sans consommer de CPU. `LIDAR_TILE_WORKERS` relève le plafond sur une
|
||
# machine confortable.
|
||
TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2))
|
||
_render_sem = threading.Semaphore(TILE_WORKERS)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Maintenance de la pyramide en tâche de fond (petites machines)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge :
|
||
# la navigation ne rend plus rien (lecture cache seule), une sondeur rescane
|
||
# les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa
|
||
# pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la
|
||
# vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes.
|
||
# Premier passage = dalle inconnue : toute la pyramide est reconstruite (les
|
||
# tuiles déjà fraîches ne coûtent qu'un stat).
|
||
|
||
def _env_flag(name):
|
||
"""Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel :
|
||
testable sans recharger le module."""
|
||
return (os.environ.get(name, "").strip().lower()
|
||
in ("1", "true", "yes", "on", "oui"))
|
||
|
||
|
||
def _bg_conf():
|
||
"""Réglages de la maintenance (relus au démarrage des fils, cf. doc)."""
|
||
return {
|
||
"interval": max(10.0, float(os.environ.get(
|
||
"LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)),
|
||
"max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get(
|
||
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
|
||
"scale": 1 if (os.environ.get(
|
||
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
|
||
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp")
|
||
or "webp").strip(),
|
||
"pause": max(0.0, float(os.environ.get(
|
||
"LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)),
|
||
"queue_max": max(64, int(os.environ.get(
|
||
"LIDAR_TILE_BACKGROUND_QUEUE_MAX", "4096") or 4096)),
|
||
}
|
||
|
||
|
||
_bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
|
||
"queued": set(), "guard": threading.Lock(), "snapshot": None,
|
||
"running": False,
|
||
"stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
|
||
"dernier_scan": None, "ajouts_dernier_scan": 0}}
|
||
|
||
|
||
def _bg_enqueue(items):
|
||
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée."""
|
||
added = 0
|
||
with _bg["guard"]:
|
||
for item in items:
|
||
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
|
||
continue
|
||
_bg["queue"].append(item)
|
||
_bg["queued"].add(item)
|
||
added += 1
|
||
return added
|
||
|
||
|
||
def _bg_pop():
|
||
with _bg["guard"]:
|
||
if _bg["queue"]:
|
||
item = _bg["queue"].popleft()
|
||
_bg["queued"].discard(item)
|
||
return item
|
||
return None
|
||
|
||
|
||
def background_scan(output_dir=None):
|
||
"""Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file.
|
||
|
||
Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le
|
||
mtime le plus récent des sources de chaque dalle : une dalle régénérée
|
||
n'invalide que ses propres tuiles (même règle que le cache).
|
||
"""
|
||
output_dir = Path(output_dir or OUTPUT_DIR)
|
||
conf = _bg_conf()
|
||
snapshot = {}
|
||
changed = {}
|
||
for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items():
|
||
for (col, row), tiers in per_cell.items():
|
||
newest = 0.0
|
||
for group in tiers:
|
||
for src in group:
|
||
m = src.mtime()
|
||
if m and m > newest:
|
||
newest = m
|
||
snapshot[(layer, col, row)] = newest
|
||
if (_bg["snapshot"] is not None
|
||
and _bg["snapshot"].get((layer, col, row)) == newest):
|
||
continue
|
||
changed.setdefault(layer, []).append((col, row))
|
||
_bg["snapshot"] = snapshot
|
||
items = []
|
||
for layer, cells in changed.items():
|
||
for col, row in cells:
|
||
bbox = tiles_mod.cell_bounds_wgs84(col, row)
|
||
for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1):
|
||
if not tiles_mod.zoom_supported(z, conf["scale"]):
|
||
continue
|
||
for x, y in tiles_mod.tiles_in_bounds(bbox, z):
|
||
items.append((layer, z, x, y))
|
||
items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord
|
||
added = _bg_enqueue(items)
|
||
_bg["stats"]["scans"] += 1
|
||
_bg["stats"]["dernier_scan"] = time.time()
|
||
_bg["stats"]["ajouts_dernier_scan"] = added
|
||
cells_changed = sum(len(v) for v in changed.values())
|
||
if items:
|
||
logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) "
|
||
f"modifiée(s), {added} tuile(s) en file")
|
||
return added
|
||
|
||
|
||
def _bg_process_one():
|
||
"""Traite une tuile de la file. Vrai si elle a dû être rendue."""
|
||
item = _bg_pop()
|
||
if item is None:
|
||
return False
|
||
layer, z, x, y = item
|
||
conf = _bg_conf()
|
||
_data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y,
|
||
conf["scale"], conf["fmt"])
|
||
if state != "pending":
|
||
_bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1
|
||
return False
|
||
tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"])
|
||
_bg["stats"]["rendues"] += 1
|
||
return True
|
||
|
||
|
||
def _bg_worker():
|
||
try:
|
||
os.nice(10) # consommation minimale : le rendu cède aux autres usages
|
||
except (AttributeError, OSError): # pragma: no cover — nice indisponible
|
||
pass
|
||
while not _bg["stop"].is_set():
|
||
try:
|
||
rendered = _bg_process_one()
|
||
except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file
|
||
logger.warning(f"Maintenance des tuiles : rendu impossible ({e})")
|
||
rendered = False
|
||
if rendered:
|
||
_bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause
|
||
else:
|
||
_bg["stop"].wait(0.5)
|
||
|
||
|
||
def _bg_poller():
|
||
while not _bg["stop"].is_set():
|
||
try:
|
||
background_scan()
|
||
except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL
|
||
logger.warning(f"Maintenance des tuiles : scan impossible ({e})")
|
||
_bg["stop"].wait(_bg_conf()["interval"])
|
||
|
||
|
||
def start_background():
|
||
"""Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés."""
|
||
if _bg["running"]:
|
||
return
|
||
_bg["stop"].clear()
|
||
_bg["running"] = True
|
||
threads = [threading.Thread(target=_bg_poller, daemon=True,
|
||
name="lidar-bg-scan")]
|
||
threads += [threading.Thread(target=_bg_worker, daemon=True,
|
||
name=f"lidar-bg-render-{i}")
|
||
for i in range(TILE_WORKERS)]
|
||
for t in threads:
|
||
t.start()
|
||
_bg["threads"] = threads
|
||
conf = _bg_conf()
|
||
logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} "
|
||
f"@{conf['scale']}x {conf['fmt']}, scan toutes les "
|
||
f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s "
|
||
f"entre rendus")
|
||
|
||
|
||
def stop_background():
|
||
_bg["stop"].set()
|
||
_bg["running"] = False
|
||
|
||
|
||
@lru_cache(maxsize=8)
|
||
def _transparent_tile(scale=1, fmt="png"):
|
||
return tiles_mod.transparent_tile(scale, fmt)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _lifespan(_app):
|
||
if _env_flag("LIDAR_TILE_BACKGROUND"):
|
||
start_background()
|
||
yield
|
||
stop_background()
|
||
|
||
|
||
_LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||
_NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$")
|
||
_MAX_UPSTREAM_BYTES = 8 * 1024 * 1024
|
||
|
||
logger = logging.getLogger("lidar")
|
||
if not logger.handlers:
|
||
_h = logging.StreamHandler(sys.stdout)
|
||
_h.setFormatter(logging.Formatter("%(message)s"))
|
||
logger.addHandler(_h)
|
||
logger.setLevel(logging.INFO)
|
||
|
||
app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan)
|
||
|
||
# Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli
|
||
# (dev, paquet installé sans bake).
|
||
_assets_dir = Path(__file__).resolve().parent / ASSETS_DIRNAME
|
||
if not (_assets_dir / "app.js").is_file():
|
||
try:
|
||
write_map_assets(_assets_dir)
|
||
except OSError:
|
||
import tempfile
|
||
_assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets"
|
||
write_map_assets(_assets_dir)
|
||
|
||
logger.info(f"Carte LiDAR (tuiles XYZ) — sortie {OUTPUT_DIR}"
|
||
+ (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local")
|
||
+ f", {TILE_WORKERS} rendu(s) simultané(s)"
|
||
+ f", {tiles_mod.FETCH_WORKERS} téléchargement(s) simultané(s)"
|
||
+ f", cache sources {tiles_mod.SOURCE_CACHE_BYTES // (1024 * 1024)} Mo")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Utilitaires
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.middleware("http")
|
||
async def _cors(request: Request, call_next):
|
||
"""CORS ouvert sur les tuiles : exigé par iD, uMap et MapLibre."""
|
||
if request.method == "OPTIONS" and request.url.path.startswith("/tiles/"):
|
||
return Response(status_code=204, headers=_cors_headers())
|
||
response = await call_next(request)
|
||
if request.url.path.startswith("/tiles/"):
|
||
response.headers.update(_cors_headers())
|
||
return response
|
||
|
||
|
||
def _cors_headers():
|
||
return {"Access-Control-Allow-Origin": "*",
|
||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||
"Access-Control-Allow-Headers": "*"}
|
||
|
||
|
||
def _base_url(request: Request):
|
||
"""URL publique du service (proxy inverse honoré)."""
|
||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||
if not host:
|
||
host = f"{request.url.hostname}:{request.url.port or PORT}"
|
||
return f"{proto}://{host}"
|
||
|
||
|
||
def _layer_or_404(layer):
|
||
if not _LAYER_RE.match(layer or ""):
|
||
raise HTTPException(404, "couche inconnue")
|
||
if layer not in tiles_mod.available_layers(OUTPUT_DIR):
|
||
raise HTTPException(404, f"couche indisponible : {layer}")
|
||
return layer
|
||
|
||
|
||
def _layer_infos():
|
||
"""Couches disponibles + libellés/descriptions du registre partagé."""
|
||
from .index import VIZ_LABELS, VIZ_LEGENDS
|
||
out = []
|
||
for key in tiles_mod.available_layers(OUTPUT_DIR):
|
||
legend = VIZ_LEGENDS.get(key, {})
|
||
out.append({"key": key,
|
||
"label": VIZ_LABELS.get(key, key),
|
||
"description": legend.get("description", "")})
|
||
return out
|
||
|
||
|
||
# --- disjoncteur amont (mêmes réglages que webapp.py) ----------------------
|
||
_UPSTREAM = {"fails": 0, "until": 0.0}
|
||
_UPSTREAM_OFFLINE_AFTER = 3
|
||
_UPSTREAM_OFFLINE_SECONDS = 120.0
|
||
|
||
|
||
def _upstream_offline():
|
||
return time.time() < _UPSTREAM["until"]
|
||
|
||
|
||
def _upstream_mark(ok):
|
||
if ok:
|
||
_UPSTREAM.update(fails=0, until=0.0)
|
||
return
|
||
_UPSTREAM["fails"] += 1
|
||
if _UPSTREAM["fails"] >= _UPSTREAM_OFFLINE_AFTER and _UPSTREAM["until"] == 0.0:
|
||
_UPSTREAM["until"] = time.time() + _UPSTREAM_OFFLINE_SECONDS
|
||
logger.info("Serveur de tuiles amont injoignable — tentatives suspendues 2 min")
|
||
|
||
|
||
def _fetch_upstream(rel_path):
|
||
"""Rapatrie une tuile depuis LIDAR_MAPS_URL (None si indisponible)."""
|
||
if not MAPS_URL or _upstream_offline():
|
||
return None
|
||
url = f"{MAPS_URL}/{rel_path.lstrip('/')}"
|
||
try:
|
||
req = urllib.request.Request(url, headers={"User-Agent": "lidar-maps-cache"})
|
||
with urllib.request.urlopen(req, timeout=20) as r:
|
||
data = r.read(_MAX_UPSTREAM_BYTES + 1)
|
||
if len(data) > _MAX_UPSTREAM_BYTES:
|
||
raise IOError("tuile amont trop volumineuse")
|
||
_upstream_mark(True)
|
||
return data
|
||
except Exception as e: # noqa: BLE001 — amont éteint : rendu local/vide
|
||
_upstream_mark(False)
|
||
logger.debug(f"Tuile amont indisponible ({url}) : {e}")
|
||
return None
|
||
|
||
|
||
# --- rendu : déduplication des requêtes concurrentes ------------------------
|
||
_locks = {}
|
||
_locks_guard = threading.Lock()
|
||
|
||
|
||
def _acquire_lock(key):
|
||
"""Verrou par tuile, compté : deux requêtes simultanées n'en rendent qu'une.
|
||
|
||
Le compteur évite deux écueils : un dictionnaire qui grossit sans fin (des
|
||
millions de tuiles) et un verrou retiré alors qu'un autre fil l'attend
|
||
encore — ce qui autoriserait deux rendus concurrents de la même tuile.
|
||
"""
|
||
with _locks_guard:
|
||
entry = _locks.get(key)
|
||
if entry is None:
|
||
entry = _locks[key] = [threading.Lock(), 0]
|
||
entry[1] += 1
|
||
return entry
|
||
|
||
|
||
def _release_lock(key, entry):
|
||
with _locks_guard:
|
||
entry[1] -= 1
|
||
if entry[1] <= 0:
|
||
_locks.pop(key, None)
|
||
|
||
|
||
def _tile_bytes(layer, z, x, y, scale, fmt):
|
||
"""Tuile encodée : cache → rendu local → amont. None si zone vide."""
|
||
key = (layer, z, x, y, scale, fmt)
|
||
entry = _acquire_lock(key)
|
||
try:
|
||
with entry[0]:
|
||
try:
|
||
with _render_sem:
|
||
data = tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, scale, fmt)
|
||
except Exception as e: # noqa: BLE001 — une tuile ne casse pas la carte
|
||
logger.warning(f"Rendu de tuile impossible ({layer} {z}/{x}/{y}) : {e}")
|
||
data = None
|
||
if data is None and MAPS_URL:
|
||
suffix = "@2x" if scale == 2 else ""
|
||
data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}")
|
||
if data:
|
||
tiles_mod._write_atomic(
|
||
tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y,
|
||
scale, fmt),
|
||
data)
|
||
finally:
|
||
_release_lock(key, entry)
|
||
return data
|
||
|
||
|
||
_MEDIA = {"png": "image/png", "webp": "image/webp", "avif": "image/avif"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tuiles
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/tiles/{layer}/{z}/{x}/{name}")
|
||
async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
|
||
"""Tuile XYZ (schéma OSM). `name` = `{y}.png` ou `{y}@2x.webp`."""
|
||
m = _NAME_RE.match(name)
|
||
if not m:
|
||
raise HTTPException(404, "nom de tuile invalide")
|
||
y = int(m.group(1))
|
||
scale = 2 if m.group(2) else 1
|
||
fmt = m.group(3)
|
||
_layer_or_404(layer)
|
||
if not tiles_mod.zoom_supported(z, scale):
|
||
raise HTTPException(404, f"zoom hors plage ({tiles_mod.TILE_MIN_Z}"
|
||
f"–{tiles_mod.TILE_MAX_NATIVE_Z})")
|
||
n = 2 ** z
|
||
if not (0 <= x < n and 0 <= y < n):
|
||
raise HTTPException(404, "tuile hors grille")
|
||
|
||
if _env_flag("LIDAR_TILE_CACHE_ONLY"):
|
||
# Mode cache seule : aucune navigation ne déclenche de rendu (ni amont).
|
||
data, state = await run_in_threadpool(
|
||
tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt)
|
||
pending = state == "pending"
|
||
empty = state == "empty"
|
||
else:
|
||
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
|
||
pending = False
|
||
empty = data is None
|
||
if empty or pending:
|
||
data = _transparent_tile(scale, fmt)
|
||
headers = dict(_cors_headers())
|
||
if pending:
|
||
# Tuile à venir (rendue par la maintenance) : jamais mémorisée par le
|
||
# navigateur, la prochaine navigation la redemande et la trouve prête.
|
||
headers["Cache-Control"] = "no-store"
|
||
headers["X-Tile-Pending"] = "1"
|
||
else:
|
||
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
|
||
else "public, max-age=300, must-revalidate")
|
||
headers["X-Tile-Empty"] = "1" if (empty or pending) else "0"
|
||
return Response(content=data, media_type=_MEDIA[fmt], headers=headers)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Découverte : TileJSON, WMTS, JOSM
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/tiles/josm.imagery.xml")
|
||
def josm_imagery(request: Request):
|
||
"""Fichier d'imagerie JOSM : ajoute toutes les couches en une fois."""
|
||
base = _base_url(request)
|
||
bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR)
|
||
entries = []
|
||
for info in _layer_infos():
|
||
b = ""
|
||
if bounds:
|
||
b = (f' <bounds min-lat="{bounds[1]:.6f}" min-lon="{bounds[0]:.6f}"'
|
||
f' max-lat="{bounds[3]:.6f}" max-lon="{bounds[2]:.6f}"/>\n')
|
||
entries.append(
|
||
" <entry>\n"
|
||
f" <name>LiDAR HD — {_xml.escape(info['label'])}</name>\n"
|
||
f" <id>lidar-hd-{info['key'].replace('_', '-')}</id>\n"
|
||
" <type>tms</type>\n"
|
||
f" <url><![CDATA[{base}/tiles/{info['key']}/{{zoom}}/{{x}}/{{y}}.png]]></url>\n"
|
||
f" <min-zoom>{tiles_mod.TILE_MIN_Z}</min-zoom>\n"
|
||
f" <max-zoom>{tiles_mod.TILE_MAX_NATIVE_Z}</max-zoom>\n"
|
||
f"{b}"
|
||
f" <description lang=\"fr\">{_xml.escape(info['description'])}</description>\n"
|
||
f" <attribution-text>{_xml.escape(ATTRIBUTION_TEXT)}</attribution-text>\n"
|
||
" <attribution-url>https://geoservices.ign.fr/lidarhd</attribution-url>\n"
|
||
" <category>other</category>\n"
|
||
" </entry>")
|
||
xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<imagery xmlns="http://josm.openstreetmap.de/maps-1.0">\n'
|
||
+ "\n".join(entries) + "\n</imagery>\n")
|
||
return Response(content=xml, media_type="application/xml",
|
||
headers=_cors_headers())
|
||
|
||
|
||
@app.get("/tiles/wmts.xml")
|
||
def wmts_capabilities(request: Request):
|
||
"""Capacités WMTS 1.0.0 (QGIS, ArcGIS) sur la grille GoogleMapsCompatible."""
|
||
base = _base_url(request)
|
||
bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0]
|
||
layers = []
|
||
for info in _layer_infos():
|
||
layers.append(f""" <Layer>
|
||
<ows:Title>{_xml.escape(info['label'])}</ows:Title>
|
||
<ows:Abstract>{_xml.escape(info['description'])}</ows:Abstract>
|
||
<ows:WGS84BoundingBox>
|
||
<ows:LowerCorner>{bounds[0]:.6f} {bounds[1]:.6f}</ows:LowerCorner>
|
||
<ows:UpperCorner>{bounds[2]:.6f} {bounds[3]:.6f}</ows:UpperCorner>
|
||
</ows:WGS84BoundingBox>
|
||
<ows:Identifier>{info['key']}</ows:Identifier>
|
||
<Style isDefault="true"><ows:Identifier>default</ows:Identifier></Style>
|
||
<Format>image/png</Format>
|
||
<TileMatrixSetLink><TileMatrixSet>GoogleMapsCompatible</TileMatrixSet></TileMatrixSetLink>
|
||
<ResourceURL format="image/png" resourceType="tile"
|
||
template="{base}/tiles/{info['key']}/{{TileMatrix}}/{{TileCol}}/{{TileRow}}.png"/>
|
||
</Layer>""")
|
||
matrices = []
|
||
for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1):
|
||
matrices.append(f""" <TileMatrix>
|
||
<ows:Identifier>{z}</ows:Identifier>
|
||
<ScaleDenominator>{559082264.0287178 / (2 ** z):.10f}</ScaleDenominator>
|
||
<TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner>
|
||
<TileWidth>256</TileWidth>
|
||
<TileHeight>256</TileHeight>
|
||
<MatrixWidth>{2 ** z}</MatrixWidth>
|
||
<MatrixHeight>{2 ** z}</MatrixHeight>
|
||
</TileMatrix>""")
|
||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<Capabilities xmlns="http://www.opengis.net/wmts/1.0"
|
||
xmlns:ows="http://www.opengis.net/ows/1.1"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0.0">
|
||
<ows:ServiceIdentification>
|
||
<ows:Title>Carte LiDAR HD</ows:Title>
|
||
<ows:Abstract>{_xml.escape(ATTRIBUTION_TEXT)}</ows:Abstract>
|
||
<ows:ServiceType>OGC WMTS</ows:ServiceType>
|
||
<ows:ServiceTypeVersion>1.0.0</ows:ServiceTypeVersion>
|
||
<ows:AccessConstraints>Licence Ouverte 2.0</ows:AccessConstraints>
|
||
</ows:ServiceIdentification>
|
||
<Contents>
|
||
{chr(10).join(layers)}
|
||
<TileMatrixSet>
|
||
<ows:Identifier>GoogleMapsCompatible</ows:Identifier>
|
||
<ows:SupportedCRS>urn:ogc:def:crs:EPSG:6.18:3:3857</ows:SupportedCRS>
|
||
<WellKnownScaleSet>urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible</WellKnownScaleSet>
|
||
{chr(10).join(matrices)}
|
||
</TileMatrixSet>
|
||
</Contents>
|
||
<ServiceMetadataURL xlink:href="{base}/tiles/wmts.xml"/>
|
||
</Capabilities>
|
||
"""
|
||
return Response(content=xml, media_type="application/xml",
|
||
headers=_cors_headers())
|
||
|
||
|
||
@app.get("/tiles/{layer}.json")
|
||
def tilejson(layer: str, request: Request):
|
||
"""TileJSON 3.0.0 (MapLibre, QGIS, Felt…)."""
|
||
_layer_or_404(layer)
|
||
from .index import VIZ_LABELS, VIZ_LEGENDS
|
||
base = _base_url(request)
|
||
bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0]
|
||
center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2,
|
||
min(tiles_mod.TILE_MAX_NATIVE_Z, 15)]
|
||
return JSONResponse({
|
||
"tilejson": "3.0.0",
|
||
"name": f"LiDAR HD — {VIZ_LABELS.get(layer, layer)}",
|
||
"description": VIZ_LEGENDS.get(layer, {}).get("description", ""),
|
||
"attribution": ATTRIBUTION,
|
||
"scheme": "xyz",
|
||
"tiles": [f"{base}/tiles/{layer}/{{z}}/{{x}}/{{y}}.png"],
|
||
"minzoom": tiles_mod.TILE_MIN_Z,
|
||
"maxzoom": tiles_mod.TILE_MAX_NATIVE_Z,
|
||
"bounds": bounds,
|
||
"center": center,
|
||
}, headers=_cors_headers())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration par défaut de la pile de couches
|
||
# ---------------------------------------------------------------------------
|
||
# L'état réglé depuis la carte (ordre, visibilité, opacité, fusion) peut être
|
||
# figé comme configuration servie à TOUT nouveau navigateur : le réglage utile
|
||
# ne se perd pas dans un localStorage et se partage sans lien.
|
||
DEFAULTS_FILE = OUTPUT_DIR / ".map-defaults.json"
|
||
_defaults_lock = threading.Lock()
|
||
|
||
|
||
def _load_defaults():
|
||
"""Configuration par défaut enregistrée, ou None si aucune."""
|
||
try:
|
||
data = json.loads(DEFAULTS_FILE.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
|
||
def _clamp01(value, fallback=1.0):
|
||
try:
|
||
return min(1.0, max(0.0, float(value)))
|
||
except (TypeError, ValueError):
|
||
return fallback
|
||
|
||
|
||
BLEND_MODES = ("normal", "multiply", "screen", "overlay", "soft-light",
|
||
"hard-light", "difference", "luminosity")
|
||
|
||
|
||
def _sanitize_defaults(req):
|
||
"""Retient ce qui est connu et borné : couches réelles, opacités 0–1, fusions valides."""
|
||
known = set(tiles_mod.available_layers(OUTPUT_DIR))
|
||
order = [k for k in (req.order or []) if k in known]
|
||
for key in sorted(known - set(order)):
|
||
order.append(key)
|
||
on = [k for k in (req.on or []) if k in known]
|
||
opacity = {k: _clamp01(v) for k, v in (req.opacity or {}).items() if k in known}
|
||
blend = {k: v for k, v in (req.blend or {}).items()
|
||
if k in known and v in BLEND_MODES}
|
||
base_in = req.base if isinstance(req.base, dict) else {}
|
||
base = {"on": bool(base_in.get("on", True)),
|
||
"opacity": _clamp01(base_in.get("opacity"), 0.85),
|
||
"dark": bool(base_in.get("dark", True))}
|
||
stack = req.stack_blend if req.stack_blend in BLEND_MODES else "normal"
|
||
return {"order": order, "on": on, "opacity": opacity, "blend": blend,
|
||
"base": base, "stack_blend": stack, "saved_at": time.time()}
|
||
|
||
|
||
class DefaultsRequest(BaseModel):
|
||
order: list = Field(default_factory=list,
|
||
description="ordre de pile, du bas vers le haut")
|
||
on: list = Field(default_factory=list, description="couches allumées")
|
||
opacity: dict = Field(default_factory=dict, description="opacité par couche, 0–1")
|
||
blend: dict = Field(default_factory=dict, description="mode de fusion par couche")
|
||
base: dict = Field(default_factory=dict,
|
||
description="fond de carte : on, opacity, dark")
|
||
stack_blend: str = Field("normal", description="fusion de la pile sur le fond")
|
||
|
||
|
||
@app.get("/api/map/defaults")
|
||
def get_defaults():
|
||
"""Configuration par défaut servie aux nouveaux navigateurs."""
|
||
return {"defaults": _load_defaults()}
|
||
|
||
|
||
@app.post("/api/map/defaults")
|
||
def set_defaults(req: DefaultsRequest):
|
||
"""Fige l'état courant comme configuration par défaut (tous navigateurs)."""
|
||
data = _sanitize_defaults(req)
|
||
with _defaults_lock:
|
||
tmp = DEFAULTS_FILE.with_suffix(".tmp")
|
||
DEFAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||
os.replace(tmp, DEFAULTS_FILE)
|
||
logger.info(f"Configuration de pile par défaut enregistrée : "
|
||
f"{len(data['on'])} couche(s) allumée(s)")
|
||
return {"enregistré": True, "defaults": data}
|
||
|
||
|
||
@app.delete("/api/map/defaults")
|
||
def clear_defaults():
|
||
"""Revient aux réglages du registre (index.py)."""
|
||
with _defaults_lock:
|
||
existed = DEFAULTS_FILE.exists()
|
||
DEFAULTS_FILE.unlink(missing_ok=True)
|
||
return {"supprimé": existed}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# API de la carte
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/api/map/meta")
|
||
def map_meta():
|
||
"""Couches, zooms, emprise et version — tout l'état initial de l'interface."""
|
||
from .index import DEFAULT_BLEND, DEFAULT_OPACITY, default_layers_present
|
||
infos = _layer_infos()
|
||
keys = [i["key"] for i in infos]
|
||
saved = _load_defaults()
|
||
# Configuration figée depuis la carte, sinon réglages du registre (index.py)
|
||
default_layers = saved["on"] if saved else default_layers_present(keys)
|
||
default_opacity = saved["opacity"] if saved else dict(DEFAULT_OPACITY)
|
||
default_blend = saved["blend"] if saved else dict(DEFAULT_BLEND)
|
||
return {
|
||
"layers": infos,
|
||
"default_layers": default_layers,
|
||
"default_opacity": default_opacity,
|
||
"default_blend": default_blend,
|
||
"default_order": saved["order"] if saved else None,
|
||
"default_base": saved["base"] if saved else None,
|
||
"default_stack_blend": saved["stack_blend"] if saved else "normal",
|
||
"defaults_saved": bool(saved),
|
||
# L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en
|
||
# 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique.
|
||
"tile_url": "tiles/{layer}/{z}/{x}/{y}@2x.webp",
|
||
"tile_size": tiles_mod.TILE_SIZE * 2,
|
||
"zoom_offset": -1,
|
||
"min_zoom": tiles_mod.TILE_MIN_Z,
|
||
"max_native_zoom": tiles_mod.TILE_MAX_NATIVE_Z - 1,
|
||
"bounds": tiles_mod.grid_bounds_wgs84(OUTPUT_DIR),
|
||
"stamp": tiles_mod.tiles_stamp(OUTPUT_DIR),
|
||
"attribution": ATTRIBUTION,
|
||
"attribution_text": ATTRIBUTION_TEXT,
|
||
"ui_version": ui_version(),
|
||
}
|
||
|
||
|
||
@app.get("/api/map/tile")
|
||
def map_tile_info(lat: float, lng: float):
|
||
"""Dalle LHD sous un point WGS84 + métadonnées de génération."""
|
||
from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles
|
||
x, y = tiles_mod.wgs84_to_l93(lng, lat)
|
||
col, row = int(x // 1000), int(y // 1000) + 1
|
||
best = None
|
||
for t in scan_tiles(OUTPUT_DIR / "visualisations"):
|
||
if (t["col"], t["row"]) != (col, row):
|
||
continue
|
||
if best is None or t["resolution"] < best["resolution"]:
|
||
best = t
|
||
if best is None:
|
||
return {"found": False, "col": col, "row": row}
|
||
meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM")
|
||
return {
|
||
"found": True,
|
||
"col": col,
|
||
"row": row,
|
||
"name": best["basename"],
|
||
"resolution": best["resolution"],
|
||
"method": meta.get("method"),
|
||
"generated": meta.get("generated"),
|
||
"layers": sorted(best["viz"]),
|
||
"suffix": _res_suffix_str(best["resolution"]),
|
||
}
|
||
|
||
|
||
class WarmRequest(BaseModel):
|
||
layers: list = Field(default_factory=list,
|
||
description="couches à pré-calculer (défaut : toutes)")
|
||
z_min: int = Field(tiles_mod.TILE_MIN_Z, description="zoom minimal")
|
||
z_max: int = Field(16, description="zoom maximal (19 = résolution native)")
|
||
bounds: Optional[list] = Field(None,
|
||
description="[ouest, sud, est, nord] WGS84 ; "
|
||
"défaut : emprise des dalles")
|
||
scale: int = Field(1, description="1 = tuiles 256 px, 2 = 512 px (@2x)")
|
||
fmt: str = Field("png", description="png, webp ou avif")
|
||
|
||
|
||
_warm = {"running": False, "done": None, "result": None}
|
||
|
||
|
||
@app.post("/api/map/warm")
|
||
def map_warm(req: WarmRequest):
|
||
"""Pré-calcule des tuiles en tâche de fond (accélère la 1re consultation)."""
|
||
if _warm["running"]:
|
||
raise HTTPException(409, "un pré-chauffage est déjà en cours")
|
||
layers = [l for l in req.layers if _LAYER_RE.match(str(l))] or \
|
||
tiles_mod.available_layers(OUTPUT_DIR)
|
||
if req.fmt not in _MEDIA:
|
||
raise HTTPException(400, f"format invalide : {req.fmt}")
|
||
_warm.update(running=True, done=None, result=None)
|
||
|
||
def _run():
|
||
try:
|
||
_warm["result"] = tiles_mod.warm(
|
||
OUTPUT_DIR, layers, req.z_min, req.z_max,
|
||
req.bounds, max(1, min(2, req.scale)), req.fmt)
|
||
except Exception as e: # noqa: BLE001 — remonté via l'état
|
||
_warm["result"] = {"erreur": str(e)}
|
||
finally:
|
||
_warm["running"] = False
|
||
_warm["done"] = time.time()
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
return {"demarré": True, "couches": layers}
|
||
|
||
|
||
@app.get("/api/map/warm")
|
||
def map_warm_status():
|
||
return dict(_warm)
|
||
|
||
|
||
@app.get("/api/map/background")
|
||
def background_status():
|
||
"""État de la maintenance de fond : file, compteurs, configuration."""
|
||
with _bg["guard"]:
|
||
queued = len(_bg["queue"])
|
||
return {"active": _bg["running"], "en_file": queued,
|
||
"configuration": _bg_conf(), "stats": dict(_bg["stats"])}
|
||
|
||
|
||
@app.post("/api/map/background")
|
||
def background_trigger():
|
||
"""Force un scan immédiat des dalles (rendu ensuite en tâche de fond)."""
|
||
if not _env_flag("LIDAR_TILE_BACKGROUND"):
|
||
raise HTTPException(409, "maintenance de fond inactive "
|
||
"(LIDAR_TILE_BACKGROUND)")
|
||
return {"tuiles_en_file": background_scan()}
|
||
|
||
|
||
@app.get("/healthz")
|
||
def healthz():
|
||
return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Interface
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def root():
|
||
return HTMLResponse(render_html(), headers={
|
||
"Cache-Control": "no-cache, no-store, must-revalidate"})
|
||
|
||
|
||
@app.get("/assets/{file_path:path}")
|
||
def assets(file_path: str, v: str = None):
|
||
"""Assets de l'interface (revalidés : ils suivent l'image, pas le cache)."""
|
||
base = _assets_dir.resolve()
|
||
p = (_assets_dir / file_path).resolve()
|
||
if base not in p.parents or not p.is_file():
|
||
raise HTTPException(404, f"asset introuvable : {file_path}")
|
||
return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
_cert = os.environ.get("LIDAR_SSL_CERTFILE")
|
||
_key = os.environ.get("LIDAR_SSL_KEYFILE")
|
||
_ssl = {"ssl_certfile": _cert, "ssl_keyfile": _key} if _cert and _key else {}
|
||
uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl)
|