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

@ -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))}