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:
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,6 +6,7 @@
|
||||
# Résultats générés
|
||||
output/
|
||||
output-webapp/
|
||||
output-maps/
|
||||
input/
|
||||
|
||||
# Fichiers temporaires
|
||||
@ -28,6 +29,7 @@ htmlcov/
|
||||
|
||||
# Interface bâchée dans l'image (régénérée au build, cf. Dockerfile.webapp)
|
||||
lidar_pipeline/webapp_assets/
|
||||
lidar_pipeline/mapserve_assets/
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
- build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU)
|
||||
- build générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes)
|
||||
- simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` peuplé à la demande depuis le worker. Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS).
|
||||
- carte à tuiles XYZ (style OSM/Google Maps, image légère `lidar-maps`, port 8975) : `docker compose -f docker-compose.maps.yml up -d --build` ou `./run.sh --serve-maps` — cf. `docs/MAPS.md`. Coexiste avec la webapp historique (8973) ; lecture seule (génération/export restent sur `webapp.py`).
|
||||
- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git) ; mise à jour = `git pull` puis `restart` (rebuild inclus). Le script pilote `docker compose` et charge automatiquement `docker-compose.webapp.override.yml` s'il existe — utilisable sur le Pi de prod (192.168.3.10) : labels Traefik `lidar.example.fr` + volume réel `/srv/lidar/output` de l'override préservés. Mise à jour distante du Pi depuis ce poste : `ssh` (checkout `/srv/lidar_rendu`, procédure dans `docs/DEPLOY_WEBAPP.md` §2).
|
||||
- test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord)
|
||||
- test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>`
|
||||
@ -22,6 +23,7 @@
|
||||
- **Generation is 0.2 m only** (policy): `/api/generate` (`GENERATE_RESOLUTIONS` in `webapp.py`), the compose `process` command and the CLI `-r` default all produce 0.2 m exclusively; 0.5 m stays available via explicit `-r 0.5`. Completeness detection (`complete_cells`) requires the viz at 0.2 m only.
|
||||
- **Génération du nord au sud** : les tuiles sont traitées par ligne décroissante (row = nord en km), colonnes croissantes — `find_laz_files` (pipeline.py) pour les passes batch et `_resolve_request` (webapp.py) pour les runs lancés depuis la carte. Les workers prennent les fichiers dans l'ordre de soumission : la carte se remplit de haut en bas pendant un run (`--file` explicite au CLI = ordre utilisateur préservé). Parallélisme de génération : `LIDAR_WORKERS` (10 dans les compose ; fallback 10 dans `webapp.py`).
|
||||
- **Sub-tuilage intégral** : `_CARTO_SUBTILED_VIZ` (vide dans `index.py`) découpe TOUTES les couches en quadrants 500 m à 0,2 m ; ortho/topo sont encodées en AVIF q75 (`_SUBTILE_DETAIL_VIZ`) contre q55 pour les rampes de couleur. Une couche qui échoue à la découpe retombe en dalle entière (`_fallback_full_dalle`) sans pénaliser les autres.
|
||||
- **Tuiles XYZ réutilisables hors du projet** : `/tiles/{layer}/{z}/{x}/{y}.png` suit le schéma OpenStreetMap (256 px, EPSG:3857, PNG RGBA transparent hors emprise, CORS `*`) — JOSM, iD, QGIS, uMap et MapLibre le consomment tel quel, avec découverte via TileJSON / WMTS / `josm.imagery.xml`. Le 512 px (`@2x.webp`) est réservé à l'interface interne (moitié moins de requêtes en HTTP/1.1). Toute évolution du gabarit d'URL casse des configurations clientes : la changer demande une décision explicite.
|
||||
- **Bilingual naming**: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French.
|
||||
- **Adding a visualization requires 4 edits**: (1) `generate_X()` in `visualizations.py`, (2) entry in `VIZ_STEPS` in `pipeline.py`, (3) entry in `COLORMAPS` in `rendering.py`, (4) entry in `VIZ_LEGENDS` in `index.py` (title/legend/description + sampled cmap gradient — single text source merged into `COLORMAPS` at import, also used by the export mosaic legend in `export.py`). Missing any one breaks the pipeline.
|
||||
- **`generate_*` signature is strict**: `(dem_file, basename, vis_dir, resolution, shared=None)` returning `Path` on success, `None` on failure. IGN overlays (`ortho`, `topo`) omit `shared`.
|
||||
|
||||
45
Dockerfile.maps
Normal file
45
Dockerfile.maps
Normal file
@ -0,0 +1,45 @@
|
||||
# Carte LiDAR à tuiles XYZ (image « lidar-maps ») — cf. docs/MAPS.md.
|
||||
#
|
||||
# Sert la pyramide de tuiles Web Mercator (schéma OpenStreetMap) rendue à la
|
||||
# demande depuis les dalles du pipeline, plus l'interface de consultation.
|
||||
# Aucun PDAL, aucun GDAL, aucun GPU : Pillow + pyproj suffisent (ARM64 inclus).
|
||||
#
|
||||
# docker compose -f docker-compose.maps.yml up -d --build
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Europe/Paris
|
||||
|
||||
# Pillow >= 11.3 : encodage/décodage AVIF natif (dalles et sous-tuiles du
|
||||
# pipeline) ; pyproj : reprojection Lambert 93 → Web Mercator sans GDAL.
|
||||
RUN pip3 install --no-cache-dir \
|
||||
fastapi \
|
||||
uvicorn \
|
||||
"pillow>=11.3" \
|
||||
pyproj
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Seuls tiles.py, mapserve.py, mapui.py et index.py (registres de couches) sont
|
||||
# importés ; les modules de traitement sont copiés mais jamais chargés.
|
||||
COPY lidar_pipeline/ ./lidar_pipeline/
|
||||
|
||||
# CSS/JS de l'interface bâchés dans l'image : le code de la carte suit l'image,
|
||||
# pas le cache de tuiles (régénéré à chaque build).
|
||||
RUN python3 -c "import sys; sys.path.insert(0, '/app'); \
|
||||
from lidar_pipeline.mapui import write_map_assets, ASSETS_DIRNAME; \
|
||||
write_map_assets('/app/lidar_pipeline/' + ASSETS_DIRNAME)"
|
||||
|
||||
RUN groupadd -g 1000 lidar && \
|
||||
useradd -u 1000 -g lidar -m -s /bin/bash lidar && \
|
||||
mkdir -p /data/output && \
|
||||
chown -R lidar:lidar /data
|
||||
|
||||
ENV LIDAR_OUTPUT_DIR=/data/output \
|
||||
LIDAR_PORT=8975 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
USER lidar
|
||||
EXPOSE 8975
|
||||
|
||||
CMD ["python3", "-m", "uvicorn", "lidar_pipeline.mapserve:app", "--host", "0.0.0.0", "--port", "8975"]
|
||||
42
docker-compose.maps.yml
Normal file
42
docker-compose.maps.yml
Normal file
@ -0,0 +1,42 @@
|
||||
# Carte à tuiles XYZ (style OpenStreetMap / Google Maps) — cf. docs/MAPS.md.
|
||||
#
|
||||
# Coexiste avec la webapp historique (port 8973) : même dossier output/, aucune
|
||||
# interférence. Les tuiles sont rendues à la demande et mises en cache dans
|
||||
# output/index_xyz/.
|
||||
#
|
||||
# docker compose -f docker-compose.maps.yml up -d --build
|
||||
# docker compose -f docker-compose.maps.yml logs -f maps
|
||||
# docker compose -f docker-compose.maps.yml down
|
||||
services:
|
||||
maps:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.maps
|
||||
image: lidar-maps
|
||||
container_name: lidar-maps
|
||||
init: true
|
||||
user: "1000:1000"
|
||||
ports:
|
||||
- "8975:8975"
|
||||
volumes:
|
||||
# Lecture des dalles + écriture du cache de tuiles (output/index_xyz/)
|
||||
- ./output:/data/output
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- LIDAR_OUTPUT_DIR=/data/output
|
||||
# Serveur de DALLES amont (webapp du pipeline) : sans données locales,
|
||||
# l'inventaire vient de son /api/tiles et les images sont rapatriées à
|
||||
# la demande dans ./output — de quoi servir tout un catalogue distant.
|
||||
# - LIDAR_SOURCE_URL=http://192.168.1.50:8973
|
||||
# Serveur de TUILES amont (autre instance lidar-maps) pour une carte
|
||||
# déportée (Raspberry Pi) : une tuile absente localement y est rapatriée.
|
||||
# - LIDAR_MAPS_URL=http://192.168.1.50:8975
|
||||
# Mention d'attribution servie dans TileJSON, WMTS, JOSM et la carte.
|
||||
# - LIDAR_ATTRIBUTION=LiDAR HD © IGN — Licence Ouverte 2.0
|
||||
# Charge maximale : rendus et téléchargements simultanés, mémoire du
|
||||
# cache d'images sources. Défauts taillés pour une petite machine ;
|
||||
# à relever sur un serveur confortable.
|
||||
# - LIDAR_TILE_WORKERS=2
|
||||
# - LIDAR_TILE_FETCH_WORKERS=2
|
||||
# - LIDAR_TILE_SOURCE_CACHE_MB=192
|
||||
restart: unless-stopped
|
||||
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`.
|
||||
619
lidar_pipeline/mapserve.py
Normal file
619
lidar_pipeline/mapserve.py
Normal file
@ -0,0 +1,619 @@
|
||||
"""Serveur de tuiles XYZ + carte « slippy » (image lidar-maps).
|
||||
|
||||
Sert la pyramide de tuiles décrite dans `tiles.py` selon le schéma
|
||||
OpenStreetMap/Google Maps, donc directement utilisable comme fond d'imagerie
|
||||
dans JOSM, iD, QGIS, uMap, MapLibre ou OsmAnd :
|
||||
|
||||
GET /tiles/{couche}/{z}/{x}/{y}.png 256 px, PNG RGBA (canonique)
|
||||
GET /tiles/{couche}/{z}/{x}/{y}@2x.webp 512 px, WebP (interface interne)
|
||||
GET /tiles/{couche}.json TileJSON 3.0.0
|
||||
GET /tiles/wmts.xml capacités WMTS 1.0.0 (QGIS…)
|
||||
GET /tiles/josm.imagery.xml fichier d'imagerie JOSM
|
||||
GET /api/map/meta couches, zooms, emprise, version
|
||||
GET /api/map/tile?lat&lng dalle sous le point (fiche info)
|
||||
POST /api/map/warm pré-calcul d'une emprise
|
||||
GET / et /assets/* interface de consultation
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.sax.saxutils as _xml
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from . import tiles as tiles_mod
|
||||
from .mapui import ASSETS_DIRNAME, render_html, ui_version, write_map_assets
|
||||
|
||||
OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output"))
|
||||
PORT = int(os.environ.get("LIDAR_PORT", "8975"))
|
||||
# Serveur de tuiles amont (machine de traitement) — mode deux machines.
|
||||
MAPS_URL = (os.environ.get("LIDAR_MAPS_URL") or "").rstrip("/")
|
||||
ATTRIBUTION = os.environ.get(
|
||||
"LIDAR_ATTRIBUTION",
|
||||
'LiDAR HD © <a href="https://geoservices.ign.fr/lidarhd">IGN</a> '
|
||||
'— Licence Ouverte 2.0')
|
||||
ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").replace("—", "—")
|
||||
|
||||
# Rendus simultanés : plafond bas par défaut — une carte se consulte souvent
|
||||
# depuis une petite machine (Raspberry Pi), et un rendu décode une dalle AVIF
|
||||
# de plusieurs dizaines de Mo. Les requêtes en excès attendent le sémaphore
|
||||
# sans consommer de CPU. `LIDAR_TILE_WORKERS` relève le plafond sur une
|
||||
# machine confortable.
|
||||
TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2))
|
||||
_render_sem = threading.Semaphore(TILE_WORKERS)
|
||||
|
||||
_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
|
||||
|
||||
logger = logging.getLogger("lidar")
|
||||
if not logger.handlers:
|
||||
_h = logging.StreamHandler(sys.stdout)
|
||||
_h.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(_h)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
app = FastAPI(title="Carte LiDAR — tuiles XYZ")
|
||||
|
||||
# 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).
|
||||
_assets_dir = Path(__file__).resolve().parent / ASSETS_DIRNAME
|
||||
if not (_assets_dir / "app.js").is_file():
|
||||
try:
|
||||
write_map_assets(_assets_dir)
|
||||
except OSError:
|
||||
import tempfile
|
||||
_assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets"
|
||||
write_map_assets(_assets_dir)
|
||||
|
||||
logger.info(f"Carte LiDAR (tuiles XYZ) — sortie {OUTPUT_DIR}"
|
||||
+ (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local")
|
||||
+ f", {TILE_WORKERS} rendu(s) simultané(s)"
|
||||
+ f", {tiles_mod.FETCH_WORKERS} téléchargement(s) simultané(s)"
|
||||
+ f", cache sources {tiles_mod.SOURCE_CACHE_BYTES // (1024 * 1024)} Mo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilitaires
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.middleware("http")
|
||||
async def _cors(request: Request, call_next):
|
||||
"""CORS ouvert sur les tuiles : exigé par iD, uMap et MapLibre."""
|
||||
if request.method == "OPTIONS" and request.url.path.startswith("/tiles/"):
|
||||
return Response(status_code=204, headers=_cors_headers())
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/tiles/"):
|
||||
response.headers.update(_cors_headers())
|
||||
return response
|
||||
|
||||
|
||||
def _cors_headers():
|
||||
return {"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"}
|
||||
|
||||
|
||||
def _base_url(request: Request):
|
||||
"""URL publique du service (proxy inverse honoré)."""
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||||
if not host:
|
||||
host = f"{request.url.hostname}:{request.url.port or PORT}"
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def _layer_or_404(layer):
|
||||
if not _LAYER_RE.match(layer or ""):
|
||||
raise HTTPException(404, "couche inconnue")
|
||||
if layer not in tiles_mod.available_layers(OUTPUT_DIR):
|
||||
raise HTTPException(404, f"couche indisponible : {layer}")
|
||||
return layer
|
||||
|
||||
|
||||
def _layer_infos():
|
||||
"""Couches disponibles + libellés/descriptions du registre partagé."""
|
||||
from .index import VIZ_LABELS, VIZ_LEGENDS
|
||||
out = []
|
||||
for key in tiles_mod.available_layers(OUTPUT_DIR):
|
||||
legend = VIZ_LEGENDS.get(key, {})
|
||||
out.append({"key": key,
|
||||
"label": VIZ_LABELS.get(key, key),
|
||||
"description": legend.get("description", "")})
|
||||
return out
|
||||
|
||||
|
||||
# --- disjoncteur amont (mêmes réglages que webapp.py) ----------------------
|
||||
_UPSTREAM = {"fails": 0, "until": 0.0}
|
||||
_UPSTREAM_OFFLINE_AFTER = 3
|
||||
_UPSTREAM_OFFLINE_SECONDS = 120.0
|
||||
|
||||
|
||||
def _upstream_offline():
|
||||
return time.time() < _UPSTREAM["until"]
|
||||
|
||||
|
||||
def _upstream_mark(ok):
|
||||
if ok:
|
||||
_UPSTREAM.update(fails=0, until=0.0)
|
||||
return
|
||||
_UPSTREAM["fails"] += 1
|
||||
if _UPSTREAM["fails"] >= _UPSTREAM_OFFLINE_AFTER and _UPSTREAM["until"] == 0.0:
|
||||
_UPSTREAM["until"] = time.time() + _UPSTREAM_OFFLINE_SECONDS
|
||||
logger.info("Serveur de tuiles amont injoignable — tentatives suspendues 2 min")
|
||||
|
||||
|
||||
def _fetch_upstream(rel_path):
|
||||
"""Rapatrie une tuile depuis LIDAR_MAPS_URL (None si indisponible)."""
|
||||
if not MAPS_URL or _upstream_offline():
|
||||
return None
|
||||
url = f"{MAPS_URL}/{rel_path.lstrip('/')}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "lidar-maps-cache"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
data = r.read(_MAX_UPSTREAM_BYTES + 1)
|
||||
if len(data) > _MAX_UPSTREAM_BYTES:
|
||||
raise IOError("tuile amont trop volumineuse")
|
||||
_upstream_mark(True)
|
||||
return data
|
||||
except Exception as e: # noqa: BLE001 — amont éteint : rendu local/vide
|
||||
_upstream_mark(False)
|
||||
logger.debug(f"Tuile amont indisponible ({url}) : {e}")
|
||||
return None
|
||||
|
||||
|
||||
# --- rendu : déduplication des requêtes concurrentes ------------------------
|
||||
_locks = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def _acquire_lock(key):
|
||||
"""Verrou par tuile, compté : deux requêtes simultanées n'en rendent qu'une.
|
||||
|
||||
Le compteur évite deux écueils : un dictionnaire qui grossit sans fin (des
|
||||
millions de tuiles) et un verrou retiré alors qu'un autre fil l'attend
|
||||
encore — ce qui autoriserait deux rendus concurrents de la même tuile.
|
||||
"""
|
||||
with _locks_guard:
|
||||
entry = _locks.get(key)
|
||||
if entry is None:
|
||||
entry = _locks[key] = [threading.Lock(), 0]
|
||||
entry[1] += 1
|
||||
return entry
|
||||
|
||||
|
||||
def _release_lock(key, entry):
|
||||
with _locks_guard:
|
||||
entry[1] -= 1
|
||||
if entry[1] <= 0:
|
||||
_locks.pop(key, None)
|
||||
|
||||
|
||||
def _tile_bytes(layer, z, x, y, scale, fmt):
|
||||
"""Tuile encodée : cache → rendu local → amont. None si zone vide."""
|
||||
key = (layer, z, x, y, scale, fmt)
|
||||
entry = _acquire_lock(key)
|
||||
try:
|
||||
with entry[0]:
|
||||
try:
|
||||
with _render_sem:
|
||||
data = tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, scale, fmt)
|
||||
except Exception as e: # noqa: BLE001 — une tuile ne casse pas la carte
|
||||
logger.warning(f"Rendu de tuile impossible ({layer} {z}/{x}/{y}) : {e}")
|
||||
data = None
|
||||
if data is None and MAPS_URL:
|
||||
suffix = "@2x" if scale == 2 else ""
|
||||
data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}")
|
||||
if data:
|
||||
tiles_mod._write_atomic(
|
||||
tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y,
|
||||
scale, fmt),
|
||||
data)
|
||||
finally:
|
||||
_release_lock(key, entry)
|
||||
return data
|
||||
|
||||
|
||||
_MEDIA = {"png": "image/png", "webp": "image/webp", "avif": "image/avif"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tuiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/tiles/{layer}/{z}/{x}/{name}")
|
||||
async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
|
||||
"""Tuile XYZ (schéma OSM). `name` = `{y}.png` ou `{y}@2x.webp`."""
|
||||
m = _NAME_RE.match(name)
|
||||
if not m:
|
||||
raise HTTPException(404, "nom de tuile invalide")
|
||||
y = int(m.group(1))
|
||||
scale = 2 if m.group(2) else 1
|
||||
fmt = m.group(3)
|
||||
_layer_or_404(layer)
|
||||
if not tiles_mod.zoom_supported(z, scale):
|
||||
raise HTTPException(404, f"zoom hors plage ({tiles_mod.TILE_MIN_Z}"
|
||||
f"–{tiles_mod.TILE_MAX_NATIVE_Z})")
|
||||
n = 2 ** z
|
||||
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)
|
||||
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"
|
||||
return Response(content=data, media_type=_MEDIA[fmt], headers=headers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Découverte : TileJSON, WMTS, JOSM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/tiles/josm.imagery.xml")
|
||||
def josm_imagery(request: Request):
|
||||
"""Fichier d'imagerie JOSM : ajoute toutes les couches en une fois."""
|
||||
base = _base_url(request)
|
||||
bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR)
|
||||
entries = []
|
||||
for info in _layer_infos():
|
||||
b = ""
|
||||
if bounds:
|
||||
b = (f' <bounds min-lat="{bounds[1]:.6f}" min-lon="{bounds[0]:.6f}"'
|
||||
f' max-lat="{bounds[3]:.6f}" max-lon="{bounds[2]:.6f}"/>\n')
|
||||
entries.append(
|
||||
" <entry>\n"
|
||||
f" <name>LiDAR HD — {_xml.escape(info['label'])}</name>\n"
|
||||
f" <id>lidar-hd-{info['key'].replace('_', '-')}</id>\n"
|
||||
" <type>tms</type>\n"
|
||||
f" <url><![CDATA[{base}/tiles/{info['key']}/{{zoom}}/{{x}}/{{y}}.png]]></url>\n"
|
||||
f" <min-zoom>{tiles_mod.TILE_MIN_Z}</min-zoom>\n"
|
||||
f" <max-zoom>{tiles_mod.TILE_MAX_NATIVE_Z}</max-zoom>\n"
|
||||
f"{b}"
|
||||
f" <description lang=\"fr\">{_xml.escape(info['description'])}</description>\n"
|
||||
f" <attribution-text>{_xml.escape(ATTRIBUTION_TEXT)}</attribution-text>\n"
|
||||
" <attribution-url>https://geoservices.ign.fr/lidarhd</attribution-url>\n"
|
||||
" <category>other</category>\n"
|
||||
" </entry>")
|
||||
xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<imagery xmlns="http://josm.openstreetmap.de/maps-1.0">\n'
|
||||
+ "\n".join(entries) + "\n</imagery>\n")
|
||||
return Response(content=xml, media_type="application/xml",
|
||||
headers=_cors_headers())
|
||||
|
||||
|
||||
@app.get("/tiles/wmts.xml")
|
||||
def wmts_capabilities(request: Request):
|
||||
"""Capacités WMTS 1.0.0 (QGIS, ArcGIS) sur la grille GoogleMapsCompatible."""
|
||||
base = _base_url(request)
|
||||
bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0]
|
||||
layers = []
|
||||
for info in _layer_infos():
|
||||
layers.append(f""" <Layer>
|
||||
<ows:Title>{_xml.escape(info['label'])}</ows:Title>
|
||||
<ows:Abstract>{_xml.escape(info['description'])}</ows:Abstract>
|
||||
<ows:WGS84BoundingBox>
|
||||
<ows:LowerCorner>{bounds[0]:.6f} {bounds[1]:.6f}</ows:LowerCorner>
|
||||
<ows:UpperCorner>{bounds[2]:.6f} {bounds[3]:.6f}</ows:UpperCorner>
|
||||
</ows:WGS84BoundingBox>
|
||||
<ows:Identifier>{info['key']}</ows:Identifier>
|
||||
<Style isDefault="true"><ows:Identifier>default</ows:Identifier></Style>
|
||||
<Format>image/png</Format>
|
||||
<TileMatrixSetLink><TileMatrixSet>GoogleMapsCompatible</TileMatrixSet></TileMatrixSetLink>
|
||||
<ResourceURL format="image/png" resourceType="tile"
|
||||
template="{base}/tiles/{info['key']}/{{TileMatrix}}/{{TileCol}}/{{TileRow}}.png"/>
|
||||
</Layer>""")
|
||||
matrices = []
|
||||
for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1):
|
||||
matrices.append(f""" <TileMatrix>
|
||||
<ows:Identifier>{z}</ows:Identifier>
|
||||
<ScaleDenominator>{559082264.0287178 / (2 ** z):.10f}</ScaleDenominator>
|
||||
<TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner>
|
||||
<TileWidth>256</TileWidth>
|
||||
<TileHeight>256</TileHeight>
|
||||
<MatrixWidth>{2 ** z}</MatrixWidth>
|
||||
<MatrixHeight>{2 ** z}</MatrixHeight>
|
||||
</TileMatrix>""")
|
||||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Capabilities xmlns="http://www.opengis.net/wmts/1.0"
|
||||
xmlns:ows="http://www.opengis.net/ows/1.1"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0.0">
|
||||
<ows:ServiceIdentification>
|
||||
<ows:Title>Carte LiDAR HD</ows:Title>
|
||||
<ows:Abstract>{_xml.escape(ATTRIBUTION_TEXT)}</ows:Abstract>
|
||||
<ows:ServiceType>OGC WMTS</ows:ServiceType>
|
||||
<ows:ServiceTypeVersion>1.0.0</ows:ServiceTypeVersion>
|
||||
<ows:AccessConstraints>Licence Ouverte 2.0</ows:AccessConstraints>
|
||||
</ows:ServiceIdentification>
|
||||
<Contents>
|
||||
{chr(10).join(layers)}
|
||||
<TileMatrixSet>
|
||||
<ows:Identifier>GoogleMapsCompatible</ows:Identifier>
|
||||
<ows:SupportedCRS>urn:ogc:def:crs:EPSG:6.18:3:3857</ows:SupportedCRS>
|
||||
<WellKnownScaleSet>urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible</WellKnownScaleSet>
|
||||
{chr(10).join(matrices)}
|
||||
</TileMatrixSet>
|
||||
</Contents>
|
||||
<ServiceMetadataURL xlink:href="{base}/tiles/wmts.xml"/>
|
||||
</Capabilities>
|
||||
"""
|
||||
return Response(content=xml, media_type="application/xml",
|
||||
headers=_cors_headers())
|
||||
|
||||
|
||||
@app.get("/tiles/{layer}.json")
|
||||
def tilejson(layer: str, request: Request):
|
||||
"""TileJSON 3.0.0 (MapLibre, QGIS, Felt…)."""
|
||||
_layer_or_404(layer)
|
||||
from .index import VIZ_LABELS, VIZ_LEGENDS
|
||||
base = _base_url(request)
|
||||
bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0]
|
||||
center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2,
|
||||
min(tiles_mod.TILE_MAX_NATIVE_Z, 15)]
|
||||
return JSONResponse({
|
||||
"tilejson": "3.0.0",
|
||||
"name": f"LiDAR HD — {VIZ_LABELS.get(layer, layer)}",
|
||||
"description": VIZ_LEGENDS.get(layer, {}).get("description", ""),
|
||||
"attribution": ATTRIBUTION,
|
||||
"scheme": "xyz",
|
||||
"tiles": [f"{base}/tiles/{layer}/{{z}}/{{x}}/{{y}}.png"],
|
||||
"minzoom": tiles_mod.TILE_MIN_Z,
|
||||
"maxzoom": tiles_mod.TILE_MAX_NATIVE_Z,
|
||||
"bounds": bounds,
|
||||
"center": center,
|
||||
}, headers=_cors_headers())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration par défaut de la pile de couches
|
||||
# ---------------------------------------------------------------------------
|
||||
# L'état réglé depuis la carte (ordre, visibilité, opacité, fusion) peut être
|
||||
# figé comme configuration servie à TOUT nouveau navigateur : le réglage utile
|
||||
# ne se perd pas dans un localStorage et se partage sans lien.
|
||||
DEFAULTS_FILE = OUTPUT_DIR / ".map-defaults.json"
|
||||
_defaults_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_defaults():
|
||||
"""Configuration par défaut enregistrée, ou None si aucune."""
|
||||
try:
|
||||
data = json.loads(DEFAULTS_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _clamp01(value, fallback=1.0):
|
||||
try:
|
||||
return min(1.0, max(0.0, float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
BLEND_MODES = ("normal", "multiply", "screen", "overlay", "soft-light",
|
||||
"hard-light", "difference", "luminosity")
|
||||
|
||||
|
||||
def _sanitize_defaults(req):
|
||||
"""Retient ce qui est connu et borné : couches réelles, opacités 0–1, fusions valides."""
|
||||
known = set(tiles_mod.available_layers(OUTPUT_DIR))
|
||||
order = [k for k in (req.order or []) if k in known]
|
||||
for key in sorted(known - set(order)):
|
||||
order.append(key)
|
||||
on = [k for k in (req.on or []) if k in known]
|
||||
opacity = {k: _clamp01(v) for k, v in (req.opacity or {}).items() if k in known}
|
||||
blend = {k: v for k, v in (req.blend or {}).items()
|
||||
if k in known and v in BLEND_MODES}
|
||||
base_in = req.base if isinstance(req.base, dict) else {}
|
||||
base = {"on": bool(base_in.get("on", True)),
|
||||
"opacity": _clamp01(base_in.get("opacity"), 0.85),
|
||||
"dark": bool(base_in.get("dark", True))}
|
||||
stack = req.stack_blend if req.stack_blend in BLEND_MODES else "normal"
|
||||
return {"order": order, "on": on, "opacity": opacity, "blend": blend,
|
||||
"base": base, "stack_blend": stack, "saved_at": time.time()}
|
||||
|
||||
|
||||
class DefaultsRequest(BaseModel):
|
||||
order: list = Field(default_factory=list,
|
||||
description="ordre de pile, du bas vers le haut")
|
||||
on: list = Field(default_factory=list, description="couches allumées")
|
||||
opacity: dict = Field(default_factory=dict, description="opacité par couche, 0–1")
|
||||
blend: dict = Field(default_factory=dict, description="mode de fusion par couche")
|
||||
base: dict = Field(default_factory=dict,
|
||||
description="fond de carte : on, opacity, dark")
|
||||
stack_blend: str = Field("normal", description="fusion de la pile sur le fond")
|
||||
|
||||
|
||||
@app.get("/api/map/defaults")
|
||||
def get_defaults():
|
||||
"""Configuration par défaut servie aux nouveaux navigateurs."""
|
||||
return {"defaults": _load_defaults()}
|
||||
|
||||
|
||||
@app.post("/api/map/defaults")
|
||||
def set_defaults(req: DefaultsRequest):
|
||||
"""Fige l'état courant comme configuration par défaut (tous navigateurs)."""
|
||||
data = _sanitize_defaults(req)
|
||||
with _defaults_lock:
|
||||
tmp = DEFAULTS_FILE.with_suffix(".tmp")
|
||||
DEFAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, DEFAULTS_FILE)
|
||||
logger.info(f"Configuration de pile par défaut enregistrée : "
|
||||
f"{len(data['on'])} couche(s) allumée(s)")
|
||||
return {"enregistré": True, "defaults": data}
|
||||
|
||||
|
||||
@app.delete("/api/map/defaults")
|
||||
def clear_defaults():
|
||||
"""Revient aux réglages du registre (index.py)."""
|
||||
with _defaults_lock:
|
||||
existed = DEFAULTS_FILE.exists()
|
||||
DEFAULTS_FILE.unlink(missing_ok=True)
|
||||
return {"supprimé": existed}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API de la carte
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/map/meta")
|
||||
def map_meta():
|
||||
"""Couches, zooms, emprise et version — tout l'état initial de l'interface."""
|
||||
from .index import DEFAULT_BLEND, DEFAULT_OPACITY, default_layers_present
|
||||
infos = _layer_infos()
|
||||
keys = [i["key"] for i in infos]
|
||||
saved = _load_defaults()
|
||||
# Configuration figée depuis la carte, sinon réglages du registre (index.py)
|
||||
default_layers = saved["on"] if saved else default_layers_present(keys)
|
||||
default_opacity = saved["opacity"] if saved else dict(DEFAULT_OPACITY)
|
||||
default_blend = saved["blend"] if saved else dict(DEFAULT_BLEND)
|
||||
return {
|
||||
"layers": infos,
|
||||
"default_layers": default_layers,
|
||||
"default_opacity": default_opacity,
|
||||
"default_blend": default_blend,
|
||||
"default_order": saved["order"] if saved else None,
|
||||
"default_base": saved["base"] if saved else None,
|
||||
"default_stack_blend": saved["stack_blend"] if saved else "normal",
|
||||
"defaults_saved": bool(saved),
|
||||
# L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en
|
||||
# 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique.
|
||||
"tile_url": "tiles/{layer}/{z}/{x}/{y}@2x.webp",
|
||||
"tile_size": tiles_mod.TILE_SIZE * 2,
|
||||
"zoom_offset": -1,
|
||||
"min_zoom": tiles_mod.TILE_MIN_Z,
|
||||
"max_native_zoom": tiles_mod.TILE_MAX_NATIVE_Z - 1,
|
||||
"bounds": tiles_mod.grid_bounds_wgs84(OUTPUT_DIR),
|
||||
"stamp": tiles_mod.tiles_stamp(OUTPUT_DIR),
|
||||
"attribution": ATTRIBUTION,
|
||||
"attribution_text": ATTRIBUTION_TEXT,
|
||||
"ui_version": ui_version(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/map/tile")
|
||||
def map_tile_info(lat: float, lng: float):
|
||||
"""Dalle LHD sous un point WGS84 + métadonnées de génération."""
|
||||
from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles
|
||||
x, y = tiles_mod.wgs84_to_l93(lng, lat)
|
||||
col, row = int(x // 1000), int(y // 1000) + 1
|
||||
best = None
|
||||
for t in scan_tiles(OUTPUT_DIR / "visualisations"):
|
||||
if (t["col"], t["row"]) != (col, row):
|
||||
continue
|
||||
if best is None or t["resolution"] < best["resolution"]:
|
||||
best = t
|
||||
if best is None:
|
||||
return {"found": False, "col": col, "row": row}
|
||||
meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM")
|
||||
return {
|
||||
"found": True,
|
||||
"col": col,
|
||||
"row": row,
|
||||
"name": best["basename"],
|
||||
"resolution": best["resolution"],
|
||||
"method": meta.get("method"),
|
||||
"generated": meta.get("generated"),
|
||||
"layers": sorted(best["viz"]),
|
||||
"suffix": _res_suffix_str(best["resolution"]),
|
||||
}
|
||||
|
||||
|
||||
class WarmRequest(BaseModel):
|
||||
layers: list = Field(default_factory=list,
|
||||
description="couches à pré-calculer (défaut : toutes)")
|
||||
z_min: int = Field(tiles_mod.TILE_MIN_Z, description="zoom minimal")
|
||||
z_max: int = Field(16, description="zoom maximal (19 = résolution native)")
|
||||
bounds: Optional[list] = Field(None,
|
||||
description="[ouest, sud, est, nord] WGS84 ; "
|
||||
"défaut : emprise des dalles")
|
||||
scale: int = Field(1, description="1 = tuiles 256 px, 2 = 512 px (@2x)")
|
||||
fmt: str = Field("png", description="png, webp ou avif")
|
||||
|
||||
|
||||
_warm = {"running": False, "done": None, "result": None}
|
||||
|
||||
|
||||
@app.post("/api/map/warm")
|
||||
def map_warm(req: WarmRequest):
|
||||
"""Pré-calcule des tuiles en tâche de fond (accélère la 1re consultation)."""
|
||||
if _warm["running"]:
|
||||
raise HTTPException(409, "un pré-chauffage est déjà en cours")
|
||||
layers = [l for l in req.layers if _LAYER_RE.match(str(l))] or \
|
||||
tiles_mod.available_layers(OUTPUT_DIR)
|
||||
if req.fmt not in _MEDIA:
|
||||
raise HTTPException(400, f"format invalide : {req.fmt}")
|
||||
_warm.update(running=True, done=None, result=None)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
_warm["result"] = tiles_mod.warm(
|
||||
OUTPUT_DIR, layers, req.z_min, req.z_max,
|
||||
req.bounds, max(1, min(2, req.scale)), req.fmt)
|
||||
except Exception as e: # noqa: BLE001 — remonté via l'état
|
||||
_warm["result"] = {"erreur": str(e)}
|
||||
finally:
|
||||
_warm["running"] = False
|
||||
_warm["done"] = time.time()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"demarré": True, "couches": layers}
|
||||
|
||||
|
||||
@app.get("/api/map/warm")
|
||||
def map_warm_status():
|
||||
return dict(_warm)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
return HTMLResponse(render_html(), headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate"})
|
||||
|
||||
|
||||
@app.get("/assets/{file_path:path}")
|
||||
def assets(file_path: str, v: str = None):
|
||||
"""Assets de l'interface (revalidés : ils suivent l'image, pas le cache)."""
|
||||
base = _assets_dir.resolve()
|
||||
p = (_assets_dir / file_path).resolve()
|
||||
if base not in p.parents or not p.is_file():
|
||||
raise HTTPException(404, f"asset introuvable : {file_path}")
|
||||
return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
_cert = os.environ.get("LIDAR_SSL_CERTFILE")
|
||||
_key = os.environ.get("LIDAR_SSL_KEYFILE")
|
||||
_ssl = {"ssl_certfile": _cert, "ssl_keyfile": _key} if _cert and _key else {}
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl)
|
||||
856
lidar_pipeline/mapui.py
Normal file
856
lidar_pipeline/mapui.py
Normal file
@ -0,0 +1,856 @@
|
||||
"""Interface de la carte à tuiles XYZ (image lidar-maps).
|
||||
|
||||
Même convention que `index.py` : le CSS et le JS vivent dans des constantes,
|
||||
sont écrits par `write_map_assets()` et bâchés dans l'image au build. La page
|
||||
est une coquille minimale : tout l'état vient de `/api/map/meta`, les couches
|
||||
sont des `L.tileLayer` standard — le navigateur gère LOD, cache et animation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS_DIRNAME = "mapserve_assets"
|
||||
|
||||
|
||||
def ui_version():
|
||||
"""Hash court du CSS+JS : sert d'URL de version pour les assets."""
|
||||
return hashlib.sha1((_MAP_CSS + _MAP_JS).encode("utf-8")).hexdigest()[:10]
|
||||
|
||||
|
||||
def write_map_assets(assets_dir):
|
||||
"""Écrit app.css / app.js (+ Leaflet vendorisé) dans `assets_dir`."""
|
||||
import shutil
|
||||
assets_dir = Path(assets_dir)
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
(assets_dir / "app.css").write_text(_MAP_CSS, encoding="utf-8")
|
||||
(assets_dir / "app.js").write_text(
|
||||
_MAP_JS.replace("__UI_VERSION__", ui_version()), encoding="utf-8")
|
||||
src_vendor = Path(__file__).parent / "assets" / "vendor"
|
||||
dst_vendor = assets_dir / "vendor"
|
||||
if src_vendor.is_dir():
|
||||
shutil.rmtree(dst_vendor, ignore_errors=True)
|
||||
shutil.copytree(src_vendor, dst_vendor)
|
||||
return assets_dir
|
||||
|
||||
|
||||
def render_html():
|
||||
"""Coquille HTML (données chargées par /api/map/meta)."""
|
||||
return _MAP_HTML.replace("__ASSETS_V__", ui_version())
|
||||
|
||||
|
||||
_MAP_HTML = """<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
||||
<title>Carte LiDAR</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3E%3Crect width=%2716%27 height=%2716%27 rx=%273%27 fill=%27%230b0e13%27/%3E%3Cpath d=%27M2 11l4-5 3 4 2-2 3 3z%27 fill=%27%235aa9e6%27/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=__ASSETS_V__"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="layercard" class="card">
|
||||
<div class="card-head">
|
||||
<button id="cardToggle" class="icon-btn" title="Replier / déplier">▾</button>
|
||||
<span class="card-title">Couches</span>
|
||||
<span id="stat" class="card-stat"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="layer-row" id="baseRow">
|
||||
<span class="grip"></span>
|
||||
<button class="eye on" id="baseToggle" title="Afficher / masquer le fond de carte"></button>
|
||||
<span class="layer-name" id="baseName" title="Cliquer : fond clair / assombri">Fond OpenStreetMap</span>
|
||||
<span class="pct" id="basePct"></span>
|
||||
<div class="layer-ctl">
|
||||
<input type="range" id="baseOpacity" min="0" max="100" step="1" title="Opacité du fond">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-edge" id="edgeTop">Haut de pile — devant</div>
|
||||
<div id="layers"></div>
|
||||
<div class="stack-edge" id="edgeBottom">Bas de pile — derrière</div>
|
||||
<div class="stack-row">
|
||||
<span>Fusion de la pile sur le fond</span>
|
||||
<select id="stackBlend" class="blend" title="Mode de fusion de l'ensemble des couches LiDAR sur le fond de carte"></select>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<button id="btnShare" class="link-btn" title="Copier un lien vers cette vue">⎘ Partager la vue</button>
|
||||
<button id="btnUse" class="link-btn" title="Utiliser ces rendus comme fond d'imagerie (JOSM, QGIS, iD…)">⤓ Utiliser dans JOSM / QGIS</button>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<button id="btnDefault" class="link-btn" title="Figer l'état courant (ordre, visibilité, opacités, fusions) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
|
||||
<button id="btnReset" class="link-btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tilecard" class="card" hidden>
|
||||
<div class="card-head">
|
||||
<span class="card-title" id="tileTitle">Dalle</span>
|
||||
<button id="tileClose" class="icon-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="card-body" id="tileBody"></div>
|
||||
</div>
|
||||
|
||||
<div id="usecard" class="card" hidden>
|
||||
<div class="card-head">
|
||||
<span class="card-title">Fond d'imagerie XYZ</span>
|
||||
<button id="useClose" class="icon-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="card-body" id="useBody"></div>
|
||||
</div>
|
||||
|
||||
<nav id="tools">
|
||||
<button id="zoomIn" title="Zoomer">+</button>
|
||||
<button id="zoomOut" title="Dézoomer">−</button>
|
||||
<button id="zoomFit" title="Recadrer sur les données">⤢</button>
|
||||
<button id="gps" title="Centrer sur ma position">⌖</button>
|
||||
</nav>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="assets/vendor/leaflet/leaflet.js"></script>
|
||||
<script src="assets/app.js?v=__ASSETS_V__"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
_MAP_CSS = """/* Carte LiDAR — tuiles XYZ, habillage minimal type carte grand public. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0e13;
|
||||
--card: rgba(16, 20, 27, 0.94);
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--text: #d9dee6;
|
||||
--muted: #8b94a3;
|
||||
--accent: #5aa9e6;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
|
||||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
#map { position: absolute; inset: 0; background: var(--bg); }
|
||||
.leaflet-container { background: var(--bg); font: inherit; }
|
||||
/* Fond OSM assombri pour laisser ressortir les rendus LiDAR. */
|
||||
.leaflet-tile-pane .leaflet-layer.base-dark .leaflet-tile { filter: brightness(0.38) contrast(1.1) saturate(0.7); }
|
||||
|
||||
.card { position: fixed; z-index: 900; background: var(--card); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||||
#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
|
||||
#tilecard { left: 12px; top: 12px; width: 320px; max-width: calc(100vw - 24px); }
|
||||
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); }
|
||||
.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
||||
.card-stat { margin-left: auto; font-size: 11px; color: var(--muted); }
|
||||
.card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; }
|
||||
#layercard.collapsed .card-body { display: none; }
|
||||
#layercard.collapsed #cardToggle { transform: rotate(-90deg); }
|
||||
.card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); }
|
||||
|
||||
button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
|
||||
.icon-btn { border: none; background: none; color: var(--muted); padding: 2px 6px; font-size: 14px; }
|
||||
.icon-btn:hover { color: var(--text); background: none; }
|
||||
.link-btn { flex: 1; font-size: 11.5px; }
|
||||
|
||||
/* Une couche tient sur deux lignes : identité (poignée, œil, nom, ordre) puis
|
||||
réglages (fusion + opacité). Les boutons ▲▼ doublent le glisser-déposer —
|
||||
seuls utilisables au doigt. */
|
||||
.layer-row { display: grid; grid-template-columns: 14px 24px minmax(0, 1fr) auto 34px;
|
||||
align-items: center; gap: 6px; padding: 5px 4px; border-radius: 8px; user-select: none; }
|
||||
.layer-ctl { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 84px;
|
||||
gap: 6px; align-items: center; padding: 2px 0 2px 38px; }
|
||||
.layer-move { display: inline-flex; gap: 2px; }
|
||||
.layer-move button { padding: 0 5px; font-size: 10px; line-height: 16px; color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.05); }
|
||||
.layer-move button:hover:not(:disabled) { color: var(--text); }
|
||||
.layer-move button:disabled { opacity: 0.25; cursor: default; }
|
||||
.blend { background: rgba(255, 255, 255, 0.06); color: var(--muted);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 2px 4px;
|
||||
font: inherit; font-size: 11px; min-width: 0; width: 100%; height: 22px; }
|
||||
.blend:hover { color: var(--text); }
|
||||
.blend option { background-color: #10141b; color: var(--text); }
|
||||
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
/* Réorganisation : la destination doit être lisible AVANT de lâcher. Une
|
||||
barre d'insertion épaisse marque le point de chute, la ligne déplacée
|
||||
s'estompe, et à l'arrivée la couche clignote pour que l'œil la retrouve. */
|
||||
.layer-row.dragging { opacity: 0.35; outline: 1px dashed var(--accent); }
|
||||
.layer-row.drop-before { box-shadow: inset 0 3px 0 0 var(--accent); }
|
||||
.layer-row.drop-after { box-shadow: inset 0 -3px 0 0 var(--accent); }
|
||||
.layer-row.drop-before::before, .layer-row.drop-after::before {
|
||||
content: '▸'; position: absolute; margin-left: -12px; color: var(--accent);
|
||||
font-size: 11px; line-height: 1;
|
||||
}
|
||||
.layer-row.drop-before::before { margin-top: -14px; }
|
||||
.layer-row.drop-after::before { margin-top: 14px; }
|
||||
.layer-row { position: relative; }
|
||||
.layer-row.just-moved { animation: moved 1s ease-out; }
|
||||
@keyframes moved {
|
||||
0% { background: rgba(90, 169, 230, 0.38); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
body.reordering, body.reordering * { cursor: grabbing !important; }
|
||||
.stack-edge {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
color: var(--muted); padding: 2px 6px;
|
||||
}
|
||||
.stack-edge::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; }
|
||||
.grip::before { content: '⠿'; }
|
||||
#baseRow .grip::before { content: ''; }
|
||||
.eye { width: 24px; height: 24px; padding: 0; border: none; background: none; color: var(--muted);
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
.eye.on { color: var(--accent); }
|
||||
.eye svg { width: 16px; height: 16px; }
|
||||
.layer-name { font-size: 12.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.layer-row.on .layer-name { color: var(--text); }
|
||||
.pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
|
||||
background: transparent; cursor: pointer; }
|
||||
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
|
||||
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%;
|
||||
background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; }
|
||||
input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
|
||||
input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; }
|
||||
|
||||
#tools { position: fixed; right: 12px; bottom: 12px; z-index: 900; display: flex; flex-direction: column; gap: 6px; }
|
||||
#tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card);
|
||||
border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||||
#gps.busy { color: var(--accent); border-color: var(--accent); }
|
||||
.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; }
|
||||
|
||||
.meta { font-size: 11.5px; color: var(--muted); margin: 2px 0; }
|
||||
.meta b { color: var(--text); font-weight: 600; }
|
||||
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
|
||||
background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 8px; word-break: break-all; margin: 4px 0 8px; }
|
||||
.use-row { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; }
|
||||
.use-row select { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 12px; }
|
||||
.use-note { font-size: 11px; color: var(--muted); line-height: 1.5; }
|
||||
.leaflet-control-attribution { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; }
|
||||
.leaflet-control-attribution a { color: var(--accent) !important; }
|
||||
@media (max-width: 720px) {
|
||||
#layercard { width: calc(100vw - 24px); }
|
||||
#tilecard { width: calc(100vw - 24px); }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
_MAP_JS = r"""'use strict';
|
||||
/* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation.
|
||||
Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier
|
||||
d'image, aucune rotation CSS, aucune mosaïque maison. */
|
||||
|
||||
const UI_VERSION = "__UI_VERSION__";
|
||||
const LS_KEY = 'lidarMapLayers_v1';
|
||||
const el = (id) => document.getElementById(id);
|
||||
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||||
|
||||
// Modes de fusion proposés (CSS mix-blend-mode) : la superposition de couches
|
||||
// ne se réduit pas à de la transparence — « Produit » assombrit le relief sans
|
||||
// délaver la rampe du dessous, « Écran » éclaircit, « Superposé »/« Doux »
|
||||
// creusent le contraste local, « Différence » fait ressortir les écarts.
|
||||
const BLEND_MODES = [
|
||||
['normal', 'Normal'],
|
||||
['multiply', 'Produit'],
|
||||
['screen', 'Écran'],
|
||||
['overlay', 'Superposé'],
|
||||
['soft-light', 'Doux'],
|
||||
['hard-light', 'Lumière crue'],
|
||||
['difference', 'Différence'],
|
||||
['luminosity', 'Luminosité'],
|
||||
];
|
||||
const BLEND_KEYS = BLEND_MODES.map(m => m[0]);
|
||||
const validBlend = (v) => (BLEND_KEYS.includes(v) ? v : null);
|
||||
const blendOptions = (current) => BLEND_MODES.map(
|
||||
m => '<option value="' + m[0] + '"' + (m[0] === current ? ' selected' : '') +
|
||||
'>' + m[1] + '</option>').join('');
|
||||
|
||||
let META = null;
|
||||
let STATE = null;
|
||||
const LAYERS = new Map(); // clé → L.TileLayer
|
||||
let dragKey = null;
|
||||
|
||||
const map = L.map('map', {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
minZoom: 3,
|
||||
maxZoom: 22,
|
||||
zoomSnap: 0.5,
|
||||
wheelPxPerZoomLevel: 90,
|
||||
worldCopyJump: false,
|
||||
preferCanvas: false
|
||||
});
|
||||
map.attributionControl.setPrefix('');
|
||||
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
|
||||
|
||||
const base = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 22, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
function toast(msg, ms) {
|
||||
const t = el('toast');
|
||||
t.textContent = msg;
|
||||
t.hidden = false;
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500);
|
||||
}
|
||||
|
||||
// --- état persisté --------------------------------------------------------
|
||||
function defaultState(meta) {
|
||||
// `default_order` n'existe que si une configuration a été figée depuis la
|
||||
// carte (POST /api/map/defaults) ; sinon on part du registre du pipeline.
|
||||
const order = (Array.isArray(meta.default_order) && meta.default_order.length)
|
||||
? meta.default_order.filter(k => meta.layers.some(l => l.key === k))
|
||||
: meta.layers.map(l => l.key);
|
||||
for (const l of meta.layers) if (!order.includes(l.key)) order.push(l.key);
|
||||
const on = {}, opacity = {}, blend = {};
|
||||
const wanted = (meta.default_layers || []).filter(k => order.includes(k));
|
||||
// Ordre figé depuis la carte : respecté tel quel. Sinon, les couches
|
||||
// allumées par défaut passent en bas de pile (bas → haut).
|
||||
const stacked = (Array.isArray(meta.default_order) && meta.default_order.length)
|
||||
? order
|
||||
: wanted.concat(order.filter(k => !wanted.includes(k)));
|
||||
for (const k of stacked) {
|
||||
on[k] = wanted.includes(k);
|
||||
const o = Number((meta.default_opacity || {})[k]);
|
||||
opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : 1;
|
||||
blend[k] = (meta.default_blend || {})[k] || 'normal';
|
||||
}
|
||||
const b = meta.default_base || {};
|
||||
return {
|
||||
order: stacked, on, opacity, blend,
|
||||
base: { on: b.on !== undefined ? !!b.on : true,
|
||||
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
|
||||
dark: b.dark !== undefined ? !!b.dark : true },
|
||||
stackBlend: validBlend(meta.default_stack_blend) || 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(meta) {
|
||||
const def = defaultState(meta);
|
||||
let st = def;
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw);
|
||||
const order = (Array.isArray(s.order) ? s.order : []).filter(k => def.order.includes(k));
|
||||
for (const k of def.order) if (!order.includes(k)) order.push(k);
|
||||
const on = {}, opacity = {}, blend = {};
|
||||
for (const k of order) {
|
||||
on[k] = !!(s.on && s.on[k]);
|
||||
const o = Number(s.opacity ? s.opacity[k] : NaN);
|
||||
opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : def.opacity[k];
|
||||
blend[k] = (s.blend && s.blend[k]) || def.blend[k] || 'normal';
|
||||
}
|
||||
st = { order, on, opacity, blend,
|
||||
base: Object.assign({}, def.base, s.base || {}),
|
||||
stackBlend: validBlend(s.stackBlend) || 'normal' };
|
||||
}
|
||||
} catch (e) { st = def; }
|
||||
const hash = parseHash();
|
||||
if (hash && hash.layers) {
|
||||
const order = hash.layers.order.filter(k => def.order.includes(k));
|
||||
for (const k of def.order) if (!order.includes(k)) order.push(k);
|
||||
st = { order, on: hash.layers.on, opacity: hash.layers.opacity,
|
||||
blend: Object.assign({}, st.blend, hash.layers.blend || {}),
|
||||
base: hash.base || st.base,
|
||||
stackBlend: hash.stackBlend || st.stackBlend || 'normal' };
|
||||
for (const k of order) {
|
||||
if (st.on[k] === undefined) st.on[k] = false;
|
||||
if (st.opacity[k] === undefined) st.opacity[k] = 1;
|
||||
if (!st.blend[k]) st.blend[k] = def.blend[k] || 'normal';
|
||||
}
|
||||
}
|
||||
return st;
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
|
||||
}
|
||||
|
||||
// --- hash de vue ----------------------------------------------------------
|
||||
// #z/lat/lng&L=clé:opacité:visible,...&B=on:opacité:sombre
|
||||
function parseHash() {
|
||||
const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
|
||||
if (!m) return null;
|
||||
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
|
||||
layers: null, base: null };
|
||||
const seg = {};
|
||||
for (const part of m[4].split('&')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
|
||||
}
|
||||
if (seg.L) {
|
||||
// clé:opacité:visible[:fusion] — le 4e champ est optionnel (liens anciens)
|
||||
const order = [], on = {}, opacity = {}, blend = {};
|
||||
for (const item of seg.L.split(',')) {
|
||||
const f = item.split(':');
|
||||
if (f.length < 3) continue;
|
||||
order.push(f[0]);
|
||||
on[f[0]] = f[2] === '1';
|
||||
const o = Number(f[1]);
|
||||
opacity[f[0]] = Number.isFinite(o) ? Math.min(100, Math.max(0, o)) / 100 : 1;
|
||||
const b = validBlend(f[3]);
|
||||
if (b) blend[f[0]] = b;
|
||||
}
|
||||
if (order.length) out.layers = { order, on, opacity, blend };
|
||||
}
|
||||
if (seg.S) out.stackBlend = validBlend(seg.S) || 'normal';
|
||||
if (seg.B) {
|
||||
const f = seg.B.split(':');
|
||||
if (f.length === 3) {
|
||||
const o = Number(f[1]);
|
||||
out.base = { on: f[0] === '1', opacity: Number.isFinite(o) ? o / 100 : 0.85, dark: f[2] === '1' };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function viewHash(full) {
|
||||
const c = map.getCenter();
|
||||
let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6);
|
||||
if (full) {
|
||||
h += '&L=' + STATE.order.map(k =>
|
||||
k + ':' + Math.round(STATE.opacity[k] * 100) + ':' + (STATE.on[k] ? '1' : '0')
|
||||
+ ':' + (STATE.blend[k] || 'normal')).join(',');
|
||||
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
|
||||
+ ':' + (STATE.base.dark ? '1' : '0');
|
||||
h += '&S=' + (STATE.stackBlend || 'normal');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
let hashTimer = null;
|
||||
function saveHashSoon() {
|
||||
clearTimeout(hashTimer);
|
||||
hashTimer = setTimeout(() => {
|
||||
history.replaceState(null, '', location.pathname + location.search + viewHash(false));
|
||||
}, 350);
|
||||
}
|
||||
|
||||
// --- couches --------------------------------------------------------------
|
||||
function tileUrl(key) {
|
||||
return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
|
||||
}
|
||||
|
||||
function buildLayers() {
|
||||
// Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la
|
||||
// pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers
|
||||
// les zones sans donnée. La fusion de la pile ENTIÈRE sur le fond reste
|
||||
// réglable à part (STATE.stackBlend).
|
||||
const stack = map.createPane('lidarStack');
|
||||
stack.style.zIndex = 400;
|
||||
stack.style.isolation = 'isolate';
|
||||
META.layers.forEach((info) => {
|
||||
const pane = 'lidar-' + info.key;
|
||||
map.createPane(pane, stack);
|
||||
const layer = L.tileLayer(tileUrl(info.key), {
|
||||
pane: pane,
|
||||
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
|
||||
// pas de requêtes inutiles sur le reste du monde.
|
||||
bounds: META.bounds
|
||||
? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]])
|
||||
: undefined,
|
||||
tileSize: META.tile_size,
|
||||
zoomOffset: META.zoom_offset,
|
||||
minNativeZoom: META.min_zoom,
|
||||
maxNativeZoom: META.max_native_zoom,
|
||||
minZoom: 0,
|
||||
maxZoom: 22,
|
||||
keepBuffer: 2,
|
||||
updateWhenZooming: false,
|
||||
crossOrigin: false,
|
||||
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||||
attribution: META.attribution
|
||||
});
|
||||
LAYERS.set(info.key, layer);
|
||||
});
|
||||
applyLayers();
|
||||
}
|
||||
|
||||
function applyLayers() {
|
||||
const stack = map.getPane('lidarStack');
|
||||
if (stack) stack.style.mixBlendMode = STATE.stackBlend || 'normal';
|
||||
let z = 400;
|
||||
for (const key of STATE.order) {
|
||||
const layer = LAYERS.get(key);
|
||||
if (!layer) continue;
|
||||
const pane = map.getPane('lidar-' + key);
|
||||
z += 1;
|
||||
pane.style.zIndex = String(z);
|
||||
pane.style.mixBlendMode = STATE.blend[key] || 'normal';
|
||||
if (STATE.on[key]) {
|
||||
if (!map.hasLayer(layer)) layer.addTo(map);
|
||||
layer.setOpacity(STATE.opacity[key]);
|
||||
} else if (map.hasLayer(layer)) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
}
|
||||
if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
|
||||
else if (map.hasLayer(base)) map.removeLayer(base);
|
||||
base.setOpacity(STATE.base.opacity);
|
||||
const container = base.getContainer && base.getContainer();
|
||||
if (container) container.classList.toggle('base-dark', STATE.base.dark);
|
||||
}
|
||||
|
||||
function moveLayer(key, delta) {
|
||||
// STATE.order est bas → haut ; le panneau affiche haut en premier.
|
||||
const ord = STATE.order;
|
||||
const i = ord.indexOf(key);
|
||||
const j = i + delta;
|
||||
if (i < 0 || j < 0 || j >= ord.length) return;
|
||||
ord.splice(j, 0, ord.splice(i, 1)[0]);
|
||||
saveState(); renderPanel(key); applyLayers();
|
||||
}
|
||||
|
||||
// Insère `src` avant ou après `target` dans l'ordre AFFICHÉ (haut → bas).
|
||||
function reorderRelative(src, target, before) {
|
||||
const top = STATE.order.slice().reverse();
|
||||
const from = top.indexOf(src);
|
||||
if (from < 0) return;
|
||||
top.splice(from, 1);
|
||||
let at = top.indexOf(target);
|
||||
if (at < 0) return;
|
||||
if (!before) at += 1;
|
||||
top.splice(at, 0, src);
|
||||
STATE.order = top.reverse();
|
||||
saveState(); renderPanel(src); applyLayers();
|
||||
}
|
||||
|
||||
function clearDropMarks() {
|
||||
document.querySelectorAll('.layer-row.drop-before, .layer-row.drop-after')
|
||||
.forEach(r => r.classList.remove('drop-before', 'drop-after'));
|
||||
}
|
||||
|
||||
function renderPanel(movedKey) {
|
||||
const host = el('layers');
|
||||
host.innerHTML = '';
|
||||
const labels = new Map(META.layers.map(l => [l.key, l]));
|
||||
// Haut de pile en premier (STATE.order est bas → haut).
|
||||
const topFirst = STATE.order.slice().reverse();
|
||||
topFirst.forEach((key, idx) => {
|
||||
const info = labels.get(key);
|
||||
if (!info) return;
|
||||
const on = !!STATE.on[key];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'layer-row' + (on ? ' on' : '')
|
||||
+ (key === movedKey ? ' just-moved' : '');
|
||||
row.dataset.layer = key;
|
||||
row.title = info.description || info.label;
|
||||
row.innerHTML =
|
||||
'<span class="grip" title="Glisser pour réordonner"></span>' +
|
||||
'<button class="eye' + (on ? ' on' : '') + '" title="Afficher / masquer">' +
|
||||
(on ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
|
||||
'<span class="layer-name">' + info.label + '</span>' +
|
||||
'<span class="layer-move">' +
|
||||
'<button class="up" title="Monter dans la pile"' +
|
||||
(idx === 0 ? ' disabled' : '') + '>▲</button>' +
|
||||
'<button class="down" title="Descendre dans la pile"' +
|
||||
(idx === topFirst.length - 1 ? ' disabled' : '') + '>▼</button>' +
|
||||
'</span>' +
|
||||
'<span class="pct">' + Math.round(STATE.opacity[key] * 100) + '%</span>' +
|
||||
(on ? '<div class="layer-ctl">' +
|
||||
'<select class="blend" title="Mode de fusion avec les couches du dessous">' +
|
||||
blendOptions(STATE.blend[key] || 'normal') + '</select>' +
|
||||
'<input type="range" min="0" max="100" step="1" value="' +
|
||||
Math.round(STATE.opacity[key] * 100) + '" title="Opacité">' +
|
||||
'</div>' : '');
|
||||
row.draggable = false;
|
||||
row.addEventListener('mousedown', (e) => {
|
||||
row.draggable = !!(e.target.closest && e.target.closest('.grip'));
|
||||
});
|
||||
row.querySelector('.eye').addEventListener('click', () => {
|
||||
STATE.on[key] = !STATE.on[key];
|
||||
if (STATE.on[key] && STATE.opacity[key] === 0) STATE.opacity[key] = 1;
|
||||
saveState(); renderPanel(); applyLayers();
|
||||
});
|
||||
row.querySelector('.up').addEventListener('click', () => moveLayer(key, 1));
|
||||
row.querySelector('.down').addEventListener('click', () => moveLayer(key, -1));
|
||||
const blend = row.querySelector('.blend');
|
||||
if (blend) blend.addEventListener('change', () => {
|
||||
STATE.blend[key] = blend.value;
|
||||
saveState();
|
||||
const pane = map.getPane('lidar-' + key);
|
||||
if (pane) pane.style.mixBlendMode = blend.value;
|
||||
});
|
||||
const range = row.querySelector('input[type="range"]');
|
||||
const pct = row.querySelector('.pct');
|
||||
if (range) {
|
||||
range.addEventListener('input', () => {
|
||||
STATE.opacity[key] = Number(range.value) / 100;
|
||||
pct.textContent = range.value + '%';
|
||||
const layer = LAYERS.get(key);
|
||||
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.opacity[key]);
|
||||
});
|
||||
range.addEventListener('change', saveState);
|
||||
}
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
dragKey = key;
|
||||
row.classList.add('dragging');
|
||||
document.body.classList.add('reordering');
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', key); // exigé par Firefox
|
||||
}
|
||||
});
|
||||
row.addEventListener('dragend', () => {
|
||||
dragKey = null;
|
||||
row.classList.remove('dragging');
|
||||
row.draggable = false;
|
||||
document.body.classList.remove('reordering');
|
||||
clearDropMarks();
|
||||
});
|
||||
row.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragKey || dragKey === key) return;
|
||||
// Moitié haute survolée → insertion au-dessus, sinon en dessous.
|
||||
const box = row.getBoundingClientRect();
|
||||
const before = (e.clientY - box.top) < box.height / 2;
|
||||
clearDropMarks();
|
||||
row.classList.add(before ? 'drop-before' : 'drop-after');
|
||||
});
|
||||
row.addEventListener('dragleave', () => {
|
||||
row.classList.remove('drop-before', 'drop-after');
|
||||
});
|
||||
row.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragKey || dragKey === key) { clearDropMarks(); return; }
|
||||
const before = row.classList.contains('drop-before');
|
||||
const src = dragKey;
|
||||
clearDropMarks();
|
||||
document.body.classList.remove('reordering');
|
||||
reorderRelative(src, key, before);
|
||||
});
|
||||
host.appendChild(row);
|
||||
});
|
||||
// La couche qui vient d'être déplacée est ramenée dans le champ de vision
|
||||
// (le panneau défile dès qu'il y a beaucoup de couches).
|
||||
if (movedKey) {
|
||||
const moved = host.querySelector('.layer-row.just-moved');
|
||||
if (moved && moved.scrollIntoView) moved.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaseRow() {
|
||||
const b = STATE.base;
|
||||
const sel = el('stackBlend');
|
||||
if (sel && !sel.options.length) {
|
||||
sel.innerHTML = blendOptions(STATE.stackBlend || 'normal');
|
||||
sel.addEventListener('change', () => {
|
||||
STATE.stackBlend = sel.value;
|
||||
saveState(); applyLayers();
|
||||
});
|
||||
} else if (sel) {
|
||||
sel.value = STATE.stackBlend || 'normal';
|
||||
}
|
||||
const toggle = el('baseToggle');
|
||||
toggle.classList.toggle('on', b.on);
|
||||
toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF;
|
||||
el('baseOpacity').value = Math.round(b.opacity * 100);
|
||||
el('basePct').textContent = Math.round(b.opacity * 100) + '%';
|
||||
el('baseRow').classList.toggle('on', b.on);
|
||||
}
|
||||
|
||||
// --- fiche dalle ----------------------------------------------------------
|
||||
function showTileInfo(data) {
|
||||
const card = el('tilecard');
|
||||
if (!data || !data.found) {
|
||||
el('tileTitle').textContent = 'Aucune dalle ici';
|
||||
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>';
|
||||
card.hidden = false;
|
||||
return;
|
||||
}
|
||||
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
|
||||
const viz = (data.layers || []).map(k => {
|
||||
const info = META.layers.find(l => l.key === k);
|
||||
return info ? info.label : k;
|
||||
}).join(', ');
|
||||
el('tileBody').innerHTML =
|
||||
'<div class="meta"><b>' + data.name + '</b></div>' +
|
||||
'<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' +
|
||||
' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' +
|
||||
(data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') +
|
||||
'<div class="meta">Couches : ' + (viz || '—') + '</div>';
|
||||
card.hidden = false;
|
||||
}
|
||||
|
||||
// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
|
||||
function renderUseCard() {
|
||||
const origin = location.origin;
|
||||
const opts = META.layers.map(l =>
|
||||
'<option value="' + l.key + '">' + l.label + '</option>').join('');
|
||||
el('useBody').innerHTML =
|
||||
'<div class="use-row"><select id="useLayer">' + opts + '</select>' +
|
||||
'<button id="useCopy">Copier</button></div>' +
|
||||
'<div class="meta">URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :</div>' +
|
||||
'<div class="mono" id="useUrl"></div>' +
|
||||
'<div class="meta">TileJSON : <a id="useJson" href="#"></a></div>' +
|
||||
'<div class="meta">WMTS : <a href="' + origin + '/tiles/wmts.xml">' + origin + '/tiles/wmts.xml</a></div>' +
|
||||
'<div class="meta">JOSM (toutes les couches) : <a href="' + origin + '/tiles/josm.imagery.xml">' + origin + '/tiles/josm.imagery.xml</a></div>' +
|
||||
'<div class="use-note">Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '</div>';
|
||||
const sel = el('useLayer');
|
||||
const refresh = () => {
|
||||
const key = sel.value;
|
||||
el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png';
|
||||
const a = el('useJson');
|
||||
a.textContent = origin + '/tiles/' + key + '.json';
|
||||
a.href = origin + '/tiles/' + key + '.json';
|
||||
};
|
||||
sel.addEventListener('change', refresh);
|
||||
el('useCopy').addEventListener('click', () => {
|
||||
const text = el('useUrl').textContent;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => toast('URL copiée'),
|
||||
() => window.prompt('URL XYZ :', text));
|
||||
} else { window.prompt('URL XYZ :', text); }
|
||||
});
|
||||
refresh();
|
||||
el('usecard').hidden = false;
|
||||
}
|
||||
|
||||
// --- démarrage ------------------------------------------------------------
|
||||
function fitData() {
|
||||
if (META.bounds) {
|
||||
map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]],
|
||||
{ padding: [30, 30] });
|
||||
} else {
|
||||
map.setView([46.6, 2.5], 6);
|
||||
}
|
||||
}
|
||||
|
||||
fetch('api/map/meta').then(r => r.json()).then(meta => {
|
||||
META = meta;
|
||||
if (!META.layers.length) {
|
||||
toast('Aucune visualisation disponible dans le cache.', 10000);
|
||||
}
|
||||
STATE = loadState(META);
|
||||
// L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
|
||||
// suite : ce qui est à l'écran est ce qui sera rechargé.
|
||||
saveState();
|
||||
el('stat').textContent = META.layers.length + ' couche(s)';
|
||||
const hash = parseHash();
|
||||
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
|
||||
else fitData();
|
||||
buildLayers();
|
||||
renderPanel();
|
||||
renderBaseRow();
|
||||
}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
|
||||
|
||||
let applyingHash = false;
|
||||
window.addEventListener('hashchange', () => {
|
||||
// Lien partagé collé dans l'onglet courant : la vue (et l'état des couches
|
||||
// s'il est présent) est appliquée sans rechargement.
|
||||
if (applyingHash || !META) return;
|
||||
const h = parseHash();
|
||||
if (!h) return;
|
||||
applyingHash = true;
|
||||
map.setView([h.lat, h.lng], h.zoom);
|
||||
if (h.layers) {
|
||||
STATE = loadState(META);
|
||||
saveState(); renderPanel(); renderBaseRow(); applyLayers();
|
||||
}
|
||||
setTimeout(() => { applyingHash = false; }, 0);
|
||||
});
|
||||
|
||||
map.on('moveend zoomend', saveHashSoon);
|
||||
map.on('click', (e) => {
|
||||
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
|
||||
.then(r => r.ok ? r.json() : null).then(showTileInfo).catch(() => {});
|
||||
});
|
||||
|
||||
el('cardToggle').addEventListener('click', () =>
|
||||
el('layercard').classList.toggle('collapsed'));
|
||||
el('tileClose').addEventListener('click', () => { el('tilecard').hidden = true; });
|
||||
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
|
||||
el('btnUse').addEventListener('click', renderUseCard);
|
||||
el('btnShare').addEventListener('click', () => {
|
||||
const url = location.origin + location.pathname + viewHash(true);
|
||||
history.replaceState(null, '', url);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(() => toast('Lien copié'),
|
||||
() => window.prompt('Lien :', url));
|
||||
} else { window.prompt('Lien :', url); }
|
||||
});
|
||||
el('btnDefault').addEventListener('click', () => {
|
||||
const body = {
|
||||
order: STATE.order,
|
||||
on: STATE.order.filter(k => STATE.on[k]),
|
||||
opacity: Object.fromEntries(STATE.order.map(k => [k, STATE.opacity[k]])),
|
||||
blend: Object.fromEntries(STATE.order.map(k => [k, STATE.blend[k] || 'normal'])),
|
||||
base: STATE.base,
|
||||
stack_blend: STATE.stackBlend || 'normal',
|
||||
};
|
||||
fetch('api/map/defaults', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
|
||||
.then(() => { META.default_order = STATE.order.slice(); toast('Configuration enregistrée comme défaut'); })
|
||||
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
|
||||
});
|
||||
|
||||
el('btnReset').addEventListener('click', () => {
|
||||
// Oublie l'état local et reprend la configuration servie par le serveur.
|
||||
try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
|
||||
fetch('api/map/meta').then(r => r.json()).then(meta => {
|
||||
META = meta;
|
||||
STATE = defaultState(META);
|
||||
saveState(); renderPanel(); renderBaseRow(); applyLayers();
|
||||
toast('Configuration par défaut rétablie');
|
||||
}).catch(() => toast('Serveur injoignable', 6000));
|
||||
});
|
||||
|
||||
el('baseToggle').addEventListener('click', () => {
|
||||
STATE.base.on = !STATE.base.on;
|
||||
saveState(); renderBaseRow(); applyLayers();
|
||||
});
|
||||
el('baseName').addEventListener('click', () => {
|
||||
STATE.base.dark = !STATE.base.dark;
|
||||
saveState(); applyLayers();
|
||||
});
|
||||
el('baseOpacity').addEventListener('input', (e) => {
|
||||
STATE.base.opacity = Number(e.target.value) / 100;
|
||||
el('basePct').textContent = e.target.value + '%';
|
||||
base.setOpacity(STATE.base.opacity);
|
||||
});
|
||||
el('baseOpacity').addEventListener('change', saveState);
|
||||
el('zoomIn').addEventListener('click', () => map.zoomIn(1));
|
||||
el('zoomOut').addEventListener('click', () => map.zoomOut(1));
|
||||
el('zoomFit').addEventListener('click', fitData);
|
||||
el('gps').addEventListener('click', () => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
toast('Géolocalisation indisponible (HTTPS requis).', 6000);
|
||||
return;
|
||||
}
|
||||
const btn = el('gps');
|
||||
btn.classList.add('busy');
|
||||
navigator.geolocation.getCurrentPosition((pos) => {
|
||||
btn.classList.remove('busy');
|
||||
const ll = [pos.coords.latitude, pos.coords.longitude];
|
||||
map.setView(ll, Math.max(map.getZoom(), 16));
|
||||
if (window._gpsMarker) window._gpsMarker.remove();
|
||||
window._gpsMarker = L.circleMarker(ll, {
|
||||
radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9
|
||||
}).addTo(map);
|
||||
}, (err) => {
|
||||
btn.classList.remove('busy');
|
||||
const why = err && err.code === 1 ? 'Autorisation refusée'
|
||||
: err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible';
|
||||
toast(why + ' (la géolocalisation exige HTTPS).', 6000);
|
||||
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { el('tilecard').hidden = true; el('usecard').hidden = true; }
|
||||
});
|
||||
"""
|
||||
532
lidar_pipeline/tests/test_mapserve.py
Normal file
532
lidar_pipeline/tests/test_mapserve.py
Normal file
@ -0,0 +1,532 @@
|
||||
"""Tests du serveur de tuiles XYZ et de sa découverte (TileJSON, WMTS, JOSM)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from lidar_pipeline.tests.test_tiles import _make_dalle, _tile_of_cell
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Requête minimale : seules l'origine et les en-têtes de proxy comptent."""
|
||||
|
||||
def __init__(self, host="carte.local:8975", proto=None, scheme="http"):
|
||||
self.headers = {"host": host}
|
||||
if proto:
|
||||
self.headers["x-forwarded-proto"] = proto
|
||||
self.url = type("U", (), {"scheme": scheme, "hostname": "carte.local",
|
||||
"port": 8975})()
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, layers=("aspect", "slope")):
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, list(layers))
|
||||
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
return mapserve
|
||||
|
||||
|
||||
def test_tile_png_is_256_rgba(tmp_path, monkeypatch):
|
||||
"""La tuile canonique est un PNG RGBA de 256 px, avec en-têtes CORS."""
|
||||
import io
|
||||
from PIL import Image
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png"))
|
||||
assert resp.media_type == "image/png"
|
||||
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
||||
assert resp.headers["X-Tile-Empty"] == "0"
|
||||
img = Image.open(io.BytesIO(resp.body))
|
||||
assert img.size == (256, 256) and img.mode in ("RGBA", "P")
|
||||
|
||||
|
||||
def test_tile_2x_is_512_webp(tmp_path, monkeypatch):
|
||||
"""Le palier @2x sert du WebP 512 px (interface interne)."""
|
||||
import io
|
||||
from PIL import Image
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.webp"))
|
||||
assert resp.media_type == "image/webp"
|
||||
assert Image.open(io.BytesIO(resp.body)).size == (512, 512)
|
||||
|
||||
|
||||
def test_tile_outside_data_is_transparent(tmp_path, monkeypatch):
|
||||
"""Hors données : tuile transparente (et pas une erreur) pour JOSM/iD."""
|
||||
import io
|
||||
from PIL import Image
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png"))
|
||||
assert resp.headers["X-Tile-Empty"] == "1"
|
||||
img = Image.open(io.BytesIO(resp.body)).convert("RGBA")
|
||||
assert img.getextrema()[3] == (0, 0)
|
||||
|
||||
|
||||
def test_tile_cache_headers(tmp_path, monkeypatch):
|
||||
"""URL versionnée → cache immuable ; sans version → revalidation courte."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
plain = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png"))
|
||||
assert "must-revalidate" in plain.headers["Cache-Control"]
|
||||
versioned = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png", v="123"))
|
||||
assert "immutable" in versioned.headers["Cache-Control"]
|
||||
|
||||
|
||||
def test_tile_rejects_bad_input(tmp_path, monkeypatch):
|
||||
"""Nom de tuile, couche, zoom et indices sont validés (404)."""
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
for layer, zz, xx, name in (("aspect", z, x, "abc.png"), # nom invalide
|
||||
("aspect", z, x, f"{y}.tif"), # format refusé
|
||||
("inconnue", z, x, f"{y}.png"), # couche absente
|
||||
("../etc", z, x, f"{y}.png"), # traversée
|
||||
("aspect", 2, x, f"{y}.png"), # zoom trop bas
|
||||
("aspect", 25, x, f"{y}.png"), # zoom trop haut
|
||||
("aspect", z, 10 ** 9, f"{y}.png")): # hors grille
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(mapserve.tile(layer, zz, xx, name))
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_tile_upstream_fallback(tmp_path, monkeypatch):
|
||||
"""Tuile absente localement : rapatriée depuis LIDAR_MAPS_URL et mise en cache."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
from lidar_pipeline import tiles
|
||||
_setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975")
|
||||
monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0})
|
||||
called = {}
|
||||
|
||||
def fake_fetch(rel_path):
|
||||
called["path"] = rel_path
|
||||
return b"\x89PNG\r\n\x1a\nFAKE"
|
||||
|
||||
monkeypatch.setattr(mapserve, "_fetch_upstream", fake_fetch)
|
||||
# Zone sans donnée locale → le rendu local échoue, l'amont prend le relais
|
||||
resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png"))
|
||||
assert called["path"] == "tiles/aspect/15/1/1.png"
|
||||
assert resp.body == b"\x89PNG\r\n\x1a\nFAKE"
|
||||
assert tiles.tile_cache_path(tmp_path, "aspect", 15, 1, 1).is_file()
|
||||
|
||||
|
||||
def test_upstream_breaker(monkeypatch):
|
||||
"""3 échecs consécutifs suspendent les tentatives amont ; un succès réarme."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0})
|
||||
for _ in range(2):
|
||||
mapserve._upstream_mark(False)
|
||||
assert mapserve._upstream_offline() is False
|
||||
mapserve._upstream_mark(False)
|
||||
assert mapserve._upstream_offline() is True
|
||||
mapserve._upstream_mark(True)
|
||||
assert mapserve._upstream_offline() is False
|
||||
|
||||
|
||||
def test_tilejson_is_valid(tmp_path, monkeypatch):
|
||||
"""TileJSON 3.0.0 : champs obligatoires, URL XYZ absolue, attribution."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
data = json.loads(mapserve.tilejson("aspect", _FakeRequest()).body)
|
||||
assert data["tilejson"] == "3.0.0"
|
||||
assert data["scheme"] == "xyz"
|
||||
assert data["tiles"] == ["http://carte.local:8975/tiles/aspect/{z}/{x}/{y}.png"]
|
||||
assert data["minzoom"] < data["maxzoom"] <= 19
|
||||
assert len(data["bounds"]) == 4 and data["bounds"][0] < data["bounds"][2]
|
||||
assert "IGN" in data["attribution"]
|
||||
|
||||
|
||||
def test_tilejson_unknown_layer(tmp_path, monkeypatch):
|
||||
"""Couche absente du disque → 404."""
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
with pytest.raises(HTTPException) as e:
|
||||
mapserve.tilejson("wavelet", _FakeRequest())
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_base_url_honours_proxy(tmp_path, monkeypatch):
|
||||
"""Derrière un proxy TLS, les URL publiées sont en https."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
data = json.loads(mapserve.tilejson(
|
||||
"aspect", _FakeRequest(host="lidar.example.fr", proto="https")).body)
|
||||
assert data["tiles"][0].startswith("https://lidar.example.fr/tiles/")
|
||||
|
||||
|
||||
def test_josm_imagery_xml(tmp_path, monkeypatch):
|
||||
"""Fichier d'imagerie JOSM : une entrée TMS par couche, URL en {zoom}/{x}/{y}."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
xml = mapserve.josm_imagery(_FakeRequest()).body.decode("utf-8")
|
||||
root = ET.fromstring(xml)
|
||||
ns = {"j": "http://josm.openstreetmap.de/maps-1.0"}
|
||||
entries = root.findall("j:entry", ns)
|
||||
assert {e.find("j:id", ns).text for e in entries} == {
|
||||
"lidar-hd-aspect", "lidar-hd-slope"}
|
||||
for entry in entries:
|
||||
assert entry.find("j:type", ns).text == "tms"
|
||||
assert "{zoom}/{x}/{y}.png" in entry.find("j:url", ns).text
|
||||
assert entry.find("j:max-zoom", ns).text == "19"
|
||||
assert entry.find("j:bounds", ns) is not None
|
||||
|
||||
|
||||
def test_wmts_capabilities(tmp_path, monkeypatch):
|
||||
"""Capacités WMTS : couches, grille GoogleMapsCompatible, gabarit REST."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
xml = mapserve.wmts_capabilities(_FakeRequest()).body.decode("utf-8")
|
||||
root = ET.fromstring(xml)
|
||||
ns = {"w": "http://www.opengis.net/wmts/1.0",
|
||||
"ows": "http://www.opengis.net/ows/1.1"}
|
||||
ids = [l.find("ows:Identifier", ns).text
|
||||
for l in root.findall(".//w:Layer", ns)]
|
||||
assert set(ids) == {"aspect", "slope"}
|
||||
# `.//w:TileMatrixSet` attraperait d'abord le renvoi de
|
||||
# TileMatrixSetLink (simple texte) : on vise la définition.
|
||||
tms = root.find("w:Contents/w:TileMatrixSet", ns)
|
||||
assert tms.find("ows:Identifier", ns).text == "GoogleMapsCompatible"
|
||||
levels = [m.find("ows:Identifier", ns).text
|
||||
for m in tms.findall("w:TileMatrix", ns)]
|
||||
assert levels[0] == "5" and levels[-1] == "19"
|
||||
template = root.find(".//w:ResourceURL", ns).get("template")
|
||||
assert template.endswith("/{TileMatrix}/{TileCol}/{TileRow}.png")
|
||||
|
||||
|
||||
def test_map_meta(tmp_path, monkeypatch):
|
||||
"""/api/map/meta décrit les couches et le contrat de tuilage de l'interface."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
meta = mapserve.map_meta()
|
||||
assert {l["key"] for l in meta["layers"]} == {"aspect", "slope"}
|
||||
assert all(l["label"] for l in meta["layers"])
|
||||
assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.webp"
|
||||
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
|
||||
assert meta["max_native_zoom"] == 18
|
||||
assert meta["bounds"] and meta["stamp"] > 0
|
||||
assert "aspect" in meta["default_layers"]
|
||||
|
||||
|
||||
def test_map_tile_info(tmp_path, monkeypatch):
|
||||
"""/api/map/tile identifie la dalle sous un point et lit ses métadonnées."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
dtm = tmp_path / "DTM"
|
||||
dtm.mkdir(parents=True, exist_ok=True)
|
||||
(dtm / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_dtm_method.txt").write_text(
|
||||
"ign", encoding="utf-8")
|
||||
from lidar_pipeline.tiles import _transformer
|
||||
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1054500, 6881500)
|
||||
data = mapserve.map_tile_info(lat=lat, lng=lon)
|
||||
assert data["found"] is True
|
||||
assert (data["col"], data["row"]) == (1054, 6882)
|
||||
assert data["method"] == "ign"
|
||||
assert data["layers"] == ["aspect", "slope"]
|
||||
# Point hors zone : réponse explicite, sans erreur
|
||||
assert mapserve.map_tile_info(lat=45.0, lng=1.0)["found"] is False
|
||||
|
||||
|
||||
def test_healthz_and_assets(tmp_path, monkeypatch):
|
||||
"""Sonde de santé et assets de l'interface (revalidés, jamais immuables)."""
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
assert mapserve.healthz()["layers"] == 2
|
||||
resp = mapserve.assets("app.js")
|
||||
assert resp.headers["Cache-Control"] == "no-cache, must-revalidate"
|
||||
with pytest.raises(HTTPException):
|
||||
mapserve.assets("introuvable.js")
|
||||
|
||||
|
||||
def test_ui_offers_reorder_and_blend_modes():
|
||||
"""Pile de couches : réordonnancement au doigt ET modes de fusion.
|
||||
|
||||
La superposition ne se réduit pas à de la transparence : chaque couche
|
||||
porte un mix-blend-mode, cantonné à la pile LiDAR par un conteneur isolé
|
||||
(sinon « Produit » assombrirait aussi le fond de carte).
|
||||
"""
|
||||
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
|
||||
# Modes proposés
|
||||
for mode in ("multiply", "screen", "overlay", "soft-light", "difference",
|
||||
"luminosity"):
|
||||
assert f"'{mode}'" in _MAP_JS, mode
|
||||
# Réordonnancement : glisser-déposer ET boutons (seuls utilisables au doigt)
|
||||
assert "function moveLayer(" in _MAP_JS
|
||||
assert "moveLayer(key, 1)" in _MAP_JS and "moveLayer(key, -1)" in _MAP_JS
|
||||
assert "dragstart" in _MAP_JS and "layer-move" in _MAP_CSS
|
||||
# Pile isolée + fusion par couche et fusion de la pile sur le fond
|
||||
assert "createPane('lidarStack')" in _MAP_JS
|
||||
assert "isolation = 'isolate'" in _MAP_JS
|
||||
assert "map.createPane(pane, stack)" in _MAP_JS
|
||||
assert "pane.style.mixBlendMode" in _MAP_JS
|
||||
assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML
|
||||
# Le lien partagé transporte la fusion (4e champ, rétrocompatible)
|
||||
assert "(STATE.blend[k] || 'normal')" in _MAP_JS
|
||||
assert "const b = validBlend(f[3]);" in _MAP_JS
|
||||
|
||||
|
||||
def test_defaults_roundtrip(tmp_path, monkeypatch):
|
||||
"""L'état réglé depuis la carte devient la configuration servie à tous."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
|
||||
|
||||
# Sans enregistrement : réglages du registre (index.py)
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["defaults_saved"] is False
|
||||
assert meta["default_order"] is None
|
||||
assert meta["default_stack_blend"] == "normal"
|
||||
|
||||
req = mapserve.DefaultsRequest(
|
||||
order=["slope", "aspect"], on=["aspect"],
|
||||
opacity={"aspect": 0.6, "slope": 1.0},
|
||||
blend={"aspect": "multiply", "slope": "normal"},
|
||||
base={"on": True, "opacity": 0.4, "dark": False},
|
||||
stack_blend="soft-light")
|
||||
assert mapserve.set_defaults(req)["enregistré"] is True
|
||||
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["defaults_saved"] is True
|
||||
assert meta["default_order"] == ["slope", "aspect"]
|
||||
assert meta["default_layers"] == ["aspect"]
|
||||
assert meta["default_opacity"]["aspect"] == 0.6
|
||||
assert meta["default_blend"]["aspect"] == "multiply"
|
||||
assert meta["default_base"] == {"on": True, "opacity": 0.4, "dark": False}
|
||||
assert meta["default_stack_blend"] == "soft-light"
|
||||
# Persisté sur disque : survit au redémarrage du conteneur
|
||||
assert (tmp_path / ".map-defaults.json").is_file()
|
||||
assert mapserve.get_defaults()["defaults"]["on"] == ["aspect"]
|
||||
|
||||
# Retrait : retour aux réglages du registre
|
||||
assert mapserve.clear_defaults()["supprimé"] is True
|
||||
assert mapserve.map_meta()["defaults_saved"] is False
|
||||
|
||||
|
||||
def test_defaults_sanitised(tmp_path, monkeypatch):
|
||||
"""Couches inconnues écartées, opacités bornées, fusions validées."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
|
||||
data = mapserve.set_defaults(mapserve.DefaultsRequest(
|
||||
order=["aspect", "inexistante"], on=["aspect", "inexistante"],
|
||||
opacity={"aspect": 5, "slope": "x", "inexistante": 0.5},
|
||||
blend={"aspect": "vaudou", "slope": "screen"},
|
||||
base={"opacity": -3}, stack_blend="vaudou"))["defaults"]
|
||||
assert "inexistante" not in data["order"] and "inexistante" not in data["on"]
|
||||
# Les couches présentes mais non citées complètent l'ordre
|
||||
assert set(data["order"]) == {"aspect", "slope"}
|
||||
assert data["opacity"]["aspect"] == 1.0 # borné à 1
|
||||
assert data["opacity"]["slope"] == 1.0 # valeur illisible → opaque
|
||||
assert data["blend"] == {"slope": "screen"} # mode inconnu écarté
|
||||
assert data["base"]["opacity"] == 0.0 # borné à 0
|
||||
assert data["stack_blend"] == "normal" # repli
|
||||
|
||||
|
||||
def test_ui_applies_server_defaults():
|
||||
"""L'interface part des défauts du serveur et sait les (re)définir."""
|
||||
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
|
||||
assert 'id="btnDefault"' in _MAP_HTML and 'id="btnReset"' in _MAP_HTML
|
||||
assert "api/map/defaults" in _MAP_JS
|
||||
assert "meta.default_order" in _MAP_JS
|
||||
assert "meta.default_base" in _MAP_JS
|
||||
assert "validBlend(meta.default_stack_blend)" in _MAP_JS
|
||||
# Réinitialiser oublie l'état local avant de reprendre celui du serveur
|
||||
assert "localStorage.removeItem(LS_KEY)" in _MAP_JS
|
||||
|
||||
|
||||
def test_ui_reorder_shows_destination():
|
||||
"""Réorganiser doit être lisible : repère de chute, fantôme, arrivée signalée.
|
||||
|
||||
Sans repère, on lâche une couche sans savoir où elle atterrit — la pile
|
||||
n'est pas un détail, elle décide de ce qu'on voit.
|
||||
"""
|
||||
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
|
||||
# Barre d'insertion selon la moitié survolée, effacée entre deux survols
|
||||
assert ".layer-row.drop-before" in _MAP_CSS and ".layer-row.drop-after" in _MAP_CSS
|
||||
assert "clearDropMarks" in _MAP_JS
|
||||
assert "box.height / 2" in _MAP_JS
|
||||
assert "row.classList.add(before ? 'drop-before' : 'drop-after')" in _MAP_JS
|
||||
# Ligne déplacée estompée pendant le glisser
|
||||
assert ".layer-row.dragging" in _MAP_CSS and "classList.add('dragging')" in _MAP_JS
|
||||
# Insertion relative (avant/après la ligne visée), pas un simple échange
|
||||
assert "function reorderRelative(" in _MAP_JS
|
||||
# Arrivée mise en évidence puis ramenée dans le champ de vision
|
||||
assert "just-moved" in _MAP_CSS and "@keyframes moved" in _MAP_CSS
|
||||
assert "scrollIntoView" in _MAP_JS
|
||||
assert "renderPanel(key)" in _MAP_JS and "renderPanel(src)" in _MAP_JS
|
||||
# Extrémités de la pile nommées
|
||||
assert 'id="edgeTop"' in _MAP_HTML and 'id="edgeBottom"' in _MAP_HTML
|
||||
assert "Haut de pile" in _MAP_HTML and "Bas de pile" in _MAP_HTML
|
||||
|
||||
|
||||
def test_ui_uses_standard_tilelayer():
|
||||
"""L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison."""
|
||||
from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version
|
||||
assert "L.tileLayer(" in _MAP_JS
|
||||
assert "tileSize: META.tile_size" in _MAP_JS
|
||||
assert "zoomOffset: META.zoom_offset" in _MAP_JS
|
||||
assert "maxNativeZoom: META.max_native_zoom" in _MAP_JS
|
||||
# Plus aucune mécanique de l'ancienne carte
|
||||
for banned in ("quadMatrix3d", "FULL_CAP", "ovTick", "layoutTiles"):
|
||||
assert banned not in _MAP_JS
|
||||
html = render_html()
|
||||
assert ui_version() in html and "__UI_VERSION__" not in html
|
||||
|
||||
|
||||
def test_write_map_assets(tmp_path):
|
||||
"""Les assets écrits portent la version et embarquent Leaflet vendorisé."""
|
||||
from lidar_pipeline.mapui import _MAP_JS, ui_version, write_map_assets
|
||||
write_map_assets(tmp_path / "ui")
|
||||
js = (tmp_path / "ui" / "app.js").read_text(encoding="utf-8")
|
||||
assert js == _MAP_JS.replace("__UI_VERSION__", ui_version())
|
||||
assert (tmp_path / "ui" / "app.css").read_text(encoding="utf-8").startswith("/*")
|
||||
assert (tmp_path / "ui" / "vendor" / "leaflet" / "leaflet.js").is_file()
|
||||
|
||||
|
||||
def test_warm_endpoint(tmp_path, monkeypatch):
|
||||
"""/api/map/warm lance le pré-calcul en tâche de fond puis rend compte."""
|
||||
import time
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "_warm", {"running": False, "done": None,
|
||||
"result": None})
|
||||
req = mapserve.WarmRequest(layers=["aspect"], z_min=12, z_max=12)
|
||||
assert mapserve.map_warm(req)["couches"] == ["aspect"]
|
||||
for _ in range(200):
|
||||
if not mapserve._warm["running"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert mapserve.map_warm_status()["result"]["rendues"] >= 1
|
||||
|
||||
|
||||
def test_tile_locks_are_refcounted(tmp_path, monkeypatch):
|
||||
"""Verrou par tuile : partagé pendant le rendu, retiré une fois libéré.
|
||||
|
||||
Sans compteur, le registre grossit sans fin (des millions de tuiles) ou un
|
||||
verrou disparaît alors qu'un autre fil l'attend — deux rendus concurrents
|
||||
de la même tuile.
|
||||
"""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
key = ("aspect", 15, 1, 1, 1, "png")
|
||||
a = mapserve._acquire_lock(key)
|
||||
b = mapserve._acquire_lock(key)
|
||||
assert a is b and a[1] == 2
|
||||
mapserve._release_lock(key, a)
|
||||
assert key in mapserve._locks
|
||||
mapserve._release_lock(key, b)
|
||||
assert key not in mapserve._locks
|
||||
|
||||
|
||||
def test_concurrent_requests_render_once(tmp_path, monkeypatch):
|
||||
"""Deux requêtes simultanées sur la même tuile ne la rendent qu'une fois."""
|
||||
import threading
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
calls = []
|
||||
real = mapserve.tiles_mod.get_tile
|
||||
|
||||
def slow_get_tile(*args, **kwargs):
|
||||
calls.append(args)
|
||||
threading.Event().wait(0.05)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(mapserve.tiles_mod, "get_tile", slow_get_tile)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
results = []
|
||||
threads = [threading.Thread(
|
||||
target=lambda: results.append(mapserve._tile_bytes("aspect", z, x, y, 1, "png")))
|
||||
for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert all(r == results[0] for r in results)
|
||||
# Les appels suivants lisent le cache disque : un seul rendu effectif
|
||||
assert len(calls) == 4 # chaque requête interroge get_tile…
|
||||
assert mapserve._locks == {} # …mais sérialisées, et les verrous libérés
|
||||
|
||||
|
||||
def test_tile_bytes_survives_render_error(tmp_path, monkeypatch):
|
||||
"""Un rendu qui échoue rend une tuile vide sans laisser de verrou fuité."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("source corrompue")
|
||||
|
||||
monkeypatch.setattr(mapserve.tiles_mod, "get_tile", boom)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
assert mapserve._tile_bytes("aspect", z, x, y, 1, "png") is None
|
||||
assert mapserve._locks == {}
|
||||
|
||||
|
||||
def test_render_concurrency_is_capped(tmp_path, monkeypatch):
|
||||
"""Le plafond de rendus simultanés est tenu (petit serveur protégé).
|
||||
|
||||
Sans lui, autant de rendus que de requêtes en vol : chacun décode une dalle
|
||||
de plusieurs dizaines de Mo — mémoire et CPU d'un Raspberry Pi saturés.
|
||||
"""
|
||||
import threading
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
|
||||
# Plafond volontairement bas pour le test
|
||||
monkeypatch.setattr(mapserve, "_render_sem", threading.Semaphore(2))
|
||||
state = {"now": 0, "peak": 0}
|
||||
guard = threading.Lock()
|
||||
real = mapserve.tiles_mod.get_tile
|
||||
|
||||
def slow(*args, **kwargs):
|
||||
with guard:
|
||||
state["now"] += 1
|
||||
state["peak"] = max(state["peak"], state["now"])
|
||||
try:
|
||||
threading.Event().wait(0.05)
|
||||
return real(*args, **kwargs)
|
||||
finally:
|
||||
with guard:
|
||||
state["now"] -= 1
|
||||
|
||||
monkeypatch.setattr(mapserve.tiles_mod, "get_tile", slow)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
# Tuiles DIFFÉRENTES : le verrou par tuile ne masque pas le sémaphore
|
||||
threads = [threading.Thread(target=mapserve._tile_bytes,
|
||||
args=("aspect", z, x + i, y, 1, "png"))
|
||||
for i in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert state["peak"] <= 2, state["peak"]
|
||||
assert mapserve._locks == {}
|
||||
|
||||
|
||||
def test_worker_limits_are_configurable(monkeypatch):
|
||||
"""Les plafonds viennent de l'environnement, avec un défaut modeste."""
|
||||
import importlib
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
import lidar_pipeline.tiles as tiles
|
||||
|
||||
# Défauts : 2 rendus, 2 téléchargements — taillés pour un petit serveur
|
||||
assert mapserve.TILE_WORKERS == 2
|
||||
assert tiles.FETCH_WORKERS == 2
|
||||
|
||||
monkeypatch.setenv("LIDAR_TILE_WORKERS", "6")
|
||||
monkeypatch.setenv("LIDAR_TILE_FETCH_WORKERS", "3")
|
||||
try:
|
||||
importlib.reload(tiles)
|
||||
importlib.reload(mapserve)
|
||||
assert mapserve.TILE_WORKERS == 6
|
||||
assert tiles.FETCH_WORKERS == 3
|
||||
# Valeur absurde : jamais zéro fil (blocage complet du service)
|
||||
monkeypatch.setenv("LIDAR_TILE_WORKERS", "0")
|
||||
importlib.reload(mapserve)
|
||||
assert mapserve.TILE_WORKERS == 1
|
||||
finally:
|
||||
monkeypatch.delenv("LIDAR_TILE_WORKERS", raising=False)
|
||||
monkeypatch.delenv("LIDAR_TILE_FETCH_WORKERS", raising=False)
|
||||
importlib.reload(tiles)
|
||||
importlib.reload(mapserve)
|
||||
489
lidar_pipeline/tests/test_tiles.py
Normal file
489
lidar_pipeline/tests/test_tiles.py
Normal file
@ -0,0 +1,489 @@
|
||||
"""Tests de la pyramide de tuiles XYZ (schéma OpenStreetMap)."""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures : dalles factices aux conventions du pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _basename(col, row):
|
||||
return f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
|
||||
|
||||
|
||||
def _make_dalle(output_dir, col, row, viz_keys, color=(200, 30, 30), px=64,
|
||||
thumbs=True):
|
||||
"""Crée une dalle (image + vignettes) comme le ferait le pipeline."""
|
||||
from PIL import Image
|
||||
base = _basename(col, row)
|
||||
vis = Path(output_dir) / "visualisations" / base
|
||||
vis.mkdir(parents=True, exist_ok=True)
|
||||
thumb_dir = Path(output_dir) / "index_thumbs"
|
||||
thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||
for key in viz_keys:
|
||||
Image.new("RGB", (px, px), color).save(
|
||||
str(vis / f"{base}_{key}.webp"), format="WEBP", lossless=True)
|
||||
if thumbs:
|
||||
Image.new("RGB", (64, 64), color).save(
|
||||
str(thumb_dir / f"{base}_{key}.jpg"), format="JPEG", quality=90)
|
||||
Image.new("RGB", (64, 64), color).save(
|
||||
str(thumb_dir / f"{base}_{key}_mid.jpg"), format="JPEG", quality=90)
|
||||
return base
|
||||
|
||||
|
||||
def _tile_of_cell(col, row, z):
|
||||
"""Indices (x, y) de la tuile du niveau z contenant le centre d'une dalle."""
|
||||
from lidar_pipeline.tiles import _transformer
|
||||
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(
|
||||
col * 1000 + 500, (row - 1) * 1000 + 500)
|
||||
n = 2 ** z
|
||||
x = int((lon + 180.0) / 360.0 * n)
|
||||
rad = math.radians(lat)
|
||||
y = int((1.0 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2.0 * n)
|
||||
return x, y
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Géométrie de la grille
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tile_bounds_3857_known_values():
|
||||
"""z0 = le monde entier ; z1/x1/y0 = quadrant nord-est."""
|
||||
from lidar_pipeline.tiles import ORIGIN, tile_bounds_3857
|
||||
w, s, e, n = tile_bounds_3857(0, 0, 0)
|
||||
assert (round(w), round(s), round(e), round(n)) == (
|
||||
round(-ORIGIN), round(-ORIGIN), round(ORIGIN), round(ORIGIN))
|
||||
w, s, e, n = tile_bounds_3857(1, 1, 0)
|
||||
assert abs(w) < 1e-6 and abs(s) < 1e-6
|
||||
assert abs(e - ORIGIN) < 1e-6 and abs(n - ORIGIN) < 1e-6
|
||||
|
||||
|
||||
def test_tile_latitude_and_resolution():
|
||||
"""Latitude du centre et résolution terrain (0,2 m/px ≈ z19 en France)."""
|
||||
from lidar_pipeline.tiles import (TILE_MAX_NATIVE_Z, target_resolution,
|
||||
tile_latitude)
|
||||
assert abs(tile_latitude(0, 0)) < 1e-9
|
||||
assert abs(tile_latitude(1, 0) - 66.51) < 0.05
|
||||
# Tuile du niveau natif à la latitude de la France métropolitaine
|
||||
z = TILE_MAX_NATIVE_Z
|
||||
y = int((1.0 - math.log(math.tan(math.radians(47)) + 1 / math.cos(math.radians(47)))
|
||||
/ math.pi) / 2.0 * 2 ** z)
|
||||
res = target_resolution(z, y)
|
||||
assert 0.15 < res < 0.25, res
|
||||
# @2x : deux fois plus fin pour le même (z, x, y)
|
||||
assert abs(target_resolution(z, y, scale=2) - res / 2) < 1e-9
|
||||
|
||||
|
||||
def test_tile_bounds_l93_covers_cell():
|
||||
"""L'emprise L93 d'une tuile contient bien la dalle qu'elle recouvre."""
|
||||
from lidar_pipeline.tiles import tile_bounds_l93
|
||||
col, row, z = 1054, 6882, 14
|
||||
x, y = _tile_of_cell(col, row, z)
|
||||
min_x, min_y, max_x, max_y = tile_bounds_l93(z, x, y)
|
||||
assert min_x < col * 1000 + 500 < max_x
|
||||
assert min_y < (row - 1) * 1000 + 500 < max_y
|
||||
|
||||
|
||||
def test_perspective_coeffs_identity_and_scale():
|
||||
"""Identité → coefficients neutres ; homothétie → facteur exact."""
|
||||
from lidar_pipeline.tiles import perspective_coeffs
|
||||
quad = [(0, 0), (256, 0), (256, 256), (0, 256)]
|
||||
c = perspective_coeffs(quad, quad)
|
||||
assert [round(v, 9) for v in c] == [1, 0, 0, 0, 1, 0, 0, 0]
|
||||
# Sortie deux fois plus grande que la source : Pillow échantillonne à x/2
|
||||
c = perspective_coeffs([(0, 0), (512, 0), (512, 512), (0, 512)], quad)
|
||||
assert abs(c[0] - 0.5) < 1e-9 and abs(c[4] - 0.5) < 1e-9
|
||||
|
||||
|
||||
def test_perspective_coeffs_degenerate():
|
||||
"""Quadrilatère dégénéré (dalle réduite à un point) → None, pas d'exception."""
|
||||
from lidar_pipeline.tiles import perspective_coeffs
|
||||
flat = [(0, 0), (0, 0), (0, 0), (0, 0)]
|
||||
assert perspective_coeffs(flat, [(0, 0), (1, 0), (1, 1), (0, 1)]) is None
|
||||
|
||||
|
||||
def test_zoom_supported_bounds():
|
||||
"""Plage de zooms servie ; @2x s'arrête un cran plus tôt (512 px)."""
|
||||
from lidar_pipeline.tiles import (TILE_MAX_NATIVE_Z, TILE_MIN_Z,
|
||||
zoom_supported)
|
||||
assert not zoom_supported(TILE_MIN_Z - 1)
|
||||
assert zoom_supported(TILE_MIN_Z)
|
||||
assert zoom_supported(TILE_MAX_NATIVE_Z)
|
||||
assert not zoom_supported(TILE_MAX_NATIVE_Z + 1)
|
||||
assert not zoom_supported(TILE_MAX_NATIVE_Z, scale=2)
|
||||
assert zoom_supported(TILE_MAX_NATIVE_Z - 1, scale=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index des sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_source_index_and_layers(tmp_path):
|
||||
"""L'index liste les couches présentes et leurs paliers (grossier → fin)."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect", "slope"])
|
||||
layers = tiles.source_index(tmp_path, force=True)
|
||||
assert set(layers) == {"aspect", "slope"}
|
||||
tiers = layers["aspect"][(1054, 6882)]
|
||||
# vignette (3,9 m/px) → intermédiaire (1,56) → dalle (0,5)
|
||||
assert [round(t[0].res, 2) for t in tiers] == [3.91, 1.56, 0.5]
|
||||
assert tiles.available_layers(tmp_path) == ["slope", "aspect"] or \
|
||||
set(tiles.available_layers(tmp_path)) == {"slope", "aspect"}
|
||||
|
||||
|
||||
def test_grid_bounds(tmp_path):
|
||||
"""Emprise L93 et WGS84 de la grille disponible."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
_make_dalle(tmp_path, 1055, 6883, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.grid_bounds_l93(tmp_path) == (1054000.0, 6881000.0,
|
||||
1056000.0, 6883000.0)
|
||||
w, s, e, n = tiles.grid_bounds_wgs84(tmp_path)
|
||||
assert 7.0 < w < 8.5 and 47.5 < s < 49.5 and e > w and n > s
|
||||
|
||||
|
||||
def test_pick_tier_by_resolution(tmp_path):
|
||||
"""Palier retenu : le plus grossier dont la résolution suffit à la tuile."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
|
||||
assert tiles._pick_tier(tiers, 10.0)[0].res == tiers[0][0].res # vignette
|
||||
assert tiles._pick_tier(tiers, 2.0)[0].res == tiers[1][0].res # intermédiaire
|
||||
assert tiles._pick_tier(tiers, 0.2)[0].res == tiers[-1][0].res # dalle
|
||||
# Cible plus fine que tout ce qui existe : on garde le palier le plus fin
|
||||
assert tiles._pick_tier(tiers, 0.01)[0].res == tiers[-1][0].res
|
||||
|
||||
|
||||
def test_subtiles_preferred_over_full_dalle(tmp_path):
|
||||
"""Les quadrants index_subtiles servent de palier fin (4× moins à décoder)."""
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
sub = tmp_path / "index_subtiles"
|
||||
sub.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
for i in range(2):
|
||||
for j in range(2):
|
||||
Image.new("RGB", (32, 32), (10, 10, 10)).save(
|
||||
str(sub / f"{base}_aspect_{i}_{j}.avif"), format="AVIF")
|
||||
except Exception:
|
||||
pytest.skip("encodeur AVIF indisponible")
|
||||
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
|
||||
quads = next(t for t in tiers if len(t) == 4)
|
||||
# À résolution égale, les quadrants passent AVANT la dalle entière
|
||||
assert tiers.index(quads) < tiers.index(next(t for t in tiers if len(t) == 1
|
||||
and t[0].path.suffix == ".webp"))
|
||||
assert len(quads) == 4
|
||||
# Emprises des quadrants : quatre demi-kilomètres jointifs
|
||||
assert {q.bounds for q in quads} == {
|
||||
(1054000.0, 6881000.0, 1054500.0, 6881500.0),
|
||||
(1054500.0, 6881000.0, 1055000.0, 6881500.0),
|
||||
(1054000.0, 6881500.0, 1054500.0, 6882000.0),
|
||||
(1054500.0, 6881500.0, 1055000.0, 6882000.0)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendu et cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_tile_paints_cell(tmp_path):
|
||||
"""Une tuile au-dessus de la dalle est peinte ; ailleurs elle est vide."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"], color=(200, 30, 30))
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
assert img is not None and img.size == (256, 256)
|
||||
r, g, b, a = img.getpixel((128, 128))
|
||||
assert a == 255 and r > 150 and g < 90 and b < 90
|
||||
# Tuile lointaine (autre continent) : aucune source
|
||||
assert tiles.render_tile(tmp_path, "aspect", z, 1, 1) is None
|
||||
|
||||
|
||||
def test_render_tile_scale2(tmp_path):
|
||||
"""`scale=2` rend la même emprise en 512 px (convention @2x)."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
img = tiles.render_tile(tmp_path, "aspect", z, x, y, scale=2)
|
||||
assert img is not None and img.size == (512, 512)
|
||||
|
||||
|
||||
def test_tile_edges_transparent_outside_data(tmp_path):
|
||||
"""Hors emprise des dalles, la tuile reste transparente (superposable)."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
# Zoom où une tuile est bien plus grande que la dalle : les bords sont vides
|
||||
z = 11
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
assert img is not None
|
||||
assert img.getpixel((0, 0))[3] == 0
|
||||
assert img.getpixel((255, 255))[3] == 0
|
||||
|
||||
|
||||
def test_get_tile_cache_and_staleness(tmp_path):
|
||||
"""Le cache disque est réutilisé, puis invalidé par une dalle régénérée."""
|
||||
import os
|
||||
import time
|
||||
from lidar_pipeline import tiles
|
||||
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
data = tiles.get_tile(tmp_path, "aspect", z, x, y)
|
||||
assert data and data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
cache = tiles.tile_cache_path(tmp_path, "aspect", z, x, y)
|
||||
assert cache.is_file()
|
||||
first = cache.stat().st_mtime
|
||||
|
||||
# Sans changement : la tuile en cache est resservie telle quelle
|
||||
time.sleep(0.02)
|
||||
assert tiles.get_tile(tmp_path, "aspect", z, x, y) == data
|
||||
assert cache.stat().st_mtime == first
|
||||
|
||||
# Dalle régénérée (mtime plus récente) : la tuile est recalculée
|
||||
src = tmp_path / "visualisations" / base / f"{base}_aspect.webp"
|
||||
newer = first + 10
|
||||
os.utime(src, (newer, newer))
|
||||
for f in (tmp_path / "index_thumbs").iterdir():
|
||||
os.utime(f, (newer, newer))
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
tiles.get_tile(tmp_path, "aspect", z, x, y)
|
||||
assert cache.stat().st_mtime > first
|
||||
|
||||
|
||||
def test_get_tile_empty_marker(tmp_path):
|
||||
"""Tuile sans donnée : None + marqueur .empty mémorisé."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.get_tile(tmp_path, "aspect", 15, 1, 1) is None
|
||||
assert tiles.empty_marker_exists(tmp_path, "aspect", 15, 1, 1)
|
||||
|
||||
|
||||
def test_get_tile_webp_scale2(tmp_path):
|
||||
"""Palier @2x en WebP : 512 px, chemin de cache distinct du 256 px."""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
data = tiles.get_tile(tmp_path, "aspect", z, x, y, scale=2, fmt="webp")
|
||||
assert data and data[:4] == b"RIFF"
|
||||
path = tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "webp")
|
||||
assert path.name.endswith("@2x.webp") and path.is_file()
|
||||
import io
|
||||
assert Image.open(io.BytesIO(data)).size == (512, 512)
|
||||
|
||||
|
||||
def test_transparent_tile_is_fully_transparent():
|
||||
"""La tuile de repli (zone vide) est entièrement transparente."""
|
||||
import io
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
img = Image.open(io.BytesIO(tiles.transparent_tile()))
|
||||
assert img.size == (256, 256)
|
||||
assert img.convert("RGBA").getextrema()[3] == (0, 0)
|
||||
|
||||
|
||||
def test_tiles_in_bounds_and_warm(tmp_path):
|
||||
"""Pré-chauffage : les tuiles de l'emprise sont calculées et mises en cache."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
bounds = tiles.grid_bounds_wgs84(tmp_path)
|
||||
assert len(tiles.tiles_in_bounds(bounds, 14)) >= 1
|
||||
report = tiles.warm(tmp_path, ["aspect"], 12, 13)
|
||||
assert report["rendues"] >= 1 and report["limite"] is False
|
||||
assert any((tmp_path / tiles.TILE_DIRNAME / "aspect").rglob("*.png"))
|
||||
|
||||
|
||||
def test_tiles_stamp_follows_sources(tmp_path):
|
||||
"""La version globale suit la mtime la plus récente des dalles."""
|
||||
import os
|
||||
from lidar_pipeline import tiles
|
||||
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
first = tiles.tiles_stamp(tmp_path)
|
||||
src = tmp_path / "visualisations" / base / f"{base}_aspect.webp"
|
||||
os.utime(src, (first / 1000 + 60, first / 1000 + 60))
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.tiles_stamp(tmp_path) > first
|
||||
|
||||
|
||||
def test_source_cache_respects_memory_budget(tmp_path, monkeypatch):
|
||||
"""Le cache d'images sources évince selon un budget en octets, pas un compte.
|
||||
|
||||
Une dalle 5000² pèse ~75 Mo décodée : un cache « N entrées » ferait
|
||||
déborder la mémoire d'une petite machine (Raspberry Pi).
|
||||
"""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
tiles.clear_source_cache()
|
||||
# Budget volontairement minuscule : une seule image tient à la fois
|
||||
monkeypatch.setattr(tiles, "SOURCE_CACHE_BYTES", 40 * 40 * 3 * 2 - 1)
|
||||
paths = []
|
||||
for i in range(3):
|
||||
f = tmp_path / f"src{i}.png"
|
||||
Image.new("RGB", (40, 40), (i * 40, 0, 0)).save(str(f))
|
||||
paths.append(f)
|
||||
for f in paths:
|
||||
tiles._open_source(str(f), f.stat().st_mtime)
|
||||
assert len(tiles._source_cache) == 1
|
||||
# La dernière source utilisée est celle qui reste
|
||||
assert str(paths[-1]) == list(tiles._source_cache)[0][0]
|
||||
tiles.clear_source_cache()
|
||||
assert tiles._source_cache == {}
|
||||
|
||||
|
||||
def test_source_cache_keyed_by_mtime(tmp_path):
|
||||
"""Une source réécrite n'est pas resservie depuis le cache."""
|
||||
import os
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
tiles.clear_source_cache()
|
||||
f = tmp_path / "src.png"
|
||||
Image.new("RGB", (8, 8), (10, 10, 10)).save(str(f))
|
||||
first = tiles._open_source(str(f), f.stat().st_mtime)
|
||||
assert first.getpixel((0, 0))[:3] == (10, 10, 10)
|
||||
Image.new("RGB", (8, 8), (200, 200, 200)).save(str(f))
|
||||
os.utime(f, (f.stat().st_mtime + 5, f.stat().st_mtime + 5))
|
||||
second = tiles._open_source(str(f), f.stat().st_mtime)
|
||||
assert second.getpixel((0, 0))[:3] == (200, 200, 200)
|
||||
tiles.clear_source_cache()
|
||||
|
||||
|
||||
def test_png_palette_option_shrinks_tiles(tmp_path, monkeypatch):
|
||||
"""`LIDAR_TILE_PNG_PALETTE=1` allège le PNG canonique (palette + alpha).
|
||||
|
||||
Mesuré sur un rendu réaliste (rampe de couleur bruitée) : une dalle unie
|
||||
compresse déjà mieux en RGBA qu'en palette, elle ne prouverait rien.
|
||||
"""
|
||||
import io
|
||||
import random
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
|
||||
base = _basename(1054, 6882)
|
||||
vis = tmp_path / "visualisations" / base
|
||||
vis.mkdir(parents=True, exist_ok=True)
|
||||
rng = random.Random(7)
|
||||
img = Image.new("RGB", (256, 256))
|
||||
px = img.load()
|
||||
for j in range(256):
|
||||
for i in range(256):
|
||||
px[i, j] = (min(255, i + rng.randint(0, 12)),
|
||||
min(255, j + rng.randint(0, 12)),
|
||||
rng.randint(40, 90))
|
||||
img.save(str(vis / f"{base}_aspect.webp"), format="WEBP", lossless=True)
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
rendered = tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
lossless = tiles._encode(rendered, "png")
|
||||
monkeypatch.setattr(tiles, "PNG_PALETTE", True)
|
||||
palette = tiles._encode(rendered, "png")
|
||||
assert len(palette) < len(lossless)
|
||||
# Le PNG palettisé reste un PNG lisible, à la bonne taille, avec alpha
|
||||
out = Image.open(io.BytesIO(palette))
|
||||
assert out.size == (256, 256)
|
||||
assert out.convert("RGBA").getextrema()[3][1] == 255
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serveur de dalles amont (LIDAR_SOURCE_URL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _remote_payload():
|
||||
"""Charge utile /api/tiles telle que la sert la webapp du pipeline."""
|
||||
base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2"
|
||||
tiles = []
|
||||
for i in range(2):
|
||||
for j in range(2):
|
||||
tiles.append({
|
||||
"col": 1054, "row": 6882, "resolution": 0.2,
|
||||
"dir_name": base, "sub_i": i, "sub_j": j, "sub_k": 2,
|
||||
"viz": {"aspect": {
|
||||
"thumb": f"index_subtiles/{base}_aspect_{i}_{j}_thumb160.webp?v=1700000000000",
|
||||
"mid": f"index_subtiles/{base}_aspect_{i}_{j}_mid.webp?v=1700000000000",
|
||||
"full": f"index_subtiles/{base}_aspect_{i}_{j}.avif?v=1700000000000",
|
||||
}},
|
||||
})
|
||||
return {"tiles": tiles, "viz_meta": {"aspect": {"label": "Aspect"}}}
|
||||
|
||||
|
||||
def test_remote_index_lists_tiles_without_local_data(tmp_path, monkeypatch):
|
||||
"""Sans aucune donnée locale, l'index vient de l'amont (quadrants placés)."""
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
|
||||
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: _remote_payload())
|
||||
layers = tiles.source_index(tmp_path, force=True)
|
||||
assert set(layers) == {"aspect"}
|
||||
tiers = layers["aspect"][(1054, 6882)]
|
||||
# Trois paliers (vignette, intermédiaire, quadrant) × 4 quadrants chacun
|
||||
assert [len(t) for t in tiers] == [4, 4, 4]
|
||||
assert [round(t[0].res, 2) for t in tiers] == [3.12, 0.78, 0.2]
|
||||
fine = tiers[-1][0]
|
||||
assert fine.url.startswith("http://amont:8973/index_subtiles/")
|
||||
assert fine.path == tmp_path / fine.url.split("8973/")[1].split("?")[0]
|
||||
# La date de référence vient du ?v= annoncé, sans rien télécharger
|
||||
assert fine.mtime() == 1700000000.0
|
||||
assert tiles.grid_bounds_l93(tmp_path) == (1054000.0, 6881000.0,
|
||||
1055000.0, 6882000.0)
|
||||
|
||||
|
||||
def test_remote_source_downloaded_on_demand(tmp_path, monkeypatch):
|
||||
"""Une source distante n'est rapatriée qu'au premier rendu qui en a besoin."""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
|
||||
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: _remote_payload())
|
||||
tiles.clear_source_cache()
|
||||
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (64, 64), (12, 200, 90)).save(buf, format="WEBP", lossless=True)
|
||||
body = buf.getvalue()
|
||||
fetched = []
|
||||
|
||||
def fake_fetch(url, dest):
|
||||
fetched.append(url)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(body)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(tiles, "_fetch_source", fake_fetch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
assert img is not None
|
||||
assert fetched, "aucune source rapatriée"
|
||||
r, g, b, a = img.getpixel((128, 128))
|
||||
assert a == 255 and g > 150 and r < 80
|
||||
# Les fichiers rapatriés atterrissent dans le cache local, au même chemin
|
||||
assert list(tmp_path.rglob("*.webp")) or list(tmp_path.rglob("*.avif"))
|
||||
# Second rendu : plus aucun téléchargement (cache local)
|
||||
before = len(fetched)
|
||||
tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
assert len(fetched) == before
|
||||
tiles.clear_source_cache()
|
||||
|
||||
|
||||
def test_remote_index_failure_keeps_local(tmp_path, monkeypatch):
|
||||
"""Amont injoignable : l'index local reste servi, sans exception."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["slope"])
|
||||
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
|
||||
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: None)
|
||||
layers = tiles.source_index(tmp_path, force=True)
|
||||
assert set(layers) == {"slope"}
|
||||
833
lidar_pipeline/tiles.py
Normal file
833
lidar_pipeline/tiles.py
Normal file
@ -0,0 +1,833 @@
|
||||
"""Pyramide de tuiles XYZ (EPSG:3857) rendue à la demande depuis les dalles.
|
||||
|
||||
Schéma de tuilage identique à celui d'OpenStreetMap / Google Maps : grille
|
||||
Web Mercator, origine au coin nord-ouest, `{z}/{x}/{y}`, tuiles de 256 px
|
||||
(512 px avec `scale=2`, convention `@2x`). Les rendus du pipeline restent des
|
||||
dalles Lambert 93 de 1 km : chaque tuile est composée à la volée en reprojetant
|
||||
les dalles qui l'intersectent (transformation projective par dalle, erreur très
|
||||
inférieure au pixel), puis mise en cache sur disque.
|
||||
|
||||
Volontairement sans GDAL ni numpy : Pillow + pyproj suffisent, l'image légère
|
||||
(Dockerfile.maps / Dockerfile.webapp) reste petite et portable ARM64.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("lidar")
|
||||
|
||||
# --- Contrat de tuilage (cf. docs/MAPS.md) ---------------------------------
|
||||
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
|
||||
WEBP_QUALITY = 78
|
||||
AVIF_QUALITY = 60
|
||||
# PNG palettisé (PNG8 + alpha) : ~5× plus léger (170 → 32 Ko sur une dalle
|
||||
# réelle) pour un écart moyen de ~4 niveaux sur une rampe de couleur. Laissé
|
||||
# DÉSACTIVÉ par défaut : le PNG canonique reste sans perte, la fidélité prime
|
||||
# sur le débit pour un produit d'interprétation. `LIDAR_TILE_PNG_PALETTE=1`
|
||||
# l'active quand la bande passante compte (consultation mobile).
|
||||
PNG_PALETTE = os.environ.get("LIDAR_TILE_PNG_PALETTE", "") == "1"
|
||||
|
||||
# Demi-circonférence équatoriale : emprise du Web Mercator (EPSG:3857).
|
||||
ORIGIN = 20037508.342789244
|
||||
|
||||
# Résolutions nominales des paliers de source réutilisés tels quels par le
|
||||
# pipeline (m/px) : vignette 256 px/km, vignette intermédiaire 640 px/km.
|
||||
_THUMB_RES = 1000.0 / 256
|
||||
_MID_RES = 1000.0 / 640
|
||||
|
||||
_SUBTILE_RE = re.compile(r"_(\d+)_(\d+)\.avif$")
|
||||
|
||||
# Serveur de dalles amont (webapp du pipeline) : quand il est défini, l'index
|
||||
# des sources vient de son /api/tiles et les images manquantes sont rapatriées
|
||||
# à la demande dans le cache local — le conteneur carte n'a alors besoin
|
||||
# d'aucune donnée locale au démarrage.
|
||||
REMOTE_SOURCE_URL = (os.environ.get("LIDAR_SOURCE_URL") or "").rstrip("/")
|
||||
REMOTE_SOURCE_TOKEN = os.environ.get("LIDAR_SOURCE_TOKEN") or None
|
||||
_REMOTE_TTL = 60.0
|
||||
_remote_cache = {"payload": None, "at": 0.0, "index": None, "root": None}
|
||||
# Téléchargements de dalles simultanés (réseau) — modeste par défaut : sur un
|
||||
# petit serveur, chaque source rapatriée est un fichier de plusieurs Mo.
|
||||
FETCH_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_FETCH_WORKERS", "2") or 2))
|
||||
_fetch_sem = threading.Semaphore(FETCH_WORKERS)
|
||||
_fetch_locks = {}
|
||||
_fetch_guard = threading.Lock()
|
||||
|
||||
# Index des sources reconstruit au plus toutes les _INDEX_TTL secondes (ou dès
|
||||
# qu'un dossier change de mtime : ajout/suppression de dalle).
|
||||
_INDEX_TTL = 20.0
|
||||
_index_cache = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Géométrie de la grille
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tile_bounds_3857(z, x, y):
|
||||
"""Emprise (ouest, sud, est, nord) d'une tuile XYZ en mètres EPSG:3857."""
|
||||
span = 2.0 * ORIGIN / (2 ** z)
|
||||
west = -ORIGIN + x * span
|
||||
north = ORIGIN - y * span
|
||||
return west, north - span, west + span, north
|
||||
|
||||
|
||||
def ground_resolution(z, scale=1):
|
||||
"""Résolution d'une tuile à l'équateur (m/px) ; ×cos(lat) sur le terrain."""
|
||||
return 2.0 * ORIGIN / (TILE_SIZE * scale * (2 ** z))
|
||||
|
||||
|
||||
def tile_latitude(z, y):
|
||||
"""Latitude (degrés) du centre d'une tuile — sert au calage de résolution."""
|
||||
n = math.pi - 2.0 * math.pi * (y + 0.5) / (2 ** z)
|
||||
return math.degrees(math.atan(math.sinh(n)))
|
||||
|
||||
|
||||
def target_resolution(z, y, scale=1):
|
||||
"""Résolution terrain visée par la tuile (m/px), latitude comprise."""
|
||||
return ground_resolution(z, scale) * math.cos(math.radians(tile_latitude(z, y)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _transformer(src, dst):
|
||||
from pyproj import Transformer
|
||||
return Transformer.from_crs(src, dst, always_xy=True)
|
||||
|
||||
|
||||
def to_l93(xs, ys):
|
||||
"""EPSG:3857 → EPSG:2154 (listes de coordonnées)."""
|
||||
return _transformer("EPSG:3857", "EPSG:2154").transform(xs, ys)
|
||||
|
||||
|
||||
def to_3857(xs, ys):
|
||||
"""EPSG:2154 → EPSG:3857 (listes de coordonnées)."""
|
||||
return _transformer("EPSG:2154", "EPSG:3857").transform(xs, ys)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def wgs84_to_l93(lon, lat):
|
||||
"""Point WGS84 → Lambert 93 (utilisé par la fiche d'information dalle)."""
|
||||
return _transformer("EPSG:4326", "EPSG:2154").transform(lon, lat)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def tile_bounds_l93(z, x, y, samples=5):
|
||||
"""Emprise L93 englobant une tuile XYZ.
|
||||
|
||||
Les bords d'une tuile ne sont pas des droites en Lambert 93 : on
|
||||
échantillonne une grille `samples`×`samples` plutôt que les seuls coins,
|
||||
sinon l'emprise est sous-estimée aux petits zooms (tuiles de centaines de km).
|
||||
"""
|
||||
west, south, east, north = tile_bounds_3857(z, x, y)
|
||||
xs, ys = [], []
|
||||
for i in range(samples):
|
||||
for j in range(samples):
|
||||
xs.append(west + (east - west) * i / (samples - 1))
|
||||
ys.append(south + (north - south) * j / (samples - 1))
|
||||
lx, ly = to_l93(xs, ys)
|
||||
return min(lx), min(ly), max(lx), max(ly)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transformation projective (mapping sortie → source, convention Pillow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _solve(matrix, rhs):
|
||||
"""Résout un système linéaire dense (pivot partiel), sans numpy."""
|
||||
n = len(rhs)
|
||||
a = [row[:] + [rhs[i]] for i, row in enumerate(matrix)]
|
||||
for col in range(n):
|
||||
piv = max(range(col, n), key=lambda r: abs(a[r][col]))
|
||||
if abs(a[piv][col]) < 1e-12:
|
||||
return None
|
||||
a[col], a[piv] = a[piv], a[col]
|
||||
inv = 1.0 / a[col][col]
|
||||
for k in range(col, n + 1):
|
||||
a[col][k] *= inv
|
||||
for r in range(n):
|
||||
if r == col:
|
||||
continue
|
||||
f = a[r][col]
|
||||
if f:
|
||||
for k in range(col, n + 1):
|
||||
a[r][k] -= f * a[col][k]
|
||||
return [a[i][n] for i in range(n)]
|
||||
|
||||
|
||||
def perspective_coeffs(dst_quad, src_quad):
|
||||
"""Coefficients Pillow `Image.PERSPECTIVE` mappant sortie → source.
|
||||
|
||||
Pillow échantillonne la SOURCE en (x', y') = ((a x + b y + c) / (g x + h y + 1),
|
||||
(d x + e y + f) / (g x + h y + 1)) pour chaque pixel (x, y) de la SORTIE :
|
||||
on résout donc les 8 inconnues à partir de 4 correspondances
|
||||
(point de sortie → point source). Retourne None si le quadrilatère est
|
||||
dégénéré (dalle réduite à un point au dézoom extrême).
|
||||
"""
|
||||
matrix, rhs = [], []
|
||||
for (dx, dy), (sx, sy) in zip(dst_quad, src_quad):
|
||||
matrix.append([dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy])
|
||||
rhs.append(sx)
|
||||
matrix.append([0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy])
|
||||
rhs.append(sy)
|
||||
return _solve(matrix, rhs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index des sources : dalles du pipeline, par couche et par palier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Source:
|
||||
"""Une image source géoréférencée (dalle entière ou quadrant).
|
||||
|
||||
`url` non nul : l'image vit sur le serveur de dalles amont et n'est
|
||||
rapatriée qu'au premier besoin (`ensure()`), dans `path`. `version` est
|
||||
alors la mtime (ms) annoncée par l'amont : elle sert de date de référence
|
||||
pour la péremption des tuiles, avant même tout téléchargement.
|
||||
"""
|
||||
|
||||
__slots__ = ("path", "bounds", "res", "url", "version")
|
||||
|
||||
def __init__(self, path, bounds, res, url=None, version=None):
|
||||
self.path = path
|
||||
self.bounds = bounds # (min_x, min_y, max_x, max_y) en L93
|
||||
self.res = res # résolution nominale (m/px)
|
||||
self.url = url
|
||||
self.version = version
|
||||
|
||||
def mtime(self):
|
||||
if self.version is not None:
|
||||
return self.version / 1000.0
|
||||
try:
|
||||
return self.path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def ensure(self):
|
||||
"""Garantit la présence locale de l'image (rapatriement si besoin)."""
|
||||
if self.url is None or self.path.is_file():
|
||||
return self.path.is_file()
|
||||
return _fetch_source(self.url, self.path)
|
||||
|
||||
|
||||
def _remote_thumb_px(k):
|
||||
"""Côté (px) de la vignette servie par l'amont : 256 par dalle, 160 par quadrant."""
|
||||
return 160.0 if k > 1 else 256.0
|
||||
|
||||
|
||||
def _fetch_source(url, dest):
|
||||
"""Télécharge une image source depuis l'amont (écriture atomique)."""
|
||||
import urllib.request
|
||||
with _fetch_guard:
|
||||
lock = _fetch_locks.setdefault(str(dest), threading.Lock())
|
||||
with lock:
|
||||
if dest.is_file():
|
||||
return True
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "lidar-maps-source"})
|
||||
if REMOTE_SOURCE_TOKEN:
|
||||
req.add_header("X-Lidar-Token", REMOTE_SOURCE_TOKEN)
|
||||
with _fetch_sem, urllib.request.urlopen(req, timeout=120) as r:
|
||||
data = r.read()
|
||||
except Exception as e: # noqa: BLE001 — amont éteint : tuile partielle
|
||||
logger.debug(f"Source amont indisponible ({url}) : {e}")
|
||||
return False
|
||||
_write_atomic(dest, data)
|
||||
logger.info(f"Source rapatriée : {dest.name} ({len(data) / 1e6:.1f} Mo)")
|
||||
return dest.is_file()
|
||||
|
||||
|
||||
def _remote_payload(force=False):
|
||||
"""Index du serveur de dalles amont (/api/tiles), en cache 60 s."""
|
||||
import json
|
||||
import urllib.request
|
||||
now = time.time()
|
||||
if not force and _remote_cache["payload"] is not None \
|
||||
and now - _remote_cache["at"] < _REMOTE_TTL:
|
||||
return _remote_cache["payload"]
|
||||
try:
|
||||
req = urllib.request.Request(f"{REMOTE_SOURCE_URL}/api/tiles",
|
||||
headers={"User-Agent": "lidar-maps-source"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
payload = json.loads(r.read().decode("utf-8"))
|
||||
except Exception as e: # noqa: BLE001 — on garde le dernier index connu
|
||||
logger.warning(f"Index amont injoignable ({REMOTE_SOURCE_URL}) : {e}")
|
||||
payload = _remote_cache["payload"]
|
||||
_remote_cache.update(payload=payload, at=now)
|
||||
return payload
|
||||
|
||||
|
||||
def _remote_index(output_dir, force=False):
|
||||
"""Inventaire construit depuis l'amont : sources non encore rapatriées.
|
||||
|
||||
Le résultat est mémoïsé avec la charge utile : le reconstruire coûte des
|
||||
centaines de millisecondes sur un catalogue de plusieurs milliers de
|
||||
dalles, ce qui se paierait à CHAQUE tuile servie.
|
||||
"""
|
||||
payload = _remote_payload(force)
|
||||
if not payload or not payload.get("tiles"):
|
||||
return {}
|
||||
if (_remote_cache["index"] is not None
|
||||
and _remote_cache["root"] == str(output_dir)
|
||||
and _remote_cache.get("built") is payload):
|
||||
return _remote_cache["index"]
|
||||
output_dir = Path(output_dir)
|
||||
layers = {}
|
||||
for entry in payload["tiles"]:
|
||||
col, row = entry.get("col"), entry.get("row")
|
||||
if col is None or row is None:
|
||||
continue
|
||||
res = float(entry.get("resolution") or 0.5)
|
||||
k = int(entry.get("sub_k") or 1)
|
||||
step = 1000.0 / k
|
||||
i, j = int(entry.get("sub_i") or 0), int(entry.get("sub_j") or 0)
|
||||
base = _cell_bounds(col, row)
|
||||
bounds = (base[0] + i * step, base[1] + j * step,
|
||||
base[0] + (i + 1) * step, base[1] + (j + 1) * step)
|
||||
for viz, info in (entry.get("viz") or {}).items():
|
||||
by_res = layers.setdefault(viz, {}).setdefault((col, row), {})
|
||||
for tier, nominal in (("thumb", step / _remote_thumb_px(k)),
|
||||
("mid", step / 640.0),
|
||||
("full", res)):
|
||||
url_rel = info.get(tier)
|
||||
if not url_rel:
|
||||
continue
|
||||
rel, _, query = url_rel.partition("?")
|
||||
version = None
|
||||
if query.startswith("v="):
|
||||
try:
|
||||
version = int(query[2:])
|
||||
except ValueError:
|
||||
version = None
|
||||
rank = 0 if k > 1 else 1
|
||||
by_res.setdefault((nominal, rank), []).append(_Source(
|
||||
output_dir / rel, bounds, nominal,
|
||||
url=f"{REMOTE_SOURCE_URL}/{url_rel}", version=version))
|
||||
out = {}
|
||||
for viz, per_cell in layers.items():
|
||||
for cell, by_res in per_cell.items():
|
||||
out.setdefault(viz, {})[cell] = [
|
||||
by_res[key] for key in sorted(by_res, key=lambda t: (-t[0], t[1]))]
|
||||
_remote_cache.update(index=out, root=str(output_dir), built=payload)
|
||||
return out
|
||||
|
||||
|
||||
def _cell_bounds(col, row):
|
||||
"""Emprise L93 d'une dalle LHD : X ∈ [col, col+1] km, Y ∈ [row-1, row] km."""
|
||||
return (col * 1000.0, (row - 1) * 1000.0, (col + 1) * 1000.0, row * 1000.0)
|
||||
|
||||
|
||||
def _dir_mtime(path):
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _split_viz_key(stem, known):
|
||||
"""`{dir_name}_{viz}` → (dir_name, viz) en s'appuyant sur les clés connues.
|
||||
|
||||
Les clés de visualisation contiennent des soulignés (`positive_openness`,
|
||||
`hillshade_multi`) : on ne peut pas couper au dernier `_`, on reconnaît un
|
||||
suffixe connu (le plus long d'abord).
|
||||
"""
|
||||
for key in known:
|
||||
if stem.endswith("_" + key):
|
||||
return stem[:-len(key) - 1], key
|
||||
return None, None
|
||||
|
||||
|
||||
def _cell_of_dir(dir_name):
|
||||
"""(col, row, résolution) d'un nom de dossier de dalle, ou None."""
|
||||
from .index import _strip_res_suffix, parse_basename_coords
|
||||
coords = parse_basename_coords(dir_name)
|
||||
if coords is None:
|
||||
return None
|
||||
_base, res = _strip_res_suffix(dir_name)
|
||||
return coords[0], coords[1], res
|
||||
|
||||
|
||||
def _build_index(output_dir):
|
||||
"""Inventaire {couche: {(col, row): [paliers du plus grossier au plus fin]}}.
|
||||
|
||||
Les trois sources du pipeline sont scannées INDÉPENDAMMENT — dalles
|
||||
(`visualisations/`), quadrants (`index_subtiles/`) et vignettes
|
||||
(`index_thumbs/`). Un cache partiel reste donc exploitable : sur une
|
||||
machine légère, seuls quadrants et vignettes sont rapatriés, jamais les
|
||||
dalles entières.
|
||||
"""
|
||||
# _SUBTILE_THUMB_PX : taille des vignettes de quadrant, définie par
|
||||
# l'index (la changer là-bas doit rester sans effet ici).
|
||||
from .index import _SUBTILE_THUMB_PX, VIZ_LABELS, scan_tiles
|
||||
|
||||
output_dir = Path(output_dir)
|
||||
vis_dir = output_dir / "visualisations"
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
sub_dir = output_dir / "index_subtiles"
|
||||
|
||||
# res → sources, par couche et par dalle ; trié en paliers à la fin.
|
||||
records = {}
|
||||
|
||||
def add(layer, col, row, res, source, rank=1):
|
||||
# Clé de palier = (résolution, rang) : à résolution égale, les
|
||||
# quadrants (rang 0) passent avant la dalle entière (rang 1) — même
|
||||
# rendu, 4× moins de pixels à décoder.
|
||||
records.setdefault(layer, {}).setdefault((col, row), {}) \
|
||||
.setdefault((res, rank), []).append(source)
|
||||
|
||||
# 1. Dalles entières (palier le plus fin quand il est présent).
|
||||
known = set(VIZ_LABELS)
|
||||
for tile in scan_tiles(vis_dir):
|
||||
col, row, res = tile["col"], tile["row"], tile["resolution"]
|
||||
cell = _cell_bounds(col, row)
|
||||
dir_path = Path(tile["dir_path"])
|
||||
for viz_key, info in tile["viz"].items():
|
||||
known.add(viz_key)
|
||||
source_dir = dir_path
|
||||
if info.get("dir_name") and info["dir_name"] != dir_path.name:
|
||||
source_dir = dir_path.parent / info["dir_name"]
|
||||
full = source_dir / info["filename"]
|
||||
if full.is_file():
|
||||
add(viz_key, col, row, res, _Source(full, cell, res))
|
||||
|
||||
# Clés les plus longues d'abord : `positive_openness` avant `openness`.
|
||||
known = sorted(known, key=len, reverse=True)
|
||||
|
||||
# 2. Quadrants (index_subtiles) : AVIF pleine résolution + ses vignettes.
|
||||
if sub_dir.is_dir():
|
||||
quads = {}
|
||||
for f in sub_dir.iterdir():
|
||||
name = f.name
|
||||
for suffix, tier in ((".avif", "full"), ("_mid.webp", "mid"),
|
||||
(f"_thumb{_SUBTILE_THUMB_PX}.webp", "thumb")):
|
||||
if not name.endswith(suffix):
|
||||
continue
|
||||
m = _SUBTILE_RE.search(name[:-len(suffix)] + ".avif")
|
||||
if not m:
|
||||
break
|
||||
stem = name[:m.start()]
|
||||
i, j = int(m.group(1)), int(m.group(2))
|
||||
dir_name, viz = _split_viz_key(stem, known)
|
||||
if dir_name is None:
|
||||
break
|
||||
quads.setdefault((dir_name, viz, tier), []).append((i, j, f))
|
||||
break
|
||||
for (dir_name, viz, tier), items in quads.items():
|
||||
cell = _cell_of_dir(dir_name)
|
||||
if cell is None:
|
||||
continue
|
||||
col, row, res = cell
|
||||
k = max(max(i for i, _j, _f in items),
|
||||
max(j for _i, j, _f in items)) + 1
|
||||
step = 1000.0 / k
|
||||
px = {"full": step / res, "mid": 640, "thumb": _SUBTILE_THUMB_PX}[tier]
|
||||
tier_res = res if tier == "full" else step / px
|
||||
base = _cell_bounds(col, row)
|
||||
for i, j, f in items:
|
||||
add(viz, col, row, tier_res, _Source(
|
||||
f, (base[0] + i * step, base[1] + j * step,
|
||||
base[0] + (i + 1) * step, base[1] + (j + 1) * step),
|
||||
tier_res), rank=0)
|
||||
|
||||
# 3. Vignettes de dalle (paliers grossiers).
|
||||
if thumb_dir.is_dir():
|
||||
for f in thumb_dir.iterdir():
|
||||
if f.suffix.lower() not in (".jpg", ".jpeg", ".webp", ".png"):
|
||||
continue
|
||||
stem = f.name[:-len(f.suffix)]
|
||||
res_px = _MID_RES if stem.endswith("_mid") else _THUMB_RES
|
||||
if stem.endswith("_mid"):
|
||||
stem = stem[:-4]
|
||||
dir_name, viz = _split_viz_key(stem, known)
|
||||
if dir_name is None:
|
||||
continue
|
||||
cell = _cell_of_dir(dir_name)
|
||||
if cell is None:
|
||||
continue
|
||||
col, row, _res = cell
|
||||
add(viz, col, row, res_px, _Source(f, _cell_bounds(col, row), res_px))
|
||||
|
||||
# Paliers triés du plus grossier au plus fin (l'ordre de choix du rendu).
|
||||
layers = {}
|
||||
for viz, per_cell in records.items():
|
||||
for (col, row), by_res in per_cell.items():
|
||||
tiers = [by_res[k] for k in sorted(by_res, key=lambda k: (-k[0], k[1]))]
|
||||
layers.setdefault(viz, {})[(col, row)] = tiers
|
||||
return layers
|
||||
|
||||
|
||||
def source_index(output_dir, force=False):
|
||||
"""Index des sources, mémoïsé (TTL + mtime des dossiers surveillés)."""
|
||||
output_dir = Path(output_dir)
|
||||
key = str(output_dir)
|
||||
stamp = tuple(_dir_mtime(output_dir / d) for d in
|
||||
("visualisations", "index_thumbs", "index_subtiles"))
|
||||
entry = _index_cache.get(key)
|
||||
now = time.time()
|
||||
if entry and not force and now - entry["at"] < _INDEX_TTL:
|
||||
# Le TTL prime sur la mtime des dossiers : en mode amont, chaque source
|
||||
# rapatriée la modifierait et provoquerait un rescan par tuile servie.
|
||||
if REMOTE_SOURCE_URL or entry["stamp"] == stamp:
|
||||
return entry["layers"]
|
||||
if REMOTE_SOURCE_URL:
|
||||
# L'amont fait autorité : il connaît toutes les dalles, le cache local
|
||||
# n'en détient qu'une partie (et grossit à chaque source rapatriée —
|
||||
# le rescanner à chaque tuile coûterait plus cher que le rendu). Les
|
||||
# sources déjà présentes sont servies depuis le disque (_Source.ensure).
|
||||
layers = dict(_remote_index(output_dir, force))
|
||||
if not layers:
|
||||
layers = _build_index(output_dir) # amont muet : cache local seul
|
||||
else:
|
||||
layers = _build_index(output_dir)
|
||||
_index_cache[key] = {"layers": layers, "stamp": stamp, "at": now}
|
||||
return layers
|
||||
|
||||
|
||||
def available_layers(output_dir):
|
||||
"""Couches présentes sur disque, ordonnées comme le panneau de la carte."""
|
||||
from .index import _VIZ_FALLBACK_ORDER
|
||||
found = set(source_index(output_dir))
|
||||
ordered = [v for v in _VIZ_FALLBACK_ORDER if v in found]
|
||||
ordered += sorted(found - set(ordered))
|
||||
return ordered
|
||||
|
||||
|
||||
def grid_bounds_l93(output_dir, layer=None):
|
||||
"""Emprise L93 (min_x, min_y, max_x, max_y) des dalles disponibles."""
|
||||
layers = source_index(output_dir)
|
||||
cells = set()
|
||||
for key, per_cell in layers.items():
|
||||
if layer and key != layer:
|
||||
continue
|
||||
cells.update(per_cell)
|
||||
if not cells:
|
||||
return None
|
||||
xs = [c for c, _r in cells]
|
||||
ys = [r for _c, r in cells]
|
||||
return (min(xs) * 1000.0, (min(ys) - 1) * 1000.0,
|
||||
(max(xs) + 1) * 1000.0, max(ys) * 1000.0)
|
||||
|
||||
|
||||
def grid_bounds_wgs84(output_dir, layer=None):
|
||||
"""Emprise WGS84 [ouest, sud, est, nord] des dalles (TileJSON, WMTS)."""
|
||||
l93 = grid_bounds_l93(output_dir, layer)
|
||||
if l93 is None:
|
||||
return None
|
||||
min_x, min_y, max_x, max_y = l93
|
||||
tr = _transformer("EPSG:2154", "EPSG:4326")
|
||||
xs = [min_x, max_x, min_x, max_x]
|
||||
ys = [min_y, min_y, max_y, max_y]
|
||||
lons, lats = tr.transform(xs, ys)
|
||||
return [min(lons), min(lats), max(lons), max(lats)]
|
||||
|
||||
|
||||
def tiles_stamp(output_dir):
|
||||
"""Version globale du jeu de tuiles (max des mtimes) pour l'URL du client."""
|
||||
newest = 0.0
|
||||
for per_cell in source_index(output_dir).values():
|
||||
for tiers in per_cell.values():
|
||||
for src in tiers[-1]:
|
||||
m = src.mtime()
|
||||
if m and m > newest:
|
||||
newest = m
|
||||
return int(newest * 1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendu d'une tuile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cache des images sources décodées : le décodage (AVIF surtout) domine le
|
||||
# coût d'une tuile et les tuiles voisines partagent leurs dalles. Le budget est
|
||||
# exprimé en OCTETS, pas en nombre d'entrées : une dalle 5000² pèse ~75 Mo
|
||||
# quand une vignette en pèse 0,2 — un cache « N entrées » ferait déborder la
|
||||
# mémoire d'une petite machine.
|
||||
SOURCE_CACHE_BYTES = int(os.environ.get("LIDAR_TILE_SOURCE_CACHE_MB", "192")) * 1024 * 1024
|
||||
_source_cache = OrderedDict()
|
||||
_source_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _open_source(path_str, mtime):
|
||||
"""Image source décodée, mémoïsée par (chemin, mtime).
|
||||
|
||||
`mtime` fait partie de la clé : une dalle régénérée invalide l'entrée.
|
||||
"""
|
||||
from PIL import Image
|
||||
key = (str(path_str), mtime)
|
||||
with _source_cache_lock:
|
||||
img = _source_cache.get(key)
|
||||
if img is not None:
|
||||
_source_cache.move_to_end(key)
|
||||
return img
|
||||
img = Image.open(str(path_str))
|
||||
img.load()
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGB")
|
||||
size = img.size[0] * img.size[1] * len(img.getbands())
|
||||
with _source_cache_lock:
|
||||
_source_cache[key] = img
|
||||
_source_cache[key].info["_bytes"] = size
|
||||
total = sum(i.info.get("_bytes", 0) for i in _source_cache.values())
|
||||
while total > SOURCE_CACHE_BYTES and len(_source_cache) > 1:
|
||||
_k, old_img = _source_cache.popitem(last=False)
|
||||
total -= old_img.info.get("_bytes", 0)
|
||||
return img
|
||||
|
||||
|
||||
def clear_source_cache():
|
||||
"""Vide le cache d'images sources (tests, pression mémoire)."""
|
||||
with _source_cache_lock:
|
||||
_source_cache.clear()
|
||||
|
||||
|
||||
def _pick_tier(tiers, target_res):
|
||||
"""Palier le plus grossier dont la résolution suffit à la tuile visée."""
|
||||
for group in tiers:
|
||||
if group[0].res <= target_res:
|
||||
return group
|
||||
return tiers[-1]
|
||||
|
||||
|
||||
def _contributing(output_dir, layer, z, x, y, scale):
|
||||
"""Sources intersectant la tuile, palier choisi selon la résolution visée."""
|
||||
per_cell = source_index(output_dir).get(layer)
|
||||
if not per_cell:
|
||||
return []
|
||||
min_x, min_y, max_x, max_y = tile_bounds_l93(z, x, y)
|
||||
target = target_resolution(z, y, scale)
|
||||
out = []
|
||||
for (col, row), tiers in per_cell.items():
|
||||
b = _cell_bounds(col, row)
|
||||
if b[2] <= min_x or b[0] >= max_x or b[3] <= min_y or b[1] >= max_y:
|
||||
continue
|
||||
for src in _pick_tier(tiers, target):
|
||||
s = src.bounds
|
||||
if s[2] <= min_x or s[0] >= max_x or s[3] <= min_y or s[1] >= max_y:
|
||||
continue
|
||||
out.append(src)
|
||||
return out
|
||||
|
||||
|
||||
def _paste_source(canvas, src, z, x, y, size, resample):
|
||||
"""Reprojette une source dans la tuile (transformation projective)."""
|
||||
from PIL import Image
|
||||
|
||||
if not src.ensure():
|
||||
return False
|
||||
mtime = src.mtime()
|
||||
if mtime is None:
|
||||
return False
|
||||
try:
|
||||
img = _open_source(str(src.path), mtime)
|
||||
except Exception as e: # noqa: BLE001 — source illisible : tuile partielle
|
||||
logger.debug(f"Source de tuile illisible ({src.path.name}) : {e}")
|
||||
return False
|
||||
|
||||
w, h = img.size
|
||||
min_x, min_y, max_x, max_y = src.bounds
|
||||
res_x = (max_x - min_x) / w
|
||||
res_y = (max_y - min_y) / h
|
||||
|
||||
# Fenêtre source utile = intersection avec l'emprise L93 de la tuile,
|
||||
# élargie de 2 px pour que l'interpolation dispose de son voisinage.
|
||||
t_min_x, t_min_y, t_max_x, t_max_y = tile_bounds_l93(z, x, y)
|
||||
c0 = max(0, int(math.floor((max(t_min_x, min_x) - min_x) / res_x)) - 2)
|
||||
c1 = min(w, int(math.ceil((min(t_max_x, max_x) - min_x) / res_x)) + 2)
|
||||
r0 = max(0, int(math.floor((max_y - min(t_max_y, max_y)) / res_y)) - 2)
|
||||
r1 = min(h, int(math.ceil((max_y - max(t_min_y, min_y)) / res_y)) + 2)
|
||||
if c1 - c0 < 1 or r1 - r0 < 1:
|
||||
return False
|
||||
|
||||
crop = img.crop((c0, r0, c1, r1))
|
||||
if crop.mode != "RGBA":
|
||||
crop = crop.convert("RGBA")
|
||||
cw, ch = crop.size
|
||||
|
||||
# Coins L93 de la fenêtre découpée → EPSG:3857 → pixels de la tuile.
|
||||
wx0 = min_x + c0 * res_x
|
||||
wx1 = min_x + c1 * res_x
|
||||
wy1 = max_y - r0 * res_y
|
||||
wy0 = max_y - r1 * res_y
|
||||
mx, my = to_3857([wx0, wx1, wx1, wx0], [wy1, wy1, wy0, wy0])
|
||||
west, south, east, north = tile_bounds_3857(z, x, y)
|
||||
px = [(m - west) / (east - west) * size for m in mx]
|
||||
py = [(north - m) / (north - south) * size for m in my]
|
||||
dst_quad = list(zip(px, py)) # NW, NE, SE, SW
|
||||
src_quad = [(0, 0), (cw, 0), (cw, ch), (0, ch)]
|
||||
coeffs = perspective_coeffs(dst_quad, src_quad)
|
||||
if coeffs is None:
|
||||
return False
|
||||
|
||||
warped = crop.transform((size, size), Image.PERSPECTIVE, coeffs,
|
||||
resample=resample, fillcolor=(0, 0, 0, 0))
|
||||
canvas.alpha_composite(warped)
|
||||
return True
|
||||
|
||||
|
||||
def render_tile(output_dir, layer, z, x, y, scale=1):
|
||||
"""Rend une tuile en mémoire. Retourne une image RGBA, ou None si vide."""
|
||||
from PIL import Image
|
||||
|
||||
sources = _contributing(output_dir, layer, z, x, y, scale)
|
||||
if not sources:
|
||||
return None
|
||||
size = TILE_SIZE * scale
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
# Agrandissement (zoom natif) : bicubique ; réduction : bilinéaire suffit
|
||||
# puisque le palier source est déjà calé sur la résolution de la tuile.
|
||||
resample = Image.BICUBIC
|
||||
painted = False
|
||||
for src in sources:
|
||||
painted |= _paste_source(canvas, src, z, x, y, size, resample)
|
||||
if not painted:
|
||||
return None
|
||||
return canvas
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache disque
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tile_cache_path(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
"""Chemin du fichier de cache d'une tuile."""
|
||||
suffix = "@2x" if scale == 2 else ""
|
||||
return (Path(output_dir) / TILE_DIRNAME / layer / str(z) / str(x)
|
||||
/ f"{y}{suffix}.{fmt}")
|
||||
|
||||
|
||||
def _encode(img, fmt):
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
if fmt == "png":
|
||||
if PNG_PALETTE:
|
||||
from PIL import Image
|
||||
img = img.quantize(colors=255, method=Image.Quantize.FASTOCTREE)
|
||||
img.save(buf, format="PNG", optimize=False)
|
||||
elif fmt == "webp":
|
||||
img.save(buf, format="WEBP", quality=WEBP_QUALITY)
|
||||
elif fmt == "avif":
|
||||
img.save(buf, format="AVIF", quality=AVIF_QUALITY)
|
||||
else:
|
||||
raise ValueError(f"format de tuile inconnu : {fmt}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_atomic(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + f".{os.getpid()}.part")
|
||||
try:
|
||||
tmp.write_bytes(data)
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
logger.debug(f"Écriture de tuile impossible ({path}) : {e}")
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def zoom_supported(z, scale=1):
|
||||
"""Vrai si le zoom demandé est dans la plage rendue par le serveur."""
|
||||
max_z = TILE_MAX_NATIVE_Z - (1 if scale == 2 else 0)
|
||||
return TILE_MIN_Z <= z <= max_z
|
||||
|
||||
|
||||
def get_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
"""Tuile encodée (bytes) depuis le cache, rendue au besoin.
|
||||
|
||||
Retourne None quand aucune donnée n'intersecte la tuile ; un marqueur
|
||||
`.empty` mémorise ce cas pour ne jamais le recalculer. Une tuile en cache
|
||||
est réutilisée tant qu'aucune dalle contributrice n'est plus récente
|
||||
qu'elle : une dalle régénérée n'invalide que ses propres tuiles.
|
||||
"""
|
||||
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
|
||||
|
||||
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()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
img = render_tile(output_dir, layer, z, x, y, scale)
|
||||
if img is None:
|
||||
_write_atomic(empty, b"")
|
||||
return None
|
||||
data = _encode(img, fmt)
|
||||
_write_atomic(cache, data)
|
||||
empty.unlink(missing_ok=True)
|
||||
return data
|
||||
|
||||
|
||||
def empty_marker_exists(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
"""Vrai si la tuile est déjà connue comme vide (négatif mémorisé)."""
|
||||
return tile_cache_path(output_dir, layer, z, x, y,
|
||||
scale, fmt).with_suffix(".empty").exists()
|
||||
|
||||
|
||||
def transparent_tile(scale=1, fmt="png"):
|
||||
"""Tuile entièrement transparente (zone sans donnée)."""
|
||||
from PIL import Image
|
||||
size = TILE_SIZE * scale
|
||||
return _encode(Image.new("RGBA", (size, size), (0, 0, 0, 0)), fmt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pré-chauffage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tiles_in_bounds(bounds_wgs84, z):
|
||||
"""Indices (x, y) des tuiles du niveau z couvrant une bbox WGS84."""
|
||||
west, south, east, north = bounds_wgs84
|
||||
n = 2 ** z
|
||||
|
||||
def _x(lon):
|
||||
return int((lon + 180.0) / 360.0 * n)
|
||||
|
||||
def _y(lat):
|
||||
lat = max(-85.0511, min(85.0511, lat))
|
||||
rad = math.radians(lat)
|
||||
return int((1.0 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2.0 * n)
|
||||
|
||||
x0, x1 = sorted((_x(west), _x(east)))
|
||||
y0, y1 = sorted((_y(north), _y(south)))
|
||||
return [(x, y)
|
||||
for x in range(max(0, x0), min(n - 1, x1) + 1)
|
||||
for y in range(max(0, y0), min(n - 1, y1) + 1)]
|
||||
|
||||
|
||||
def warm(output_dir, layers, z_min, z_max, bounds_wgs84=None, scale=1,
|
||||
fmt="png", limit=20000):
|
||||
"""Pré-calcule les tuiles d'une emprise (retourne le compte rendu)."""
|
||||
if bounds_wgs84 is None:
|
||||
bounds_wgs84 = grid_bounds_wgs84(output_dir)
|
||||
if bounds_wgs84 is None:
|
||||
return {"rendues": 0, "vides": 0, "limite": False}
|
||||
done = empty = 0
|
||||
for layer in layers:
|
||||
for z in range(max(TILE_MIN_Z, z_min), min(TILE_MAX_NATIVE_Z, z_max) + 1):
|
||||
if not zoom_supported(z, scale):
|
||||
continue
|
||||
for x, y in tiles_in_bounds(bounds_wgs84, z):
|
||||
if done + empty >= limit:
|
||||
return {"rendues": done, "vides": empty, "limite": True}
|
||||
if get_tile(output_dir, layer, z, x, y, scale, fmt) is None:
|
||||
empty += 1
|
||||
else:
|
||||
done += 1
|
||||
return {"rendues": done, "vides": empty, "limite": False}
|
||||
@ -173,7 +173,7 @@ from fastapi.staticfiles import StaticFiles as _StaticFiles
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles",
|
||||
"index_overview")
|
||||
"index_overview", "index_xyz")
|
||||
_FETCH_SEMAPHORE = threading.Semaphore(4)
|
||||
_FETCH_LOCKS = {}
|
||||
_FETCH_LOCKS_GUARD = threading.Lock()
|
||||
@ -320,7 +320,7 @@ class _OnDemandStaticFiles(_NoCacheStaticFiles):
|
||||
|
||||
|
||||
for name in ("index_thumbs", "index_subtiles", "index_overview",
|
||||
"visualisations", "DTM"):
|
||||
"index_xyz", "visualisations", "DTM"):
|
||||
_dir = OUTPUT_DIR / name
|
||||
_dir.mkdir(parents=True, exist_ok=True)
|
||||
if name in _ONDEMAND_PREFIXES and GENERATION_URL:
|
||||
|
||||
52
run.sh
52
run.sh
@ -109,6 +109,9 @@ SERVE_PORT=8973
|
||||
WEBAPP_FLAG=0
|
||||
WEBAPP_SERVE_PORT=8973
|
||||
WEBAPP_IMAGE="lidar-webapp"
|
||||
MAPS_FLAG=0
|
||||
MAPS_SERVE_PORT=8975
|
||||
MAPS_IMAGE="lidar-maps"
|
||||
|
||||
# Parse arguments manually (more robust than getopts for mixed short/long options)
|
||||
while [ $# -gt 0 ]; do
|
||||
@ -154,6 +157,10 @@ while [ $# -gt 0 ]; do
|
||||
WEBAPP_FLAG=1; shift
|
||||
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then WEBAPP_SERVE_PORT="$1"; shift; fi
|
||||
;;
|
||||
--serve-maps)
|
||||
MAPS_FLAG=1; shift
|
||||
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then MAPS_SERVE_PORT="$1"; shift; fi
|
||||
;;
|
||||
-h|--help|-help)
|
||||
echo "Pipeline LiDAR Archéologique"
|
||||
echo ""
|
||||
@ -188,6 +195,10 @@ while [ $# -gt 0 ]; do
|
||||
echo " carte servie depuis le cache local de tuiles, génération"
|
||||
echo " déléguée via LIDAR_GENERATION_URL (tuiles à la demande)"
|
||||
echo " (cf. docs/DEPLOY_WEBAPP.md)"
|
||||
echo " --serve-maps [PORT]"
|
||||
echo " Carte à tuiles XYZ (image légère lidar-maps, défaut 8975) :"
|
||||
echo " schéma OpenStreetMap, réutilisable dans JOSM/QGIS/iD"
|
||||
echo " (cf. docs/MAPS.md)"
|
||||
echo " --test Exécuter les tests unitaires"
|
||||
echo " -h Afficher cette aide"
|
||||
echo ""
|
||||
@ -254,6 +265,18 @@ build_webapp_image() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Image carte à tuiles XYZ (cf. Dockerfile.maps)
|
||||
build_maps_image() {
|
||||
local log="/tmp/lidar_maps_build_$$.log"
|
||||
echo "Mise à jour de l'image Docker carte (cache)..."
|
||||
if ! docker build -f Dockerfile.maps -t "$MAPS_IMAGE" "$SCRIPT_DIR" >"$log" 2>&1; then
|
||||
cat "$log" >&2
|
||||
echo "Échec du build de l'image carte (journal : $log)" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$log"
|
||||
}
|
||||
|
||||
# Check for --test flag first
|
||||
if [ "$TEST_FLAG" -eq 1 ]; then
|
||||
build_image
|
||||
@ -301,6 +324,35 @@ if [ "$SERVE_FLAG" -eq 1 ]; then
|
||||
python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
|
||||
fi
|
||||
|
||||
# Mode --serve-maps : carte à tuiles XYZ (image légère lidar-maps).
|
||||
# Les tuiles suivent le schéma OpenStreetMap ({z}/{x}/{y}.png) : la carte est
|
||||
# consultable ici et les mêmes URLs servent de fond d'imagerie dans JOSM, iD,
|
||||
# QGIS ou uMap. Variables optionnelles depuis l'environnement hôte :
|
||||
# LIDAR_MAPS_URL serveur de tuiles amont (carte déportée)
|
||||
# LIDAR_ATTRIBUTION mention d'attribution servie aux clients
|
||||
if [ "$MAPS_FLAG" -eq 1 ]; then
|
||||
build_maps_image
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
echo "============================================"
|
||||
echo " Carte LiDAR — tuiles XYZ (image légère)"
|
||||
echo "============================================"
|
||||
echo " Carte : http://127.0.0.1:${MAPS_SERVE_PORT}/"
|
||||
echo " Tuiles XYZ : http://127.0.0.1:${MAPS_SERVE_PORT}/tiles/{couche}/{z}/{x}/{y}.png"
|
||||
echo " JOSM / QGIS : /tiles/josm.imagery.xml, /tiles/wmts.xml"
|
||||
echo " Amont : ${LIDAR_MAPS_URL:-aucun (rendu local)}"
|
||||
echo "============================================"
|
||||
MAPS_ENV=()
|
||||
[ -n "${LIDAR_MAPS_URL:-}" ] && MAPS_ENV+=(-e LIDAR_MAPS_URL="$LIDAR_MAPS_URL")
|
||||
[ -n "${LIDAR_ATTRIBUTION:-}" ] && MAPS_ENV+=(-e LIDAR_ATTRIBUTION="$LIDAR_ATTRIBUTION")
|
||||
exec docker run --rm --init \
|
||||
--user 1000:1000 \
|
||||
-p "${MAPS_SERVE_PORT}:8975" \
|
||||
-v "${OUTPUT_DIR}:/data/output" \
|
||||
-e LIDAR_OUTPUT_DIR=/data/output \
|
||||
"${MAPS_ENV[@]}" \
|
||||
"$MAPS_IMAGE"
|
||||
fi
|
||||
|
||||
# Mode --serve-webapp : conteneur webapp SEUL (image légère, sans PDAL/GPU).
|
||||
# La carte est servie depuis le cache local de tuiles (output/) ; la
|
||||
# génération est déléguée à la machine de traitement et les tuiles sont
|
||||
|
||||
Reference in New Issue
Block a user