diff --git a/.gitignore b/.gitignore index 24831de..4e3cc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index b980c1b..84218d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.` @@ -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`. diff --git a/Dockerfile.maps b/Dockerfile.maps new file mode 100644 index 0000000..6d3c22b --- /dev/null +++ b/Dockerfile.maps @@ -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"] diff --git a/docker-compose.maps.yml b/docker-compose.maps.yml new file mode 100644 index 0000000..c9dea0b --- /dev/null +++ b/docker-compose.maps.yml @@ -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 diff --git a/docs/MAPS.md b/docs/MAPS.md new file mode 100644 index 0000000..79959ac --- /dev/null +++ b/docs/MAPS.md @@ -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://:8975/tiles/slope/{zoom}/{x}/{y}.png`. Pour tout ajouter d'un + coup : *Imagery preferences → Offline/Custom → Add imagery source XML* avec + `http://:8975/tiles/josm.imagery.xml`. +- **iD** — *Fond de carte → Personnalisé*, coller + `http://:8975/tiles/slope/{z}/{x}/{y}.png`. +- **QGIS** — *XYZ Tiles → Nouvelle connexion* (même URL, zoom max 19), ou + *WMS/WMTS → Nouveau* avec `http://: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=` (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`. diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py new file mode 100644 index 0000000..d28db48 --- /dev/null +++ b/lidar_pipeline/mapserve.py @@ -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 © IGN ' + '— 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' \n') + entries.append( + " \n" + f" LiDAR HD — {_xml.escape(info['label'])}\n" + f" lidar-hd-{info['key'].replace('_', '-')}\n" + " tms\n" + f" \n" + f" {tiles_mod.TILE_MIN_Z}\n" + f" {tiles_mod.TILE_MAX_NATIVE_Z}\n" + f"{b}" + f" {_xml.escape(info['description'])}\n" + f" {_xml.escape(ATTRIBUTION_TEXT)}\n" + " https://geoservices.ign.fr/lidarhd\n" + " other\n" + " ") + xml = ('\n' + '\n' + + "\n".join(entries) + "\n\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""" + {_xml.escape(info['label'])} + {_xml.escape(info['description'])} + + {bounds[0]:.6f} {bounds[1]:.6f} + {bounds[2]:.6f} {bounds[3]:.6f} + + {info['key']} + + image/png + GoogleMapsCompatible + + """) + matrices = [] + for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1): + matrices.append(f""" + {z} + {559082264.0287178 / (2 ** z):.10f} + -20037508.3427892 20037508.3427892 + 256 + 256 + {2 ** z} + {2 ** z} + """) + xml = f""" + + + Carte LiDAR HD + {_xml.escape(ATTRIBUTION_TEXT)} + OGC WMTS + 1.0.0 + Licence Ouverte 2.0 + + +{chr(10).join(layers)} + + GoogleMapsCompatible + urn:ogc:def:crs:EPSG:6.18:3:3857 + urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible +{chr(10).join(matrices)} + + + + +""" + 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) diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py new file mode 100644 index 0000000..0d2dfd3 --- /dev/null +++ b/lidar_pipeline/mapui.py @@ -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 = """ + + + + +Carte LiDAR + + + + + +
+ +
+
+ + Couches + +
+
+
+ + + Fond OpenStreetMap + +
+ +
+
+
Haut de pile — devant
+
+
Bas de pile — derrière
+
+ Fusion de la pile sur le fond + +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + +""" + + +_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 = ''; +const SVG_EYE_OFF = ''; + +// 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 => '').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: '© OpenStreetMap' +}).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 = + '' + + '' + + '' + info.label + '' + + '' + + '' + + '' + + '' + + '' + Math.round(STATE.opacity[key] * 100) + '%' + + (on ? '
' + + '' + + '' + + '
' : ''); + 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 = '
Pas de rendu disponible à cet endroit.
'; + 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 = + '
' + data.name + '
' + + '
Résolution : ' + String(data.resolution).replace('.', ',') + ' m/px' + + ' · Classification : ' + (data.method || 'inconnue') + '
' + + (data.generated ? '
Généré le ' + data.generated + '
' : '') + + '
Couches : ' + (viz || '—') + '
'; + card.hidden = false; +} + +// --- fond d'imagerie externe (JOSM, QGIS, iD…) ---------------------------- +function renderUseCard() { + const origin = location.origin; + const opts = META.layers.map(l => + '').join(''); + el('useBody').innerHTML = + '
' + + '
' + + '
URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' + + '
' + + '
TileJSON :
' + + '' + + '
JOSM (toutes les couches) : ' + origin + '/tiles/josm.imagery.xml
' + + '
Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
'; + 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; } +}); +""" diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py new file mode 100644 index 0000000..fcbbdf4 --- /dev/null +++ b/lidar_pipeline/tests/test_mapserve.py @@ -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) diff --git a/lidar_pipeline/tests/test_tiles.py b/lidar_pipeline/tests/test_tiles.py new file mode 100644 index 0000000..a7dc6ec --- /dev/null +++ b/lidar_pipeline/tests/test_tiles.py @@ -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"} diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py new file mode 100644 index 0000000..ae2cff8 --- /dev/null +++ b/lidar_pipeline/tiles.py @@ -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} diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index 148873f..5bb34ab 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -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: diff --git a/run.sh b/run.sh index de8d8fb..0ff7df2 100755 --- a/run.sh +++ b/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