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.
874 lines
33 KiB
Python
874 lines
33 KiB
Python
"""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 / Dockerfile.webapp) 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
|
||
WEBP_QUALITY = 78
|
||
AVIF_QUALITY = 60
|
||
# 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 (webapp du pipeline) : 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()
|
||
|
||
# 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")
|
||
|
||
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
|
||
|
||
def mtime(self):
|
||
if self.version is not None:
|
||
return self.version / 1000.0
|
||
try:
|
||
return self.path.stat().st_mtime
|
||
except OSError:
|
||
return None
|
||
|
||
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()
|
||
return _fetch_source(self.url, self.path)
|
||
|
||
|
||
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
|
||
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}")
|
||
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()
|
||
if not force and _remote_cache["payload"] is not None \
|
||
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=30) 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
|
||
|
||
|
||
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)
|
||
_index_cache[key] = {"layers": layers, "stamp": stamp, "at": now}
|
||
return layers
|
||
|
||
|
||
def available_layers(output_dir):
|
||
"""Couches présentes sur disque, ordonnées comme le panneau de la carte."""
|
||
from .index import _VIZ_FALLBACK_ORDER
|
||
found = set(source_index(output_dir))
|
||
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
|
||
img = Image.open(str(path_str))
|
||
img.load()
|
||
if img.mode not in ("RGB", "RGBA"):
|
||
img = img.convert("RGB")
|
||
size = img.size[0] * img.size[1] * len(img.getbands())
|
||
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 _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)
|
||
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_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")
|
||
sources = _contributing(output_dir, layer, z, x, y, scale)
|
||
if not sources:
|
||
if 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
|
||
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"
|
||
if empty.exists():
|
||
return None, "empty"
|
||
newest = 0.0
|
||
for src in sources:
|
||
m = src.mtime()
|
||
if m and m > newest:
|
||
newest = m
|
||
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}
|