From 3d415de86c09b63734dea74c752ef2f0297f7326 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 13:00:49 +0200 Subject: [PATCH] =?UTF-8?q?G=C3=A9n=C3=A9rer=20d'avance=20toute=20la=20pyr?= =?UTF-8?q?amide=20jusqu'au=20natif,=20plafonner=20la=20carte=20au=20zoom?= =?UTF-8?q?=2019=20et=20reprendre=20les=20dalles=20refus=C3=A9es=20par=20l?= =?UTF-8?q?a=20file=20pleine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docker-compose.maps.yml | 7 +-- docs/MAPS.md | 32 +++++++------ lidar_pipeline/mapserve.py | 67 +++++++++++++++++++++------ lidar_pipeline/mapui.py | 8 ++-- lidar_pipeline/tests/test_mapserve.py | 59 ++++++++++++++++++++++- lidar_pipeline/tiles.py | 17 ++++--- 7 files changed, 149 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc2edd1..525767f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ - **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, sauf le natif 19 = 0,2 m/px demandé tel quel en `18@2x`, rendu à la volée ; au-delà Leaflet étire le natif, jamais de tuile agrandie côté serveur ; `max_native_zoom` de `/api/map/meta` = 19 en numérotation standard) ; maintenance de fond en AVIF sur les seuls niveaux stockés. ~24 Mo → 1,2 Mo sur 8 dalles. +- **Pyramide complète générée d'avance** : par défaut `tiles.zoom_cached` stocke TOUS les niveaux jusqu'au natif (`TILE_CACHE_MAX_Z` = `TILE_MAX_NATIVE_Z` 19, `TILE_EVEN_LEVELS` off) et la maintenance de fond les génère d'avance jusqu'à `18@2x` (défaut de `LIDAR_TILE_BACKGROUND_MAX_Z`) ; sur le Pi elle les TÉLÉCHARGE du worker. Carte plafonnée au zoom 19 (`maxZoom`, 1 px écran = 1 px LiDAR, jamais de tuile agrandie). Raison : le rendu à la volée du Pi (~170 ms/tuile, 3 à la fois) donnait 1,5–6 s par écran aux zooms 17–19. File de maintenance bornée (`queue_max`) : chaque dalle garde dans `_bg["incomplete"]` l'indice de sa première tuile refusée faute de place et reprend de là aux scans suivants (avant : le premier scan saturait la file et le reste de la pyramide n'était jamais généré). ~110 000 tuiles @2x pour 3 240 dalles (~5 Go). Stockage réduit toujours possible (`LIDAR_TILE_EVEN_LEVELS=1` → `EvenLevelTileLayer`, niveaux pairs, natif 19 demandé tel quel). - **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). - **Carte autonome (Pi sans worker)** : amont (`LIDAR_SOURCE_URL`/`LIDAR_MAPS_URL`) éteint ⇒ la carte reste servie depuis le disque. `_remote_payload` mémorise aussi l'échec (TTL 60 s, délai 10 s : sinon chaque requête repayait le délai réseau et `/api/map/meta` ne répondait plus), puis l'index local (`_build_index`) prend le relais ; coupe-circuits sur les sources (`_SOURCE_OFFLINE`, 60 s) et les tuiles (`_UPSTREAM`, réarmé à expiration) ; source rapatriée datée à sa version amont (`os.utime`) pour que l'index local garde les mêmes dates ; pas de requête amont pour une tuile sans dalle quand l'inventaire vient de l'amont. En cache seule, une tuile périmée reste servie (`no-store`, `X-Tile-Pending`) en attendant la nouvelle. - **Pyramide téléchargée en tâche de fond** : avec `LIDAR_MAPS_URL`, la maintenance (`_bg_process_one`) TÉLÉCHARGE les tuiles des niveaux stockés depuis l'amont (stat `telechargees`), sans attendre l'affichage ; rendu local en repli si l'amont ne répond pas. diff --git a/docker-compose.maps.yml b/docker-compose.maps.yml index da06f47..1e105b0 100644 --- a/docker-compose.maps.yml +++ b/docker-compose.maps.yml @@ -51,9 +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. + # Pyramide complète (défaut) : tous les niveaux jusqu'au natif (z19, + # 0,2 m/px) sont stockés et générés d'avance. Stockage réduit : niveaux + # pairs seuls et/ou plafond plus bas, le reste rendu à la volée avec un + # cache mémoire. # - LIDAR_TILE_CACHE_MAX_Z=16 # - LIDAR_TILE_EVEN_LEVELS=1 # - LIDAR_TILE_MEMORY_CACHE_MB=64 diff --git a/docs/MAPS.md b/docs/MAPS.md index acfdbbc..18f3278 100644 --- a/docs/MAPS.md +++ b/docs/MAPS.md @@ -79,17 +79,22 @@ 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 +## Pyramide complète générée d'avance -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. +Par défaut, **tous** les niveaux jusqu'au natif (19 en numérotation OSM +standard = 0,2 m/px, servi en `18@2x` par l'interface) sont écrits sur +disque en AVIF et générés d'avance par la maintenance de fond (sur une carte +alimentée par l'amont : téléchargés depuis `LIDAR_MAPS_URL`). Aucun niveau +n'est rendu à la volée : le rendu à la demande d'un Raspberry Pi (~170 ms par +tuile, 3 à la fois) donnait 1,5 à 6 s par écran aux zooms 17–19. L'interface +est plafonnée au zoom 19 (1 px écran = 1 px LiDAR) : jamais de tuile +agrandie. Ordre de grandeur mesuré : ~110 000 tuiles @2x pour 3 240 dalles, +dont 81 000 au niveau natif, ~5 Go. + +Stockage réduit (disque compté) : `LIDAR_TILE_CACHE_MAX_Z` plus bas et/ou +`LIDAR_TILE_EVEN_LEVELS=1` (niveaux pairs seuls ; l'interface réduit alors +les tuiles du niveau supérieur aux zooms impairs, les autres niveaux sont +rendus à la volée avec un cache mémoire `LIDAR_TILE_MEMORY_CACHE_MB`). Une carte alimentée par un serveur de dalles amont (`LIDAR_SOURCE_URL`, cas du Pi) rapatrie et **garde localement** les sources de chaque dalle dès @@ -261,8 +266,7 @@ environment: transparente avec `X-Tile-Pending: 1` et `Cache-Control: no-store` (le navigateur la redemande : dès que la maintenance l'a rendue, elle apparaît). Plus aucun rendu local sur le chemin des requêtes ; avec `LIDAR_MAPS_URL`, - une tuile manquante (ex. zoom au-delà de `LIDAR_TILE_BACKGROUND_MAX_Z`) - y est rapatriée — un téléchargement de quelques dizaines de Ko, mis en + une tuile manquante (pas encore passée par la maintenance) y est rapatriée — un téléchargement de quelques dizaines de Ko, mis en cache — puis servie : la navigation couvre tous les zooms sans jamais calculer sur la petite machine. - **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle @@ -277,12 +281,12 @@ environment: | Variable | Défaut | Rôle | |---|---|---| -| `LIDAR_TILE_BACKGROUND_MAX_Z` | `16` | niveau maximal entretenu en fond (le natif 17–19 reste au pré-chauffage manuel) | +| `LIDAR_TILE_BACKGROUND_MAX_Z` | natif (`18` en @2x, `19` en 256 px) | niveau maximal entretenu en fond, numérotation des URL | | `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tuiles entretenues : 2 = 512 px (celles de l'interface) | | `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format des tuiles entretenues (celui de l'interface) | | `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) entre deux rendus — le levier de la discrétion | | `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | secondes entre deux scans des dalles | -| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | file d'attente bornée (au-delà : ignoré jusqu'au prochain scan) — une entrée ne coûte que quelques octets | +| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | file d'attente bornée — chaque dalle mémorise la première tuile refusée faute de place et reprend de là aux scans suivants, jusqu'à ce que toute sa pyramide soit passée | Pilotage : `GET /api/map/background` (état, compteurs, file), `POST /api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index 4b1f2d4..a8c6826 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -149,15 +149,20 @@ def _env_flag(name): in ("1", "true", "yes", "on", "oui")) +def _bg_scale(): + return 1 if os.environ.get("LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1" else 2 + + def _bg_conf(): """Réglages de la maintenance (relus au démarrage des fils, cf. doc).""" return { "interval": max(10.0, float(os.environ.get( "LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)), + # Défaut : jusqu'au natif (18@2x = 0,2 m/px) — pyramide complète. "max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get( - "LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)), - "scale": 1 if (os.environ.get( - "LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2, + "LIDAR_TILE_BACKGROUND_MAX_Z", "") or (tiles_mod.TILE_MAX_NATIVE_Z - ( + 0 if _bg_scale() == 1 else 1)))), + "scale": _bg_scale(), "fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "avif") or "avif").strip(), "pause": max(0.0, float(os.environ.get( @@ -169,17 +174,20 @@ def _bg_conf(): _bg = {"threads": [], "stop": threading.Event(), "queue": deque(), "queued": set(), "guard": threading.Lock(), "snapshot": None, - "prefetch": deque(), + "prefetch": deque(), "incomplete": {}, "running": False, "stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0, "dernier_scan": None, "ajouts_dernier_scan": 0}} -def _bg_enqueue(items, front=False): +def _bg_enqueue(items, front=False, rejected=None): """Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée. + rejected : ensemble complété des tuiles refusées faute de place. 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. + rendues) ; une tuile déjà en file y est remontée. L'arriéré n'est pas + tronqué pour leur faire place (ses dalles ne seraient jamais reprises) : + la file déborde alors brièvement de la tête ajoutée. """ added = 0 with _bg["guard"]: @@ -189,12 +197,18 @@ def _bg_enqueue(items, front=False): 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"]))]) + _bg["queue"].extend(rest) + if rejected is not None: + rejected.update(fresh[_bg_conf()["queue_max"]:]) 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"]: + if item in _bg["queued"]: + continue + if len(_bg["queue"]) >= _bg_conf()["queue_max"]: + if rejected is not None: + rejected.add(item) continue _bg["queue"].append(item) _bg["queued"].add(item) @@ -223,6 +237,7 @@ def background_scan(output_dir=None): first_scan = _bg["snapshot"] is None snapshot = {} changed = {} + resume = {} # dalle inchangée, pyramide pas encore toute en file → reprise for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items(): for (col, row), tiers in per_cell.items(): newest = 0.0 @@ -234,6 +249,9 @@ def background_scan(output_dir=None): snapshot[(layer, col, row)] = newest if (_bg["snapshot"] is not None and _bg["snapshot"].get((layer, col, row)) == newest): + if (layer, col, row) in _bg["incomplete"]: + resume[(layer, col, row)] = _bg["incomplete"][(layer, col, row)] + changed.setdefault(layer, []).append((col, row)) continue changed.setdefault(layer, []).append((col, row)) _bg["snapshot"] = snapshot @@ -253,20 +271,41 @@ def background_scan(output_dir=None): if src.url is not None and key not in pending and not src.path.is_file(): _bg["prefetch"].append(src) pending.add(key) - items = [] + # File bornée (queue_max) : sur un grand territoire, le premier scan la + # remplissait et le reste de la pyramide n'était jamais généré. Chaque + # dalle garde donc sa position : les tuiles refusées faute de place sont + # reprises aux scans suivants (en queue, derrière l'arriéré), à partir de + # la première refusée. Les dalles qui viennent de changer passent en + # tête (la pyramide suit le rendu en cours, hors premier scan). + order = lambda t: (t[1], t[0], t[2], t[3]) # noqa: E731 — bas niveaux d'abord + items, retry, per_cell = [], [], {} for layer, cells in changed.items(): for col, row in cells: + cell = (layer, col, row) bbox = tiles_mod.cell_bounds_wgs84(col, row) + mine = [] for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1): 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 - # 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) + mine.append((layer, z, x, y)) + mine.sort(key=order) + start = resume.get(cell, 0) + per_cell[cell] = (start, mine) + (retry if cell in resume else items).extend(mine[start:]) + items.sort(key=order) + retry.sort(key=order) + rejected = set() + added = _bg_enqueue(items, front=first_scan is False, rejected=rejected) + added += _bg_enqueue(retry, rejected=rejected) + incomplete = {} + for cell, (start, mine) in per_cell.items(): + for i in range(start, len(mine)): + if mine[i] in rejected: + incomplete[cell] = i + break + _bg["incomplete"] = incomplete _bg["stats"]["scans"] += 1 _bg["stats"]["dernier_scan"] = time.time() _bg["stats"]["ajouts_dernier_scan"] = added diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py index 6ecb930..6cdf4a7 100644 --- a/lidar_pipeline/mapui.py +++ b/lidar_pipeline/mapui.py @@ -383,7 +383,9 @@ const map = L.map('map', { zoomControl: false, attributionControl: true, minZoom: 3, - maxZoom: 22, + // Zoom plafonné au natif (19 = 0,2 m/px, 1 px écran = 1 px LiDAR) : toute + // la pyramide est générée d'avance, jamais de tuile agrandie ni étirée. + maxZoom: 19, zoomSnap: 0.5, wheelPxPerZoomLevel: 90, worldCopyJump: false, @@ -411,7 +413,7 @@ const ZoomAnimTileLayer = L.TileLayer.extend({ }); const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 22, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark', + maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark', // Sinon _setView recrée un niveau en cours d'animation malgré noUpdate. updateWhenZooming: false, attribution: '© OpenStreetMap' @@ -608,7 +610,7 @@ function buildLayers() { minNativeZoom: META.min_zoom, maxNativeZoom: META.max_native_zoom, minZoom: 0, - maxZoom: 22, + maxZoom: 19, // Tuiles de 512 px : une rangée de réserve hors écran vaut les deux // rangées de 256 px du fond. Deux rangées doublaient les images // décodées gardées en mémoire (1 Mo chacune) pour peu de gain. diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 6bf4902..1887d0d 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -206,7 +206,7 @@ def test_map_meta(tmp_path, monkeypatch): 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.avif" - assert meta["even_levels"] is True + assert meta["even_levels"] is False # pyramide complète générée d'avance assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1 assert meta["max_native_zoom"] == 19 assert meta["bounds"] and meta["stamp"] > 0 @@ -581,6 +581,7 @@ def _reset_bg(mapserve): mapserve._bg["queue"].clear() mapserve._bg["queued"].clear() mapserve._bg["snapshot"] = None + mapserve._bg["incomplete"] = {} mapserve._bg["stats"].update(rendues=0, a_jour=0, vides=0, scans=0, dernier_scan=None, ajouts_dernier_scan=0) @@ -1190,3 +1191,59 @@ def test_zoom_animation_transforms_only_on_all_tile_layers(): assert re.search(r"const base = new ZoomAnimTileLayer\(", _MAP_JS) assert "const EvenLevelTileLayer = ZoomAnimTileLayer.extend(" in _MAP_JS assert "L.tileLayer(" not in _MAP_JS.replace("L.tileLayer` standard", "") + + +def test_default_policy_pregenerates_every_level_up_to_native(monkeypatch): + """Par défaut, toute la pyramide jusqu'au natif (18@2x = 0,2 m/px) est + stockée et générée d'avance : aucun niveau rendu à la volée.""" + import importlib + import lidar_pipeline.mapserve as mapserve + from lidar_pipeline import tiles + for name in ("LIDAR_TILE_CACHE_MAX_Z", "LIDAR_TILE_EVEN_LEVELS", + "LIDAR_TILE_BACKGROUND_MAX_Z", "LIDAR_TILE_BACKGROUND_SCALE"): + monkeypatch.delenv(name, raising=False) + try: + importlib.reload(tiles) + assert all(tiles.zoom_cached(z, 2) for z in range(tiles.TILE_MIN_Z, 19)) + assert all(tiles.zoom_cached(z, 1) for z in range(tiles.TILE_MIN_Z, 20)) + assert mapserve._bg_conf()["max_z"] == 18 and mapserve._bg_conf()["scale"] == 2 + monkeypatch.setenv("LIDAR_TILE_BACKGROUND_SCALE", "1") + assert mapserve._bg_conf()["max_z"] == 19 + finally: + importlib.reload(tiles) + + +def test_background_scan_retries_cells_beyond_queue_max(tmp_path, monkeypatch): + """File pleine : les dalles dont la pyramide n'a pas trouvé place sont + reprises aux scans suivants jusqu'à ce que toutes leurs tuiles passent.""" + import collections + import lidar_pipeline.mapserve as mapserve + from lidar_pipeline import tiles + monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", False) + monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 19) + _setup(tmp_path, monkeypatch) + _make_dalle(tmp_path, 1055, 6882, ["aspect"]) + conf = dict(mapserve._bg_conf(), max_z=18, scale=2, queue_max=40) + monkeypatch.setattr(mapserve, "_bg_conf", lambda: conf) + for key, val in (("queue", collections.deque()), ("queued", set()), + ("snapshot", None), ("prefetch", collections.deque()), + ("incomplete", {})): + monkeypatch.setitem(mapserve._bg, key, val) + expected = set() + for layer, per_cell in tiles.source_index(tmp_path, force=True).items(): + for col, row in per_cell: + bbox = tiles.cell_bounds_wgs84(col, row) + for z in range(tiles.TILE_MIN_Z, 19): + expected.update((layer, z, x, y) for x, y in tiles.tiles_in_bounds(bbox, z)) + assert len(expected) > 3 * conf["queue_max"] + seen = set() + for _ in range(100): + mapserve.background_scan(tmp_path) + seen.update(mapserve._bg["queue"]) + mapserve._bg["queue"].clear() # file vidée par les rendeurs + mapserve._bg["queued"].clear() + if not mapserve._bg["incomplete"]: + break + assert seen == expected + # Pyramide passée en entier : les scans suivants se taisent + assert mapserve.background_scan(tmp_path) == 0 diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py index c1c791a..272daef 100644 --- a/lidar_pipeline/tiles.py +++ b/lidar_pipeline/tiles.py @@ -28,13 +28,16 @@ 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" +# Niveaux stockés, en numérotation OSM standard (tuile 256 px ; une tuile @2x +# de niveau z vaut une 256 px de z+1). Par défaut TOUS les niveaux jusqu'au +# natif sont écrits sur disque et générés d'avance par la maintenance : le +# rendu à la volée d'un Pi (~170 ms par tuile, 3 à la fois) donnait 1,5 à 6 s +# par écran aux forts zooms. Stockage réduit (petite machine, disque compté) : +# LIDAR_TILE_CACHE_MAX_Z plus bas et/ou LIDAR_TILE_EVEN_LEVELS=1 (niveaux +# pairs seuls, les autres rendus à la volée avec un cache mémoire borné). +TILE_CACHE_MAX_Z = int(os.environ.get("LIDAR_TILE_CACHE_MAX_Z", str(TILE_MAX_NATIVE_Z)) + or TILE_MAX_NATIVE_Z) +TILE_EVEN_LEVELS = os.environ.get("LIDAR_TILE_EVEN_LEVELS", "0").strip() == "1" WEBP_QUALITY = 78 AVIF_QUALITY = 60 AVIF_SPEED = 9 # encodage rapide (cf. rendering.AVIF_SPEED : ×7, +3 % de taille)