Mettre à jour la pyramide pendant le rendu et réduire son stockage

Le pipeline régénère l'inventaire après chaque dalle par défaut (tous
lanceurs, sauf --no-index), avec une passe différée quand l'anti-rebond
ignore une dalle. La carte surveille l'inventaire toutes les 10 s et place
les tuiles des dalles modifiées en tête de file de maintenance : la
pyramide suit le rendu au lieu d'attendre la fin du lot.

Stockage réduit pour le Raspberry Pi : seuls les niveaux standard pairs
jusqu'à z16 sont écrits sur disque, en AVIF ; les autres, dont le niveau
le plus fin (~75 % de la pyramide), sont rendus à la volée avec un cache
mémoire, y compris en mode cache seule. L'interface ne demande que les
niveaux pairs et réduit ceux du niveau supérieur aux zooms impairs.
Mesuré sur 8 dalles : ~24 Mo de pyramide avant, 1,2 Mo après.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 02:31:11 +02:00
parent f836408d6f
commit 434b7f12fd
11 changed files with 299 additions and 35 deletions

View File

@ -34,6 +34,8 @@
- **Filename special-cases** in `_expected_output_path()`: `pos_open` → `positive_openness`, `neg_open` → `negative_openness`, `hillshade` → `hillshade_multi`. - **Filename special-cases** in `_expected_output_path()`: `pos_open` → `positive_openness`, `neg_open` → `negative_openness`, `hillshade` → `hillshade_multi`.
- **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`). - **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. - **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) ; maintenance de fond en AVIF sur les seuls niveaux stockés. ~24 Mo → 1,2 Mo sur 8 dalles.
- **Encodage AVIF rapide** : `AVIF_SPEED = 9` (`rendering.py`, `tiles.py`, `_SUBTILE_AVIF_SPEED` dans `index.py`) — dalle 5000 × 5000 px encodée en 0,6 s au lieu de 4 s (+3 % de taille, −0,3 dB) ; l'encodage était l'étape la plus longue du rendu d'une couche. - **Encodage AVIF rapide** : `AVIF_SPEED = 9` (`rendering.py`, `tiles.py`, `_SUBTILE_AVIF_SPEED` dans `index.py`) — dalle 5000 × 5000 px encodée en 0,6 s au lieu de 4 s (+3 % de taille, −0,3 dB) ; l'encodage était l'étape la plus longue du rendu d'une couche.
- **Préparation sur GPU** : `_fill_nans` (transformée de distance `cupyx`) et les gradients de `SharedDEM` passent sur GPU quand il est actif (repli CPU). `NUMBA_CACHE_DIR=/tmp/numba-cache` (Dockerfile) évite la recompilation des noyaux numba à chaque worker. - **Préparation sur GPU** : `_fill_nans` (transformée de distance `cupyx`) et les gradients de `SharedDEM` passent sur GPU quand il est actif (repli CPU). `NUMBA_CACHE_DIR=/tmp/numba-cache` (Dockerfile) évite la recompilation des noyaux numba à chaque worker.
- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98). - **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98).

View File

@ -51,4 +51,10 @@ services:
# - LIDAR_TILE_WORKERS=2 # - LIDAR_TILE_WORKERS=2
# - LIDAR_TILE_FETCH_WORKERS=2 # - LIDAR_TILE_FETCH_WORKERS=2
# - LIDAR_TILE_SOURCE_CACHE_MB=192 # - 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.
# - LIDAR_TILE_CACHE_MAX_Z=16
# - LIDAR_TILE_EVEN_LEVELS=1
# - LIDAR_TILE_MEMORY_CACHE_MB=64
restart: unless-stopped restart: unless-stopped

View File

@ -34,8 +34,9 @@ réglage. Une demande lancée pendant un run part en **file
d'attente** (persistée, jamais de coupure du travail en place) ; la d'attente** (persistée, jamais de coupure du travail en place) ; la
progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu = progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu =
en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis
SIGKILL). À la fin du run, la carte se rafraîchit et la maintenance de SIGKILL). Pendant le run, chaque dalle terminée apparaît sur la carte : l'inventaire
pyramide repart automatiquement. est mis à jour après chaque dalle et la maintenance de pyramide traite ses
tuiles en priorité (surveillance toutes les 10 s).
Les boutons sont masqués si le navigateur vient d'une IP hors Les boutons sont masqués si le navigateur vient d'une IP hors
`LIDAR_REGEN_CIDR` (défaut : localhost + plages privées RFC1918) ou si aucun `LIDAR_REGEN_CIDR` (défaut : localhost + plages privées RFC1918) ou si aucun
@ -78,6 +79,18 @@ 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 Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces
URL pour la couche choisie. URL pour la couche choisie.
## Stockage réduit de la pyramide
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.
## Fiche de dalle et rose des vents ## Fiche de dalle et rose des vents
Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre

View File

@ -270,8 +270,8 @@ def main():
parser.add_argument( parser.add_argument(
"--incremental-index", "--incremental-index",
action="store_true", action="store_true",
help="Régénérer le catalogue après chaque tuile terminée (affichage en direct " help="Conservé pour compatibilité : le catalogue est désormais régénéré après "
"sur la carte, sans attendre la fin du run)" "chaque tuile terminée par défaut (sauf --no-index)"
) )
args = parser.parse_args() args = parser.parse_args()

View File

@ -158,8 +158,8 @@ def _bg_conf():
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)), "LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
"scale": 1 if (os.environ.get( "scale": 1 if (os.environ.get(
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2, "LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp") "fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "avif")
or "webp").strip(), or "avif").strip(),
"pause": max(0.0, float(os.environ.get( "pause": max(0.0, float(os.environ.get(
"LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)), "LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)),
"queue_max": max(1024, int(os.environ.get( "queue_max": max(1024, int(os.environ.get(
@ -174,10 +174,24 @@ _bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
"dernier_scan": None, "ajouts_dernier_scan": 0}} "dernier_scan": None, "ajouts_dernier_scan": 0}}
def _bg_enqueue(items): def _bg_enqueue(items, front=False):
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée.""" """Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée.
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.
"""
added = 0 added = 0
with _bg["guard"]: with _bg["guard"]:
if front:
fresh = [it for it in items]
keep = set(fresh)
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"]))])
added = sum(1 for it in fresh if it not in _bg["queued"])
_bg["queued"] = set(_bg["queue"])
return added
for item in items: for item in items:
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]: if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
continue continue
@ -205,6 +219,7 @@ def background_scan(output_dir=None):
""" """
output_dir = Path(output_dir or OUTPUT_DIR) output_dir = Path(output_dir or OUTPUT_DIR)
conf = _bg_conf() conf = _bg_conf()
first_scan = _bg["snapshot"] is None
snapshot = {} snapshot = {}
changed = {} changed = {}
for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items(): for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items():
@ -226,12 +241,15 @@ def background_scan(output_dir=None):
for col, row in cells: for col, row in cells:
bbox = tiles_mod.cell_bounds_wgs84(col, row) bbox = tiles_mod.cell_bounds_wgs84(col, row)
for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1): for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1):
if not tiles_mod.zoom_supported(z, conf["scale"]): if not (tiles_mod.zoom_supported(z, conf["scale"])
and tiles_mod.zoom_cached(z, conf["scale"])):
continue continue
for x, y in tiles_mod.tiles_in_bounds(bbox, z): for x, y in tiles_mod.tiles_in_bounds(bbox, z):
items.append((layer, z, x, y)) items.append((layer, z, x, y))
items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord
added = _bg_enqueue(items) # 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)
_bg["stats"]["scans"] += 1 _bg["stats"]["scans"] += 1
_bg["stats"]["dernier_scan"] = time.time() _bg["stats"]["dernier_scan"] = time.time()
_bg["stats"]["ajouts_dernier_scan"] = added _bg["stats"]["ajouts_dernier_scan"] = added
@ -276,13 +294,37 @@ def _bg_worker():
_bg["stop"].wait(0.5) _bg["stop"].wait(0.5)
def _inventory_mark():
"""Signature bon marché de l'inventaire des dalles (fichier local ou
charge utile amont), pour déclencher un scan dès qu'une dalle arrive."""
try:
return (OUTPUT_DIR / "index_tiles.json").stat().st_mtime
except OSError:
pass
if not tiles_mod.REMOTE_SOURCE_URL:
return None
try:
payload = tiles_mod._remote_payload()
return payload.get("stamp") if payload else None
except Exception: # noqa: BLE001
return None
BG_WATCH_S = 10.0 # surveillance de l'inventaire pendant les runs
def _bg_poller(): def _bg_poller():
last_scan, last_mark = 0.0, _inventory_mark()
while not _bg["stop"].is_set(): while not _bg["stop"].is_set():
try: mark = _inventory_mark()
background_scan() due = time.time() - last_scan >= _bg_conf()["interval"]
except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL if due or mark != last_mark:
logger.warning(f"Maintenance des tuiles : scan impossible ({e})") try:
_bg["stop"].wait(_bg_conf()["interval"]) background_scan()
except Exception as e: # noqa: BLE001 — prochain scan au prochain tour
logger.warning(f"Maintenance des tuiles : scan impossible ({e})")
last_scan, last_mark = time.time(), mark
_bg["stop"].wait(min(BG_WATCH_S, _bg_conf()["interval"]))
def start_background(): def start_background():
@ -570,7 +612,7 @@ async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
if not (0 <= x < n and 0 <= y < n): if not (0 <= x < n and 0 <= y < n):
raise HTTPException(404, "tuile hors grille") raise HTTPException(404, "tuile hors grille")
if _env_flag("LIDAR_TILE_CACHE_ONLY"): if _env_flag("LIDAR_TILE_CACHE_ONLY") and tiles_mod.zoom_cached(z, scale):
# Mode cache seule : aucune navigation ne déclenche de RENDU local. # Mode cache seule : aucune navigation ne déclenche de RENDU local.
# Une tuile absente (ex. zoom > LIDAR_TILE_BACKGROUND_MAX_Z, jamais # Une tuile absente (ex. zoom > LIDAR_TILE_BACKGROUND_MAX_Z, jamais
# couverte par la maintenance) est rapatriée du serveur amont # couverte par la maintenance) est rapatriée du serveur amont
@ -847,7 +889,10 @@ def map_meta():
"defaults_saved": bool(saved), "defaults_saved": bool(saved),
# L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en # L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en
# 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique. # 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique.
"tile_url": "tiles/{layer}/{z}/{x}/{y}@2x.webp", "tile_url": "tiles/{layer}/{z}/{x}/{y}@2x.avif",
# Niveaux standard pairs seulement (stockage réduit) : l'interface
# réduit ceux du niveau supérieur aux zooms impairs.
"even_levels": tiles_mod.TILE_EVEN_LEVELS,
"tile_size": tiles_mod.TILE_SIZE * 2, "tile_size": tiles_mod.TILE_SIZE * 2,
"zoom_offset": -1, "zoom_offset": -1,
"min_zoom": tiles_mod.TILE_MIN_Z, "min_zoom": tiles_mod.TILE_MIN_Z,

View File

@ -540,6 +540,22 @@ function tileUrl(key) {
return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : ''); return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
} }
// Couche à niveaux standard pairs seulement (stockage réduit côté serveur) :
// à un zoom impair, les tuiles du niveau pair SUPÉRIEUR sont affichées
// réduites de moitié (nettes) ; au-delà du zoom natif, le niveau inférieur.
// Le niveau de tuile Leaflet est ici la numérotation standard (zoomOffset −1
// avec des tuiles de 512 px).
const EvenLevelTileLayer = L.TileLayer.extend({
_clampZoom(zoom) {
let z = L.TileLayer.prototype._clampZoom.call(this, zoom);
if (this.options.evenLevels && z % 2) {
const maxN = this.options.maxNativeZoom;
z = (maxN !== undefined && z + 1 > maxN) ? z - 1 : z + 1;
}
return z;
}
});
function buildLayers() { function buildLayers() {
// Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la // Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la
// pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers // pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers
@ -554,7 +570,8 @@ function buildLayers() {
const pane = map.getPane(paneName) || map.createPane(paneName, stack); const pane = map.getPane(paneName) || map.createPane(paneName, stack);
const existing = LAYERS.get(info.key); const existing = LAYERS.get(info.key);
if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); } if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); }
const layer = L.tileLayer(tileUrl(info.key), { const layer = new EvenLevelTileLayer(tileUrl(info.key), {
evenLevels: !!META.even_levels,
pane: paneName, pane: paneName,
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors — // Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
// pas de requêtes inutiles sur le reste du monde. // pas de requêtes inutiles sur le reste du monde.

View File

@ -11,6 +11,7 @@ import logging
import multiprocessing import multiprocessing
import os import os
import shutil import shutil
import threading
import time import time
from concurrent.futures import ProcessPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError from concurrent.futures import ProcessPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime from datetime import datetime
@ -156,11 +157,16 @@ class LidarArchaeoPipeline:
self.output_format = output_format self.output_format = output_format
self.gpu_ids = gpu_ids self.gpu_ids = gpu_ids
self.no_index = no_index self.no_index = no_index
self.incremental_index = incremental_index # Inventaire mis à jour après chaque dalle par défaut : la carte (et sa
# maintenance de pyramide) suit le rendu en cours, quel que soit le
# lanceur (carte, compose, run.sh). --no-index le désactive.
self.incremental_index = bool(incremental_index) or not no_index
self.strip_align = strip_align self.strip_align = strip_align
self.openness_downsample = openness_downsample self.openness_downsample = openness_downsample
self.edge_buffer = float(edge_buffer) self.edge_buffer = float(edge_buffer)
self._last_index_rebuild = 0.0 self._last_index_rebuild = 0.0
self._index_timer = None
self._index_lock = threading.Lock()
self.temp_dir = self.output_dir / "temp" self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists(): if not self.input_dir.exists():
@ -681,20 +687,32 @@ class LidarArchaeoPipeline:
""" """
if self.no_index: if self.no_index:
return return
now = time.time() wait = 3.0 - (time.time() - self._last_index_rebuild)
if now - self._last_index_rebuild < 3.0: if wait > 0:
# Anti-rebond : la dalle n'est pas oubliée, une passe différée la
# couvre dès la fin de l'intervalle (sans attendre la dalle suivante).
with self._index_lock:
if self._index_timer is None:
self._index_timer = threading.Timer(wait, self._rebuild_index_now)
self._index_timer.daemon = True
self._index_timer.start()
return return
self._last_index_rebuild = now self._rebuild_index_now()
try:
from .index import build_index def _rebuild_index_now(self):
saved_level = logger.getEffectiveLevel() with self._index_lock:
logger.setLevel(logging.WARNING) self._index_timer = None
self._last_index_rebuild = time.time()
try: try:
build_index(self.output_dir, self.output_format) from .index import build_index
finally: saved_level = logger.getEffectiveLevel()
logger.setLevel(saved_level) logger.setLevel(logging.WARNING)
except Exception as e: try:
logger.debug(f"Rebuild incrémental de l'index ignoré : {e}") build_index(self.output_dir, self.output_format)
finally:
logger.setLevel(saved_level)
except Exception as e:
logger.debug(f"Rebuild incrémental de l'index ignoré : {e}")
def process_all(self, files=None): def process_all(self, files=None):
"""Process all LAZ files in input directory (or an explicit list).""" """Process all LAZ files in input directory (or an explicit list)."""

View File

@ -205,7 +205,8 @@ def test_map_meta(tmp_path, monkeypatch):
meta = mapserve.map_meta() meta = mapserve.map_meta()
assert {l["key"] for l in meta["layers"]} == {"relief_oriente", "aspect", "slope"} assert {l["key"] for l in meta["layers"]} == {"relief_oriente", "aspect", "slope"}
assert all(l["label"] for l in meta["layers"]) assert all(l["label"] for l in meta["layers"])
assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.webp" assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.avif"
assert meta["even_levels"] is True
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1 assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
assert meta["max_native_zoom"] == 18 assert meta["max_native_zoom"] == 18
assert meta["bounds"] and meta["stamp"] > 0 assert meta["bounds"] and meta["stamp"] > 0
@ -558,6 +559,9 @@ def _reset_bg(mapserve):
def test_tile_cache_only_never_renders(tmp_path, monkeypatch): def test_tile_cache_only_never_renders(tmp_path, monkeypatch):
"""Mode cache seule : tuile manquante = transparente non mémorisable.""" """Mode cache seule : tuile manquante = transparente non mémorisable."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
mapserve = _setup(tmp_path, monkeypatch, layers=("slope",)) mapserve = _setup(tmp_path, monkeypatch, layers=("slope",))
from lidar_pipeline import tiles from lidar_pipeline import tiles
monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1")
@ -857,6 +861,9 @@ def test_cache_only_fetches_missing_tile_upstream(tmp_path, monkeypatch):
"""Cache seule + LIDAR_MAPS_URL : une tuile absente est rapatriee du serveur """Cache seule + LIDAR_MAPS_URL : une tuile absente est rapatriee du serveur
amont (aucun rendu local : les zooms au-dela de la maintenance s'affichent). amont (aucun rendu local : les zooms au-dela de la maintenance s'affichent).
""" """
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
import lidar_pipeline.mapserve as mapserve import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles from lidar_pipeline import tiles
_setup(tmp_path, monkeypatch) _setup(tmp_path, monkeypatch)
@ -922,3 +929,47 @@ def test_ui_tile_card_and_wind_rose():
assert 'id="rose"' in _MAP_HTML and 'id="roseCanvas"' in _MAP_HTML assert 'id="rose"' in _MAP_HTML and 'id="roseCanvas"' in _MAP_HTML
assert "api/map/ign?col=" in _MAP_JS and "Télécharger le nuage LiDAR" in _MAP_JS assert "api/map/ign?col=" in _MAP_JS and "Télécharger le nuage LiDAR" in _MAP_JS
assert "compass + 90" in _MAP_JS # même repère que le rendu (aspect = boussole + 90°) assert "compass + 90" in _MAP_JS # même repère que le rendu (aspect = boussole + 90°)
def test_fresh_tiles_jump_the_maintenance_queue(monkeypatch):
"""Tuiles d'une dalle tout juste rendue : en tête de file, devant l'arriéré."""
import lidar_pipeline.mapserve as mapserve
monkeypatch.setitem(mapserve._bg, "queue", __import__("collections").deque())
monkeypatch.setitem(mapserve._bg, "queued", set())
backlog = [("relief_oriente", 16, i, 0) for i in range(5)]
mapserve._bg_enqueue(backlog)
fresh = [("relief_oriente", 12, 9, 9), ("relief_oriente", 16, 3, 0)]
mapserve._bg_enqueue(fresh, front=True)
q = list(mapserve._bg["queue"])
assert q[:2] == fresh and len(q) == 6 and q.count(("relief_oriente", 16, 3, 0)) == 1
def test_cache_only_renders_unstored_levels(tmp_path, monkeypatch):
"""Cache seule (Pi) : un niveau non stocké est rendu à la volée — jamais
une tuile transparente « en attente » qui n'arriverait jamais."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
_setup(tmp_path, monkeypatch)
monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1")
z = 17
x, y = _tile_of_cell(1054, 6882, z)
resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.avif"))
assert resp.headers.get("X-Tile-Pending") != "1" and resp.headers["X-Tile-Empty"] == "0"
assert not tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif").exists()
def test_background_scan_only_queues_stored_levels(tmp_path, monkeypatch):
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
_setup(tmp_path, monkeypatch)
monkeypatch.setitem(mapserve._bg, "queue", __import__("collections").deque())
monkeypatch.setitem(mapserve._bg, "queued", set())
monkeypatch.setitem(mapserve._bg, "snapshot", None)
mapserve.background_scan(tmp_path)
zs = {z for (_l, z, _x, _y) in mapserve._bg["queue"]}
assert zs and all(tiles.zoom_cached(z, 2) for z in zs)
assert zs <= {5, 7, 9, 11, 13, 15}

View File

@ -33,6 +33,28 @@ class TestVizSteps:
p = LidarArchaeoPipeline(tmp_path, tmp_path / "out2", only_viz=["slope"]) p = LidarArchaeoPipeline(tmp_path, tmp_path / "out2", only_viz=["slope"])
assert [n for n, _ in p.viz_steps] == ["slope"] assert [n for n, _ in p.viz_steps] == ["slope"]
def test_incremental_index_on_by_default(self, tmp_path):
"""La carte suit le rendu en cours quel que soit le lanceur."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
assert LidarArchaeoPipeline(tmp_path, tmp_path / "o").incremental_index
assert not LidarArchaeoPipeline(tmp_path, tmp_path / "o2", no_index=True).incremental_index
def test_debounced_tile_is_indexed_later(self, tmp_path, monkeypatch):
"""Une dalle terminée pendant l'anti-rebond est reprise par une passe
différée, sans attendre la dalle suivante."""
import time
import lidar_pipeline.index as index
from lidar_pipeline.pipeline import LidarArchaeoPipeline
calls = []
monkeypatch.setattr(index, "build_index", lambda *a, **k: calls.append(time.time()))
p = LidarArchaeoPipeline(tmp_path, tmp_path / "o")
p._rebuild_index_incremental() # passe immédiate
p._last_index_rebuild = time.time() - 2.8 # anti-rebond presque écoulé
p._rebuild_index_incremental() # différée (~0,2 s)
p._rebuild_index_incremental() # déjà programmée : pas de doublon
time.sleep(0.6)
assert len(calls) == 2
def test_ortho_and_topo_present(self): def test_ortho_and_topo_present(self):
from lidar_pipeline.pipeline import VIZ_STEPS from lidar_pipeline.pipeline import VIZ_STEPS
names = [name for name, _ in VIZ_STEPS] names = [name for name, _ in VIZ_STEPS]

View File

@ -239,8 +239,11 @@ def test_tile_edges_transparent_outside_data(tmp_path):
assert img.getpixel((255, 255))[3] == 0 assert img.getpixel((255, 255))[3] == 0
def test_get_tile_cache_and_staleness(tmp_path): def test_get_tile_cache_and_staleness(tmp_path, monkeypatch):
"""Le cache disque est réutilisé, puis invalidé par une dalle régénérée.""" """Le cache disque est réutilisé, puis invalidé par une dalle régénérée."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
import os import os
import time import time
from lidar_pipeline import tiles from lidar_pipeline import tiles
@ -270,8 +273,11 @@ def test_get_tile_cache_and_staleness(tmp_path):
assert cache.stat().st_mtime > first assert cache.stat().st_mtime > first
def test_get_tile_empty_marker(tmp_path): def test_get_tile_empty_marker(tmp_path, monkeypatch):
"""Tuile sans donnée : None + marqueur .empty mémorisé.""" """Tuile sans donnée : None + marqueur .empty mémorisé."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
from lidar_pipeline import tiles from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"]) _make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True) tiles.source_index(tmp_path, force=True)
@ -514,3 +520,33 @@ def test_cached_tile_states(tmp_path):
data, state = tiles.cached_tile(tmp_path, "slope", 14, 0, 0) data, state = tiles.cached_tile(tmp_path, "slope", 14, 0, 0)
assert state == "empty" and data is None assert state == "empty" and data is None
assert tiles.empty_marker_exists(tmp_path, "slope", 14, 0, 0) assert tiles.empty_marker_exists(tmp_path, "slope", 14, 0, 0)
def test_storage_policy_even_levels_up_to_max(monkeypatch):
"""Stockage réduit : niveaux standard pairs ≤ LIDAR_TILE_CACHE_MAX_Z (une
tuile @2x de niveau z vaut une 256 px de z+1)."""
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
assert tiles.zoom_cached(16, 1) and not tiles.zoom_cached(15, 1)
assert tiles.zoom_cached(15, 2) and not tiles.zoom_cached(16, 2) # @2x z15 = z16 standard
assert not tiles.zoom_cached(18, 1) and not tiles.zoom_cached(17, 2) # niveau fin : à la volée
def test_unstored_level_rendered_on_the_fly_without_disk(tmp_path, monkeypatch):
"""Niveau non stocké : tuile rendue, rien d'écrit sur disque, seconde
demande servie par le cache mémoire."""
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
z = 17
x, y = _tile_of_cell(1054, 6882, z)
calls = []
real = tiles.render_tile
monkeypatch.setattr(tiles, "render_tile", lambda *a, **k: calls.append(1) or real(*a, **k))
data = tiles.get_tile(tmp_path, "aspect", z, x, y, 1, "png")
assert data and data[:4] == b"\x89PNG"
assert not tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 1, "png").exists()
assert tiles.get_tile(tmp_path, "aspect", z, x, y, 1, "png") == data and len(calls) == 1

View File

@ -28,6 +28,13 @@ TILE_SIZE = 256 # taille canonique (OSM/XYZ) ; @2x → 512
TILE_MIN_Z = 5 TILE_MIN_Z = 5
TILE_MAX_NATIVE_Z = 19 # 0,2 m/px ≈ résolution du z19 à la latitude 47° 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 TILE_DIRNAME = "index_xyz" # cache disque, sous le dossier de sortie
# Stockage réduit (petite machine) : niveaux exprimés en numérotation OSM
# standard (tuile 256 px ; une tuile @2x de niveau z vaut une 256 px de z+1).
# Seuls les niveaux PAIRS jusqu'à LIDAR_TILE_CACHE_MAX_Z sont écrits sur
# disque ; les autres sont rendus à la volée (cache mémoire borné). Mesuré sur
# 8 dalles : ~24 Mo → ~1 Mo de pyramide, le niveau le plus fin pesant ~75 %.
TILE_CACHE_MAX_Z = int(os.environ.get("LIDAR_TILE_CACHE_MAX_Z", "16") or 16)
TILE_EVEN_LEVELS = os.environ.get("LIDAR_TILE_EVEN_LEVELS", "1").strip() != "0"
WEBP_QUALITY = 78 WEBP_QUALITY = 78
AVIF_QUALITY = 60 AVIF_QUALITY = 60
AVIF_SPEED = 9 # encodage rapide (cf. rendering.AVIF_SPEED : ×7, +3 % de taille) AVIF_SPEED = 9 # encodage rapide (cf. rendering.AVIF_SPEED : ×7, +3 % de taille)
@ -745,6 +752,41 @@ def _write_atomic(path, data):
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
def zoom_cached(z, scale=1):
"""Vrai si les tuiles de ce niveau sont stockées sur disque."""
zc = z + (1 if scale == 2 else 0)
if zc > TILE_CACHE_MAX_Z:
return False
return not (TILE_EVEN_LEVELS and zc % 2)
# Tuiles rendues à la volée (niveaux non stockés) : cache mémoire borné,
# pour qu'un aller-retour de navigation ne recalcule pas tout.
MEMORY_CACHE_BYTES = int(os.environ.get("LIDAR_TILE_MEMORY_CACHE_MB", "64")) * 1024 * 1024
_mem_tiles = OrderedDict()
_mem_tiles_bytes = [0]
_mem_tiles_lock = threading.Lock()
def _mem_get(key):
with _mem_tiles_lock:
data = _mem_tiles.get(key)
if data is not None:
_mem_tiles.move_to_end(key)
return data
def _mem_put(key, data):
with _mem_tiles_lock:
if key in _mem_tiles:
return
_mem_tiles[key] = data
_mem_tiles_bytes[0] += len(data)
while _mem_tiles_bytes[0] > MEMORY_CACHE_BYTES and _mem_tiles:
_, old = _mem_tiles.popitem(last=False)
_mem_tiles_bytes[0] -= len(old)
def zoom_supported(z, scale=1): def zoom_supported(z, scale=1):
"""Vrai si le zoom demandé est dans la plage rendue par le serveur.""" """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) max_z = TILE_MAX_NATIVE_Z - (1 if scale == 2 else 0)
@ -761,9 +803,10 @@ def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
""" """
cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt) cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt)
empty = cache.with_suffix(".empty") empty = cache.with_suffix(".empty")
stored = zoom_cached(z, scale)
sources = _contributing(output_dir, layer, z, x, y, scale) sources = _contributing(output_dir, layer, z, x, y, scale)
if not sources: if not sources:
if not empty.exists(): if stored and not empty.exists():
_write_atomic(empty, b"") _write_atomic(empty, b"")
return None return None
@ -772,6 +815,17 @@ def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
m = src.mtime() m = src.mtime()
if m and m > newest: if m and m > newest:
newest = m newest = m
if not stored:
# Niveau non stocké : rendu à la volée, cache mémoire seulement.
key = (str(output_dir), layer, z, x, y, scale, fmt, newest)
data = _mem_get(key)
if data is None:
img = render_tile(output_dir, layer, z, x, y, scale)
if img is None:
return None
data = _encode(img, fmt)
_mem_put(key, data)
return data
try: try:
if cache.stat().st_mtime >= newest: if cache.stat().st_mtime >= newest:
return cache.read_bytes() return cache.read_bytes()