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`.
- **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) ; 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.
- **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).

View File

@ -51,4 +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.
# - LIDAR_TILE_CACHE_MAX_Z=16
# - LIDAR_TILE_EVEN_LEVELS=1
# - LIDAR_TILE_MEMORY_CACHE_MB=64
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
progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu =
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
pyramide repart automatiquement.
SIGKILL). Pendant le run, chaque dalle terminée apparaît sur la carte : l'inventaire
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
`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
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
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(
"--incremental-index",
action="store_true",
help="Régénérer le catalogue après chaque tuile terminée (affichage en direct "
"sur la carte, sans attendre la fin du run)"
help="Conservé pour compatibilité : le catalogue est désormais régénéré après "
"chaque tuile terminée par défaut (sauf --no-index)"
)
args = parser.parse_args()

View File

@ -158,8 +158,8 @@ def _bg_conf():
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
"scale": 1 if (os.environ.get(
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp")
or "webp").strip(),
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "avif")
or "avif").strip(),
"pause": max(0.0, float(os.environ.get(
"LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)),
"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}}
def _bg_enqueue(items):
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée."""
def _bg_enqueue(items, front=False):
"""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
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:
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
continue
@ -205,6 +219,7 @@ def background_scan(output_dir=None):
"""
output_dir = Path(output_dir or OUTPUT_DIR)
conf = _bg_conf()
first_scan = _bg["snapshot"] is None
snapshot = {}
changed = {}
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:
bbox = tiles_mod.cell_bounds_wgs84(col, row)
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
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
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"]["dernier_scan"] = time.time()
_bg["stats"]["ajouts_dernier_scan"] = added
@ -276,13 +294,37 @@ def _bg_worker():
_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():
last_scan, last_mark = 0.0, _inventory_mark()
while not _bg["stop"].is_set():
try:
background_scan()
except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL
logger.warning(f"Maintenance des tuiles : scan impossible ({e})")
_bg["stop"].wait(_bg_conf()["interval"])
mark = _inventory_mark()
due = time.time() - last_scan >= _bg_conf()["interval"]
if due or mark != last_mark:
try:
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():
@ -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):
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.
# Une tuile absente (ex. zoom > LIDAR_TILE_BACKGROUND_MAX_Z, jamais
# couverte par la maintenance) est rapatriée du serveur amont
@ -847,7 +889,10 @@ def map_meta():
"defaults_saved": bool(saved),
# 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.
"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,
"zoom_offset": -1,
"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 : '');
}
// 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() {
// Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la
// 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 existing = LAYERS.get(info.key);
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,
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
// pas de requêtes inutiles sur le reste du monde.

View File

@ -11,6 +11,7 @@ import logging
import multiprocessing
import os
import shutil
import threading
import time
from concurrent.futures import ProcessPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime
@ -156,11 +157,16 @@ class LidarArchaeoPipeline:
self.output_format = output_format
self.gpu_ids = gpu_ids
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.openness_downsample = openness_downsample
self.edge_buffer = float(edge_buffer)
self._last_index_rebuild = 0.0
self._index_timer = None
self._index_lock = threading.Lock()
self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists():
@ -681,20 +687,32 @@ class LidarArchaeoPipeline:
"""
if self.no_index:
return
now = time.time()
if now - self._last_index_rebuild < 3.0:
wait = 3.0 - (time.time() - self._last_index_rebuild)
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
self._last_index_rebuild = now
try:
from .index import build_index
saved_level = logger.getEffectiveLevel()
logger.setLevel(logging.WARNING)
self._rebuild_index_now()
def _rebuild_index_now(self):
with self._index_lock:
self._index_timer = None
self._last_index_rebuild = time.time()
try:
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}")
from .index import build_index
saved_level = logger.getEffectiveLevel()
logger.setLevel(logging.WARNING)
try:
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):
"""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()
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.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["max_native_zoom"] == 18
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):
"""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",))
from lidar_pipeline import tiles
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
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
from lidar_pipeline import tiles
_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 "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°)
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"])
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):
from lidar_pipeline.pipeline import 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
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."""
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 time
from lidar_pipeline import tiles
@ -270,8 +273,11 @@ def test_get_tile_cache_and_staleness(tmp_path):
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é."""
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
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
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)
assert state == "empty" and data is None
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_MAX_NATIVE_Z = 19 # 0,2 m/px ≈ résolution du z19 à la latitude 47°
TILE_DIRNAME = "index_xyz" # cache disque, sous le dossier de sortie
# Stockage réduit (petite machine) : niveaux exprimés en numérotation OSM
# standard (tuile 256 px ; une tuile @2x de niveau z vaut une 256 px de z+1).
# Seuls les niveaux PAIRS jusqu'à LIDAR_TILE_CACHE_MAX_Z sont écrits sur
# disque ; les autres sont rendus à la volée (cache mémoire borné). Mesuré sur
# 8 dalles : ~24 Mo → ~1 Mo de pyramide, le niveau le plus fin pesant ~75 %.
TILE_CACHE_MAX_Z = int(os.environ.get("LIDAR_TILE_CACHE_MAX_Z", "16") or 16)
TILE_EVEN_LEVELS = os.environ.get("LIDAR_TILE_EVEN_LEVELS", "1").strip() != "0"
WEBP_QUALITY = 78
AVIF_QUALITY = 60
AVIF_SPEED = 9 # encodage rapide (cf. rendering.AVIF_SPEED : ×7, +3 % de taille)
@ -745,6 +752,41 @@ def _write_atomic(path, data):
tmp.unlink(missing_ok=True)
def zoom_cached(z, scale=1):
"""Vrai si les tuiles de ce niveau sont stockées sur disque."""
zc = z + (1 if scale == 2 else 0)
if zc > TILE_CACHE_MAX_Z:
return False
return not (TILE_EVEN_LEVELS and zc % 2)
# Tuiles rendues à la volée (niveaux non stockés) : cache mémoire borné,
# pour qu'un aller-retour de navigation ne recalcule pas tout.
MEMORY_CACHE_BYTES = int(os.environ.get("LIDAR_TILE_MEMORY_CACHE_MB", "64")) * 1024 * 1024
_mem_tiles = OrderedDict()
_mem_tiles_bytes = [0]
_mem_tiles_lock = threading.Lock()
def _mem_get(key):
with _mem_tiles_lock:
data = _mem_tiles.get(key)
if data is not None:
_mem_tiles.move_to_end(key)
return data
def _mem_put(key, data):
with _mem_tiles_lock:
if key in _mem_tiles:
return
_mem_tiles[key] = data
_mem_tiles_bytes[0] += len(data)
while _mem_tiles_bytes[0] > MEMORY_CACHE_BYTES and _mem_tiles:
_, old = _mem_tiles.popitem(last=False)
_mem_tiles_bytes[0] -= len(old)
def zoom_supported(z, scale=1):
"""Vrai si le zoom demandé est dans la plage rendue par le serveur."""
max_z = TILE_MAX_NATIVE_Z - (1 if scale == 2 else 0)
@ -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)
empty = cache.with_suffix(".empty")
stored = zoom_cached(z, scale)
sources = _contributing(output_dir, layer, z, x, y, scale)
if not sources:
if not empty.exists():
if stored and not empty.exists():
_write_atomic(empty, b"")
return None
@ -772,6 +815,17 @@ def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
m = src.mtime()
if m and m > newest:
newest = m
if not stored:
# Niveau non stocké : rendu à la volée, cache mémoire seulement.
key = (str(output_dir), layer, z, x, y, scale, fmt, newest)
data = _mem_get(key)
if data is None:
img = render_tile(output_dir, layer, z, x, y, scale)
if img is None:
return None
data = _encode(img, fmt)
_mem_put(key, data)
return data
try:
if cache.stat().st_mtime >= newest:
return cache.read_bytes()