Ajouter une carte à tuiles XYZ (image lidar-maps) réutilisable dans OSM
L'ancienne carte n'est pas une carte à tuiles : une div Leaflet par dalle,
rotée en CSS pour coller la grille Lambert 93 sur le Web Mercator, trois
paliers d'images choisis à la main, un plafond d'images pleine résolution et
une mosaïque d'overview pour boucher les trous au dézoom. Des centaines de
nœuds DOM, des AVIF de 2500² à 5000² décodés dans le navigateur, un LOD
maison — et rien de réutilisable hors de cette page.
Nouvelle image légère (Pillow + pyproj, ni GDAL ni PDAL, port 8975) servant
une pyramide XYZ EPSG:3857 au schéma OpenStreetMap, rendue à la demande
depuis les dalles et mise en cache dans output/index_xyz/ :
- tiles.py : grille XYZ, reprojection par transformation projective dalle par
dalle (calage mesuré < 1 px), choix du palier source parmi ceux que le
pipeline produit déjà (vignette, intermédiaire, quadrant, dalle), cache
périmé dès qu'une dalle contributrice est plus récente, marqueur .empty
pour les zones sans donnée, cache d'images sources à budget mémoire ;
- mapserve.py : /tiles/{couche}/{z}/{x}/{y}.png (256 px canonique) et
@2x.webp (512 px, interface), TileJSON, WMTS, josm.imagery.xml, CORS —
les rendus deviennent un fond d'imagerie pour JOSM, iD, QGIS, uMap ;
- mapui.py : une L.tileLayer par couche dans un pane isolé — LOD, cache et
animation natifs de Leaflet ; pile réordonnable (glisser avec barre
d'insertion, ou boutons ▲▼ au doigt), modes de fusion CSS par couche,
configuration figeable comme défaut de tous les navigateurs.
Deux amonts pour les déploiements en deux machines : LIDAR_SOURCE_URL
(webapp du pipeline — inventaire complet, dalles rapatriées à la demande) et
LIDAR_MAPS_URL (autre instance carte). Charge bornée et réglable, taillée
par défaut pour une petite machine : 2 rendus et 2 téléchargements
simultanés, 192 Mo de cache de sources.
Mesuré sur 849 dalles réelles : première tuile d'une zone 0,9–3,2 s
(téléchargement compris), ~1 ms ensuite ; un chemin OSM se superpose
exactement à la trace du rendu de pente, et les coutures entre tuiles
restent sous le bruit naturel du terrain.
L'interface historique (port 8973) n'est pas touchée : génération et export
y restent, les deux cartes coexistent.
This commit is contained in:
248
docs/MAPS.md
Normal file
248
docs/MAPS.md
Normal file
@ -0,0 +1,248 @@
|
||||
# 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.
|
||||
|
||||
Elle coexiste avec la webapp historique (port 8973), sans rien lui changer :
|
||||
même dossier `output/`, génération et export restent là-bas.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 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` |
|
||||
| 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.
|
||||
|
||||
## 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`.
|
||||
Reference in New Issue
Block a user