Générer d'avance toute la pyramide jusqu'au natif, plafonner la carte au zoom 19 et reprendre les dalles refusées par la file pleine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 13:00:49 +02:00
parent 34152269aa
commit 3d415de86c
7 changed files with 149 additions and 43 deletions

View File

@ -35,7 +35,7 @@
- **Génération imposée** : la carte ne propose plus de réglage de classification ni de raccord — `_build_command` (`mapserve.py`) passe toujours `--ground-classification ign --ign-classes sol --edge-buffer 100` ; les champs `ground_class`/`ign_classes`/`reclassify`/`edge_buffer` éventuellement envoyés sont ignorés. Défauts CLI alignés (`ign`, `100`).
- **Classification IGN par extraction directe** : `_extract_ign_ground` (`dtm.py`) filtre les classes avec laspy (retours ≥ 1) et écrit le LAS sol sans passer par PDAL (4,9 s au lieu de 13,5 s par dalle) ; la lecture de la détection automatique est réutilisée (`_LAST_READ`). PDAL reste le repli.
- **Pyramide mise à jour pendant le rendu** : le pipeline régénère l'inventaire après chaque dalle **par défaut** (`incremental_index` sauf `--no-index`, quel que soit le lanceur), avec passe différée quand l'anti-rebond de 3 s ignore une dalle. Côté carte, `_bg_poller` surveille l'inventaire toutes les `BG_WATCH_S` = 10 s (fichier local ou charge utile amont) et lance un scan dès qu'il change ; les tuiles des dalles modifiées passent **en tête** de file (`_bg_enqueue(front=True)`), devant l'arriéré.
- **Pyramide à stockage réduit** : `tiles.zoom_cached` — seuls les niveaux standard pairs ≤ `TILE_CACHE_MAX_Z` (16) vont sur disque (une tuile @2x de z vaut une 256 px de z+1) ; les autres sont rendus à la volée (`get_tile`, cache mémoire `_mem_tiles`), même en `LIDAR_TILE_CACHE_ONLY`. Interface en AVIF (`@2x.avif`) avec `EvenLevelTileLayer` (niveau de tuile arrondi au pair supérieur, sauf le natif 19 = 0,2 m/px demandé tel quel en `18@2x`, rendu à la volée ; au-delà Leaflet étire le natif, jamais de tuile agrandie côté serveur ; `max_native_zoom` de `/api/map/meta` = 19 en numérotation standard) ; maintenance de fond en AVIF sur les seuls niveaux stockés. ~24 Mo → 1,2 Mo sur 8 dalles.
- **Pyramide complète générée d'avance** : par défaut `tiles.zoom_cached` stocke TOUS les niveaux jusqu'au natif (`TILE_CACHE_MAX_Z` = `TILE_MAX_NATIVE_Z` 19, `TILE_EVEN_LEVELS` off) et la maintenance de fond les génère d'avance jusqu'à `18@2x` (défaut de `LIDAR_TILE_BACKGROUND_MAX_Z`) ; sur le Pi elle les TÉLÉCHARGE du worker. Carte plafonnée au zoom 19 (`maxZoom`, 1 px écran = 1 px LiDAR, jamais de tuile agrandie). Raison : le rendu à la volée du Pi (~170 ms/tuile, 3 à la fois) donnait 1,5–6 s par écran aux zooms 17–19. File de maintenance bornée (`queue_max`) : chaque dalle garde dans `_bg["incomplete"]` l'indice de sa première tuile refusée faute de place et reprend de là aux scans suivants (avant : le premier scan saturait la file et le reste de la pyramide n'était jamais généré). ~110 000 tuiles @2x pour 3 240 dalles (~5 Go). Stockage réduit toujours possible (`LIDAR_TILE_EVEN_LEVELS=1` → `EvenLevelTileLayer`, niveaux pairs, natif 19 demandé tel quel).
- **Mémoire bornée de la carte (Pi, `mem_limit: 1g`)** : `_open_source` (`tiles.py`) met en cache une **copie détachée** de chaque source (une image AVIF ouverte garde son décodeur, ~18 Mo de plus par quadrant 2500²) et compte 4 octets/pixel (PIL stocke le RGB sur 32 bits) ; `Dockerfile.maps` fixe `MALLOC_MMAP_THRESHOLD_=1048576` pour que glibc rende les grands tampons au système. Sans ces deux points, la navigation à fort zoom (niveaux rendus à la volée) montait à ~700 Mo de RSS et le conteneur était tué en boucle par l'OOM killer (502 côté Traefik).
- **Carte autonome (Pi sans worker)** : amont (`LIDAR_SOURCE_URL`/`LIDAR_MAPS_URL`) éteint ⇒ la carte reste servie depuis le disque. `_remote_payload` mémorise aussi l'échec (TTL 60 s, délai 10 s : sinon chaque requête repayait le délai réseau et `/api/map/meta` ne répondait plus), puis l'index local (`_build_index`) prend le relais ; coupe-circuits sur les sources (`_SOURCE_OFFLINE`, 60 s) et les tuiles (`_UPSTREAM`, réarmé à expiration) ; source rapatriée datée à sa version amont (`os.utime`) pour que l'index local garde les mêmes dates ; pas de requête amont pour une tuile sans dalle quand l'inventaire vient de l'amont. En cache seule, une tuile périmée reste servie (`no-store`, `X-Tile-Pending`) en attendant la nouvelle.
- **Pyramide téléchargée en tâche de fond** : avec `LIDAR_MAPS_URL`, la maintenance (`_bg_process_one`) TÉLÉCHARGE les tuiles des niveaux stockés depuis l'amont (stat `telechargees`), sans attendre l'affichage ; rendu local en repli si l'amont ne répond pas.

View File

@ -51,9 +51,10 @@ services:
# - LIDAR_TILE_WORKERS=2
# - LIDAR_TILE_FETCH_WORKERS=2
# - LIDAR_TILE_SOURCE_CACHE_MB=192
# Stockage réduit (défauts) : seuls les niveaux standard pairs jusqu'à
# z16 sont écrits sur disque (AVIF) ; les autres sont rendus à la volée
# avec un cache mémoire. LIDAR_TILE_EVEN_LEVELS=0 stocke tous les niveaux.
# Pyramide complète (défaut) : tous les niveaux jusqu'au natif (z19,
# 0,2 m/px) sont stockés et générés d'avance. Stockage réduit : niveaux
# pairs seuls et/ou plafond plus bas, le reste rendu à la volée avec un
# cache mémoire.
# - LIDAR_TILE_CACHE_MAX_Z=16
# - LIDAR_TILE_EVEN_LEVELS=1
# - LIDAR_TILE_MEMORY_CACHE_MB=64

View File

@ -79,17 +79,22 @@ Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces
URL pour la couche choisie.
## Stockage réduit de la pyramide
## Pyramide complète générée d'avance
Pour une petite machine (Raspberry Pi 5), la pyramide n'écrit sur disque que
les niveaux **pairs** en numérotation OSM standard, jusqu'à
`LIDAR_TILE_CACHE_MAX_Z` (16), en AVIF. Les autres niveaux, dont le plus fin
qui pèse à lui seul environ 75 % de la pyramide, sont rendus à la volée
depuis les sous-tuiles, avec un cache mémoire (`LIDAR_TILE_MEMORY_CACHE_MB`,
64 Mo), y compris en mode cache seule. L'interface ne demande que les niveaux
pairs : à un zoom impair, elle réduit les tuiles du niveau supérieur (nettes).
Mesuré sur 8 dalles : environ 24 Mo de pyramide avant, 1,2 Mo après.
`LIDAR_TILE_EVEN_LEVELS=0` rétablit le stockage de tous les niveaux.
Par défaut, **tous** les niveaux jusqu'au natif (19 en numérotation OSM
standard = 0,2 m/px, servi en `18@2x` par l'interface) sont écrits sur
disque en AVIF et générés d'avance par la maintenance de fond (sur une carte
alimentée par l'amont : téléchargés depuis `LIDAR_MAPS_URL`). Aucun niveau
n'est rendu à la volée : le rendu à la demande d'un Raspberry Pi (~170 ms par
tuile, 3 à la fois) donnait 1,5 à 6 s par écran aux zooms 17–19. L'interface
est plafonnée au zoom 19 (1 px écran = 1 px LiDAR) : jamais de tuile
agrandie. Ordre de grandeur mesuré : ~110 000 tuiles @2x pour 3 240 dalles,
dont 81 000 au niveau natif, ~5 Go.
Stockage réduit (disque compté) : `LIDAR_TILE_CACHE_MAX_Z` plus bas et/ou
`LIDAR_TILE_EVEN_LEVELS=1` (niveaux pairs seuls ; l'interface réduit alors
les tuiles du niveau supérieur aux zooms impairs, les autres niveaux sont
rendus à la volée avec un cache mémoire `LIDAR_TILE_MEMORY_CACHE_MB`).
Une carte alimentée par un serveur de dalles amont (`LIDAR_SOURCE_URL`, cas
du Pi) rapatrie et **garde localement** les sources de chaque dalle dès
@ -261,8 +266,7 @@ environment:
transparente avec `X-Tile-Pending: 1` et `Cache-Control: no-store` (le
navigateur la redemande : dès que la maintenance l'a rendue, elle apparaît).
Plus aucun rendu local sur le chemin des requêtes ; avec `LIDAR_MAPS_URL`,
une tuile manquante (ex. zoom au-delà de `LIDAR_TILE_BACKGROUND_MAX_Z`)
y est rapatriée — un téléchargement de quelques dizaines de Ko, mis en
une tuile manquante (pas encore passée par la maintenance) y est rapatriée — un téléchargement de quelques dizaines de Ko, mis en
cache — puis servie : la navigation couvre tous les zooms sans jamais
calculer sur la petite machine.
- **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle
@ -277,12 +281,12 @@ environment:
| Variable | Défaut | Rôle |
|---|---|---|
| `LIDAR_TILE_BACKGROUND_MAX_Z` | `16` | niveau maximal entretenu en fond (le natif 17–19 reste au pré-chauffage manuel) |
| `LIDAR_TILE_BACKGROUND_MAX_Z` | natif (`18` en @2x, `19` en 256 px) | niveau maximal entretenu en fond, numérotation des URL |
| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tuiles entretenues : 2 = 512 px (celles de l'interface) |
| `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format des tuiles entretenues (celui de l'interface) |
| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) entre deux rendus — le levier de la discrétion |
| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | secondes entre deux scans des dalles |
| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | file d'attente bornée (au-delà : ignoré jusqu'au prochain scan) — une entrée ne coûte que quelques octets |
| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | file d'attente bornée — chaque dalle mémorise la première tuile refusée faute de place et reprend de là aux scans suivants, jusqu'à ce que toute sa pyramide soit passée |
Pilotage : `GET /api/map/background` (état, compteurs, file), `POST
/api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel

View File

@ -149,15 +149,20 @@ def _env_flag(name):
in ("1", "true", "yes", "on", "oui"))
def _bg_scale():
return 1 if os.environ.get("LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1" else 2
def _bg_conf():
"""Réglages de la maintenance (relus au démarrage des fils, cf. doc)."""
return {
"interval": max(10.0, float(os.environ.get(
"LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)),
# Défaut : jusqu'au natif (18@2x = 0,2 m/px) — pyramide complète.
"max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get(
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
"scale": 1 if (os.environ.get(
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
"LIDAR_TILE_BACKGROUND_MAX_Z", "") or (tiles_mod.TILE_MAX_NATIVE_Z - (
0 if _bg_scale() == 1 else 1)))),
"scale": _bg_scale(),
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "avif")
or "avif").strip(),
"pause": max(0.0, float(os.environ.get(
@ -169,17 +174,20 @@ def _bg_conf():
_bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
"queued": set(), "guard": threading.Lock(), "snapshot": None,
"prefetch": deque(),
"prefetch": deque(), "incomplete": {},
"running": False,
"stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
"dernier_scan": None, "ajouts_dernier_scan": 0}}
def _bg_enqueue(items, front=False):
def _bg_enqueue(items, front=False, rejected=None):
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée.
rejected : ensemble complété des tuiles refusées faute de place.
front=True : en tête de file, dans l'ordre donné (dalles tout juste
rendues) ; une tuile déjà en file y est remontée.
rendues) ; une tuile déjà en file y est remontée. L'arriéré n'est pas
tronqué pour leur faire place (ses dalles ne seraient jamais reprises) :
la file déborde alors brièvement de la tête ajoutée.
"""
added = 0
with _bg["guard"]:
@ -189,12 +197,18 @@ def _bg_enqueue(items, front=False):
rest = [it for it in _bg["queue"] if it not in keep]
_bg["queue"].clear()
_bg["queue"].extend(fresh[:_bg_conf()["queue_max"]])
_bg["queue"].extend(rest[:max(0, _bg_conf()["queue_max"] - len(_bg["queue"]))])
_bg["queue"].extend(rest)
if rejected is not None:
rejected.update(fresh[_bg_conf()["queue_max"]:])
added = sum(1 for it in fresh if it not in _bg["queued"])
_bg["queued"] = set(_bg["queue"])
return added
for item in items:
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
if item in _bg["queued"]:
continue
if len(_bg["queue"]) >= _bg_conf()["queue_max"]:
if rejected is not None:
rejected.add(item)
continue
_bg["queue"].append(item)
_bg["queued"].add(item)
@ -223,6 +237,7 @@ def background_scan(output_dir=None):
first_scan = _bg["snapshot"] is None
snapshot = {}
changed = {}
resume = {} # dalle inchangée, pyramide pas encore toute en file → reprise
for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items():
for (col, row), tiers in per_cell.items():
newest = 0.0
@ -234,6 +249,9 @@ def background_scan(output_dir=None):
snapshot[(layer, col, row)] = newest
if (_bg["snapshot"] is not None
and _bg["snapshot"].get((layer, col, row)) == newest):
if (layer, col, row) in _bg["incomplete"]:
resume[(layer, col, row)] = _bg["incomplete"][(layer, col, row)]
changed.setdefault(layer, []).append((col, row))
continue
changed.setdefault(layer, []).append((col, row))
_bg["snapshot"] = snapshot
@ -253,20 +271,41 @@ def background_scan(output_dir=None):
if src.url is not None and key not in pending and not src.path.is_file():
_bg["prefetch"].append(src)
pending.add(key)
items = []
# File bornée (queue_max) : sur un grand territoire, le premier scan la
# remplissait et le reste de la pyramide n'était jamais généré. Chaque
# dalle garde donc sa position : les tuiles refusées faute de place sont
# reprises aux scans suivants (en queue, derrière l'arriéré), à partir de
# la première refusée. Les dalles qui viennent de changer passent en
# tête (la pyramide suit le rendu en cours, hors premier scan).
order = lambda t: (t[1], t[0], t[2], t[3]) # noqa: E731 — bas niveaux d'abord
items, retry, per_cell = [], [], {}
for layer, cells in changed.items():
for col, row in cells:
cell = (layer, col, row)
bbox = tiles_mod.cell_bounds_wgs84(col, row)
mine = []
for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1):
if not (tiles_mod.zoom_supported(z, conf["scale"])
and tiles_mod.zoom_cached(z, conf["scale"])):
continue
for x, y in tiles_mod.tiles_in_bounds(bbox, z):
items.append((layer, z, x, y))
items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord
# Dalles qui viennent de changer : en tête de file, devant l'arriéré —
# la pyramide suit le rendu en cours (hors premier scan, qui construit tout).
added = _bg_enqueue(items, front=first_scan is False)
mine.append((layer, z, x, y))
mine.sort(key=order)
start = resume.get(cell, 0)
per_cell[cell] = (start, mine)
(retry if cell in resume else items).extend(mine[start:])
items.sort(key=order)
retry.sort(key=order)
rejected = set()
added = _bg_enqueue(items, front=first_scan is False, rejected=rejected)
added += _bg_enqueue(retry, rejected=rejected)
incomplete = {}
for cell, (start, mine) in per_cell.items():
for i in range(start, len(mine)):
if mine[i] in rejected:
incomplete[cell] = i
break
_bg["incomplete"] = incomplete
_bg["stats"]["scans"] += 1
_bg["stats"]["dernier_scan"] = time.time()
_bg["stats"]["ajouts_dernier_scan"] = added

View File

@ -383,7 +383,9 @@ const map = L.map('map', {
zoomControl: false,
attributionControl: true,
minZoom: 3,
maxZoom: 22,
// Zoom plafonné au natif (19 = 0,2 m/px, 1 px écran = 1 px LiDAR) : toute
// la pyramide est générée d'avance, jamais de tuile agrandie ni étirée.
maxZoom: 19,
zoomSnap: 0.5,
wheelPxPerZoomLevel: 90,
worldCopyJump: false,
@ -411,7 +413,7 @@ const ZoomAnimTileLayer = L.TileLayer.extend({
});
const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
// Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
updateWhenZooming: false,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@ -608,7 +610,7 @@ function buildLayers() {
minNativeZoom: META.min_zoom,
maxNativeZoom: META.max_native_zoom,
minZoom: 0,
maxZoom: 22,
maxZoom: 19,
// Tuiles de 512 px : une rangée de réserve hors écran vaut les deux
// rangées de 256 px du fond. Deux rangées doublaient les images
// décodées gardées en mémoire (1 Mo chacune) pour peu de gain.

View File

@ -206,7 +206,7 @@ def test_map_meta(tmp_path, monkeypatch):
assert {l["key"] for l in meta["layers"]} == {"relief_oriente", "aspect", "slope"}
assert all(l["label"] for l in meta["layers"])
assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.avif"
assert meta["even_levels"] is True
assert meta["even_levels"] is False # pyramide complète générée d'avance
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
assert meta["max_native_zoom"] == 19
assert meta["bounds"] and meta["stamp"] > 0
@ -581,6 +581,7 @@ def _reset_bg(mapserve):
mapserve._bg["queue"].clear()
mapserve._bg["queued"].clear()
mapserve._bg["snapshot"] = None
mapserve._bg["incomplete"] = {}
mapserve._bg["stats"].update(rendues=0, a_jour=0, vides=0, scans=0,
dernier_scan=None, ajouts_dernier_scan=0)
@ -1190,3 +1191,59 @@ def test_zoom_animation_transforms_only_on_all_tile_layers():
assert re.search(r"const base = new ZoomAnimTileLayer\(", _MAP_JS)
assert "const EvenLevelTileLayer = ZoomAnimTileLayer.extend(" in _MAP_JS
assert "L.tileLayer(" not in _MAP_JS.replace("L.tileLayer` standard", "")
def test_default_policy_pregenerates_every_level_up_to_native(monkeypatch):
"""Par défaut, toute la pyramide jusqu'au natif (18@2x = 0,2 m/px) est
stockée et générée d'avance : aucun niveau rendu à la volée."""
import importlib
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
for name in ("LIDAR_TILE_CACHE_MAX_Z", "LIDAR_TILE_EVEN_LEVELS",
"LIDAR_TILE_BACKGROUND_MAX_Z", "LIDAR_TILE_BACKGROUND_SCALE"):
monkeypatch.delenv(name, raising=False)
try:
importlib.reload(tiles)
assert all(tiles.zoom_cached(z, 2) for z in range(tiles.TILE_MIN_Z, 19))
assert all(tiles.zoom_cached(z, 1) for z in range(tiles.TILE_MIN_Z, 20))
assert mapserve._bg_conf()["max_z"] == 18 and mapserve._bg_conf()["scale"] == 2
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_SCALE", "1")
assert mapserve._bg_conf()["max_z"] == 19
finally:
importlib.reload(tiles)
def test_background_scan_retries_cells_beyond_queue_max(tmp_path, monkeypatch):
"""File pleine : les dalles dont la pyramide n'a pas trouvé place sont
reprises aux scans suivants jusqu'à ce que toutes leurs tuiles passent."""
import collections
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", False)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 19)
_setup(tmp_path, monkeypatch)
_make_dalle(tmp_path, 1055, 6882, ["aspect"])
conf = dict(mapserve._bg_conf(), max_z=18, scale=2, queue_max=40)
monkeypatch.setattr(mapserve, "_bg_conf", lambda: conf)
for key, val in (("queue", collections.deque()), ("queued", set()),
("snapshot", None), ("prefetch", collections.deque()),
("incomplete", {})):
monkeypatch.setitem(mapserve._bg, key, val)
expected = set()
for layer, per_cell in tiles.source_index(tmp_path, force=True).items():
for col, row in per_cell:
bbox = tiles.cell_bounds_wgs84(col, row)
for z in range(tiles.TILE_MIN_Z, 19):
expected.update((layer, z, x, y) for x, y in tiles.tiles_in_bounds(bbox, z))
assert len(expected) > 3 * conf["queue_max"]
seen = set()
for _ in range(100):
mapserve.background_scan(tmp_path)
seen.update(mapserve._bg["queue"])
mapserve._bg["queue"].clear() # file vidée par les rendeurs
mapserve._bg["queued"].clear()
if not mapserve._bg["incomplete"]:
break
assert seen == expected
# Pyramide passée en entier : les scans suivants se taisent
assert mapserve.background_scan(tmp_path) == 0

View File

@ -28,13 +28,16 @@ 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"
# 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_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)