diff --git a/AGENTS.md b/AGENTS.md index d1ba12e..a558415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/docker-compose.maps.yml b/docker-compose.maps.yml index 79fa7e8..da06f47 100644 --- a/docker-compose.maps.yml +++ b/docker-compose.maps.yml @@ -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 diff --git a/docs/MAPS.md b/docs/MAPS.md index 28c8b74..ee7f9e2 100644 --- a/docs/MAPS.md +++ b/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 diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 7723c1a..7ccfac2 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -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() diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index b9398df..77b1e14 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -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, diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py index fe80ec9..1532e4f 100644 --- a/lidar_pipeline/mapui.py +++ b/lidar_pipeline/mapui.py @@ -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. diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index 22dc894..eba7a27 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -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).""" diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 88dc6bf..1fb4915 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -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} diff --git a/lidar_pipeline/tests/test_pipeline.py b/lidar_pipeline/tests/test_pipeline.py index 109735d..123b577 100644 --- a/lidar_pipeline/tests/test_pipeline.py +++ b/lidar_pipeline/tests/test_pipeline.py @@ -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] diff --git a/lidar_pipeline/tests/test_tiles.py b/lidar_pipeline/tests/test_tiles.py index 0a1be09..ae70538 100644 --- a/lidar_pipeline/tests/test_tiles.py +++ b/lidar_pipeline/tests/test_tiles.py @@ -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 diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py index 2946a0f..e01e82c 100644 --- a/lidar_pipeline/tiles.py +++ b/lidar_pipeline/tiles.py @@ -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()