From 934e2dab55ddb1fd1824371ec1cc4b4f5bcbd829 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 10:50:03 +0200 Subject: [PATCH] =?UTF-8?q?Borner=20la=20m=C3=A9moire=20de=20la=20carte=20?= =?UTF-8?q?(OOM=20du=20Pi)=20et=20rallumer=20le=20relief=20orient=C3=A9=20?= =?UTF-8?q?par=20d=C3=A9faut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + Dockerfile.maps | 7 ++++++- lidar_pipeline/mapserve.py | 10 ++++++++-- lidar_pipeline/tests/test_mapserve.py | 25 +++++++++++++++++++++++++ lidar_pipeline/tests/test_tiles.py | 21 +++++++++++++++++++++ lidar_pipeline/tiles.py | 15 ++++++++++----- 6 files changed, 71 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a558415..20bb433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ - **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. +- **Mémoire bornée de la carte (Pi, `mem_limit: 1g`)** : `_open_source` (`tiles.py`) met en cache une **copie détachée** de chaque source (une image AVIF ouverte garde son décodeur, ~18 Mo de plus par quadrant 2500²) et compte 4 octets/pixel (PIL stocke le RGB sur 32 bits) ; `Dockerfile.maps` fixe `MALLOC_MMAP_THRESHOLD_=1048576` pour que glibc rende les grands tampons au système. Sans ces deux points, la navigation à fort zoom (niveaux rendus à la volée) montait à ~700 Mo de RSS et le conteneur était tué en boucle par l'OOM killer (502 côté Traefik). - **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/Dockerfile.maps b/Dockerfile.maps index 6d3c22b..02ec403 100644 --- a/Dockerfile.maps +++ b/Dockerfile.maps @@ -35,9 +35,14 @@ RUN groupadd -g 1000 lidar && \ mkdir -p /data/output && \ chown -R lidar:lidar /data +# Seuil mmap FIXE (1 Mo) : les grands tampons d'image (décodage des dalles, +# ~25 Mo) sont rendus au système dès leur libération. Avec le seuil +# dynamique de glibc, ils restaient dans le tas après la première libération +# et la mémoire du conteneur ne redescendait jamais (OOM sur le Pi). ENV LIDAR_OUTPUT_DIR=/data/output \ LIDAR_PORT=8975 \ - PYTHONPATH=/app + PYTHONPATH=/app \ + MALLOC_MMAP_THRESHOLD_=1048576 USER lidar EXPOSE 8975 diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index 8206add..95ea596 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -901,8 +901,14 @@ def map_meta(): infos = _layer_infos() keys = [i["key"] for i in infos] saved = _load_defaults() - # Configuration figée depuis la carte, sinon réglages du registre (index.py) - default_layers = saved["on"] if saved else default_layers_present(keys) + # Configuration figée depuis la carte, sinon réglages du registre (index.py). + # Couches figées toutes disparues (ex. passage au seul relief orienté) : + # registre, sinon un nouveau navigateur arrive sur une carte sans LiDAR. + saved_on = [k for k in saved["on"] if k in keys] if saved else [] + if saved and (saved_on or not saved["on"]): + default_layers = saved_on + else: + default_layers = default_layers_present(keys) default_opacity = saved["opacity"] if saved else dict(DEFAULT_OPACITY) default_blend = saved["blend"] if saved else dict(DEFAULT_BLEND) return { diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 62d992b..2589501 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -320,6 +320,31 @@ def test_defaults_roundtrip(tmp_path, monkeypatch): assert mapserve.map_meta()["defaults_saved"] is False +def test_defaults_obsolete_layers_fall_back(tmp_path, monkeypatch): + """Défauts figés sur des couches qui ne sont plus servies : registre. + + Sinon un nouveau navigateur arrive sur une carte sans aucune couche + LiDAR allumée (cas de la prod après le passage au seul relief orienté). + """ + import json + import lidar_pipeline.mapserve as mapserve + _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + defaults = tmp_path / ".map-defaults.json" + monkeypatch.setattr(mapserve, "DEFAULTS_FILE", defaults) + defaults.write_text(json.dumps({ + "order": ["aspect", "positive_openness"], + "on": ["aspect", "positive_openness"], "opacity": {}, "blend": {}, + "base": {"on": True, "opacity": 0.85, "dark": True}, + "stack_blend": "normal"}), encoding="utf-8") + meta = mapserve.map_meta() + assert meta["default_layers"] == ["relief_oriente"] + # Tout éteint volontairement (couches connues) : respecté + defaults.write_text(json.dumps({ + "order": ["relief_oriente"], "on": [], "opacity": {}, "blend": {}, + "base": {}, "stack_blend": "normal"}), encoding="utf-8") + assert mapserve.map_meta()["default_layers"] == [] + + def test_defaults_sanitised(tmp_path, monkeypatch): """Couches inconnues écartées, opacités bornées, fusions validées.""" import lidar_pipeline.mapserve as mapserve diff --git a/lidar_pipeline/tests/test_tiles.py b/lidar_pipeline/tests/test_tiles.py index ae70538..56f9d29 100644 --- a/lidar_pipeline/tests/test_tiles.py +++ b/lidar_pipeline/tests/test_tiles.py @@ -361,6 +361,27 @@ def test_source_cache_respects_memory_budget(tmp_path, monkeypatch): assert tiles._source_cache == {} +def test_source_cache_holds_plain_decoded_images(tmp_path): + """Le cache ne retient que les pixels : ni fichier ouvert ni décodeur. + + Une image AVIF ouverte garde son décodeur (tampons libavif/dav1d) : + ~43 Mo retenus par quadrant 2500² au lieu de 25 — le conteneur du Pi + (1 Go) était tué par l'OOM killer en navigation à fort zoom. Le budget + compte 4 octets/pixel : PIL stocke le RGB sur 32 bits. + """ + from PIL import Image + from lidar_pipeline import tiles + tiles.clear_source_cache() + f = tmp_path / "src.png" + Image.new("RGB", (40, 30), (10, 20, 30)).save(str(f)) + img = tiles._open_source(str(f), f.stat().st_mtime) + assert type(img) is Image.Image + assert getattr(img, "fp", None) is None + assert img.info["_bytes"] == 40 * 30 * 4 + assert img.getpixel((0, 0))[:3] == (10, 20, 30) + tiles.clear_source_cache() + + def test_source_cache_keyed_by_mtime(tmp_path): """Une source réécrite n'est pas resservie depuis le cache.""" import os diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py index 12157dc..0f12473 100644 --- a/lidar_pipeline/tiles.py +++ b/lidar_pipeline/tiles.py @@ -588,11 +588,16 @@ def _open_source(path_str, mtime): if img is not None: _source_cache.move_to_end(key) return img - img = Image.open(str(path_str)) - img.load() - if img.mode not in ("RGB", "RGBA"): - img = img.convert("RGB") - size = img.size[0] * img.size[1] * len(img.getbands()) + # Copie détachée du fichier : une image AVIF ouverte garde son décodeur + # (tampons libavif/dav1d, ~18 Mo par quadrant 2500²) tant qu'elle vit — + # en cache, cela doublait la mémoire réelle et faisait tuer le conteneur + # du Pi (1 Go) par l'OOM killer en navigation à fort zoom. + with Image.open(str(path_str)) as opened: + opened.load() + img = (opened.copy() if opened.mode in ("RGB", "RGBA") + else opened.convert("RGB")) + # PIL stocke RGB comme RGBA : 4 octets par pixel dans les deux cas. + size = img.size[0] * img.size[1] * 4 with _source_cache_lock: _source_cache[key] = img _source_cache[key].info["_bytes"] = size