Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts, compose files and AGENTS.md are now English. Data keys stay unchanged (relief_oriente, densite_sol, visualisations/, API JSON keys, link params). Wrong comments and help defaults found along the way are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -1,14 +1,14 @@
|
||||
"""Pyramide de tuiles XYZ (EPSG:3857) rendue à la demande depuis les dalles.
|
||||
"""XYZ tile pyramid (EPSG:3857) rendered on demand from the tiles.
|
||||
|
||||
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.
|
||||
Same tiling scheme as OpenStreetMap / Google Maps: Web Mercator grid, origin
|
||||
at the north-west corner, `{z}/{x}/{y}`, 256 px tiles (512 px with
|
||||
`scale=2`, `@2x` convention). The pipeline outputs remain 1 km Lambert 93
|
||||
tiles: each XYZ tile is composed on the fly by reprojecting the source tiles
|
||||
that intersect it (one projective transform per source tile, error well below
|
||||
a pixel), then cached on disk.
|
||||
|
||||
Volontairement sans GDAL ni numpy : Pillow + pyproj suffisent, l'image légère
|
||||
(Dockerfile.maps) reste petite et portable ARM64.
|
||||
Deliberately free of GDAL and numpy: Pillow + pyproj are enough, so the
|
||||
lightweight image (Dockerfile.maps) stays small and ARM64-portable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -23,77 +23,76 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("lidar")
|
||||
|
||||
# --- Contrat de tuilage (cf. docs/MAPS.md) ---------------------------------
|
||||
TILE_SIZE = 256 # taille canonique (OSM/XYZ) ; @2x → 512
|
||||
# --- Tiling contract (see docs/MAPS.md) ------------------------------------
|
||||
TILE_SIZE = 256 # canonical size (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
|
||||
# Niveaux stockés, en numérotation OSM standard (tuile 256 px ; une tuile @2x
|
||||
# de niveau z vaut une 256 px de z+1). Par défaut TOUS les niveaux jusqu'au
|
||||
# natif sont écrits sur disque et générés d'avance par la maintenance : le
|
||||
# rendu à la volée d'un Pi (~170 ms par tuile, 3 à la fois) donnait 1,5 à 6 s
|
||||
# par écran aux forts zooms. Stockage réduit (petite machine, disque compté) :
|
||||
# LIDAR_TILE_CACHE_MAX_Z plus bas et/ou LIDAR_TILE_EVEN_LEVELS=1 (niveaux
|
||||
# pairs seuls, les autres rendus à la volée avec un cache mémoire borné).
|
||||
TILE_MAX_NATIVE_Z = 19 # 0.2 m/px ≈ z19 resolution at latitude 47°
|
||||
TILE_DIRNAME = "index_xyz" # disk cache, under the output directory
|
||||
# Stored levels, in standard OSM numbering (256 px tile; an @2x tile at
|
||||
# level z equals a 256 px tile at z+1). By default ALL levels up to native are
|
||||
# written to disk and generated ahead of time by the background maintenance:
|
||||
# on-the-fly rendering on a Pi (~170 ms per tile, 3 at a time) took 1.5 to 6 s
|
||||
# per screen at high zoom. Reduced storage (small machine, limited disk):
|
||||
# lower LIDAR_TILE_CACHE_MAX_Z and/or LIDAR_TILE_EVEN_LEVELS=1 (even levels
|
||||
# only, the others rendered on the fly with a bounded memory cache).
|
||||
TILE_CACHE_MAX_Z = int(os.environ.get("LIDAR_TILE_CACHE_MAX_Z", str(TILE_MAX_NATIVE_Z))
|
||||
or TILE_MAX_NATIVE_Z)
|
||||
TILE_EVEN_LEVELS = os.environ.get("LIDAR_TILE_EVEN_LEVELS", "0").strip() == "1"
|
||||
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).
|
||||
AVIF_SPEED = 9 # fast encoding (see rendering.AVIF_SPEED: ×7, +3 % size)
|
||||
# Palettized PNG (PNG8 + alpha): ~5× lighter (170 → 32 KB on a real tile) for
|
||||
# a mean error of ~4 levels on a color ramp. DISABLED by default: the
|
||||
# canonical PNG stays lossless, fidelity comes before bandwidth for an
|
||||
# interpretation product. `LIDAR_TILE_PNG_PALETTE=1` enables it when
|
||||
# bandwidth matters (mobile viewing).
|
||||
PNG_PALETTE = os.environ.get("LIDAR_TILE_PNG_PALETTE", "") == "1"
|
||||
|
||||
# Couches en aplats de niveaux codés, rééchantillonnées au plus proche voisin
|
||||
# Layers made of flat coded levels, resampled with nearest neighbour
|
||||
NEAREST_LAYERS = frozenset({'densite_sol'})
|
||||
|
||||
# Demi-circonférence équatoriale : emprise du Web Mercator (EPSG:3857).
|
||||
# Equatorial half-circumference: extent of 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.
|
||||
# Nominal resolutions of the source tiers reused as-is from the pipeline
|
||||
# (m/px): thumbnail 256 px/km, intermediate thumbnail 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.
|
||||
# Upstream tile server (the worker's mapserve): when set, the source index
|
||||
# comes from its /api/tiles and missing images are fetched on demand into the
|
||||
# local cache — the map container then needs no local data at startup.
|
||||
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.
|
||||
# Concurrent source downloads (network) — modest by default: on a small
|
||||
# server, each fetched source is a file of several MB.
|
||||
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 circuit breaker: after a failure, no further attempt for
|
||||
# _SOURCE_OFFLINE_S — without it, every source in a fetch queue would pay the
|
||||
# network timeout of a powered-off upstream again and the maintenance would
|
||||
# render nothing (the map must stay self-sufficient).
|
||||
_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).
|
||||
# Source index rebuilt at most every _INDEX_TTL seconds (or as soon as a
|
||||
# directory's mtime changes: tile added/removed).
|
||||
_INDEX_TTL = 20.0
|
||||
_index_cache = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Géométrie de la grille
|
||||
# Grid geometry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tile_bounds_3857(z, x, y):
|
||||
"""Emprise (ouest, sud, est, nord) d'une tuile XYZ en mètres EPSG:3857."""
|
||||
"""Extent (west, south, east, north) of an XYZ tile in EPSG:3857 metres."""
|
||||
span = 2.0 * ORIGIN / (2 ** z)
|
||||
west = -ORIGIN + x * span
|
||||
north = ORIGIN - y * span
|
||||
@ -101,18 +100,18 @@ def tile_bounds_3857(z, x, y):
|
||||
|
||||
|
||||
def ground_resolution(z, scale=1):
|
||||
"""Résolution d'une tuile à l'équateur (m/px) ; ×cos(lat) sur le terrain."""
|
||||
"""Tile resolution at the equator (m/px); ×cos(lat) on the ground."""
|
||||
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."""
|
||||
"""Latitude (degrees) of a tile's centre — used to match resolution."""
|
||||
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."""
|
||||
"""Ground resolution targeted by the tile (m/px), latitude included."""
|
||||
return ground_resolution(z, scale) * math.cos(math.radians(tile_latitude(z, y)))
|
||||
|
||||
|
||||
@ -123,28 +122,28 @@ def _transformer(src, dst):
|
||||
|
||||
|
||||
def to_l93(xs, ys):
|
||||
"""EPSG:3857 → EPSG:2154 (listes de coordonnées)."""
|
||||
"""EPSG:3857 → EPSG:2154 (coordinate lists)."""
|
||||
return _transformer("EPSG:3857", "EPSG:2154").transform(xs, ys)
|
||||
|
||||
|
||||
def to_3857(xs, ys):
|
||||
"""EPSG:2154 → EPSG:3857 (listes de coordonnées)."""
|
||||
"""EPSG:2154 → EPSG:3857 (coordinate lists)."""
|
||||
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)."""
|
||||
"""WGS84 point → Lambert 93 (used by the tile information card)."""
|
||||
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.
|
||||
"""L93 extent enclosing an XYZ tile.
|
||||
|
||||
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).
|
||||
A tile's edges are not straight lines in Lambert 93: a `samples`×`samples`
|
||||
grid is sampled rather than the corners alone, otherwise the extent is
|
||||
underestimated at low zoom (tiles spanning hundreds of km).
|
||||
"""
|
||||
west, south, east, north = tile_bounds_3857(z, x, y)
|
||||
xs, ys = [], []
|
||||
@ -157,11 +156,11 @@ def tile_bounds_l93(z, x, y, samples=5):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transformation projective (mapping sortie → source, convention Pillow)
|
||||
# Projective transform (output → source mapping, Pillow convention)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _solve(matrix, rhs):
|
||||
"""Résout un système linéaire dense (pivot partiel), sans numpy."""
|
||||
"""Solve a dense linear system (partial pivoting), without numpy."""
|
||||
n = len(rhs)
|
||||
a = [row[:] + [rhs[i]] for i, row in enumerate(matrix)]
|
||||
for col in range(n):
|
||||
@ -183,13 +182,13 @@ def _solve(matrix, rhs):
|
||||
|
||||
|
||||
def perspective_coeffs(dst_quad, src_quad):
|
||||
"""Coefficients Pillow `Image.PERSPECTIVE` mappant sortie → source.
|
||||
"""Pillow `Image.PERSPECTIVE` coefficients mapping output → 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).
|
||||
Pillow samples the SOURCE at (x', y') = ((a x + b y + c) / (g x + h y + 1),
|
||||
(d x + e y + f) / (g x + h y + 1)) for each pixel (x, y) of the OUTPUT:
|
||||
the 8 unknowns are therefore solved from 4 correspondences
|
||||
(output point → source point). Returns None if the quadrilateral is
|
||||
degenerate (tile shrunk to a point at extreme zoom-out).
|
||||
"""
|
||||
matrix, rhs = [], []
|
||||
for (dx, dy), (sx, sy) in zip(dst_quad, src_quad):
|
||||
@ -201,32 +200,32 @@ def perspective_coeffs(dst_quad, src_quad):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index des sources : dalles du pipeline, par couche et par palier
|
||||
# Source index: pipeline tiles, per layer and per tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Source:
|
||||
"""Une image source géoréférencée (dalle entière ou quadrant).
|
||||
"""A georeferenced source image (whole tile or 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.
|
||||
Non-null `url`: the image lives on the upstream tile server and is only
|
||||
fetched on first use (`ensure()`), into `path`. `version` is then the
|
||||
mtime (ms) announced by the upstream: it serves as the reference date for
|
||||
tile staleness, even before any download.
|
||||
"""
|
||||
|
||||
__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.bounds = bounds # (min_x, min_y, max_x, max_y) in L93
|
||||
self.res = res # nominal resolution (m/px)
|
||||
self.url = url
|
||||
self.version = version
|
||||
self.seen = 0.0 # 1re apparition de la dalle dans l'inventaire
|
||||
self.seen = 0.0 # first appearance of the tile in the inventory
|
||||
|
||||
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)."""
|
||||
"""Reference date for tile staleness: the source's version, pushed
|
||||
forward to its first appearance in the inventory (a tile written
|
||||
before, but inventoried after, an XYZ tile makes that XYZ tile stale)."""
|
||||
if self.version is not None:
|
||||
base = self.version / 1000.0
|
||||
else:
|
||||
@ -237,14 +236,14 @@ class _Source:
|
||||
return max(base, self.seen)
|
||||
|
||||
def ensure(self):
|
||||
"""Garantit la présence locale de l'image (rapatriement si besoin)."""
|
||||
"""Ensure the image is present locally (fetch it if needed)."""
|
||||
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.
|
||||
# Dated at its upstream version: the local index (upstream down)
|
||||
# finds the same date and already-rendered tiles stay fresh.
|
||||
try:
|
||||
os.utime(self.path, (self.version / 1000.0, self.version / 1000.0))
|
||||
except OSError:
|
||||
@ -253,12 +252,12 @@ class _Source:
|
||||
|
||||
|
||||
def _remote_thumb_px(k):
|
||||
"""Côté (px) de la vignette servie par l'amont : 256 par dalle, 160 par quadrant."""
|
||||
"""Side (px) of the thumbnail served by the upstream: 256 per tile, 160 per 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)."""
|
||||
"""Download a source image from the upstream (atomic write)."""
|
||||
import urllib.request
|
||||
with _fetch_guard:
|
||||
lock = _fetch_locks.setdefault(str(dest), threading.Lock())
|
||||
@ -274,22 +273,23 @@ def _fetch_source(url, dest):
|
||||
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}")
|
||||
except Exception as e: # noqa: BLE001 — upstream down: partial tile
|
||||
logger.debug(f"Upstream source unavailable ({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)")
|
||||
logger.info(f"Source fetched: {dest.name} ({len(data) / 1e6:.1f} MB)")
|
||||
return dest.is_file()
|
||||
|
||||
|
||||
def _remote_payload(force=False):
|
||||
"""Index du serveur de dalles amont (/api/tiles), en cache 60 s."""
|
||||
"""Index from the upstream tile server (/api/tiles), cached for 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.
|
||||
# Failures are remembered too (TTL): with the upstream down and no known
|
||||
# inventory, at most one call per TTL — not a network timeout on every
|
||||
# map request.
|
||||
if not force and _remote_cache["at"] \
|
||||
and now - _remote_cache["at"] < _REMOTE_TTL:
|
||||
return _remote_cache["payload"]
|
||||
@ -298,8 +298,8 @@ def _remote_payload(force=False):
|
||||
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}")
|
||||
except Exception as e: # noqa: BLE001 — keep the last known index
|
||||
logger.warning(f"Upstream index unreachable ({REMOTE_SOURCE_URL}): {e}")
|
||||
payload = _remote_cache["payload"]
|
||||
_remote_cache.update(payload=payload, at=now)
|
||||
return payload
|
||||
@ -309,11 +309,11 @@ _SAFE_BASENAME = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
def _persist_remote_quality(output_dir, payload):
|
||||
"""Recopie la table qualité amont en sidecars locaux.
|
||||
"""Copy the upstream quality table into local sidecars.
|
||||
|
||||
L'encart qualité de l'export PDF lit toujours le disque local : la
|
||||
carte autonome (amont éteint) garde ainsi les dernières valeurs connues.
|
||||
Returns: nombre de sidecars (ré)écrits.
|
||||
The quality inset of the PDF export always reads the local disk: the
|
||||
self-sufficient map (upstream down) thus keeps the last known values.
|
||||
Returns: number of sidecars (re)written.
|
||||
"""
|
||||
from .quality import QUALITY_VERSION, write_quality
|
||||
written = 0
|
||||
@ -325,16 +325,16 @@ def _persist_remote_quality(output_dir, payload):
|
||||
try:
|
||||
written += bool(write_quality(output_dir, base, data))
|
||||
except OSError as e:
|
||||
logger.debug(f"Sidecar qualité non recopié ({base}) : {e}")
|
||||
logger.debug(f"Quality sidecar not copied ({base}): {e}")
|
||||
return written
|
||||
|
||||
|
||||
def _remote_index(output_dir, force=False):
|
||||
"""Inventaire construit depuis l'amont : sources non encore rapatriées.
|
||||
"""Inventory built from the upstream: sources not fetched yet.
|
||||
|
||||
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.
|
||||
The result is memoized alongside the payload: rebuilding it costs
|
||||
hundreds of milliseconds on a catalogue of several thousand tiles, which
|
||||
would be paid on EVERY tile served.
|
||||
"""
|
||||
payload = _remote_payload(force)
|
||||
if not payload or not payload.get("tiles"):
|
||||
@ -386,12 +386,12 @@ def _remote_index(output_dir, force=False):
|
||||
|
||||
|
||||
def _cell_bounds(col, row):
|
||||
"""Emprise L93 d'une dalle LHD : X ∈ [col, col+1] km, Y ∈ [row-1, row] km."""
|
||||
"""L93 extent of an LHD tile: 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)."""
|
||||
"""WGS84 extent [west, south, east, north] of an LHD tile (4 L93 corners)."""
|
||||
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)
|
||||
@ -407,11 +407,11 @@ def _dir_mtime(path):
|
||||
|
||||
|
||||
def _split_viz_key(stem, known):
|
||||
"""`{dir_name}_{viz}` → (dir_name, viz) en s'appuyant sur les clés connues.
|
||||
"""`{dir_name}_{viz}` → (dir_name, viz), relying on the known keys.
|
||||
|
||||
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).
|
||||
Visualization keys contain underscores (`positive_openness`,
|
||||
`hillshade_multi`): the stem cannot be split at the last `_`, so a known
|
||||
suffix is matched instead (longest first).
|
||||
"""
|
||||
for key in known:
|
||||
if stem.endswith("_" + key):
|
||||
@ -420,7 +420,7 @@ def _split_viz_key(stem, known):
|
||||
|
||||
|
||||
def _cell_of_dir(dir_name):
|
||||
"""(col, row, résolution) d'un nom de dossier de dalle, ou None."""
|
||||
"""(col, row, resolution) of a tile directory name, or None."""
|
||||
from .index import _strip_res_suffix, parse_basename_coords
|
||||
coords = parse_basename_coords(dir_name)
|
||||
if coords is None:
|
||||
@ -430,16 +430,16 @@ def _cell_of_dir(dir_name):
|
||||
|
||||
|
||||
def _build_index(output_dir):
|
||||
"""Inventaire {couche: {(col, row): [paliers du plus grossier au plus fin]}}.
|
||||
"""Inventory {layer: {(col, row): [tiers from coarsest to finest]}}.
|
||||
|
||||
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.
|
||||
The three pipeline sources are scanned INDEPENDENTLY — whole tiles
|
||||
(`visualisations/`), quadrants (`index_subtiles/`) and thumbnails
|
||||
(`index_thumbs/`). A partial cache therefore remains usable: on a
|
||||
lightweight machine, only quadrants and thumbnails are fetched, never
|
||||
whole tiles.
|
||||
"""
|
||||
# _SUBTILE_THUMB_PX : taille des vignettes de quadrant, définie par
|
||||
# l'index (la changer là-bas doit rester sans effet ici).
|
||||
# _SUBTILE_THUMB_PX: size of the quadrant thumbnails, defined by the
|
||||
# index (changing it there must have no effect here).
|
||||
from .index import _SUBTILE_THUMB_PX, VIZ_LABELS, scan_tiles
|
||||
|
||||
output_dir = Path(output_dir)
|
||||
@ -447,17 +447,17 @@ def _build_index(output_dir):
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
sub_dir = output_dir / "index_subtiles"
|
||||
|
||||
# res → sources, par couche et par dalle ; trié en paliers à la fin.
|
||||
# res → sources, per layer and per tile; sorted into tiers at the end.
|
||||
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.
|
||||
# Tier key = (resolution, rank): at equal resolution, quadrants
|
||||
# (rank 0) come before the whole tile (rank 1) — same rendering,
|
||||
# 4× fewer pixels to decode.
|
||||
records.setdefault(layer, {}).setdefault((col, row), {}) \
|
||||
.setdefault((res, rank), []).append(source)
|
||||
|
||||
# 1. Dalles entières (palier le plus fin quand il est présent).
|
||||
# 1. Whole tiles (finest tier when present).
|
||||
known = set(VIZ_LABELS)
|
||||
for tile in scan_tiles(vis_dir):
|
||||
col, row, res = tile["col"], tile["row"], tile["resolution"]
|
||||
@ -472,15 +472,16 @@ def _build_index(output_dir):
|
||||
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`.
|
||||
# Longest keys first: `positive_openness` before `openness`.
|
||||
known = sorted(known, key=len, reverse=True)
|
||||
|
||||
# 2. Quadrants (index_subtiles) : AVIF pleine résolution + ses vignettes.
|
||||
# 2. Quadrants (index_subtiles): full-resolution AVIF (lossless WebP for
|
||||
# flat-level layers) + their thumbnails.
|
||||
if sub_dir.is_dir():
|
||||
quads = {}
|
||||
for f in sub_dir.iterdir():
|
||||
name = f.name
|
||||
# « .webp » plein en dernier : les vignettes finissent aussi en .webp
|
||||
# Plain ".webp" last: thumbnails also end in .webp
|
||||
for suffix, tier in ((".avif", "full"), ("_mid.webp", "mid"),
|
||||
(f"_thumb{_SUBTILE_THUMB_PX}.webp", "thumb"),
|
||||
(".webp", "full")):
|
||||
@ -513,7 +514,7 @@ def _build_index(output_dir):
|
||||
base[0] + (i + 1) * step, base[1] + (j + 1) * step),
|
||||
tier_res), rank=0)
|
||||
|
||||
# 3. Vignettes de dalle (paliers grossiers).
|
||||
# 3. Tile thumbnails (coarse tiers).
|
||||
if thumb_dir.is_dir():
|
||||
for f in thumb_dir.iterdir():
|
||||
if f.suffix.lower() not in (".jpg", ".jpeg", ".webp", ".png"):
|
||||
@ -531,7 +532,7 @@ def _build_index(output_dir):
|
||||
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).
|
||||
# Tiers sorted from coarsest to finest (the rendering's order of choice).
|
||||
layers = {}
|
||||
for viz, per_cell in records.items():
|
||||
for (col, row), by_res in per_cell.items():
|
||||
@ -546,15 +547,16 @@ _seen_lock = threading.Lock()
|
||||
|
||||
|
||||
def _apply_seen(output_dir, layers):
|
||||
"""Date chaque dalle de sa première apparition dans l'inventaire.
|
||||
"""Date each source tile by its first appearance in the inventory.
|
||||
|
||||
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).
|
||||
A source tile can enter the inventory AFTER an XYZ tile covering it was
|
||||
rendered while carrying an older date (written before, inventoried after:
|
||||
upstream index TTL, inventory debounce). Comparing version dates alone
|
||||
would leave the XYZ tile "fresh" — a permanent hole at that level, all
|
||||
the way to the browser (unchanged stamp). Persistent registry
|
||||
(index_xyz/.sources_seen.json): a restart makes nothing stale; on the
|
||||
very first inventory, nothing is dated (everything still has to be
|
||||
rendered).
|
||||
"""
|
||||
import json
|
||||
path = Path(output_dir) / TILE_DIRNAME / _SEEN_FILE
|
||||
@ -566,10 +568,10 @@ def _apply_seen(output_dir, layers):
|
||||
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).
|
||||
# No registry: very first inventory (nothing to invalidate),
|
||||
# unless a tile cache already exists (upgrade) — its tiles may
|
||||
# have been rendered before some source tiles arrived: they are
|
||||
# all made stale once (the old one keeps being served meanwhile).
|
||||
cache_dir = path.parent
|
||||
upgraded = cache_dir.is_dir() and any(
|
||||
p.is_dir() for p in cache_dir.iterdir())
|
||||
@ -591,12 +593,12 @@ def _apply_seen(output_dir, layers):
|
||||
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}")
|
||||
except Exception as e: # noqa: BLE001 — registry lost: staleness by version only
|
||||
logger.debug(f"Tile registry not written ({path}): {e}")
|
||||
|
||||
|
||||
def source_index(output_dir, force=False):
|
||||
"""Index des sources, mémoïsé (TTL + mtime des dossiers surveillés)."""
|
||||
"""Source index, memoized (TTL + mtime of the watched directories)."""
|
||||
output_dir = Path(output_dir)
|
||||
key = str(output_dir)
|
||||
stamp = tuple(_dir_mtime(output_dir / d) for d in
|
||||
@ -604,18 +606,19 @@ def source_index(output_dir, force=False):
|
||||
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.
|
||||
# The TTL takes precedence over the directories' mtime: in upstream
|
||||
# mode, each fetched source would change it and trigger a rescan per
|
||||
# tile served.
|
||||
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).
|
||||
# The upstream is authoritative: it knows every tile, the local cache
|
||||
# only holds part of them (and grows with each fetched source —
|
||||
# rescanning it on every tile would cost more than rendering). Sources
|
||||
# already present are served from disk (_Source.ensure).
|
||||
layers = dict(_remote_index(output_dir, force))
|
||||
if not layers:
|
||||
layers = _build_index(output_dir) # amont muet : cache local seul
|
||||
layers = _build_index(output_dir) # silent upstream: local cache only
|
||||
else:
|
||||
layers = _build_index(output_dir)
|
||||
_apply_seen(output_dir, layers)
|
||||
@ -624,8 +627,8 @@ def source_index(output_dir, force=False):
|
||||
|
||||
|
||||
def available_layers(output_dir):
|
||||
"""Couches présentes sur disque et affichées (PANEL_VIZ), ordonnées
|
||||
comme le panneau de la carte."""
|
||||
"""Layers present on disk and displayed (PANEL_VIZ), ordered like the
|
||||
map panel."""
|
||||
from . import index as index_mod
|
||||
from .index import _VIZ_FALLBACK_ORDER
|
||||
found = set(source_index(output_dir))
|
||||
@ -637,7 +640,7 @@ def available_layers(output_dir):
|
||||
|
||||
|
||||
def grid_bounds_l93(output_dir, layer=None):
|
||||
"""Emprise L93 (min_x, min_y, max_x, max_y) des dalles disponibles."""
|
||||
"""L93 extent (min_x, min_y, max_x, max_y) of the available tiles."""
|
||||
layers = source_index(output_dir)
|
||||
cells = set()
|
||||
for key, per_cell in layers.items():
|
||||
@ -653,7 +656,7 @@ def grid_bounds_l93(output_dir, layer=None):
|
||||
|
||||
|
||||
def grid_bounds_wgs84(output_dir, layer=None):
|
||||
"""Emprise WGS84 [ouest, sud, est, nord] des dalles (TileJSON, WMTS)."""
|
||||
"""WGS84 extent [west, south, east, north] of the tiles (TileJSON, WMTS)."""
|
||||
l93 = grid_bounds_l93(output_dir, layer)
|
||||
if l93 is None:
|
||||
return None
|
||||
@ -666,7 +669,7 @@ def grid_bounds_wgs84(output_dir, layer=None):
|
||||
|
||||
|
||||
def tiles_stamp(output_dir):
|
||||
"""Version globale du jeu de tuiles (max des mtimes) pour l'URL du client."""
|
||||
"""Global version of the tile set (max of the finest-tier mtimes) for the client URL."""
|
||||
newest = 0.0
|
||||
for per_cell in source_index(output_dir).values():
|
||||
for tiers in per_cell.values():
|
||||
@ -678,23 +681,23 @@ def tiles_stamp(output_dir):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendu d'une tuile
|
||||
# Rendering a tile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 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.
|
||||
# Cache of decoded source images: decoding (AVIF above all) dominates the
|
||||
# cost of a tile, and neighbouring tiles share their sources. The budget is
|
||||
# expressed in BYTES, not in number of entries: a 5000² tile weighs ~100 MB
|
||||
# (4 bytes/px) while a thumbnail weighs 0.26 MB — an "N entries" cache would
|
||||
# overflow the memory of a small 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).
|
||||
"""Decoded source image, memoized by (path, mtime).
|
||||
|
||||
`mtime` fait partie de la clé : une dalle régénérée invalide l'entrée.
|
||||
`mtime` is part of the key: a regenerated tile invalidates the entry.
|
||||
"""
|
||||
from PIL import Image
|
||||
key = (str(path_str), mtime)
|
||||
@ -703,15 +706,15 @@ def _open_source(path_str, mtime):
|
||||
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.
|
||||
# Copy detached from the file: an open AVIF image keeps its decoder
|
||||
# (libavif/dav1d buffers, ~18 MB per 2500² quadrant) for as long as it
|
||||
# lives — cached, this doubled the real memory footprint and got the Pi's
|
||||
# container (1 GB) killed by the OOM killer when browsing at high 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.
|
||||
# PIL stores RGB like RGBA: 4 bytes per pixel in both cases.
|
||||
size = img.size[0] * img.size[1] * 4
|
||||
with _source_cache_lock:
|
||||
_source_cache[key] = img
|
||||
@ -724,16 +727,16 @@ def _open_source(path_str, mtime):
|
||||
|
||||
|
||||
def clear_source_cache():
|
||||
"""Vide le cache d'images sources (tests, pression mémoire)."""
|
||||
"""Empty the source image cache (tests, memory pressure)."""
|
||||
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)."""
|
||||
"""Tiers a self-sufficient machine must keep to render ALL its levels
|
||||
without the upstream: from the coarsest up to the first tier that reaches
|
||||
the finest resolution (a whole tile at the same resolution as its
|
||||
quadrants would be a duplicate)."""
|
||||
if not tiers:
|
||||
return []
|
||||
finest = min(group[0].res for group in tiers)
|
||||
@ -746,7 +749,7 @@ def sources_to_keep(tiers):
|
||||
|
||||
|
||||
def _pick_tier(tiers, target_res):
|
||||
"""Palier le plus grossier dont la résolution suffit à la tuile visée."""
|
||||
"""Coarsest tier whose resolution is sufficient for the target tile."""
|
||||
for group in tiers:
|
||||
if group[0].res <= target_res:
|
||||
return group
|
||||
@ -754,8 +757,8 @@ def _pick_tier(tiers, target_res):
|
||||
|
||||
|
||||
def sources_in_bbox(output_dir, layer, bbox, target_res):
|
||||
"""Sources d'une couche intersectant une emprise L93, au palier le plus
|
||||
grossier suffisant pour target_res (m/px). Retourne [((col, row), source)]."""
|
||||
"""Sources of a layer intersecting an L93 extent, at the coarsest tier
|
||||
sufficient for target_res (m/px). Returns [((col, row), source)]."""
|
||||
per_cell = source_index(output_dir).get(layer)
|
||||
if not per_cell:
|
||||
return []
|
||||
@ -774,13 +777,13 @@ def sources_in_bbox(output_dir, layer, bbox, target_res):
|
||||
|
||||
|
||||
def _contributing(output_dir, layer, z, x, y, scale):
|
||||
"""Sources intersectant la tuile, palier choisi selon la résolution visée."""
|
||||
"""Sources intersecting the tile, tier chosen from the target resolution."""
|
||||
return [src for _cell, src in sources_in_bbox(
|
||||
output_dir, layer, tile_bounds_l93(z, x, y), target_resolution(z, y, scale))]
|
||||
|
||||
|
||||
def load_source(src):
|
||||
"""Image décodée d'une source (rapatriée si besoin), ou None si illisible."""
|
||||
"""Decoded image of a source (fetched if needed), or None if unreadable."""
|
||||
if not src.ensure():
|
||||
return None
|
||||
mtime = src.mtime()
|
||||
@ -788,13 +791,13 @@ def load_source(src):
|
||||
return None
|
||||
try:
|
||||
return _open_source(str(src.path), mtime)
|
||||
except Exception as e: # noqa: BLE001 — source illisible : rendu partiel
|
||||
logger.debug(f"Source illisible ({src.path.name}) : {e}")
|
||||
except Exception as e: # noqa: BLE001 — unreadable source: partial rendering
|
||||
logger.debug(f"Unreadable source ({src.path.name}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _paste_source(canvas, src, z, x, y, size, resample):
|
||||
"""Reprojette une source dans la tuile (transformation projective)."""
|
||||
"""Reproject a source into the tile (projective transform)."""
|
||||
from PIL import Image
|
||||
|
||||
img = load_source(src)
|
||||
@ -806,8 +809,8 @@ def _paste_source(canvas, src, z, x, y, size, resample):
|
||||
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.
|
||||
# Useful source window = intersection with the tile's L93 extent, widened
|
||||
# by 2 px so the interpolation has its neighbourhood.
|
||||
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)
|
||||
@ -821,7 +824,7 @@ def _paste_source(canvas, src, z, x, y, size, resample):
|
||||
crop = crop.convert("RGBA")
|
||||
cw, ch = crop.size
|
||||
|
||||
# Coins L93 de la fenêtre découpée → EPSG:3857 → pixels de la tuile.
|
||||
# L93 corners of the cropped window → EPSG:3857 → tile pixels.
|
||||
wx0 = min_x + c0 * res_x
|
||||
wx1 = min_x + c1 * res_x
|
||||
wy1 = max_y - r0 * res_y
|
||||
@ -843,7 +846,7 @@ def _paste_source(canvas, src, z, x, y, size, resample):
|
||||
|
||||
|
||||
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."""
|
||||
"""Render a tile in memory. Returns an RGBA image, or None if empty."""
|
||||
from PIL import Image
|
||||
|
||||
sources = _contributing(output_dir, layer, z, x, y, scale)
|
||||
@ -851,10 +854,11 @@ def render_tile(output_dir, layer, z, x, y, scale=1):
|
||||
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.
|
||||
# Aplats de niveaux codés (densité, 1 m/px) : plus proche voisin, sinon
|
||||
# l'agrandissement aux zooms 18–19 invente des gris intermédiaires.
|
||||
# Bicubic in both directions: needed when upscaling (native zoom), and
|
||||
# harmless when downscaling since the source tier already matches the
|
||||
# tile's resolution.
|
||||
# Flat coded levels (density, 1 m/px): nearest neighbour, otherwise
|
||||
# upscaling at zooms 18–19 invents intermediate greys.
|
||||
resample = Image.NEAREST if layer in NEAREST_LAYERS else Image.BICUBIC
|
||||
painted = False
|
||||
for src in sources:
|
||||
@ -865,11 +869,11 @@ def render_tile(output_dir, layer, z, x, y, scale=1):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache disque
|
||||
# Disk cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tile_cache_path(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
"""Chemin du fichier de cache d'une tuile."""
|
||||
"""Path of a tile's cache file."""
|
||||
suffix = "@2x" if scale == 2 else ""
|
||||
return (Path(output_dir) / TILE_DIRNAME / layer / str(z) / str(x)
|
||||
/ f"{y}{suffix}.{fmt}")
|
||||
@ -888,7 +892,7 @@ def _encode(img, fmt):
|
||||
elif fmt == "avif":
|
||||
img.save(buf, format="AVIF", quality=AVIF_QUALITY, speed=AVIF_SPEED)
|
||||
else:
|
||||
raise ValueError(f"format de tuile inconnu : {fmt}")
|
||||
raise ValueError(f"unknown tile format: {fmt}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@ -897,28 +901,28 @@ def _write_atomic(path, data):
|
||||
tmp = path.with_name(path.name + f".{os.getpid()}.part")
|
||||
try:
|
||||
tmp.write_bytes(data)
|
||||
# Datée à l'horloge précise (time.time) : celle des dates de première
|
||||
# apparition des dalles (_apply_seen). L'horloge grossière du noyau
|
||||
# (mtime par défaut) retarde de quelques ms — une tuile écrite juste
|
||||
# après l'arrivée d'une dalle paraîtrait sinon plus vieille qu'elle.
|
||||
# Dated with the precise clock (time.time), the one used for the
|
||||
# tiles' first-appearance dates (_apply_seen). The kernel's coarse
|
||||
# clock (default mtime) lags by a few ms — a tile written right after
|
||||
# a source tile arrived would otherwise look older than it.
|
||||
now = time.time()
|
||||
os.utime(tmp, (now, now))
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
logger.debug(f"Écriture de tuile impossible ({path}) : {e}")
|
||||
logger.debug(f"Cannot write tile ({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."""
|
||||
"""True if tiles at this level are stored on disk."""
|
||||
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.
|
||||
# Tiles rendered on the fly (unstored levels): bounded memory cache, so that
|
||||
# panning back and forth does not recompute everything.
|
||||
MEMORY_CACHE_BYTES = int(os.environ.get("LIDAR_TILE_MEMORY_CACHE_MB", "64")) * 1024 * 1024
|
||||
_mem_tiles = OrderedDict()
|
||||
_mem_tiles_bytes = [0]
|
||||
@ -945,18 +949,18 @@ def _mem_put(key, data):
|
||||
|
||||
|
||||
def zoom_supported(z, scale=1):
|
||||
"""Vrai si le zoom demandé est dans la plage rendue par le serveur."""
|
||||
"""True if the requested zoom is within the range rendered by the server."""
|
||||
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.
|
||||
"""Encoded tile (bytes) from the cache, rendered when needed.
|
||||
|
||||
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.
|
||||
Returns None when no data intersects the tile; an `.empty` marker records
|
||||
this case so it is never recomputed (stored levels only). A cached tile is
|
||||
reused as long as no contributing source tile is newer than it: a
|
||||
regenerated source tile only invalidates its own XYZ tiles.
|
||||
"""
|
||||
cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt)
|
||||
empty = cache.with_suffix(".empty")
|
||||
@ -973,7 +977,7 @@ def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
if m and m > newest:
|
||||
newest = m
|
||||
if not stored:
|
||||
# Niveau non stocké : rendu à la volée, cache mémoire seulement.
|
||||
# Unstored level: rendered on the fly, memory cache only.
|
||||
key = (str(output_dir), layer, z, x, y, scale, fmt, newest)
|
||||
data = _mem_get(key)
|
||||
if data is None:
|
||||
@ -1000,19 +1004,19 @@ def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
|
||||
|
||||
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é)."""
|
||||
"""True if the tile is already known to be empty (remembered negative)."""
|
||||
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).
|
||||
"""Tile from the cache ONLY — never rendered (cache-only mode).
|
||||
|
||||
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.
|
||||
Returns ``(data, state)``:
|
||||
- ``"fresh"`` : tile cached and newer than its source tiles;
|
||||
- ``"empty"`` : no source intersects it (`.empty` marker set);
|
||||
- ``"pending"``: data present but tile missing or stale — to be rendered
|
||||
elsewhere (background task, warm-up), not while browsing.
|
||||
"""
|
||||
cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt)
|
||||
empty = cache.with_suffix(".empty")
|
||||
@ -1026,10 +1030,10 @@ def cached_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
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.
|
||||
# Same freshness rule as for the tile: an .empty marker older than a
|
||||
# source (tile appeared since, newer upstream version) is invalidated —
|
||||
# otherwise an area browsed before it was generated would stay
|
||||
# transparent forever.
|
||||
if empty.exists():
|
||||
try:
|
||||
if newest and empty.stat().st_mtime >= newest:
|
||||
@ -1046,18 +1050,18 @@ def cached_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
|
||||
|
||||
def transparent_tile(scale=1, fmt="png"):
|
||||
"""Tuile entièrement transparente (zone sans donnée)."""
|
||||
"""Fully transparent tile (area without data)."""
|
||||
from PIL import Image
|
||||
size = TILE_SIZE * scale
|
||||
return _encode(Image.new("RGBA", (size, size), (0, 0, 0, 0)), fmt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pré-chauffage
|
||||
# Warm-up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tiles_in_bounds(bounds_wgs84, z):
|
||||
"""Indices (x, y) des tuiles du niveau z couvrant une bbox WGS84."""
|
||||
"""Indices (x, y) of the level-z tiles covering a WGS84 bbox."""
|
||||
west, south, east, north = bounds_wgs84
|
||||
n = 2 ** z
|
||||
|
||||
@ -1078,7 +1082,8 @@ def tiles_in_bounds(bounds_wgs84, z):
|
||||
|
||||
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)."""
|
||||
"""Pre-render the tiles of an extent (returns the report; keys
|
||||
`rendues`/`vides`/`limite` are part of the API payload)."""
|
||||
if bounds_wgs84 is None:
|
||||
bounds_wgs84 = grid_bounds_wgs84(output_dir)
|
||||
if bounds_wgs84 is None:
|
||||
|
||||
Reference in New Issue
Block a user