lidar-maps : navigation en cache seule et pyramide entretenue en tâche de fond

Sur une petite machine (Raspberry Pi), LIDAR_TILE_CACHE_ONLY sert les tuiles
depuis le cache uniquement (tuile absente = transparente non mémorisable,
X-Tile-Pending) et LIDAR_TILE_BACKGROUND fait surveiller les dalles par un
sondeur : chaque dalle nouvelle ou régénérée par le worker remet sa pyramide
en file, rendue à basse priorité et au ralenti. Scan et état pilotables via
/api/map/background, pré-calcul exhaustif toujours via /api/map/warm.
This commit is contained in:
Antoine Jacquin
2026-09-23 19:05:58 +02:00
parent 053eb2f529
commit 900332cdbc
5 changed files with 416 additions and 10 deletions

View File

@ -23,6 +23,7 @@ docker compose -f docker-compose.maps.yml logs -f maps
| Variante haute densité | `/tiles/{couche}/{z}/{x}/{y}@2x.webp` — 512 px (interface interne) |
| Zooms | 5 → 19 natif (0,2 m/px ≈ z19 en France) ; au-delà, sur-zoom côté client |
| Hors emprise | tuile entièrement transparente (superposable), en-tête `X-Tile-Empty: 1` |
| En attente (cache seule) | tuile transparente, en-têtes `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, jamais mémorisée par le navigateur |
| CORS | `Access-Control-Allow-Origin: *` sur `/tiles/*` |
| Attribution | `LIDAR_ATTRIBUTION`, défaut « LiDAR HD © IGN — Licence Ouverte 2.0 » |
@ -164,8 +165,7 @@ Le service est borné à chaque étage, rien n'est illimité :
Les requêtes en excès attendent le sémaphore **avant** tout décodage : elles ne
consomment ni CPU ni mémoire. Un navigateur en HTTP/1.1 n'ouvre de toute façon
que 6 connexions par origine, toutes couches confondues ; derrière un proxy
HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge.
que 6 connexions par origine, toutes couches confondues ; derrière un proxy HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge.
Le pré-chauffage (`/api/map/warm`) est séquentiel : il ne peut pas saturer la
machine, seulement prendre du temps.
@ -173,6 +173,47 @@ machine, seulement prendre du temps.
Sur un Raspberry Pi 2 Go, `LIDAR_TILE_WORKERS=1` et
`LIDAR_TILE_SOURCE_CACHE_MB=64` restent confortables.
## Cache seule + maintenance de fond (petit Raspberry Pi)
Sur une machine qui ne doit jamais calculer au fil de la navigation (un Pi qui
tient aussi d'autres services), deux variables inversent la charge :
```yaml
environment:
- LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend plus rien
- LIDAR_TILE_BACKGROUND=1 # une tâche de fond entretient la pyramide
```
- **`LIDAR_TILE_CACHE_ONLY=1`** — une tuile absente ou périmée est servie
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, ni rapatriement amont, sur le chemin des requêtes.
- **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle
régulier (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s) ; chaque dalle nouvelle ou
régénérée (le worker vient de produire, le cache à la demande vient de
rapatrier) met sa pyramide en file d'attente. Des rendeurs à basse priorité
(`os.nice`) la vident au rythme d'une tuile par
`LIDAR_TILE_BACKGROUND_PAUSE` seconde (1 s). Premier démarrage : TOUTES les
dalles sont inconnues, la pyramide se reconstruit entière — les tuiles déjà
fraîches ne coûtent qu'un `stat`. Les niveaux partent en file du plus petit
zoom au plus grand : la carte se remplit d'abord grossièrement.
| 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_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` | `4096` | file d'attente bornée (au-delà : ignoré jusqu'au prochain scan) |
Pilotage : `GET /api/map/background` (état, compteurs, file), `POST
/api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel
exhaustif, tous zooms/formats, cf. ci-dessous). Enfin, plafonner le conteneur
lui-même (`mem_limit` + `memswap_limit` dans un override compose) garantit
qu'un rendu déréglé ne peut plus emporter la machine : le tueur OOM ne
toucherait que `lidar-maps`.
## Pré-chauffage
```bash

View File

@ -18,6 +18,19 @@ Les tuiles sont rendues à la demande puis mises en cache sur disque
(`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un
serveur amont : une tuile absente localement y est rapatriée (quelques dizaines
de Ko) au lieu de rapatrier les dalles entières.
Sur une petite machine (Raspberry Pi), deux réglages inversent la charge :
- `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile
absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par
le navigateur) et apparaît après passage de la maintenance ;
- `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles
(nouvelles ou régénérées) et met leur pyramide à jour en file d'attente,
un rendu à la fois, à basse priorité (`os.nice`) et cadencé
(`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de
toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà
fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan,
`POST /api/map/warm` reste le pré-calcul manuel exhaustif.
"""
import json
@ -30,6 +43,9 @@ import time
import urllib.parse
import urllib.request
import xml.sax.saxutils as _xml
from collections import deque
from contextlib import asynccontextmanager
from functools import lru_cache
from pathlib import Path
from typing import Optional
@ -59,6 +75,201 @@ ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("&copy;", "©").r
TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2))
_render_sem = threading.Semaphore(TILE_WORKERS)
# ---------------------------------------------------------------------------
# Maintenance de la pyramide en tâche de fond (petites machines)
# ---------------------------------------------------------------------------
#
# `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge :
# la navigation ne rend plus rien (lecture cache seule), une sondeur rescane
# les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa
# pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la
# vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes.
# Premier passage = dalle inconnue : toute la pyramide est reconstruite (les
# tuiles déjà fraîches ne coûtent qu'un stat).
def _env_flag(name):
"""Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel :
testable sans recharger le module."""
return (os.environ.get(name, "").strip().lower()
in ("1", "true", "yes", "on", "oui"))
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)),
"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,
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp")
or "webp").strip(),
"pause": max(0.0, float(os.environ.get(
"LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)),
"queue_max": max(64, int(os.environ.get(
"LIDAR_TILE_BACKGROUND_QUEUE_MAX", "4096") or 4096)),
}
_bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
"queued": set(), "guard": threading.Lock(), "snapshot": None,
"running": False,
"stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
"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."""
added = 0
with _bg["guard"]:
for item in items:
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
continue
_bg["queue"].append(item)
_bg["queued"].add(item)
added += 1
return added
def _bg_pop():
with _bg["guard"]:
if _bg["queue"]:
item = _bg["queue"].popleft()
_bg["queued"].discard(item)
return item
return None
def background_scan(output_dir=None):
"""Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file.
Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le
mtime le plus récent des sources de chaque dalle : une dalle régénérée
n'invalide que ses propres tuiles (même règle que le cache).
"""
output_dir = Path(output_dir or OUTPUT_DIR)
conf = _bg_conf()
snapshot = {}
changed = {}
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
for group in tiers:
for src in group:
m = src.mtime()
if m and m > newest:
newest = m
snapshot[(layer, col, row)] = newest
if (_bg["snapshot"] is not None
and _bg["snapshot"].get((layer, col, row)) == newest):
continue
changed.setdefault(layer, []).append((col, row))
_bg["snapshot"] = snapshot
items = []
for layer, cells in changed.items():
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"]):
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)
_bg["stats"]["scans"] += 1
_bg["stats"]["dernier_scan"] = time.time()
_bg["stats"]["ajouts_dernier_scan"] = added
cells_changed = sum(len(v) for v in changed.values())
if items:
logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) "
f"modifiée(s), {added} tuile(s) en file")
return added
def _bg_process_one():
"""Traite une tuile de la file. Vrai si elle a dû être rendue."""
item = _bg_pop()
if item is None:
return False
layer, z, x, y = item
conf = _bg_conf()
_data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y,
conf["scale"], conf["fmt"])
if state != "pending":
_bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1
return False
tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"])
_bg["stats"]["rendues"] += 1
return True
def _bg_worker():
try:
os.nice(10) # consommation minimale : le rendu cède aux autres usages
except (AttributeError, OSError): # pragma: no cover — nice indisponible
pass
while not _bg["stop"].is_set():
try:
rendered = _bg_process_one()
except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file
logger.warning(f"Maintenance des tuiles : rendu impossible ({e})")
rendered = False
if rendered:
_bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause
else:
_bg["stop"].wait(0.5)
def _bg_poller():
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"])
def start_background():
"""Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés."""
if _bg["running"]:
return
_bg["stop"].clear()
_bg["running"] = True
threads = [threading.Thread(target=_bg_poller, daemon=True,
name="lidar-bg-scan")]
threads += [threading.Thread(target=_bg_worker, daemon=True,
name=f"lidar-bg-render-{i}")
for i in range(TILE_WORKERS)]
for t in threads:
t.start()
_bg["threads"] = threads
conf = _bg_conf()
logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} "
f"@{conf['scale']}x {conf['fmt']}, scan toutes les "
f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s "
f"entre rendus")
def stop_background():
_bg["stop"].set()
_bg["running"] = False
@lru_cache(maxsize=8)
def _transparent_tile(scale=1, fmt="png"):
return tiles_mod.transparent_tile(scale, fmt)
@asynccontextmanager
async def _lifespan(_app):
if _env_flag("LIDAR_TILE_BACKGROUND"):
start_background()
yield
stop_background()
_LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$")
_NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$")
_MAX_UPSTREAM_BYTES = 8 * 1024 * 1024
@ -70,7 +281,7 @@ if not logger.handlers:
logger.addHandler(_h)
logger.setLevel(logging.INFO)
app = FastAPI(title="Carte LiDAR — tuiles XYZ")
app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan)
# Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli
# (dev, paquet installé sans bake).
@ -255,14 +466,28 @@ 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")
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
empty = data is None
if empty:
data = tiles_mod.transparent_tile(scale, fmt)
if _env_flag("LIDAR_TILE_CACHE_ONLY"):
# Mode cache seule : aucune navigation ne déclenche de rendu (ni amont).
data, state = await run_in_threadpool(
tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt)
pending = state == "pending"
empty = state == "empty"
else:
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
pending = False
empty = data is None
if empty or pending:
data = _transparent_tile(scale, fmt)
headers = dict(_cors_headers())
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
else "public, max-age=300, must-revalidate")
headers["X-Tile-Empty"] = "1" if empty else "0"
if pending:
# Tuile à venir (rendue par la maintenance) : jamais mémorisée par le
# navigateur, la prochaine navigation la redemande et la trouve prête.
headers["Cache-Control"] = "no-store"
headers["X-Tile-Pending"] = "1"
else:
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
else "public, max-age=300, must-revalidate")
headers["X-Tile-Empty"] = "1" if (empty or pending) else "0"
return Response(content=data, media_type=_MEDIA[fmt], headers=headers)
@ -586,6 +811,24 @@ def map_warm_status():
return dict(_warm)
@app.get("/api/map/background")
def background_status():
"""État de la maintenance de fond : file, compteurs, configuration."""
with _bg["guard"]:
queued = len(_bg["queue"])
return {"active": _bg["running"], "en_file": queued,
"configuration": _bg_conf(), "stats": dict(_bg["stats"])}
@app.post("/api/map/background")
def background_trigger():
"""Force un scan immédiat des dalles (rendu ensuite en tâche de fond)."""
if not _env_flag("LIDAR_TILE_BACKGROUND"):
raise HTTPException(409, "maintenance de fond inactive "
"(LIDAR_TILE_BACKGROUND)")
return {"tuiles_en_file": background_scan()}
@app.get("/healthz")
def healthz():
return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))}

View File

@ -530,3 +530,68 @@ def test_worker_limits_are_configurable(monkeypatch):
monkeypatch.delenv("LIDAR_TILE_FETCH_WORKERS", raising=False)
importlib.reload(tiles)
importlib.reload(mapserve)
def _reset_bg(mapserve):
mapserve._bg["queue"].clear()
mapserve._bg["queued"].clear()
mapserve._bg["snapshot"] = None
mapserve._bg["stats"].update(rendues=0, a_jour=0, vides=0, scans=0,
dernier_scan=None, ajouts_dernier_scan=0)
def test_tile_cache_only_never_renders(tmp_path, monkeypatch):
"""Mode cache seule : tuile manquante = transparente non mémorisable."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["slope"])
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
tiles.source_index(tmp_path, force=True)
monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1")
z = 15
x, y = _tile_of_cell(1054, 6882, z)
resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png"))
assert resp.headers["X-Tile-Pending"] == "1"
assert resp.headers["X-Tile-Empty"] == "1"
assert resp.headers["Cache-Control"] == "no-store"
assert not tiles.tile_cache_path(tmp_path, "slope", z, x, y).exists()
# Une fois rendue par ailleurs : servie depuis le cache, mémorisable
tiles.get_tile(tmp_path, "slope", z, x, y)
resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png", v="s"))
assert resp.headers["X-Tile-Empty"] == "0"
assert "X-Tile-Pending" not in resp.headers
assert "immutable" in resp.headers["Cache-Control"]
def test_background_scan_detects_and_fills(tmp_path, monkeypatch):
"""La maintenance détecte les dalles, remplit la pyramide, puis se tait."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["slope"])
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_MAX_Z", "14")
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_SCALE", "1")
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_FMT", "png")
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_PAUSE", "0")
_reset_bg(mapserve)
try:
# Premier scan : tout est nouveau, bas niveaux en tête de file
assert mapserve.background_scan(tmp_path) > 0
zs = [t[1] for t in mapserve._bg["queue"]]
assert zs == sorted(zs) and max(zs) <= 14
processed = 0
while mapserve._bg["queue"] and processed < 1000:
mapserve._bg_process_one()
processed += 1
assert not mapserve._bg["queue"]
assert mapserve._bg["stats"]["rendues"] > 0
x, y = _tile_of_cell(1054, 6882, 14)
assert tiles.tile_cache_path(
tmp_path, "slope", 14, x, y, 1, "png").exists()
# Rien n'a changé : plus aucun ajout
assert mapserve.background_scan(tmp_path) == 0
# Une dalle nouvelle (worker) : sa pyramide repart seule en file
_make_dalle(tmp_path, 1055, 6882, ["slope"])
assert mapserve.background_scan(tmp_path) > 0
finally:
_reset_bg(mapserve)

View File

@ -487,3 +487,20 @@ def test_remote_index_failure_keeps_local(tmp_path, monkeypatch):
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: None)
layers = tiles.source_index(tmp_path, force=True)
assert set(layers) == {"slope"}
def test_cached_tile_states(tmp_path):
"""`cached_tile` : lecture cache seule — fresh / pending / empty, sans rendu."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["slope"])
x, y = _tile_of_cell(1054, 6882, 14)
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
assert state == "pending" and data is None
assert not tiles.tile_cache_path(tmp_path, "slope", 14, x, y).exists()
tiles.get_tile(tmp_path, "slope", 14, x, y) # rendu (maintenance, warm…)
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
assert state == "fresh" and data
# Hors données : état empty + marqueur posé, toujours sans rendu
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)

View File

@ -325,6 +325,15 @@ def _cell_bounds(col, row):
return (col * 1000.0, (row - 1) * 1000.0, (col + 1) * 1000.0, row * 1000.0)
def cell_bounds_wgs84(col, row):
"""Emprise WGS84 [ouest, sud, est, nord] d'une dalle LHD (4 coins L93)."""
min_x, min_y, max_x, max_y = _cell_bounds(col, row)
xs = (min_x, min_x, max_x, max_x)
ys = (min_y, max_y, min_y, max_y)
lons, lats = _transformer("EPSG:2154", "EPSG:4326").transform(xs, ys)
return (min(lons), min(lats), max(lons), max(lats))
def _dir_mtime(path):
try:
return path.stat().st_mtime
@ -780,6 +789,37 @@ def empty_marker_exists(output_dir, layer, z, x, y, scale=1, fmt="png"):
scale, fmt).with_suffix(".empty").exists()
def cached_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
"""Tuile depuis le cache UNIQUEMENT — jamais de rendu (mode cache seule).
Retourne ``(data, état)`` :
- ``"fresh"`` : tuile en cache et plus récente que ses dalles sources ;
- ``"empty"`` : aucune source n'intersecte (marqueur `.empty` posé) ;
- ``"pending"``: donnée présente mais tuile absente ou périmée — à rendre
ailleurs (tâche de fond, pré-chauffage), pas au fil de la navigation.
"""
cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt)
empty = cache.with_suffix(".empty")
sources = _contributing(output_dir, layer, z, x, y, scale)
if not sources:
if not empty.exists():
_write_atomic(empty, b"")
return None, "empty"
if empty.exists():
return None, "empty"
newest = 0.0
for src in sources:
m = src.mtime()
if m and m > newest:
newest = m
try:
if cache.stat().st_mtime >= newest:
return cache.read_bytes(), "fresh"
except OSError:
pass
return None, "pending"
def transparent_tile(scale=1, fmt="png"):
"""Tuile entièrement transparente (zone sans donnée)."""
from PIL import Image