"""Pyramide de tuiles XYZ (EPSG:3857) rendue à la demande depuis les dalles. Schéma de tuilage identique à celui d'OpenStreetMap / Google Maps : grille Web Mercator, origine au coin nord-ouest, `{z}/{x}/{y}`, tuiles de 256 px (512 px avec `scale=2`, convention `@2x`). Les rendus du pipeline restent des dalles Lambert 93 de 1 km : chaque tuile est composée à la volée en reprojetant les dalles qui l'intersectent (transformation projective par dalle, erreur très inférieure au pixel), puis mise en cache sur disque. Volontairement sans GDAL ni numpy : Pillow + pyproj suffisent, l'image légère (Dockerfile.maps) reste petite et portable ARM64. """ import logging import math import os import re import threading import time from collections import OrderedDict from functools import lru_cache from pathlib import Path logger = logging.getLogger("lidar") # --- Contrat de tuilage (cf. docs/MAPS.md) --------------------------------- TILE_SIZE = 256 # taille canonique (OSM/XYZ) ; @2x → 512 TILE_MIN_Z = 5 TILE_MAX_NATIVE_Z = 19 # 0,2 m/px ≈ résolution du z19 à la latitude 47° TILE_DIRNAME = "index_xyz" # cache disque, sous le dossier de sortie # Stockage réduit (petite machine) : niveaux exprimés en numérotation OSM # standard (tuile 256 px ; une tuile @2x de niveau z vaut une 256 px de z+1). # Seuls les niveaux PAIRS jusqu'à LIDAR_TILE_CACHE_MAX_Z sont écrits sur # disque ; les autres sont rendus à la volée (cache mémoire borné). Mesuré sur # 8 dalles : ~24 Mo → ~1 Mo de pyramide, le niveau le plus fin pesant ~75 %. TILE_CACHE_MAX_Z = int(os.environ.get("LIDAR_TILE_CACHE_MAX_Z", "16") or 16) TILE_EVEN_LEVELS = os.environ.get("LIDAR_TILE_EVEN_LEVELS", "1").strip() != "0" WEBP_QUALITY = 78 AVIF_QUALITY = 60 AVIF_SPEED = 9 # encodage rapide (cf. rendering.AVIF_SPEED : ×7, +3 % de taille) # PNG palettisé (PNG8 + alpha) : ~5× plus léger (170 → 32 Ko sur une dalle # réelle) pour un écart moyen de ~4 niveaux sur une rampe de couleur. Laissé # DÉSACTIVÉ par défaut : le PNG canonique reste sans perte, la fidélité prime # sur le débit pour un produit d'interprétation. `LIDAR_TILE_PNG_PALETTE=1` # l'active quand la bande passante compte (consultation mobile). PNG_PALETTE = os.environ.get("LIDAR_TILE_PNG_PALETTE", "") == "1" # Demi-circonférence équatoriale : emprise du Web Mercator (EPSG:3857). ORIGIN = 20037508.342789244 # Résolutions nominales des paliers de source réutilisés tels quels par le # pipeline (m/px) : vignette 256 px/km, vignette intermédiaire 640 px/km. _THUMB_RES = 1000.0 / 256 _MID_RES = 1000.0 / 640 _SUBTILE_RE = re.compile(r"_(\d+)_(\d+)\.avif$") # Serveur de dalles amont (mapserve du worker) : quand il est défini, l'index # des sources vient de son /api/tiles et les images manquantes sont rapatriées # à la demande dans le cache local — le conteneur carte n'a alors besoin # d'aucune donnée locale au démarrage. REMOTE_SOURCE_URL = (os.environ.get("LIDAR_SOURCE_URL") or "").rstrip("/") REMOTE_SOURCE_TOKEN = os.environ.get("LIDAR_SOURCE_TOKEN") or None _REMOTE_TTL = 60.0 _remote_cache = {"payload": None, "at": 0.0, "index": None, "root": None} # Téléchargements de dalles simultanés (réseau) — modeste par défaut : sur un # petit serveur, chaque source rapatriée est un fichier de plusieurs Mo. FETCH_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_FETCH_WORKERS", "2") or 2)) _fetch_sem = threading.Semaphore(FETCH_WORKERS) _fetch_locks = {} _fetch_guard = threading.Lock() # Coupe-circuit des sources : après un échec, plus aucune tentative pendant # _SOURCE_OFFLINE_S — sans lui, chaque source d'une file de rapatriement # repaierait le délai réseau d'un amont éteint et la maintenance ne rendrait # plus rien (la carte doit rester autonome). _SOURCE_OFFLINE_S = 60.0 _SOURCE_OFFLINE = {"until": 0.0} # Index des sources reconstruit au plus toutes les _INDEX_TTL secondes (ou dès # qu'un dossier change de mtime : ajout/suppression de dalle). _INDEX_TTL = 20.0 _index_cache = {} # --------------------------------------------------------------------------- # Géométrie de la grille # --------------------------------------------------------------------------- def tile_bounds_3857(z, x, y): """Emprise (ouest, sud, est, nord) d'une tuile XYZ en mètres EPSG:3857.""" span = 2.0 * ORIGIN / (2 ** z) west = -ORIGIN + x * span north = ORIGIN - y * span return west, north - span, west + span, north def ground_resolution(z, scale=1): """Résolution d'une tuile à l'équateur (m/px) ; ×cos(lat) sur le terrain.""" return 2.0 * ORIGIN / (TILE_SIZE * scale * (2 ** z)) def tile_latitude(z, y): """Latitude (degrés) du centre d'une tuile — sert au calage de résolution.""" n = math.pi - 2.0 * math.pi * (y + 0.5) / (2 ** z) return math.degrees(math.atan(math.sinh(n))) def target_resolution(z, y, scale=1): """Résolution terrain visée par la tuile (m/px), latitude comprise.""" return ground_resolution(z, scale) * math.cos(math.radians(tile_latitude(z, y))) @lru_cache(maxsize=4) def _transformer(src, dst): from pyproj import Transformer return Transformer.from_crs(src, dst, always_xy=True) def to_l93(xs, ys): """EPSG:3857 → EPSG:2154 (listes de coordonnées).""" return _transformer("EPSG:3857", "EPSG:2154").transform(xs, ys) def to_3857(xs, ys): """EPSG:2154 → EPSG:3857 (listes de coordonnées).""" return _transformer("EPSG:2154", "EPSG:3857").transform(xs, ys) @lru_cache(maxsize=4096) def wgs84_to_l93(lon, lat): """Point WGS84 → Lambert 93 (utilisé par la fiche d'information dalle).""" return _transformer("EPSG:4326", "EPSG:2154").transform(lon, lat) @lru_cache(maxsize=4096) def tile_bounds_l93(z, x, y, samples=5): """Emprise L93 englobant une tuile XYZ. Les bords d'une tuile ne sont pas des droites en Lambert 93 : on échantillonne une grille `samples`×`samples` plutôt que les seuls coins, sinon l'emprise est sous-estimée aux petits zooms (tuiles de centaines de km). """ west, south, east, north = tile_bounds_3857(z, x, y) xs, ys = [], [] for i in range(samples): for j in range(samples): xs.append(west + (east - west) * i / (samples - 1)) ys.append(south + (north - south) * j / (samples - 1)) lx, ly = to_l93(xs, ys) return min(lx), min(ly), max(lx), max(ly) # --------------------------------------------------------------------------- # Transformation projective (mapping sortie → source, convention Pillow) # --------------------------------------------------------------------------- def _solve(matrix, rhs): """Résout un système linéaire dense (pivot partiel), sans numpy.""" n = len(rhs) a = [row[:] + [rhs[i]] for i, row in enumerate(matrix)] for col in range(n): piv = max(range(col, n), key=lambda r: abs(a[r][col])) if abs(a[piv][col]) < 1e-12: return None a[col], a[piv] = a[piv], a[col] inv = 1.0 / a[col][col] for k in range(col, n + 1): a[col][k] *= inv for r in range(n): if r == col: continue f = a[r][col] if f: for k in range(col, n + 1): a[r][k] -= f * a[col][k] return [a[i][n] for i in range(n)] def perspective_coeffs(dst_quad, src_quad): """Coefficients Pillow `Image.PERSPECTIVE` mappant sortie → source. Pillow échantillonne la SOURCE en (x', y') = ((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) pour chaque pixel (x, y) de la SORTIE : on résout donc les 8 inconnues à partir de 4 correspondances (point de sortie → point source). Retourne None si le quadrilatère est dégénéré (dalle réduite à un point au dézoom extrême). """ matrix, rhs = [], [] for (dx, dy), (sx, sy) in zip(dst_quad, src_quad): matrix.append([dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]) rhs.append(sx) matrix.append([0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]) rhs.append(sy) return _solve(matrix, rhs) # --------------------------------------------------------------------------- # Index des sources : dalles du pipeline, par couche et par palier # --------------------------------------------------------------------------- class _Source: """Une image source géoréférencée (dalle entière ou quadrant). `url` non nul : l'image vit sur le serveur de dalles amont et n'est rapatriée qu'au premier besoin (`ensure()`), dans `path`. `version` est alors la mtime (ms) annoncée par l'amont : elle sert de date de référence pour la péremption des tuiles, avant même tout téléchargement. """ __slots__ = ("path", "bounds", "res", "url", "version", "seen") def __init__(self, path, bounds, res, url=None, version=None): self.path = path self.bounds = bounds # (min_x, min_y, max_x, max_y) en L93 self.res = res # résolution nominale (m/px) self.url = url self.version = version self.seen = 0.0 # 1re apparition de la dalle dans l'inventaire def mtime(self): """Date de référence pour la péremption des tuiles : version de la source, repoussée à sa première apparition dans l'inventaire (une dalle écrite avant mais inventoriée après une tuile la périme).""" if self.version is not None: base = self.version / 1000.0 else: try: base = self.path.stat().st_mtime except OSError: return None return max(base, self.seen) def ensure(self): """Garantit la présence locale de l'image (rapatriement si besoin).""" if self.url is None or self.path.is_file(): return self.path.is_file() if not _fetch_source(self.url, self.path): return False if self.version is not None: # Datée à sa version amont : l'index local (amont éteint) retrouve # la même date et les tuiles déjà faites restent fraîches. try: os.utime(self.path, (self.version / 1000.0, self.version / 1000.0)) except OSError: pass return True def _remote_thumb_px(k): """Côté (px) de la vignette servie par l'amont : 256 par dalle, 160 par quadrant.""" return 160.0 if k > 1 else 256.0 def _fetch_source(url, dest): """Télécharge une image source depuis l'amont (écriture atomique).""" import urllib.request with _fetch_guard: lock = _fetch_locks.setdefault(str(dest), threading.Lock()) with lock: if dest.is_file(): return True if time.time() < _SOURCE_OFFLINE["until"]: return False try: req = urllib.request.Request( url, headers={"User-Agent": "lidar-maps-source"}) if REMOTE_SOURCE_TOKEN: req.add_header("X-Lidar-Token", REMOTE_SOURCE_TOKEN) with _fetch_sem, urllib.request.urlopen(req, timeout=120) as r: data = r.read() except Exception as e: # noqa: BLE001 — amont éteint : tuile partielle logger.debug(f"Source amont indisponible ({url}) : {e}") _SOURCE_OFFLINE["until"] = time.time() + _SOURCE_OFFLINE_S return False _write_atomic(dest, data) logger.info(f"Source rapatriée : {dest.name} ({len(data) / 1e6:.1f} Mo)") return dest.is_file() def _remote_payload(force=False): """Index du serveur de dalles amont (/api/tiles), en cache 60 s.""" import json import urllib.request now = time.time() # Échec mémorisé lui aussi (TTL) : amont éteint sans inventaire connu, un # appel par TTL au plus — pas un délai réseau à chaque requête de la carte. if not force and _remote_cache["at"] \ and now - _remote_cache["at"] < _REMOTE_TTL: return _remote_cache["payload"] try: req = urllib.request.Request(f"{REMOTE_SOURCE_URL}/api/tiles", headers={"User-Agent": "lidar-maps-source"}) with urllib.request.urlopen(req, timeout=10) as r: payload = json.loads(r.read().decode("utf-8")) except Exception as e: # noqa: BLE001 — on garde le dernier index connu logger.warning(f"Index amont injoignable ({REMOTE_SOURCE_URL}) : {e}") payload = _remote_cache["payload"] _remote_cache.update(payload=payload, at=now) return payload def _remote_index(output_dir, force=False): """Inventaire construit depuis l'amont : sources non encore rapatriées. Le résultat est mémoïsé avec la charge utile : le reconstruire coûte des centaines de millisecondes sur un catalogue de plusieurs milliers de dalles, ce qui se paierait à CHAQUE tuile servie. """ payload = _remote_payload(force) if not payload or not payload.get("tiles"): return {} if (_remote_cache["index"] is not None and _remote_cache["root"] == str(output_dir) and _remote_cache.get("built") is payload): return _remote_cache["index"] output_dir = Path(output_dir) layers = {} for entry in payload["tiles"]: col, row = entry.get("col"), entry.get("row") if col is None or row is None: continue res = float(entry.get("resolution") or 0.5) k = int(entry.get("sub_k") or 1) step = 1000.0 / k i, j = int(entry.get("sub_i") or 0), int(entry.get("sub_j") or 0) base = _cell_bounds(col, row) bounds = (base[0] + i * step, base[1] + j * step, base[0] + (i + 1) * step, base[1] + (j + 1) * step) for viz, info in (entry.get("viz") or {}).items(): by_res = layers.setdefault(viz, {}).setdefault((col, row), {}) for tier, nominal in (("thumb", step / _remote_thumb_px(k)), ("mid", step / 640.0), ("full", res)): url_rel = info.get(tier) if not url_rel: continue rel, _, query = url_rel.partition("?") version = None if query.startswith("v="): try: version = int(query[2:]) except ValueError: version = None rank = 0 if k > 1 else 1 by_res.setdefault((nominal, rank), []).append(_Source( output_dir / rel, bounds, nominal, url=f"{REMOTE_SOURCE_URL}/{url_rel}", version=version)) out = {} for viz, per_cell in layers.items(): for cell, by_res in per_cell.items(): out.setdefault(viz, {})[cell] = [ by_res[key] for key in sorted(by_res, key=lambda t: (-t[0], t[1]))] _remote_cache.update(index=out, root=str(output_dir), built=payload) return out def _cell_bounds(col, row): """Emprise L93 d'une dalle LHD : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.""" return (col * 1000.0, (row - 1) * 1000.0, (col + 1) * 1000.0, row * 1000.0) def cell_bounds_wgs84(col, row): """Emprise WGS84 [ouest, sud, est, nord] d'une dalle LHD (4 coins L93).""" min_x, min_y, max_x, max_y = _cell_bounds(col, row) xs = (min_x, min_x, max_x, max_x) ys = (min_y, max_y, min_y, max_y) lons, lats = _transformer("EPSG:2154", "EPSG:4326").transform(xs, ys) return (min(lons), min(lats), max(lons), max(lats)) def _dir_mtime(path): try: return path.stat().st_mtime except OSError: return None def _split_viz_key(stem, known): """`{dir_name}_{viz}` → (dir_name, viz) en s'appuyant sur les clés connues. Les clés de visualisation contiennent des soulignés (`positive_openness`, `hillshade_multi`) : on ne peut pas couper au dernier `_`, on reconnaît un suffixe connu (le plus long d'abord). """ for key in known: if stem.endswith("_" + key): return stem[:-len(key) - 1], key return None, None def _cell_of_dir(dir_name): """(col, row, résolution) d'un nom de dossier de dalle, ou None.""" from .index import _strip_res_suffix, parse_basename_coords coords = parse_basename_coords(dir_name) if coords is None: return None _base, res = _strip_res_suffix(dir_name) return coords[0], coords[1], res def _build_index(output_dir): """Inventaire {couche: {(col, row): [paliers du plus grossier au plus fin]}}. Les trois sources du pipeline sont scannées INDÉPENDAMMENT — dalles (`visualisations/`), quadrants (`index_subtiles/`) et vignettes (`index_thumbs/`). Un cache partiel reste donc exploitable : sur une machine légère, seuls quadrants et vignettes sont rapatriés, jamais les dalles entières. """ # _SUBTILE_THUMB_PX : taille des vignettes de quadrant, définie par # l'index (la changer là-bas doit rester sans effet ici). from .index import _SUBTILE_THUMB_PX, VIZ_LABELS, scan_tiles output_dir = Path(output_dir) vis_dir = output_dir / "visualisations" thumb_dir = output_dir / "index_thumbs" sub_dir = output_dir / "index_subtiles" # res → sources, par couche et par dalle ; trié en paliers à la fin. records = {} def add(layer, col, row, res, source, rank=1): # Clé de palier = (résolution, rang) : à résolution égale, les # quadrants (rang 0) passent avant la dalle entière (rang 1) — même # rendu, 4× moins de pixels à décoder. records.setdefault(layer, {}).setdefault((col, row), {}) \ .setdefault((res, rank), []).append(source) # 1. Dalles entières (palier le plus fin quand il est présent). known = set(VIZ_LABELS) for tile in scan_tiles(vis_dir): col, row, res = tile["col"], tile["row"], tile["resolution"] cell = _cell_bounds(col, row) dir_path = Path(tile["dir_path"]) for viz_key, info in tile["viz"].items(): known.add(viz_key) source_dir = dir_path if info.get("dir_name") and info["dir_name"] != dir_path.name: source_dir = dir_path.parent / info["dir_name"] full = source_dir / info["filename"] if full.is_file(): add(viz_key, col, row, res, _Source(full, cell, res)) # Clés les plus longues d'abord : `positive_openness` avant `openness`. known = sorted(known, key=len, reverse=True) # 2. Quadrants (index_subtiles) : AVIF pleine résolution + ses vignettes. if sub_dir.is_dir(): quads = {} for f in sub_dir.iterdir(): name = f.name for suffix, tier in ((".avif", "full"), ("_mid.webp", "mid"), (f"_thumb{_SUBTILE_THUMB_PX}.webp", "thumb")): if not name.endswith(suffix): continue m = _SUBTILE_RE.search(name[:-len(suffix)] + ".avif") if not m: break stem = name[:m.start()] i, j = int(m.group(1)), int(m.group(2)) dir_name, viz = _split_viz_key(stem, known) if dir_name is None: break quads.setdefault((dir_name, viz, tier), []).append((i, j, f)) break for (dir_name, viz, tier), items in quads.items(): cell = _cell_of_dir(dir_name) if cell is None: continue col, row, res = cell k = max(max(i for i, _j, _f in items), max(j for _i, j, _f in items)) + 1 step = 1000.0 / k px = {"full": step / res, "mid": 640, "thumb": _SUBTILE_THUMB_PX}[tier] tier_res = res if tier == "full" else step / px base = _cell_bounds(col, row) for i, j, f in items: add(viz, col, row, tier_res, _Source( f, (base[0] + i * step, base[1] + j * step, base[0] + (i + 1) * step, base[1] + (j + 1) * step), tier_res), rank=0) # 3. Vignettes de dalle (paliers grossiers). if thumb_dir.is_dir(): for f in thumb_dir.iterdir(): if f.suffix.lower() not in (".jpg", ".jpeg", ".webp", ".png"): continue stem = f.name[:-len(f.suffix)] res_px = _MID_RES if stem.endswith("_mid") else _THUMB_RES if stem.endswith("_mid"): stem = stem[:-4] dir_name, viz = _split_viz_key(stem, known) if dir_name is None: continue cell = _cell_of_dir(dir_name) if cell is None: continue col, row, _res = cell add(viz, col, row, res_px, _Source(f, _cell_bounds(col, row), res_px)) # Paliers triés du plus grossier au plus fin (l'ordre de choix du rendu). layers = {} for viz, per_cell in records.items(): for (col, row), by_res in per_cell.items(): tiers = [by_res[k] for k in sorted(by_res, key=lambda k: (-k[0], k[1]))] layers.setdefault(viz, {})[(col, row)] = tiers return layers _SEEN_FILE = ".sources_seen.json" _seen_cache = {} _seen_lock = threading.Lock() def _apply_seen(output_dir, layers): """Date chaque dalle de sa première apparition dans l'inventaire. Une dalle peut entrer dans l'inventaire APRÈS le rendu d'une tuile qui la couvre tout en portant une date plus ancienne (écrite avant, inventoriée après : TTL de l'index amont, anti-rebond de l'inventaire). Comparer les seules dates de version laisserait la tuile « fraîche » — trou permanent à ce niveau, jusque dans le navigateur (stamp inchangé). Registre persistant (index_xyz/.sources_seen.json) : un redémarrage ne périme rien ; au tout premier inventaire, rien n'est daté (tout est à rendre). """ import json path = Path(output_dir) / TILE_DIRNAME / _SEEN_FILE with _seen_lock: seen = _seen_cache.get(str(path)) first = False if seen is None: try: seen = {k: float(v) for k, v in json.loads(path.read_text(encoding="utf-8")).items()} except (OSError, ValueError, AttributeError): # Pas de registre : tout premier inventaire (rien à périmer), # sauf si un cache de tuiles existe déjà (mise à jour) — ses # tuiles ont pu être faites avant l'arrivée de dalles : toutes # sont périmées une fois (l'ancienne reste servie en attendant). cache_dir = path.parent upgraded = cache_dir.is_dir() and any( p.is_dir() for p in cache_dir.iterdir()) seen, first = {}, not upgraded _seen_cache[str(path)] = seen now = time.time() added = False for layer, per_cell in layers.items(): for (col, row), tiers in per_cell.items(): key = f"{layer}|{col}|{row}" at = seen.get(key) if at is None: at = seen[key] = 0.0 if first else now added = True if at: for group in tiers: for src in group: src.seen = at if added or first: try: _write_atomic(path, json.dumps(seen).encode("utf-8")) except Exception as e: # noqa: BLE001 — registre perdu : péremption par version seule logger.debug(f"Registre des dalles non écrit ({path}) : {e}") def source_index(output_dir, force=False): """Index des sources, mémoïsé (TTL + mtime des dossiers surveillés).""" output_dir = Path(output_dir) key = str(output_dir) stamp = tuple(_dir_mtime(output_dir / d) for d in ("visualisations", "index_thumbs", "index_subtiles")) entry = _index_cache.get(key) now = time.time() if entry and not force and now - entry["at"] < _INDEX_TTL: # Le TTL prime sur la mtime des dossiers : en mode amont, chaque source # rapatriée la modifierait et provoquerait un rescan par tuile servie. if REMOTE_SOURCE_URL or entry["stamp"] == stamp: return entry["layers"] if REMOTE_SOURCE_URL: # L'amont fait autorité : il connaît toutes les dalles, le cache local # n'en détient qu'une partie (et grossit à chaque source rapatriée — # le rescanner à chaque tuile coûterait plus cher que le rendu). Les # sources déjà présentes sont servies depuis le disque (_Source.ensure). layers = dict(_remote_index(output_dir, force)) if not layers: layers = _build_index(output_dir) # amont muet : cache local seul else: layers = _build_index(output_dir) _apply_seen(output_dir, layers) _index_cache[key] = {"layers": layers, "stamp": stamp, "at": now} return layers def available_layers(output_dir): """Couches présentes sur disque et affichées (PANEL_VIZ), ordonnées comme le panneau de la carte.""" from . import index as index_mod from .index import _VIZ_FALLBACK_ORDER found = set(source_index(output_dir)) if index_mod.PANEL_VIZ is not None: found &= set(index_mod.PANEL_VIZ) ordered = [v for v in _VIZ_FALLBACK_ORDER if v in found] ordered += sorted(found - set(ordered)) return ordered def grid_bounds_l93(output_dir, layer=None): """Emprise L93 (min_x, min_y, max_x, max_y) des dalles disponibles.""" layers = source_index(output_dir) cells = set() for key, per_cell in layers.items(): if layer and key != layer: continue cells.update(per_cell) if not cells: return None xs = [c for c, _r in cells] ys = [r for _c, r in cells] return (min(xs) * 1000.0, (min(ys) - 1) * 1000.0, (max(xs) + 1) * 1000.0, max(ys) * 1000.0) def grid_bounds_wgs84(output_dir, layer=None): """Emprise WGS84 [ouest, sud, est, nord] des dalles (TileJSON, WMTS).""" l93 = grid_bounds_l93(output_dir, layer) if l93 is None: return None min_x, min_y, max_x, max_y = l93 tr = _transformer("EPSG:2154", "EPSG:4326") xs = [min_x, max_x, min_x, max_x] ys = [min_y, min_y, max_y, max_y] lons, lats = tr.transform(xs, ys) return [min(lons), min(lats), max(lons), max(lats)] def tiles_stamp(output_dir): """Version globale du jeu de tuiles (max des mtimes) pour l'URL du client.""" newest = 0.0 for per_cell in source_index(output_dir).values(): for tiers in per_cell.values(): for src in tiers[-1]: m = src.mtime() if m and m > newest: newest = m return int(newest * 1000) # --------------------------------------------------------------------------- # Rendu d'une tuile # --------------------------------------------------------------------------- # Cache des images sources décodées : le décodage (AVIF surtout) domine le # coût d'une tuile et les tuiles voisines partagent leurs dalles. Le budget est # exprimé en OCTETS, pas en nombre d'entrées : une dalle 5000² pèse ~75 Mo # quand une vignette en pèse 0,2 — un cache « N entrées » ferait déborder la # mémoire d'une petite machine. SOURCE_CACHE_BYTES = int(os.environ.get("LIDAR_TILE_SOURCE_CACHE_MB", "192")) * 1024 * 1024 _source_cache = OrderedDict() _source_cache_lock = threading.Lock() def _open_source(path_str, mtime): """Image source décodée, mémoïsée par (chemin, mtime). `mtime` fait partie de la clé : une dalle régénérée invalide l'entrée. """ from PIL import Image key = (str(path_str), mtime) with _source_cache_lock: img = _source_cache.get(key) if img is not None: _source_cache.move_to_end(key) return img # Copie détachée du fichier : une image AVIF ouverte garde son décodeur # (tampons libavif/dav1d, ~18 Mo par quadrant 2500²) tant qu'elle vit — # en cache, cela doublait la mémoire réelle et faisait tuer le conteneur # du Pi (1 Go) par l'OOM killer en navigation à fort zoom. with Image.open(str(path_str)) as opened: opened.load() img = (opened.copy() if opened.mode in ("RGB", "RGBA") else opened.convert("RGB")) # PIL stocke RGB comme RGBA : 4 octets par pixel dans les deux cas. size = img.size[0] * img.size[1] * 4 with _source_cache_lock: _source_cache[key] = img _source_cache[key].info["_bytes"] = size total = sum(i.info.get("_bytes", 0) for i in _source_cache.values()) while total > SOURCE_CACHE_BYTES and len(_source_cache) > 1: _k, old_img = _source_cache.popitem(last=False) total -= old_img.info.get("_bytes", 0) return img def clear_source_cache(): """Vide le cache d'images sources (tests, pression mémoire).""" with _source_cache_lock: _source_cache.clear() def sources_to_keep(tiers): """Paliers qu'une machine autonome doit garder pour rendre TOUS ses niveaux sans l'amont : du plus grossier jusqu'au premier palier qui atteint la résolution la plus fine (une dalle entière de même résolution que ses quadrants serait un doublon).""" if not tiers: return [] finest = min(group[0].res for group in tiers) keep = [] for group in tiers: keep.extend(group) if group[0].res <= finest: break return keep def _pick_tier(tiers, target_res): """Palier le plus grossier dont la résolution suffit à la tuile visée.""" for group in tiers: if group[0].res <= target_res: return group return tiers[-1] def _contributing(output_dir, layer, z, x, y, scale): """Sources intersectant la tuile, palier choisi selon la résolution visée.""" per_cell = source_index(output_dir).get(layer) if not per_cell: return [] min_x, min_y, max_x, max_y = tile_bounds_l93(z, x, y) target = target_resolution(z, y, scale) out = [] for (col, row), tiers in per_cell.items(): b = _cell_bounds(col, row) if b[2] <= min_x or b[0] >= max_x or b[3] <= min_y or b[1] >= max_y: continue for src in _pick_tier(tiers, target): s = src.bounds if s[2] <= min_x or s[0] >= max_x or s[3] <= min_y or s[1] >= max_y: continue out.append(src) return out def _paste_source(canvas, src, z, x, y, size, resample): """Reprojette une source dans la tuile (transformation projective).""" from PIL import Image if not src.ensure(): return False mtime = src.mtime() if mtime is None: return False try: img = _open_source(str(src.path), mtime) except Exception as e: # noqa: BLE001 — source illisible : tuile partielle logger.debug(f"Source de tuile illisible ({src.path.name}) : {e}") return False w, h = img.size min_x, min_y, max_x, max_y = src.bounds res_x = (max_x - min_x) / w res_y = (max_y - min_y) / h # Fenêtre source utile = intersection avec l'emprise L93 de la tuile, # élargie de 2 px pour que l'interpolation dispose de son voisinage. t_min_x, t_min_y, t_max_x, t_max_y = tile_bounds_l93(z, x, y) c0 = max(0, int(math.floor((max(t_min_x, min_x) - min_x) / res_x)) - 2) c1 = min(w, int(math.ceil((min(t_max_x, max_x) - min_x) / res_x)) + 2) r0 = max(0, int(math.floor((max_y - min(t_max_y, max_y)) / res_y)) - 2) r1 = min(h, int(math.ceil((max_y - max(t_min_y, min_y)) / res_y)) + 2) if c1 - c0 < 1 or r1 - r0 < 1: return False crop = img.crop((c0, r0, c1, r1)) if crop.mode != "RGBA": crop = crop.convert("RGBA") cw, ch = crop.size # Coins L93 de la fenêtre découpée → EPSG:3857 → pixels de la tuile. wx0 = min_x + c0 * res_x wx1 = min_x + c1 * res_x wy1 = max_y - r0 * res_y wy0 = max_y - r1 * res_y mx, my = to_3857([wx0, wx1, wx1, wx0], [wy1, wy1, wy0, wy0]) west, south, east, north = tile_bounds_3857(z, x, y) px = [(m - west) / (east - west) * size for m in mx] py = [(north - m) / (north - south) * size for m in my] dst_quad = list(zip(px, py)) # NW, NE, SE, SW src_quad = [(0, 0), (cw, 0), (cw, ch), (0, ch)] coeffs = perspective_coeffs(dst_quad, src_quad) if coeffs is None: return False warped = crop.transform((size, size), Image.PERSPECTIVE, coeffs, resample=resample, fillcolor=(0, 0, 0, 0)) canvas.alpha_composite(warped) return True def render_tile(output_dir, layer, z, x, y, scale=1): """Rend une tuile en mémoire. Retourne une image RGBA, ou None si vide.""" from PIL import Image sources = _contributing(output_dir, layer, z, x, y, scale) if not sources: return None size = TILE_SIZE * scale canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) # Agrandissement (zoom natif) : bicubique ; réduction : bilinéaire suffit # puisque le palier source est déjà calé sur la résolution de la tuile. resample = Image.BICUBIC painted = False for src in sources: painted |= _paste_source(canvas, src, z, x, y, size, resample) if not painted: return None return canvas # --------------------------------------------------------------------------- # Cache disque # --------------------------------------------------------------------------- def tile_cache_path(output_dir, layer, z, x, y, scale=1, fmt="png"): """Chemin du fichier de cache d'une tuile.""" suffix = "@2x" if scale == 2 else "" return (Path(output_dir) / TILE_DIRNAME / layer / str(z) / str(x) / f"{y}{suffix}.{fmt}") def _encode(img, fmt): import io buf = io.BytesIO() if fmt == "png": if PNG_PALETTE: from PIL import Image img = img.quantize(colors=255, method=Image.Quantize.FASTOCTREE) img.save(buf, format="PNG", optimize=False) elif fmt == "webp": img.save(buf, format="WEBP", quality=WEBP_QUALITY) elif fmt == "avif": img.save(buf, format="AVIF", quality=AVIF_QUALITY, speed=AVIF_SPEED) else: raise ValueError(f"format de tuile inconnu : {fmt}") return buf.getvalue() def _write_atomic(path, data): path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(path.name + f".{os.getpid()}.part") try: tmp.write_bytes(data) os.replace(tmp, path) except OSError as e: logger.debug(f"Écriture de tuile impossible ({path}) : {e}") tmp.unlink(missing_ok=True) def zoom_cached(z, scale=1): """Vrai si les tuiles de ce niveau sont stockées sur disque.""" zc = z + (1 if scale == 2 else 0) if zc > TILE_CACHE_MAX_Z: return False return not (TILE_EVEN_LEVELS and zc % 2) # Tuiles rendues à la volée (niveaux non stockés) : cache mémoire borné, # pour qu'un aller-retour de navigation ne recalcule pas tout. MEMORY_CACHE_BYTES = int(os.environ.get("LIDAR_TILE_MEMORY_CACHE_MB", "64")) * 1024 * 1024 _mem_tiles = OrderedDict() _mem_tiles_bytes = [0] _mem_tiles_lock = threading.Lock() def _mem_get(key): with _mem_tiles_lock: data = _mem_tiles.get(key) if data is not None: _mem_tiles.move_to_end(key) return data def _mem_put(key, data): with _mem_tiles_lock: if key in _mem_tiles: return _mem_tiles[key] = data _mem_tiles_bytes[0] += len(data) while _mem_tiles_bytes[0] > MEMORY_CACHE_BYTES and _mem_tiles: _, old = _mem_tiles.popitem(last=False) _mem_tiles_bytes[0] -= len(old) def zoom_supported(z, scale=1): """Vrai si le zoom demandé est dans la plage rendue par le serveur.""" max_z = TILE_MAX_NATIVE_Z - (1 if scale == 2 else 0) return TILE_MIN_Z <= z <= max_z def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"): """Tuile encodée (bytes) depuis le cache, rendue au besoin. Retourne None quand aucune donnée n'intersecte la tuile ; un marqueur `.empty` mémorise ce cas pour ne jamais le recalculer. Une tuile en cache est réutilisée tant qu'aucune dalle contributrice n'est plus récente qu'elle : une dalle régénérée n'invalide que ses propres tuiles. """ cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt) empty = cache.with_suffix(".empty") stored = zoom_cached(z, scale) sources = _contributing(output_dir, layer, z, x, y, scale) if not sources: if stored and not empty.exists(): _write_atomic(empty, b"") return None newest = 0.0 for src in sources: m = src.mtime() if m and m > newest: newest = m if not stored: # Niveau non stocké : rendu à la volée, cache mémoire seulement. key = (str(output_dir), layer, z, x, y, scale, fmt, newest) data = _mem_get(key) if data is None: img = render_tile(output_dir, layer, z, x, y, scale) if img is None: return None data = _encode(img, fmt) _mem_put(key, data) return data try: if cache.stat().st_mtime >= newest: return cache.read_bytes() except OSError: pass img = render_tile(output_dir, layer, z, x, y, scale) if img is None: _write_atomic(empty, b"") return None data = _encode(img, fmt) _write_atomic(cache, data) empty.unlink(missing_ok=True) return data def empty_marker_exists(output_dir, layer, z, x, y, scale=1, fmt="png"): """Vrai si la tuile est déjà connue comme vide (négatif mémorisé).""" return tile_cache_path(output_dir, layer, z, x, y, scale, fmt).with_suffix(".empty").exists() def cached_tile(output_dir, layer, z, x, y, scale=1, fmt="png"): """Tuile depuis le cache UNIQUEMENT — jamais de rendu (mode cache seule). Retourne ``(data, état)`` : - ``"fresh"`` : tuile en cache et plus récente que ses dalles sources ; - ``"empty"`` : aucune source n'intersecte (marqueur `.empty` posé) ; - ``"pending"``: donnée présente mais tuile absente ou périmée — à rendre ailleurs (tâche de fond, pré-chauffage), pas au fil de la navigation. """ cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt) empty = cache.with_suffix(".empty") sources = _contributing(output_dir, layer, z, x, y, scale) if not sources: if not empty.exists(): _write_atomic(empty, b"") return None, "empty" newest = 0.0 for src in sources: m = src.mtime() if m and m > newest: newest = m # Même règle de fraîcheur que la tuile : un marqueur .empty plus vieux # qu'une source (dalle apparue depuis, version amont plus neuve) est # invalidé — sinon une zone naviguée avant sa génération resterait # transparente à jamais. if empty.exists(): try: if newest and empty.stat().st_mtime >= newest: return None, "empty" except OSError: return None, "empty" empty.unlink(missing_ok=True) try: if cache.stat().st_mtime >= newest: return cache.read_bytes(), "fresh" except OSError: pass return None, "pending" def transparent_tile(scale=1, fmt="png"): """Tuile entièrement transparente (zone sans donnée).""" from PIL import Image size = TILE_SIZE * scale return _encode(Image.new("RGBA", (size, size), (0, 0, 0, 0)), fmt) # --------------------------------------------------------------------------- # Pré-chauffage # --------------------------------------------------------------------------- def tiles_in_bounds(bounds_wgs84, z): """Indices (x, y) des tuiles du niveau z couvrant une bbox WGS84.""" west, south, east, north = bounds_wgs84 n = 2 ** z def _x(lon): return int((lon + 180.0) / 360.0 * n) def _y(lat): lat = max(-85.0511, min(85.0511, lat)) rad = math.radians(lat) return int((1.0 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2.0 * n) x0, x1 = sorted((_x(west), _x(east))) y0, y1 = sorted((_y(north), _y(south))) return [(x, y) for x in range(max(0, x0), min(n - 1, x1) + 1) for y in range(max(0, y0), min(n - 1, y1) + 1)] def warm(output_dir, layers, z_min, z_max, bounds_wgs84=None, scale=1, fmt="png", limit=20000): """Pré-calcule les tuiles d'une emprise (retourne le compte rendu).""" if bounds_wgs84 is None: bounds_wgs84 = grid_bounds_wgs84(output_dir) if bounds_wgs84 is None: return {"rendues": 0, "vides": 0, "limite": False} done = empty = 0 for layer in layers: for z in range(max(TILE_MIN_Z, z_min), min(TILE_MAX_NATIVE_Z, z_max) + 1): if not zoom_supported(z, scale): continue for x, y in tiles_in_bounds(bounds_wgs84, z): if done + empty >= limit: return {"rendues": done, "vides": empty, "limite": True} if get_tile(output_dir, layer, z, x, y, scale, fmt) is None: empty += 1 else: done += 1 return {"rendues": done, "vides": empty, "limite": False}