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:
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
17
docs/MAPS.md
17
docs/MAPS.md
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user