La carte à tuiles XYZ devient la seule interface : l'API de génération (preview, generate, status, stop, file, cell), le job unique + file persistante, la délégation LIDAR_GENERATION_URL et les garde-fous (token, LIDAR_REGEN_CIDR) passent de webapp.py à mapserve.py ; l'interface gagne les boutons + Zone / ⤒ Compléter / régénération à la dalle, les options de run et la progression cadre par cadre. mapserve sert aussi l'inventaire /api/tiles et les dalles en statique versionné : le worker image complète remplace la webapp sur le 8973 du générateur, le Pi n'exécute plus que lidar-maps. index.py se réduit aux registres partagés + vignettes/sous-tuiles/inventaire (l'UI HTML/JS et les mosaïques d'overview partent avec export.py et les compose/scripts de la webapp).
321 lines
16 KiB
Markdown
321 lines
16 KiB
Markdown
# Carte à tuiles XYZ (image `lidar-maps`)
|
||
|
||
Carte « slippy » classique — même schéma de tuiles que Google Maps et
|
||
**OpenStreetMap** — servie par une image légère dédiée. Les rendus du pipeline
|
||
deviennent ainsi un **fond d'imagerie réutilisable** dans JOSM, iD, QGIS, uMap,
|
||
MapLibre ou OsmAnd, en plus de l'interface de consultation fournie.
|
||
|
||
C'est la SEULE interface web du projet (l'ancienne webapp historique a été
|
||
retirée) : elle embarque aussi la **génération de tuiles** — locale sur
|
||
l'image complète (worker, `./run.sh --serve`), déléguée via
|
||
`LIDAR_GENERATION_URL` sur la machine légère (cf. `docs/DEPLOY_WEBAPP.md`).
|
||
|
||
```bash
|
||
docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/
|
||
docker compose -f docker-compose.maps.yml logs -f maps
|
||
./run.sh --serve-maps # équivalent en conteneur au premier plan
|
||
```
|
||
|
||
## Générer des tuiles depuis la carte
|
||
|
||
- **+ Zone** (barre d'outils) — dessiner un rectangle : les dalles LHD de 1 km
|
||
qui l'intersectent sont téléchargées depuis la géoplateforme IGN puis
|
||
traitées (0,2 m, options ci-dessous) ; les zones et clics successifs
|
||
s'additionnent ;
|
||
- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui
|
||
manquent au moins une des couches demandées ;
|
||
- **↻ Générer cette dalle** (fiche d'infos au clic) — une dalle précise,
|
||
même sans données existantes.
|
||
|
||
Options du run : couches visées (défaut : les couches du panneau),
|
||
classification du sol (`ign`/`auto`/`smrf`/`csf`), classes LAS IGN
|
||
(`sol,unclassified…`), régénération forcée, reclassification, raccord des
|
||
bords (bande 100 m). 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.
|
||
|
||
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
|
||
backend de génération n'existe (image légère sans `LIDAR_GENERATION_URL`).
|
||
Sur la machine légère, tout est transmis au worker
|
||
(`LIDAR_GENERATION_URL`), qui exécute le pipeline complet ; ses dalles sont
|
||
rapatriées à la demande par l'inventaire `/api/tiles` + les statiques
|
||
versionnées (`LIDAR_SOURCE_URL`).
|
||
|
||
## Contrat de tuilage
|
||
|
||
| Point | Valeur |
|
||
|---|---|
|
||
| Projection | EPSG:3857, schéma XYZ OSM (origine nord-ouest, `y` vers le sud) |
|
||
| URL canonique | `/tiles/{couche}/{z}/{x}/{y}.png` — 256 px, PNG RGBA |
|
||
| 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 » |
|
||
|
||
Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
|
||
(WMTS 1.0.0, grille `GoogleMapsCompatible`), `/tiles/josm.imagery.xml`
|
||
(toutes les couches d'un coup dans JOSM).
|
||
|
||
## Utiliser les tuiles ailleurs
|
||
|
||
- **JOSM** — *Imagery → Imagery preferences → + TMS*, coller
|
||
`http://<hôte>:8975/tiles/slope/{zoom}/{x}/{y}.png`. Pour tout ajouter d'un
|
||
coup : *Imagery preferences → Offline/Custom → Add imagery source XML* avec
|
||
`http://<hôte>:8975/tiles/josm.imagery.xml`.
|
||
- **iD** — *Fond de carte → Personnalisé*, coller
|
||
`http://<hôte>:8975/tiles/slope/{z}/{x}/{y}.png`.
|
||
- **QGIS** — *XYZ Tiles → Nouvelle connexion* (même URL, zoom max 19), ou
|
||
*WMS/WMTS → Nouveau* avec `http://<hôte>:8975/tiles/wmts.xml`.
|
||
- **uMap / MapLibre / Leaflet** — même gabarit XYZ, ou le TileJSON.
|
||
- **OsmAnd** — source de tuiles en ligne, gabarit XYZ, zoom max 19.
|
||
|
||
Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces
|
||
URL pour la couche choisie.
|
||
|
||
## Pile de couches
|
||
|
||
Chaque couche du panneau porte trois réglages, tous persistés et transportés
|
||
par le lien de partage :
|
||
|
||
- **ordre** — poignée ⠿ en glisser-déposer, ou boutons ▲▼ (les seuls
|
||
utilisables au doigt). La liste est encadrée par « Haut de pile — devant » et
|
||
« Bas de pile — derrière ». Pendant un glisser, la destination est explicite :
|
||
la ligne tirée s'estompe et une **barre d'insertion** marque le point de
|
||
chute, au-dessus ou en dessous de la ligne survolée selon la moitié visée ;
|
||
à l'arrivée, la couche déplacée clignote brièvement et est ramenée dans le
|
||
champ de vision ;
|
||
- **opacité** — curseur par couche ;
|
||
- **mode de fusion** — `mix-blend-mode` CSS : Normal, Produit, Écran,
|
||
Superposé, Doux, Lumière crue, Différence, Luminosité. La superposition ne se
|
||
réduit donc pas à de la transparence : « Produit » pose un ombrage sur une
|
||
rampe de couleur sans la délaver, « Superposé » creuse le contraste local,
|
||
« Différence » fait ressortir les écarts entre deux couches.
|
||
|
||
### Figer la configuration
|
||
|
||
Le bouton **★ Définir par défaut** enregistre l'état courant — ordre, couches
|
||
allumées, opacités, modes de fusion, fond de carte et fusion de la pile — dans
|
||
`output/.map-defaults.json`. Tout navigateur sans réglage local part alors de
|
||
cette configuration ; **↺ Réinitialiser** oublie l'état local et y revient.
|
||
|
||
```bash
|
||
curl http://localhost:8975/api/map/defaults # configuration servie
|
||
curl -X DELETE http://localhost:8975/api/map/defaults # retour au registre
|
||
```
|
||
|
||
Sans fichier enregistré, les défauts viennent du registre du pipeline
|
||
(`DEFAULT_LAYERS`, `DEFAULT_OPACITY`, `DEFAULT_BLEND` dans `index.py`). Les
|
||
valeurs reçues sont filtrées : couches inconnues écartées, opacités bornées à
|
||
0–1, modes de fusion validés.
|
||
|
||
Les couches LiDAR vivent dans un conteneur **isolé** (`isolation: isolate`) :
|
||
les fusions agissent entre elles, jamais sur le fond de carte à travers les
|
||
zones sans donnée. La fusion de la **pile entière sur le fond** se règle à
|
||
part, en bas du panneau.
|
||
|
||
> **Licence** — LiDAR HD est diffusé sous **Licence Ouverte 2.0** :
|
||
> l'attribution IGN est obligatoire et doit rester visible chez le client.
|
||
> Avant d'utiliser ces rendus comme calque de **saisie** dans OpenStreetMap,
|
||
> vérifier la position de la communauté (OSM-FR) sur la source concernée.
|
||
|
||
## Comment une tuile est fabriquée
|
||
|
||
1. L'emprise de la tuile est convertie en Lambert 93 (échantillonnage 5×5 du
|
||
contour : les bords ne sont pas droits en L93).
|
||
2. Les dalles de 1 km qui l'intersectent sont retrouvées par leur nom
|
||
(`LHD_FXX_{col}_{row}` → X ∈ [col, col+1] km, Y ∈ [row−1, row] km).
|
||
3. Pour chacune, le **palier source** le plus grossier suffisant est choisi
|
||
parmi ceux que le pipeline produit déjà : vignette `index_thumbs`
|
||
(≈3,9 m/px), vignette intermédiaire `_mid` (1,56 m/px), quadrants
|
||
`index_subtiles` (2500 px, 4× moins à décoder que la dalle) puis la dalle.
|
||
Les trois dossiers sont scannés **indépendamment** : un cache partiel — une
|
||
machine légère ne rapatrie que quadrants et vignettes, jamais les dalles
|
||
entières — reste entièrement exploitable.
|
||
4. La fenêtre utile est découpée puis reprojetée par **transformation
|
||
projective** (`Image.transform(..., PERSPECTIVE)`), dalle par dalle : le
|
||
calage mesuré est inférieur au pixel.
|
||
5. Le résultat est encodé (PNG ou WebP) et écrit dans
|
||
`output/index_xyz/{couche}/{z}/{x}/{y}[@2x].{ext}`.
|
||
|
||
Aucune dépendance GDAL/PDAL : **Pillow + pyproj** uniquement.
|
||
|
||
### Cache et péremption
|
||
|
||
- Une tuile en cache est resservie tant qu'**aucune dalle contributrice n'est
|
||
plus récente qu'elle** : régénérer une dalle n'invalide que ses tuiles.
|
||
- Une tuile sans donnée est mémorisée par un marqueur `.empty` — jamais
|
||
recalculée.
|
||
- Les images sources décodées sont gardées dans un petit cache LRU : le
|
||
décodage AVIF domine le coût, les tuiles voisines le réutilisent.
|
||
- Côté navigateur, l'interface ajoute `?v=<stamp>` (mtime la plus récente) et
|
||
reçoit alors un cache immuable ; les clients OSM utilisent l'URL nue, servie
|
||
en revalidation courte.
|
||
|
||
### Mesures (dalles réelles 0,2 m, bloc 3×3 km, conteneur sans GPU)
|
||
|
||
| | premier rendu | depuis le cache |
|
||
|---|---|---|
|
||
| z10–z14 | 50–200 ms | 3–9 ms |
|
||
| z16–z18 (résolution native) | 46–130 ms | 3–9 ms |
|
||
|
||
Poids d'une tuile de pente à z17, mesuré sur la même tuile :
|
||
|
||
| encodage | poids | fidélité |
|
||
|---|---|---|
|
||
| PNG RGBA (défaut) | 210 Ko | sans perte |
|
||
| PNG palettisé (`LIDAR_TILE_PNG_PALETTE=1`) | 55 Ko | écart moyen 5,8 niveaux |
|
||
| WebP q78 (`…/{y}.webp`) | 38 Ko | avec perte, visuellement propre |
|
||
|
||
Le PNG canonique reste **sans perte** par défaut : ces rendus servent à
|
||
l'interprétation, pas à l'illustration. Pour un usage en fond d'imagerie où le
|
||
débit compte, deux leviers : demander l'URL `.webp` (les clients qui la
|
||
supportent : QGIS, iD, MapLibre, uMap) ou activer `LIDAR_TILE_PNG_PALETTE=1`.
|
||
|
||
Contrôles de calage effectués sur données réelles : un chemin OSM se superpose
|
||
exactement à la trace visible dans la couche de pente, et l'écart de couture
|
||
entre tuiles voisines reste **inférieur au bruit naturel du terrain** (écart
|
||
mesuré 35–53 niveaux contre une médiane de 49–53 entre deux colonnes voisines
|
||
prises au hasard dans la même tuile).
|
||
|
||
## Charge et petites machines
|
||
|
||
Le service est borné à chaque étage, rien n'est illimité :
|
||
|
||
| Étage | Défaut | Réglage |
|
||
|---|---|---|
|
||
| Rendus simultanés | 2 | `LIDAR_TILE_WORKERS` |
|
||
| Téléchargements de dalles simultanés | 2 | `LIDAR_TILE_FETCH_WORKERS` |
|
||
| Même tuile demandée en parallèle | 1 rendu, les autres attendent son résultat | — |
|
||
| Mémoire des sources décodées | 192 Mo | `LIDAR_TILE_SOURCE_CACHE_MB` |
|
||
|
||
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.
|
||
|
||
Le pré-chauffage (`/api/map/warm`) est séquentiel : il ne peut pas saturer la
|
||
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` | `65536` | file d'attente bornée (au-delà : ignoré jusqu'au prochain scan) — une entrée ne coûte que quelques octets |
|
||
|
||
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
|
||
curl -X POST http://localhost:8975/api/map/warm \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"layers": ["slope"], "z_min": 10, "z_max": 16}'
|
||
curl http://localhost:8975/api/map/warm # avancement / compte rendu
|
||
```
|
||
|
||
Sans `bounds`, l'emprise des dalles disponibles est utilisée. Utile après un
|
||
gros run pour que la première consultation soit instantanée.
|
||
|
||
## Deux machines
|
||
|
||
Deux amonts complémentaires, selon ce que la machine locale possède.
|
||
|
||
`LIDAR_SOURCE_URL` désigne la **webapp du pipeline** (port 8973) : l'inventaire
|
||
des dalles vient de son `/api/tiles` et chaque image source est rapatriée au
|
||
premier rendu qui en a besoin. Le conteneur carte démarre alors sans aucune
|
||
donnée locale et sert tout le catalogue distant :
|
||
|
||
```bash
|
||
docker run -d --name lidar-maps --network host \
|
||
-e LIDAR_SOURCE_URL=http://127.0.0.1:8973 \
|
||
-e LIDAR_OUTPUT_DIR=/data/output -e LIDAR_PORT=8975 \
|
||
-v "$PWD/output-maps:/data/output" --user "$(id -u):$(id -g)" lidar-maps
|
||
```
|
||
|
||
Mesuré sur 849 dalles réelles servies par un tunnel SSH : première tuile d'une
|
||
zone 0,9–3,2 s (téléchargement d'un quadrant de 1,8 Mo compris), puis **~1 ms**
|
||
depuis le cache local, qui ne garde que ce qui a été consulté.
|
||
|
||
`LIDAR_MAPS_URL` désigne un serveur de tuiles amont (machine de traitement) :
|
||
une tuile absente localement y est rapatriée (quelques dizaines de Ko) puis
|
||
mise en cache — au lieu de rapatrier les dalles entières. Un disjoncteur
|
||
suspend les tentatives 2 minutes après 3 échecs consécutifs : carte
|
||
consultable même worker éteint.
|
||
|
||
## Variables d'environnement
|
||
|
||
| Variable | Défaut | Rôle |
|
||
|---|---|---|
|
||
| `LIDAR_OUTPUT_DIR` | `/data/output` | dalles lues, cache `index_xyz/` écrit |
|
||
| `LIDAR_PORT` | `8975` | port d'écoute |
|
||
| `LIDAR_SOURCE_URL` | — | webapp du pipeline : inventaire + dalles rapatriées à la demande |
|
||
| `LIDAR_SOURCE_TOKEN` | — | jeton si la webapp amont exige `LIDAR_API_TOKEN` |
|
||
| `LIDAR_MAPS_URL` | — | serveur de tuiles amont (carte déportée) |
|
||
| `LIDAR_ATTRIBUTION` | LiDAR HD © IGN | mention servie (TileJSON, WMTS, JOSM, carte) |
|
||
| `LIDAR_TILE_PNG_PALETTE` | — | `1` : PNG palettisé (~4× plus léger, écart ~6 niveaux) |
|
||
| `LIDAR_TILE_SOURCE_CACHE_MB` | `192` | budget mémoire du cache d'images sources décodées |
|
||
| `LIDAR_TILE_WORKERS` | `2` | rendus de tuiles simultanés |
|
||
| `LIDAR_TILE_FETCH_WORKERS` | `2` | téléchargements de dalles simultanés (mode `LIDAR_SOURCE_URL`) |
|
||
| `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` | — | HTTPS direct (GPS sur téléphone) |
|
||
|
||
## Dépannage
|
||
|
||
- **Carte vide, `layers: 0`** (`curl /healthz`) : aucun rendu dans
|
||
`output/visualisations/`, ou dossier monté au mauvais endroit.
|
||
- **Tuiles transparentes partout** : zoom hors plage (5–19) ou zone sans
|
||
dalle ; l'en-tête `X-Tile-Empty` le confirme.
|
||
- **Première consultation lente** : normal, chaque tuile est rendue une fois —
|
||
pré-chauffer (ci-dessus).
|
||
- **JOSM refuse l'URL** : utiliser le gabarit `{zoom}/{x}/{y}` (JOSM) et non
|
||
`{z}/{x}/{y}` (Leaflet/QGIS).
|
||
- **Couche absente du menu** : elle n'existe pas sur disque pour ces dalles —
|
||
`/api/map/meta` liste ce qui est réellement disponible.
|
||
|
||
## Références
|
||
|
||
- `lidar_pipeline/tiles.py` — grille, reprojection, cache, pré-chauffage.
|
||
- `lidar_pipeline/mapserve.py` — routes tuiles/TileJSON/WMTS/JOSM et API carte.
|
||
- `lidar_pipeline/mapui.py` — interface (Leaflet `L.tileLayer`, LOD natif).
|
||
- `Dockerfile.maps`, `docker-compose.maps.yml`, `./run.sh --serve-maps`.
|