Porter la génération de tuiles dans lidar-maps et retirer l'ancienne webapp

La carte à tuiles XYZ devient la seule interface : l'API de génération
(preview, generate, status, stop, file, cell), le job unique + file
persistante, la délégation LIDAR_GENERATION_URL et les garde-fous
(token, LIDAR_REGEN_CIDR) passent de webapp.py à mapserve.py ; l'interface
gagne les boutons + Zone / ⤒ Compléter / régénération à la dalle, les
options de run et la progression cadre par cadre. mapserve sert aussi
l'inventaire /api/tiles et les dalles en statique versionné : le worker
image complète remplace la webapp sur le 8973 du générateur, le Pi
n'exécute plus que lidar-maps. index.py se réduit aux registres partagés
+ vignettes/sous-tuiles/inventaire (l'UI HTML/JS et les mosaïques
d'overview partent avec export.py et les compose/scripts de la webapp).
This commit is contained in:
Antoine Jacquin
2026-09-23 23:21:05 +02:00
parent 81defa85c9
commit 0a2ccee958
31 changed files with 1774 additions and 8465 deletions

7
.gitignore vendored
View File

@ -27,8 +27,7 @@ build/
.coverage .coverage
htmlcov/ htmlcov/
# Interface bâchée dans l'image (régénérée au build, cf. Dockerfile.webapp) # Interface bâchée dans l'image (régénérée au build, cf. Dockerfile.maps)
lidar_pipeline/webapp_assets/
lidar_pipeline/mapserve_assets/ lidar_pipeline/mapserve_assets/
# Docker # Docker
@ -48,10 +47,7 @@ lidar_pipeline/mapserve_assets/
# Fichiers de configuration locaux # Fichiers de configuration locaux
.env .env
.env.local .env.local
webapp.env
# Certificat TLS auto-signé (webapp HTTPS / GPS téléphone, cf. make-tls-cert.sh)
tls/
# Données et artefacts internes (jamais dans le dépôt) # Données et artefacts internes (jamais dans le dépôt)
data/ data/
@ -61,5 +57,4 @@ notebooks/
# Éventuels fichiers de cache matplotlib # Éventuels fichiers de cache matplotlib
matplotlibrc matplotlibrc
docker-compose.webapp.override.yml
output-test/ output-test/

View File

@ -2,30 +2,30 @@
- install: `docker build -t lidar-lidar .` (deps baked into image) - install: `docker build -t lidar-lidar .` (deps baked into image)
- build: `docker build -t lidar-lidar .` - build: `docker build -t lidar-lidar .`
- 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 carte légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`) : `docker compose -f docker-compose.maps.yml up -d --build` (image `Dockerfile.maps`, 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) - build générateur de tuiles (machine de traitement) : `docker compose -f docker-compose.worker.yml up -d --build` (service `worker` = mapserve sur l'image complète, API + tuiles + inventaire pour les cartes 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 all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord)
- test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>` - test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>`
- test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>` - test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>`
- lint: not configured - lint: not configured
- format: not configured - format: not configured
- after every edit: `./run.sh --test` - after every edit: `./run.sh --test`
- **RÈGLE 1 — toujours lancer via docker compose** (jamais `docker run` direct) : carte/API → `docker compose up -d --build serve` (port 8973) ; traitement ponctuel → `docker compose run --rm --build process [options]` ; logs → `docker compose logs -f serve` ; arrêt → `docker compose down`. - **RÈGLE 1 — toujours lancer via docker compose** (jamais `docker run` direct) : carte/API locale → `docker compose up -d --build serve` (port 8973, mapserve sur image complète) ; traitement ponctuel → `docker compose run --rm --build process [options]` ; logs → `docker compose logs -f serve` ; arrêt → `docker compose down`.
- **RÈGLE 2 — TOUJOURS `--build` : le code est baké dans l'image (jamais monté).** Sans `--build`, `up`/`run` réutilisent l'image existante et l'ANCIEN code tourne. `--build` est quasi instantané grâce au cache (le .dockerignore exclut input/ et output/ du contexte). Après édition : `docker compose up -d --build serve` recrée le conteneur sur du neuf. - **RÈGLE 2 — TOUJOURS `--build` : le code est baké dans l'image (jamais monté).** Sans `--build`, `up`/`run` réutilisent l'image existante et l'ANCIEN code tourne. `--build` est quasi instantané grâce au cache (le .dockerignore exclut input/ et output/ du contexte). Après édition : `docker compose up -d --build serve` recrée le conteneur sur du neuf.
- test rapide sans rebuild (code monté par-dessus l'image): `docker run --rm -e PYTHONPATH=/app -v $(pwd)/lidar_pipeline:/app/lidar_pipeline lidar-lidar python3 -m pytest --pyargs lidar_pipeline.tests` - test rapide sans rebuild (code monté par-dessus l'image) : `docker run --rm -e PYTHONPATH=/app -v $(pwd)/lidar_pipeline:/app/lidar_pipeline lidar-lidar python3 -m pytest --pyargs lidar_pipeline.tests -q` (~3 min ; ajouter `timeout 600` devant, et PAS de pipe `| tail` qui masque la progression)
- debug: `./run.sh --debug` (file:line logging); container shell: `docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output --entrypoint bash lidar-lidar` - debug: `./run.sh --debug` (file:line logging); container shell: `docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output --entrypoint bash lidar-lidar`
- mise à jour du Pi de prod (192.168.3.10, checkout `/srv/lidar_rendu`, override maps + Traefik) : `ssh` — `ssh 192.168.3.10 "cd /srv/lidar_rendu && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"` (procédure dans `docs/DEPLOY_WEBAPP.md`).
## Conventions ## Conventions
- **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. - **Un seul serveur web : `mapserve.py`** (image `lidar-maps`, port 8975 léger / 8973 worker). L'ancienne webapp (`webapp.py`, `export.py`, index.html/`_APP_JS`) a été supprimée : la génération de tuiles (portée de la webapp historique — `/api/preview`, `/api/generate`, `/api/status`, `/api/stop`, `/api/queue/clear`, `/api/cell`) vit dans `mapserve.py`, l'interface dans `mapui.py` (constantes `_MAP_HTML`/`_MAP_CSS`/`_MAP_JS`, écrites par `write_map_assets()` et bâchées dans les images). Sur l'image légère sans `LIDAR_GENERATION_URL`, `/api/status` répond `available: false` et l'interface masque les boutons.
- **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`). - **`index.py` = catalogue + registres partagés** (plus d'interface) : `VIZ_LABELS`/`VIZ_LEGENDS`, défauts d'affichage (`DEFAULT_LAYERS`/`DEFAULT_OPACITY`/`DEFAULT_BLEND`), `PANEL_VIZ`/`KEYWORD_TO_STEP`, `scan_tiles`/`cells_with_all_viz`, vignettes + sous-tuiles + inventaire `index_tiles.json` (`build_index`). L'inventaire est servi par `/api/tiles` de mapserve aux machines légères (`LIDAR_SOURCE_URL`).
- **Generation is 0.2 m only** (policy): `/api/generate` (`GENERATE_RESOLUTIONS` in `mapserve.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` (mapserve.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 ; `auto` sinon).
- **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. - **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. - **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. - **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. - **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 served in `/api/map/meta` and le TileJSON). 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`. - **`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`.
- **Return `None` on failure, never raise**: `dtm.py`, `visualizations.py`, and `ign.py` all return `None` to let the pipeline continue. Raising aborts the entire file. - **Return `None` on failure, never raise**: `dtm.py`, `visualizations.py`, and `ign.py` all return `None` to let the pipeline continue. Raising aborts the entire file.
- **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it. - **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it.
@ -33,25 +33,24 @@
- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98). - **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98).
- **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`. - **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`.
- **Gigue intra-faisceau (2ᵉ passe du calage)** : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas. `_strip_jitter_offsets` découpe chaque faisceau en fenêtres de temps GPS (`STRIP_JITTER_BIN` = 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥ `STRIP_JITTER_MIN_CELLS` = 40 cellules), lisse la série (médiane glissante `STRIP_JITTER_SMOOTH` = 5 fenêtres), la borne à ± `STRIP_JITTER_MAX` (10 cm) puis l'interpole au temps GPS de chaque point (`_apply_strip_jitter`) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimension `gps_time` (silencieusement ignorée sinon). Sidecar version 2 (séries dans `jitter`), couverte par `--no-strip-align`. - **Gigue intra-faisceau (2ᵉ passe du calage)** : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas. `_strip_jitter_offsets` découpe chaque faisceau en fenêtres de temps GPS (`STRIP_JITTER_BIN` = 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥ `STRIP_JITTER_MIN_CELLS` = 40 cellules), lisse la série (médiane glissante `STRIP_JITTER_SMOOTH` = 5 fenêtres), la borne à ± `STRIP_JITTER_MAX` (10 cm) puis l'interpole au temps GPS de chaque point (`_apply_strip_jitter`) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimension `gps_time` (silencieusement ignorée sinon). Sidecar version 2 (séries dans `jitter`), couverte par `--no-strip-align`.
- **Openness sous-échantillonnée** : `generate_openness` calcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (`OPENNESS_DOWNSAMPLE = 2` : max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît. `--openness-downsample 1` = pleine résolution. SVF et openness anisotrope ne sont PAS concernés. - **Openness sous-échantillonnée** : `generate_openness` calcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (`OPENNESS_DOWNSAMPLE = 2` : max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît. `--openness-downsample 1` = pleine résolution. SVF et openness anisotrope ne sont PAS concernées.
- **Raccord des bords entre tuiles** : les rendus à grand noyau (openness/SVF : rayons 100 m ; LRM : 15 m) tronquent leur fenêtre au bord de dalle — bandes d'artefacts à chaque changement de tuile. `--edge-buffer N` (défaut 0 = off ; case « Raccord des bords » de la webapp, `EDGE_BUFFER_METERS` = 100 m dans `webapp.py`) fait rastériser le DTM sur la **dalle nominale 1 km alignée sur la grille** plus une bande de N m remplie avec les points sol des 8 LAZ voisines (`_neighbor_ground_points` dans `dtm.py` : PDAL en flux, découpe + filtre de classes IGN ; voisine absente = téléchargement automatique depuis le catalogue IGN avant le run, **isolée dans `input/edge_neighbors/`** pour ne pas gonfler le corpus des passes globales (dédupliqué sur tout le lot, `_fetch_edge_neighbors` dans `pipeline.py`) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puis `rendering.py` (`_core_tile_window`, via `tif_to_crop`/`tif_to_png`) recadre les sorties sur la dalle 1 km exacte lue dans le nom LHD — les AVIF restent des carrés 1 km alignés dans la mosaïque. Tampon consigné dans le tag GeoTIFF `LIDAR_EDGE_BUFFER` du DTM : changer `--edge-buffer` invalide le cache DTM automatiquement (tag absent = 0). Bandes voisines non calées par faisceaux (contexte seul, recadrée hors image finale). Coût : ~7 s de lecture par voisine + ~44 % de pixels en plus à 100 m/0,2 m. Nom hors pattern LHD : option ignorée (bornes d'en-tête, pas de recadrage). - **Raccord des bords entre tuiles** : les rendus à grand noyau (openness/SVF : rayons 100 m ; LRM : 15 m) tronquent leur fenêtre au bord de dalle — bandes d'artefacts à chaque changement de tuile. `--edge-buffer N` (défaut 0 = off ; case « Raccord des bords » de la carte, `EDGE_BUFFER_METERS` = 100 m dans `mapserve.py`) fait rastériser le DTM sur la **dalle nominale 1 km alignée sur la grille** plus une bande de N m remplie avec les points sol des 8 LAZ voisines (`_neighbor_ground_points` dans `dtm.py` : PDAL en flux, découpe + filtre de classes IGN ; voisine absente = téléchargement automatique depuis le catalogue IGN avant le run, **isolée dans `input/edge_neighbors/`** pour ne pas gonfler le corpus des passes globales (dédupliqué sur tout le lot, `_fetch_edge_neighbors` dans `pipeline.py`) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puis `rendering.py` (`_core_tile_window`, via `tif_to_crop`/`tif_to_png`) recadre les sorties sur la dalle 1 km exacte lue dans le nom LHD — les AVIF restent des carrés 1 km alignés dans la mosaïque. Tampon consigné dans le tag GeoTIFF `LIDAR_EDGE_BUFFER` du DTM : changer `--edge-buffer` invalide le cache DTM automatiquement (tag absent = 0). Bandes voisines non calées par faisceaux (contexte seul, recadrée hors image finale). Coût : ~7 s de lecture par voisine + ~44 % de pixels en plus à 100 m/0,2 m. Nom hors pattern LHD : option ignorée (bornes d'en-tête, pas de recadrage).
- **Tests use lazy imports inside each test function**, never at module top, to avoid importing CuPy/GDAL at import time. - **Tests use lazy imports inside each test function**, never at module top, to avoid importing CuPy/GDAL at import time.
- **`_`-prefixed names are critical private**: `_create_ground_pipeline`, `_fallback_to_smrf`, `_fill_nans`, `_init_gpu`, `_process_file_standalone` — do not call from outside their module. - **`_`-prefixed names are critical private**: `_create_ground_pipeline`, `_fallback_to_smrf`, `_fill_nans`, `_init_gpu`, `_process_file_standalone` — do not call from outside their module.
- **`build_index()` writes 3 files**: `output/index.html` (data shell, `const TILES` embedded), `output/assets/app.css` and `output/assets/app.js` (source: `_APP_CSS`/`_APP_JS` constants in `index.py`). `webapp.py` serves `/assets` with no-cache headers. Each tile carries `meta` — ground method read from `DTM/*_dtm{_rXpY}_method.txt` (falls back to the primary-resolution sidecar) + per-viz dates/sizes. - **`build_index()` écrit l'inventaire + les paliers sources** : `output/index_tiles.json` (dalles, couches, URLs versionnées — servi par `/api/tiles`), vignettes `index_thumbs/` (≈3,9 m/px + `_mid` 1,56 m/px) et quadrants `index_subtiles/` — paliers de la pyramide XYZ (`tiles.py`). Chaque tuile du run en cours porte ses coins WGS84 pour les cadres de progression. Plus d'HTML : l'interface vit dans `mapui.py`.
## Architecture Notes (from code audit 2025-09) ## Architecture Notes (from code audit 2025-09)
### Module structure & data flow ### Module structure & data flow
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map) - `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (catalogue : vignettes + inventaire)
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls. - `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation). `/api/presets` (GET/POST/DELETE) shares layer presets between browsers (`output/.presets.json`, localStorage echo in the interface as fallback). - `mapserve.py` (FastAPI, image `lidar-maps`) sert la pyramide XYZ (`tiles.py`), l'interface (`mapui.py`), l'inventaire `/api/tiles` + statiques dalles pour les machines légères, et l'API de génération : pipeline en sous-processus sur l'image complète, délégation via `LIDAR_GENERATION_URL` sur l'image légère (Raspberry Pi). Job unique + file persistante (`_job_lock` est un **RLock** : `/api/status` appelle `_queue_summary()` sous verrou). `LIDAR_API_TOKEN` (worker) / `LIDAR_REMOTE_TOKEN` (léger) protègent les appels ; `LIDAR_REGEN_CIDR` réserve la génération au réseau local.
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress. - `progress.py` writes JSONL events (O_APPEND, atomic) read by mapserve for live progress (frames on the map).
- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.
### Key design decisions (intentional, do not "fix") ### Key design decisions (intentional, do not "fix")
- **`_res_suffix` hardcodes 0.5 as "no suffix"**: coupled to `index.py` parsing (`_strip_res_suffix` defaults to 0.5 when no suffix). Changing requires sidecar metadata. - **`_res_suffix` hardcodes 0.5 as "no suffix"**: coupled to `index.py` parsing (`_strip_res_suffix` defaults to 0.5 when no suffix). Changing requires sidecar metadata.
- **GPU scoring** (`major*1000 + minor*100 + mem_mi`): compute capability priority is intentional — a newer GPU with less VRAM is preferred. - **GPU scoring** (`major*1000 + minor*100 + mem_mi`): compute capability priority is intentional — a newer GPU with less VRAM is preferred.
- **`webapp.py` reads env at import time**: deployment-focused single-purpose server, env is set once in docker-compose. - **`mapserve.py` reads env at import time**: deployment-focused single-purpose server, env is set once in docker-compose.
- **Repeated try/except in visualizations** (14× same pattern): intentional convention for uniform `return None` behavior. - **Repeated try/except in visualizations** (14× same pattern): intentional convention for uniform `return None` behavior.
- **`_d8_accumulate_numba` defines `@njit` inside the function**: `cache=True` makes subsequent calls fast; the Python function object creation is negligible. - **`_d8_accumulate_numba` defines `@njit` inside the function**: `cache=True` makes subsequent calls fast; the Python function object creation is negligible.
- **`pkill -9 -f "pdal pipeline"`** in cli.py signal handler: belt-and-suspenders alongside `os.killpg`. Scoped to "pdal pipeline" to avoid killing unrelated PDAL processes. - **`pkill -9 -f "pdal pipeline"`** in cli.py signal handler: belt-and-suspenders alongside `os.killpg`. Scoped to "pdal pipeline" to avoid killing unrelated PDAL processes.

View File

@ -38,13 +38,12 @@ COPY setup.py .
COPY lidar_pipeline/ ./lidar_pipeline/ COPY lidar_pipeline/ ./lidar_pipeline/
RUN pip3 install --no-cache-dir . RUN pip3 install --no-cache-dir .
# CSS/JS de l'interface (app.js/app.css + Leaflet) bâchés dans l'image : # CSS/JS de l'interface de la carte (app.js/app.css + Leaflet) bâchés dans
# la webapp les sert depuis webapp_assets/ du paquet installé, plus depuis # l'image : mapserve les sert depuis mapserve_assets/ du paquet installé.
# le volume (cf. Dockerfile.webapp). cd /tmp : sans lui, python3 -c met le # cd /tmp : sans lui, python3 -c met le répertoire courant (/app, copie
# répertoire courant (/app, copie source) sur sys.path et le bake atterrirait # source) sur sys.path et le bake atterrirait dans la copie au lieu du
# dans la copie au lieu du paquet installé (dist-packages) qu'utilise le # paquet installé (dist-packages) qu'utilise le conteneur au runtime.
# conteneur au runtime. RUN cd /tmp && python3 -c "import pathlib, lidar_pipeline.mapui as m; m.write_map_assets(pathlib.Path(m.__file__).resolve().parent / m.ASSETS_DIRNAME)"
RUN cd /tmp && python3 -c "import pathlib, lidar_pipeline.index as i; i._write_ui_assets(pathlib.Path(i.__file__).resolve().parent / 'webapp_assets')"
# Copy backward-compatible entry point # Copy backward-compatible entry point
COPY process_lidar.py /usr/local/bin/ COPY process_lidar.py /usr/local/bin/

View File

@ -1,58 +0,0 @@
# Webapp légère (carte interactive + vignettes) — Raspberry Pi / ARM64.
#
# Sert la carte, régénère index/vignettes/sous-tuiles localement et délègue
# la génération de tuiles (PDAL/GPU) à la machine de traitement via
# LIDAR_GENERATION_URL — cf. docs/DEPLOY_WEBAPP.md.
#
# Build sur la machine cible (multi-plateforme natif) :
# docker build -f Dockerfile.webapp -t lidar-webapp .
FROM python:3.12-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Paris
# Client SSH : inspection/accès à la machine de traitement si besoin. La
# clé se monte dans /home/lidar/.ssh. Les tuiles étant servies à la demande
# (plus de rsync dans l'image), seul le client SSH est conservé.
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Dépendances de la seule interface : FastAPI/uvicorn (serveur), Pillow
# (vignettes + sous-tuiles AVIF — natif depuis la 11.3, wheels ARM64
# incluses), pyproj (géoréférencement léger, sans GDAL).
RUN pip3 install --no-cache-dir \
fastapi \
uvicorn \
"pillow>=11.3" \
pyproj
# Code : webapp.py + index.py seulement utilisés — les modules de traitement
# (dtm, visualizations...) sont copiés mais jamais importés par la webapp.
COPY lidar_pipeline/ ./lidar_pipeline/
COPY webapp-entrypoint.sh /webapp-entrypoint.sh
# CSS/JS de l'interface (app.js/app.css + Leaflet) bâchés dans l'image : la
# webapp les sert depuis /app/lidar_pipeline/webapp_assets, plus depuis le
# volume — le code de l'interface suit l'image, régénéré à chaque build.
RUN python3 -c "import sys; sys.path.insert(0, '/app'); from lidar_pipeline.index import _write_ui_assets; _write_ui_assets('/app/lidar_pipeline/webapp_assets')"
RUN groupadd -g 1000 lidar && \
useradd -u 1000 -g lidar -m -s /bin/bash lidar && \
mkdir -p /data/output /data/input && \
chown -R lidar:lidar /data && \
chmod +x /webapp-entrypoint.sh
ENV LIDAR_INPUT_DIR=/data/input \
LIDAR_OUTPUT_DIR=/data/output \
LIDAR_PORT=8973
USER lidar
EXPOSE 8973
# Sert en HTTP, ou en HTTPS si LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE sont
# définis (requis pour la géolocalisation GPS depuis un téléphone).
CMD ["/webapp-entrypoint.sh"]

View File

@ -1,64 +0,0 @@
# Simulation LOCALE du mode deux machines (cf. docs/DEPLOY_WEBAPP.md).
#
# Fait tourner sur une même machine les deux rôles normalement répartis sur
# deux machines, dans des conteneurs/images indépendants :
# - worker : machine de traitement (image complète, GPU) — API de
# génération sur le port hôte 8974 ;
# - webapp : interface légère (image Dockerfile.webapp, sans PDAL/GPU) —
# carte sur le port hôte 8973, avec son PROPRE cache de tuiles
# (./output-webapp) peuplé à la demande depuis le worker,
# comme le fait le Raspberry Pi.
#
# Intérêt en local : mettre à jour le code de l'interface (rebuild webapp)
# sans toucher au worker ni aux générations en cours, et réciproquement.
#
# docker compose -f docker-compose.local-2m.yml up -d --build
# docker compose -f docker-compose.local-2m.yml logs -f webapp worker
# docker compose -f docker-compose.local-2m.yml down
# Retour au mode tout-en-un : docker compose up -d --build serve
services:
worker:
build: .
image: lidar-lidar
container_name: lidar-worker
init: true
user: "1000:1000"
gpus: all # retirer cette ligne sur une machine sans GPU
ports:
- "8974:8973" # API joignable en direct (debug) et par la webapp
volumes:
# input/ en écriture : l'API y télécharge les dalles IGN manquantes
- ./input:/data/input
- ./output:/data/output
environment:
- TZ=Europe/Paris
- LIDAR_INPUT_DIR=/data/input
- LIDAR_OUTPUT_DIR=/data/output
- LIDAR_GPU=1
- LIDAR_WORKERS=10
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
restart: unless-stopped
webapp:
build:
context: .
dockerfile: Dockerfile.webapp
image: lidar-webapp
container_name: lidar-webapp
init: true
user: "1000:1000"
ports:
- "8973:8973"
volumes:
# Cache local propre à la webapp (équivalent du disque du Raspberry
# Pi) — PAS le ./output du worker : la séparation rendu/interface doit
# être réelle, les tuiles arrivent à la demande depuis le worker.
- ./output-webapp:/data/output
environment:
- TZ=Europe/Paris
- LIDAR_OUTPUT_DIR=/data/output
# Backend de génération = worker du même réseau compose (DNS docker) ;
# les tuiles sont servies à la demande depuis le worker (cache peuplé
# au fil des consultations, vignettes régénérées localement par /api/sync)
- LIDAR_GENERATION_URL=http://lidar-worker:8973
restart: unless-stopped

View File

@ -1,7 +1,9 @@
# Carte à tuiles XYZ (style OpenStreetMap / Google Maps) — cf. docs/MAPS.md. # Carte à tuiles XYZ (style OpenStreetMap / Google Maps) — cf. docs/MAPS.md.
# #
# Coexiste avec la webapp historique (port 8973) : même dossier output/, aucune # Sert la pyramide de tuiles + l'interface de consultation + la génération
# interférence. Les tuiles sont rendues à la demande et mises en cache dans # (déléguée à la machine de traitement via LIDAR_GENERATION_URL sur une
# machine légère, locale sur l'image complète). Même dossier output/ que le
# pipeline ; les tuiles sont rendues à la demande et mises en cache dans
# output/index_xyz/. # output/index_xyz/.
# #
# docker compose -f docker-compose.maps.yml up -d --build # docker compose -f docker-compose.maps.yml up -d --build
@ -24,7 +26,17 @@ services:
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- LIDAR_OUTPUT_DIR=/data/output - LIDAR_OUTPUT_DIR=/data/output
# Serveur de DALLES amont (webapp du pipeline) : sans données locales, # Générateur de tuiles (worker, cf. docker-compose.worker.yml) : la
# génération lancée depuis la carte y est déléguée. Sans cette
# variable ET sans pipeline local, les boutons de génération sont
# masqués (image légère de consultation seule).
# - LIDAR_GENERATION_URL=http://192.168.1.50:8973
# Jeton si le worker exige LIDAR_API_TOKEN.
# - LIDAR_REMOTE_TOKEN=un-secret-à-partager
# Réseau autorisé à lancer les générations (défaut : localhost +
# plages privées RFC1918 ; liste de CIDR, vide = tout autoriser).
# - LIDAR_REGEN_CIDR=192.168.1.0/24
# Serveur de DALLES amont (mapserve du worker) : sans données locales,
# l'inventaire vient de son /api/tiles et les images sont rapatriées à # l'inventaire vient de son /api/tiles et les images sont rapatriées à
# la demande dans ./output — de quoi servir tout un catalogue distant. # la demande dans ./output — de quoi servir tout un catalogue distant.
# - LIDAR_SOURCE_URL=http://192.168.1.50:8973 # - LIDAR_SOURCE_URL=http://192.168.1.50:8973

View File

@ -1,37 +0,0 @@
# docker-compose.webapp.override.yml — EXEMPLE (copier sans le suffixe .example)
#
# NON VERSIONNÉ (ignoré par git) : il vit sur le Pi. Ce fichier démontre la
# config Traefik qui termine le TLS (certificat porté par le proxy) et expose
# la carte en https://<domaine> — contexte sécurisé requis par le bouton ⌖
# (« centrer sur la position GPS » du téléphone).
#
# cp docker-compose.webapp.override.yml.example docker-compose.webapp.override.yml
# # adapter LIDAR_WEBAPP_HOST (et si besoin l'entrypoint / le certresolver)
#
# docker compose ne merge PAS automatiquement un override au nom custom
# (il n'auto-charge que docker-compose.override.yml) : passer -f explicitement.
# docker compose -f docker-compose.webapp.yml \
# -f docker-compose.webapp.override.yml up -d --build
#
# docker compose applique ce fichier par-dessus docker-compose.webapp.yml ;
# il suffit de la clé `labels`, le reste hérite du compose principal. Le
# webapp sert du HTTP ; Traefik y accède via le port 8973 du conteneur.
#
# Le domaine se définit par LIDAR_WEBAPP_HOST (dans un .env à côté du compose
# ou l'environnement) ; défaut lidar.local. Remplacer par le domaine réel, ou
# simplement écrire le domaine en dur dans la règle Host() ci-dessous.
services:
webapp:
labels:
# Activer le provider Docker pour ce conteneur.
- traefik.enable=true
# Router sur le domaine (adapter) vers le service webapp:8973.
- "traefik.http.routers.lidar-webapp.rule=Host(`${LIDAR_WEBAPP_HOST:-lidar.local}`)"
# Entrée HTTPS (443) + TLS avec le certificat porté par Traefik.
- traefik.http.routers.lidar-webapp.entrypoints=websecure
- traefik.http.routers.lidar-webapp.tls=true
# Si un certresolver spécifique (ACME / Let's Encrypt…) plutôt que le
# certificat par défaut du proxy :
# - "traefik.http.routers.lidar-webapp.tls.certresolver=letsencrypt"
# Service explicite vers le conteneur (nom_docker:port) :
# - "traefik.http.services.lidar-webapp.loadbalancer.server.port=8973"

View File

@ -1,51 +0,0 @@
# Webapp légère pour Raspberry Pi — cf. docs/DEPLOY_WEBAPP.md.
#
# La carte et les vignettes sont servies localement ; la génération de tuiles
# (téléchargement IGN + traitement PDAL/GPU) est déléguée à la machine de
# traitement = service `worker` du docker-compose.worker.yml de cette machine.
#
# docker compose -f docker-compose.webapp.yml up -d --build
# docker compose -f docker-compose.webapp.yml logs -f webapp
services:
webapp:
build:
context: .
dockerfile: Dockerfile.webapp
image: lidar-webapp
container_name: lidar-webapp
init: true
user: "1000:1000"
ports:
- "8973:8973"
volumes:
# Cache local des tuiles, peuplé à la demande depuis LIDAR_GENERATION_URL
- ./output:/data/output
# Clé SSH du hôte (déploiement, git pull distant) si nécessaire
# - ~/.ssh:/home/lidar/.ssh:ro
# Configuration SSH (known_hosts) si nécessaire
# - ~/.ssh/config:/home/lidar/.ssh/config:ro
environment:
- TZ=Europe/Paris
- LIDAR_OUTPUT_DIR=/data/output
# Adresse du backend de génération des tuiles (machine de traitement,
# service `worker` de son docker-compose.worker.yml) — à définir :
# LIDAR_GENERATION_URL=http://<ip-machine-puissante>:8973
# Sans cette variable : consultation autonome du cache local, aucune
# génération possible depuis l'interface.
# - LIDAR_GENERATION_URL=http://192.168.1.50:8973
# Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement
# - LIDAR_REMOTE_TOKEN=change-moi
# Les tuiles sont servies à la demande depuis LIDAR_GENERATION_URL
# (cache peuplé au fil des consultations) : plus de rsync ni de sync
# périodique ; le bouton ↻ / POST /api/sync déclenchent le rebuild.
# Réseau autorisé à lancer les générations depuis la carte (défaut :
# localhost + plages privées RFC1918 ; chaîne vide = restriction levée).
# Les autres clients consultent la carte en lecture seule (boutons
# masqués, API 403).
# - LIDAR_REGEN_CIDR=192.168.1.0/24
# Le webapp sert du HTTP. Le TLS est terminé par le reverse proxy (Traefik,
# certificat porté par le proxy) : la configuration Traefik (routage HTTPS)
# vit dans le fichier override local docker-compose.webapp.override.yml
# (non versionné, sur le Pi) — cf. docs/DEPLOY_WEBAPP.md « Centrer la
# carte sur la position GPS » et docker-compose.webapp.override.yml.example.
restart: unless-stopped

View File

@ -1,10 +1,10 @@
# Machine de traitement — générateur de tuiles (cf. docs/DEPLOY_WEBAPP.md). # Machine de traitement — générateur de tuiles (cf. docs/DEPLOY_WEBAPP.md).
# #
# Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les # Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les
# webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent : # cartes distantes (Raspberry Pi, docker-compose.maps.yml + LIDAR_GENERATION_URL)
# dessin d'une zone → téléchargement IGN + traitement GPU ici, les tuiles # appellent : dessin d'une zone → téléchargement IGN + traitement GPU ici,
# sont servies à la demande par la webapp (cache peuplé au fil des # dalles et tuiles servies aux machines légères (/api/tiles + statiques,
# consultations). # /tiles/XYZ).
# #
# docker compose -f docker-compose.worker.yml up -d --build # docker compose -f docker-compose.worker.yml up -d --build
# docker compose -f docker-compose.worker.yml logs -f worker # docker compose -f docker-compose.worker.yml logs -f worker
@ -13,7 +13,7 @@
# Traitement batch ponctuel des dalles présentes dans input/ : # Traitement batch ponctuel des dalles présentes dans input/ :
# docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,0.2 | --force] # docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,0.2 | --force]
services: services:
# Générateur de tuiles : API de génération (webapp complète, GPU) # Générateur de tuiles : carte XYZ + API de génération (image complète, GPU)
worker: worker:
build: . build: .
image: lidar-lidar image: lidar-lidar
@ -22,7 +22,8 @@ services:
user: "1000:1000" user: "1000:1000"
gpus: all # retirer cette ligne sur une machine sans GPU gpus: all # retirer cette ligne sur une machine sans GPU
ports: ports:
- "8973:8973" # joignable par la webapp distante (LIDAR_GENERATION_URL) - "8973:8973" # joignable par les cartes distantes (LIDAR_GENERATION_URL
# et LIDAR_SOURCE_URL pointent ici)
volumes: volumes:
# input/ en écriture : l'API y télécharge les dalles IGN manquantes # input/ en écriture : l'API y télécharge les dalles IGN manquantes
- ./input:/data/input - ./input:/data/input
@ -31,7 +32,8 @@ services:
- TZ=Europe/Paris - TZ=Europe/Paris
- LIDAR_INPUT_DIR=/data/input - LIDAR_INPUT_DIR=/data/input
- LIDAR_OUTPUT_DIR=/data/output - LIDAR_OUTPUT_DIR=/data/output
# Les générations lancées depuis une webapp distante utilisent le GPU - LIDAR_PORT=8973
# Les générations lancées depuis une carte distante utilisent le GPU
- LIDAR_GPU=1 - LIDAR_GPU=1
- LIDAR_WORKERS=auto - LIDAR_WORKERS=auto
# Une ligne par worker : BLAS/OpenMP mono-thread, sinon 12 workers × N # Une ligne par worker : BLAS/OpenMP mono-thread, sinon 12 workers × N
@ -41,9 +43,9 @@ services:
- MKL_NUM_THREADS=1 - MKL_NUM_THREADS=1
- NUMEXPR_NUM_THREADS=1 - NUMEXPR_NUM_THREADS=1
# Protéger l'API si le réseau n'est pas de confiance : même valeur que # Protéger l'API si le réseau n'est pas de confiance : même valeur que
# LIDAR_REMOTE_TOKEN sur chaque webapp distante (sinon, laisser commenté) # LIDAR_REMOTE_TOKEN sur chaque carte distante (sinon, laisser commenté)
# - LIDAR_API_TOKEN=change-moi # - LIDAR_API_TOKEN=change-moi
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 command: python3 -m uvicorn lidar_pipeline.mapserve:app --host 0.0.0.0 --port 8973
restart: unless-stopped restart: unless-stopped
# Traitement ponctuel des dalles input/ (une passe puis arrêt) # Traitement ponctuel des dalles input/ (une passe puis arrêt)

View File

@ -1,7 +1,7 @@
# Lancement du pipeline LiDAR — TOUJOURS via docker compose : # Lancement du pipeline LiDAR — TOUJOURS via docker compose :
# docker compose up -d --build serve # TOUJOURS --build : le code est baké dans # docker compose up -d --build serve # TOUJOURS --build : le code est baké dans
# # l'image, sans --build l'ANCIEN code tourne # # l'image, sans --build l'ANCIEN code tourne
# # (carte interactive + API sur http://localhost:8973) # # (carte + tuiles XYZ + API sur http://localhost:8973)
# docker compose logs -f serve # journal du serveur # docker compose logs -f serve # journal du serveur
# docker compose down # arrêt # docker compose down # arrêt
# Traitement ponctuel (sans serveur) — --build pour la même raison : # Traitement ponctuel (sans serveur) — --build pour la même raison :
@ -10,7 +10,7 @@
# docker compose --profile interactive up -d jupyter # docker compose --profile interactive up -d jupyter
services: services:
# Carte interactive + API de génération (mode ./run.sh --serve) # Carte + tuiles XYZ + API de génération (mode ./run.sh --serve)
serve: serve:
build: . build: .
image: lidar-lidar image: lidar-lidar
@ -28,18 +28,19 @@ services:
- TZ=Europe/Paris - TZ=Europe/Paris
- LIDAR_INPUT_DIR=/data/input - LIDAR_INPUT_DIR=/data/input
- LIDAR_OUTPUT_DIR=/data/output - LIDAR_OUTPUT_DIR=/data/output
- LIDAR_PORT=8973
# Les générations lancées depuis la carte utilisent le GPU # Les générations lancées depuis la carte utilisent le GPU
- LIDAR_GPU=1 - LIDAR_GPU=1
- LIDAR_WORKERS=10 - LIDAR_WORKERS=10
# Machine de traitement pour une webapp distante (Raspberry Pi) : # Machine de traitement pour une carte distante (Raspberry Pi) :
# décommenter et définir la même valeur en LIDAR_REMOTE_TOKEN là-bas # décommenter et définir la même valeur en LIDAR_REMOTE_TOKEN là-bas
# (protège /api/generate, /api/preview, /api/rebuild, /api/sync). # (protège /api/generate, /api/preview, /api/stop).
# - LIDAR_API_TOKEN=change-moi # - LIDAR_API_TOKEN=change-moi
# Réseaux autorisés à lancer les générations depuis la carte (défaut : # Réseaux autorisés à lancer les générations depuis la carte (défaut :
# localhost + plages privées RFC1918 ; liste de CIDR séparés par # localhost + plages privées RFC1918 ; liste de CIDR séparés par
# virgules, vide = tout autoriser). # virgules, vide = tout autoriser).
# - LIDAR_REGEN_CIDR=192.168.1.0/24 # - LIDAR_REGEN_CIDR=192.168.1.0/24
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 command: python3 -m uvicorn lidar_pipeline.mapserve:app --host 0.0.0.0 --port 8973
restart: unless-stopped restart: unless-stopped
# Traitement ponctuel des dalles input/ (une passe puis arrêt) # Traitement ponctuel des dalles input/ (une passe puis arrêt)

View File

@ -1,27 +1,33 @@
# Déploiement webapp légère + machine de traitement # Déploiement carte légère + machine de traitement
Architecture deux machines : la **webapp** (carte interactive, vignettes, Architecture deux machines : la **carte** (interface, pyramide de tuiles XYZ,
API) tourne sur un Raspberry Pi ; la **génération de tuiles** (téléchargement API) tourne sur un Raspberry Pi ; la **génération de tuiles** (téléchargement
IGN, PDAL, GPU) tourne sur une machine puissante. Les deux exécutent le même IGN, PDAL, GPU) tourne sur une machine puissante. Les deux exécutent le même
code (`lidar_pipeline.webapp`), dans deux images Docker différentes. serveur (`lidar_pipeline.mapserve`, image `lidar-maps`), la machine puissante
en version complète (pipeline inclus).
``` ```
Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp) Navigateur ──HTTP──▶ Raspberry Pi (image légère lidar-maps, port 8975)
│ sert carte + vignettes (régénérées localement) │ sert la carte + la pyramide (cache seule + maintenance
│ de fond, LIDAR_TILE_CACHE_ONLY / LIDAR_TILE_BACKGROUND)
│ /api/generate, /api/preview, /api/status │ /api/generate, /api/preview, /api/status
│ └─ transmis à ──▶ machine de traitement │ └─ transmis à ──▶ machine de traitement
│ /api/sync : rebuild local (tuiles servies à la demande) │ dalles rapatriées du worker (LIDAR_SOURCE_URL :
│ inventaire /api/tiles + statiques versionnées)
▼ ▼
Machine de traitement (image complète, Machine de traitement (image complète,
docker-compose.worker.yml service `worker`) : docker-compose.worker.yml service `worker`) :
téléchargement IGN + pipeline GPU (générateur de tuiles) téléchargement IGN + pipeline GPU (générateur de tuiles)
+ pyramide de tuiles + inventaire des dalles
``` ```
## Machine de traitement (générateur de tuiles) ## Machine de traitement (générateur de tuiles)
Le service `worker` joue le rôle de générateur : il accepte les demandes Le service `worker` joue le rôle de générateur : il accepte les demandes de
de génération envoyées par les webapp distantes (téléchargement IGN + génération envoyées par les cartes distantes (téléchargement IGN + traitement
traitement PDAL/GPU) et expose les tuiles produites, servies à la demande par les webapp. PDAL/GPU), sert sa propre pyramide de tuiles et l'inventaire des dalles
(`/api/tiles` + `visualisations/`, `index_thumbs/`, `index_subtiles/` en
statique) que les machines légères rapatrient à la demande.
```bash ```bash
docker compose -f docker-compose.worker.yml up -d --build # API sur http://<ip-machine>:8973 docker compose -f docker-compose.worker.yml up -d --build # API sur http://<ip-machine>:8973
@ -31,7 +37,7 @@ docker compose -f docker-compose.worker.yml logs -f worker
Traitement batch ponctuel des dalles déjà présentes dans `input/` : Traitement batch ponctuel des dalles déjà présentes dans `input/` :
```bash ```bash
docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,0.2 | --force] docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.2 | --force]
``` ```
Sur une machine sans GPU : retirer les lignes `gpus: all` (traitement CPU, Sur une machine sans GPU : retirer les lignes `gpus: all` (traitement CPU,
@ -44,21 +50,18 @@ environment:
- LIDAR_API_TOKEN=un-secret-à-partager - LIDAR_API_TOKEN=un-secret-à-partager
``` ```
## Raspberry Pi (webapp légère) ## Raspberry Pi (carte légère)
### 0. Prérequis sur le Pi ### 0. Prérequis sur le Pi
- **Architecture** : image construite nativement sur la machine (ARM64 ou - **Architecture** : image construite nativement sur la machine (ARM64 ou
x86_64, vérifier avec `uname -m`). Le build se fait sur le Pi lui-même x86_64, vérifier avec `uname -m`). Le build se fait sur le Pi lui-même
(`Dockerfile.webapp` : base `python:3.12-slim`, ~200 Mo, sans PDAL/GDAL). (`Dockerfile.maps` : base `python:3.12-slim`, ~200 Mo, sans PDAL/GDAL).
- **Docker + plugin compose** : installation officielle - **Docker + plugin compose** : installation officielle
[docs.docker.com/engine/install](https://docs.docker.com/engine/install/) [docs.docker.com/engine/install](https://docs.docker.com/engine/install/)
(tester avec `docker compose version`). (tester avec `docker compose version`).
- **Clé SSH côté hôte** : montée dans le conteneur pour le déploiement et le - **Espace disque** : prévoir la taille du cache (dalles rapatriées + tuiles
`git pull` distant (le rsync de tuiles, supprimé, n'en avait pas besoin). rendues ; compter la taille de `output/` sur la machine de traitement).
- **Espace disque** : prévoir la taille du cache de tuiles (compter la
taille de `output/` sur la machine de traitement, ~quelques dizaines de
Mo par dalle et par résolution).
### 1. Copier le code sur le Pi (git clone) ### 1. Copier le code sur le Pi (git clone)
@ -67,323 +70,121 @@ git clone ssh://git@git.example.fr:2222/code_public/lidar_rendu.git lidar
cd lidar cd lidar
``` ```
Le clone SSH utilise la clé de l'hôte (`~/.ssh`, section 2). Si le serveur `output/` et `input/` sont ignorés par git : le dépôt ne contient que le
git n'est pas encore connu, faire une première connexion pour accepter son code, le cache se remplit à la demande depuis la machine de traitement.
empreinte. `output/` et `input/` sont ignorés par git : le dépôt ne contient
que le code, le cache de tuiles se remplit à la demande depuis la machine de
traitement (section 4).
### 2. Accès SSH (déploiement) ### 2. Lancer la carte
Le rsync ayant été retiré, le SSH ne sert plus à ramener les tuiles (elles `docker-compose.maps.yml` (versionné) sert de base ; la configuration locale
sont servies à la demande, section 4). Il sert au déploiement et au support du Pi vit dans un **override** `docker-compose.maps.override.yml` (non
: se connecter à la machine de traitement pour y inspecter `output/` ou y versionné) :
déposer des tuiles manuellement.
```bash ```yaml
ssh-keygen -t ed25519 # si pas encore de clé name: lidar-maps
ssh-copy-id lidar@<ip-machine> # compte sur la machine de traitement services:
ssh lidar@<ip-machine> exit # 1re connexion : enregistre known_hosts maps:
volumes: !override # Compose >= 2.24 (remplace ./output)
- /srv/lidar/output:/data/output
mem_limit: 1g
memswap_limit: 1g
environment:
- TZ=Europe/Paris
- LIDAR_SOURCE_URL=http://192.168.1.50:8973 # worker (dalles + inventaire)
- LIDAR_GENERATION_URL=http://192.168.1.50:8973 # worker (génération déléguée)
# - LIDAR_REMOTE_TOKEN=un-secret-à-partager # si LIDAR_API_TOKEN côté worker
- LIDAR_TILE_WORKERS=1
- LIDAR_TILE_SOURCE_CACHE_MB=64
- LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend RIEN
- LIDAR_TILE_BACKGROUND=1 # la pyramide est entretenue en tâche de fond
- LIDAR_TILE_BACKGROUND_PAUSE=1.0
networks: [webapp, proxy]
labels: # routage Traefik éventuel
- "traefik.enable=true"
- "traefik.http.routers.lidar-maps.rule=Host(`lidar.example.fr`)"
- "traefik.http.routers.lidar-maps.entrypoints=websecure"
- "traefik.http.routers.lidar-maps.tls.certresolver=myresolver"
- "traefik.http.services.lidar-maps.loadbalancer.server.port=8975"
networks:
webapp:
name: lidar_rendu_default
external: true
proxy:
name: proxy
external: true
``` ```
La dernière commande évite le prompt « authenticity of host » en connexion ```bash
non interactive (le conteneur ne peut pas répondre). mkdir -p /srv/lidar/output # appartenant à l'uid 1000 (conteneur)
docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build
```
Le cache de tuiles du Pi se remplit à la demande depuis la machine de La carte est sur `http://<ip-pi>:8975/` (et via Traefik si configuré).
traitement ; ce compte SSH sert à l'inspecter ou à y déposer des tuiles
manuellement si besoin.
Avec `serve-webapp.sh` et `run.sh`, la clé SSH de l'hôte (`~/.ssh`, clé + `LIDAR_TILE_CACHE_ONLY=1` + `LIDAR_TILE_BACKGROUND=1` inversent la charge sur
known_hosts) est montée automatiquement en lecture seule dans le conteneur ; un petit Pi : la navigation ne rend rien (tuile absente = transparente,
avec `docker compose`, le montage équivalent est à décommenter dans `X-Tile-Pending`), une tâche de fond surveille les dalles nouvelles ou
`docker-compose.webapp.yml` (voir l'option c ci-dessous). régénérées et entretient la pyramide à basse priorité — cf. `docs/MAPS.md`
§ « Cache seule + maintenance de fond ».
#### Wrapper `ssh` (déploiement depuis le poste de pilotage) ### 3. Génération de tuiles depuis la carte
Le poste de pilotage (agent Crush) interdit l'appel `ssh` direct : le wrapper Les boutons **+ Zone** (dessiner un rectangle → téléchargement IGN + run sur
`ssh`, créé une fois (puis `chmod +x ssh`), le worker), **⤒ Compléter** (dalles déjà téléchargées incomplètes) et
fournit l'accès autorisé avec transfert d'agent (`-A`) — les `git pull` **↻ Générer cette dalle** (fiche d'infos au clic) sont masqués si :
distants utilisent la clé locale — et refuse toute interaction (BatchMode) :
- le navigateur vient d'une IP hors `LIDAR_REGEN_CIDR` (défaut : boucle
locale + plages privées RFC1918 ; liste de CIDR séparés par virgules,
chaîne vide pour lever la restriction). L'IP est celle de la connexion
(conservée par le DNAT Docker pour les clients du LAN) ; derrière un
reverse proxy local (dans le réseau autorisé), `X-Forwarded-For` désigne
le client réel ;
- le worker est injoignable ET aucun pipeline local n'existe.
Une demande lancée pendant un run part en **file d'attente** côté worker
(jamais de coupure du travail en place) ; la progression s'affiche dalle par
dalle (cadres orange/bleu/rouge sur la carte) et le bouton **Arrêter** envoie
un SIGTERM au pipeline. À la fin du run, la carte se rafraîchit et la
maintenance de pyramide repart automatiquement.
### 4. Centrer la carte sur la position GPS (téléphone)
Les navigateurs n'exposent l'API Geolocation qu'en **contexte sécurisé**
(HTTPS) : servir la carte derrière Traefik (TLS) comme ci-dessus, ou en
dernier recours en HTTPS direct (l'entrée autonome `python -m
lidar_pipeline.mapserve` honore `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE`).
## Mise à jour
### Machine de traitement
```bash
cd <checkout> && git pull && docker compose -f docker-compose.worker.yml up -d --build
```
### Pi (depuis le poste de pilotage)
Le poste de pilotage interdit l'appel `ssh` direct : le wrapper
`ssh` fournit l'accès autorisé avec transfert d'agent (`-A`) —
les `git pull` distants utilisent la clé locale :
```bash ```bash
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
HOST="${1:-192.168.3.10}" # machine webapp par défaut (le Pi) HOST="${1:-192.168.3.10}" # machine carte par défaut (le Pi)
shift || true shift || true
exec ssh -A -o BatchMode=yes -o ConnectTimeout=5 \ exec ssh -A -o BatchMode=yes -o ConnectTimeout=5 \
-o StrictHostKeyChecking=accept-new "$HOST" "$@" -o StrictHostKeyChecking=accept-new "$HOST" "$@"
``` ```
Usage : `ssh` (Pi par défaut), `ssh <hôte> [commande]`. Mise à jour Mise à jour complète du Pi (checkout git en `/srv/lidar_rendu`,
complète du Pi (checkout git en `/srv/lidar_rendu`, avec avec l'override Traefik) :
l'override Traefik — jamais `serve-webapp.sh` en prod) :
```bash ```bash
ssh 192.168.3.10 "cd /srv/lidar_rendu && git pull && \ ssh 192.168.3.10 "cd /srv/lidar_rendu && git pull && \
docker compose -f docker-compose.webapp.yml -f docker-compose.webapp.override.yml up -d --build" docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"
``` ```
### 3. Configurer et lancer Le code de l'interface est bâché dans l'image (`Dockerfile.maps`) : **TOUT**
changement d'interface exige le rebuild (`--build`), sans lui l'ancien code
Trois façons, au choix : tourne.
**a. `serve-webapp.sh` (recommandé)** — script de gestion de la stack. Il
pilote `docker compose` et charge automatiquement
`docker-compose.webapp.override.yml` s'il existe : les labels Traefik et le
volume réel de l'override sont préservés. La configuration vit dans
`webapp.env` (copie du modèle, non versionné) :
```bash
cp webapp.env.example webapp.env
nano webapp.env # LIDAR_WEBAPP_HOST (labels), jeton...
./serve-webapp.sh # démarre (build + up -d) et attend le serveur
```
Sous-commandes : `stop` (compose down), `restart` (rebuild + recréation),
`status` (conteneur + état du rebuild), `sync` (rebuild + vignettes),
`logs`. Le port hôte vient du mapping compose ; `WEBAPP_PORT` ne sert plus
qu'aux sondes locales quand le conteneur est arrêté.
**b. `run.sh`** — conteneur au premier plan (Ctrl-C arrête tout) : pratique
pour tester, les variables se passent depuis l'environnement hôte, `~/.ssh`
est monté automatiquement :
```bash
LIDAR_GENERATION_URL=http://192.168.1.50:8973 \
LIDAR_REMOTE_TOKEN=un-secret-à-partager \
./run.sh --serve-webapp # port 8973, ou --serve-webapp 9000
```
Les tuiles sont servies à la demande : une image absente du cache local est
téléchargée depuis la machine de traitement au premier affichage, le cache
se remplit ainsi progressivement. Le bouton ↻, la fin d'un run ou `POST
/api/sync` déclenchent un rebuild de l'index et régénèrent les vignettes.
`LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST
/api/generate` : zones, sélection, complétion, régénération) aux clients
dont l'IP est dans un des réseaux indiqués — liste de CIDR séparés par
virgules, chaîne vide pour lever la restriction. Défaut :
`127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (boucle locale
+ plages privées RFC1918 : LAN, hôte Docker via la passerelle 172.x ; les
IP publiques restent refusées). L'IP est celle de la connexion (conservée
par le DNAT Docker pour les clients du LAN) ; derrière un reverse proxy
local (dans le réseau autorisé), `X-Forwarded-For` désigne le client réel.
Les autres clients consultent la carte normalement : les boutons de
génération sont masqués et l'API répond 403.
**c. `docker compose`** — éditer `docker-compose.webapp.yml` :
- `LIDAR_GENERATION_URL` : `http://<ip-machine>:8973`
- `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine
(inutile si aucun token là-bas)
- le cache local se remplit à la demande depuis `LIDAR_GENERATION_URL` (pas
de `LIDAR_SYNC_CMD`)
Décommenter aussi le montage de la clé SSH (déploiement) :
```yaml
volumes:
- ./output:/data/output
- ~/.ssh:/home/lidar/.ssh:ro
```
Les vignettes (`index_thumbs/`, `index_subtiles/`) ne se synchronisent pas :
elles sont régénérées sur place par le Pi (`/api/sync` → `build_index`),
c'est le seul travail lourd qu'il fait. Exclure `*.tif` évite de copier des
intermédiaires éventuels ; les sidecars `output/DTM/*_method.txt` servent au
panneau d'infos et sont synchronisés.
```bash
mkdir -p output
docker compose -f docker-compose.webapp.yml up -d --build
```
La carte est sur `http://<ip-pi>:8973/`.
`run.sh` lance le conteneur au premier plan (Ctrl-C arrête tout) : pratique
pour tester. Pour une installation permanente, `serve-webapp.sh` (option a)
ou `docker compose` (option c) redémarrent la carte après un reboot du Pi
(`--restart unless-stopped`).
### 4. Premier chargement
Si aucune tuile n'a encore été synchronisée, déclencher une première fois :
```bash
curl -X POST http://<ip-pi>:8973/api/sync
```
(puis attendre la fin : `curl http://<ip-pi>:8973/api/sync` → `"running":
false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes).
### 5. Centrer la carte sur la position GPS (téléphone)
Le bouton **⌖** (barre d'outils de la carte, à côté du recadrage) recentre la
carte sur la position GPS du téléphone et y dessine un marqueur vert.
Les navigateurs n'exposent l'API Geolocation qu'en **contexte sécurisé**
(HTTPS). Servie en `http://<ip-pi>:8973`, la carte ne peut donc pas obtenir la
position sur téléphone : le bouton affiche alors « connexion sécurisée (HTTPS)
requise ».
**TLS terminé par le proxy (cas d'usage ici)** — le certificat est porté par
**Traefik** : le webapp sert du HTTP, Traefik expose `https://<domaine>` et y
applique son certificat (valable, sans avertissement). Le téléphone se connecte
sur l'URL Traefik → contexte sécurisé → le bouton ⌖ fonctionne.
La config Traefik (routage HTTPS) vit dans le fichier **override** local
`docker-compose.webapp.override.yml` (non versionné, sur le Pi) — Compose le
merge par-dessus `docker-compose.webapp.yml`. Le modèle versionné
`docker-compose.webapp.override.yml.example` fournit les labels (provider
Docker) qui routent `Host(\`${LIDAR_WEBAPP_HOST}\`)` vers le service, en
`websecure` (443) + TLS avec le certificat porté par Traefik. Sur le Pi :
```bash
cp docker-compose.webapp.override.yml.example docker-compose.webapp.override.yml
# adapter le domaine : LIDAR_WEBAPP_HOST=carte.example.fr (dans un .env à
# côté du compose, l'environnement, ou en dur dans la règle Host())
# docker compose n'auto-charge PAS un override au nom custom (seulement
# docker-compose.override.yml) → passer -f explicitement :
docker compose -f docker-compose.webapp.yml \
-f docker-compose.webapp.override.yml up -d --build
```
Adapter si le proxy n'est pas en `websecure` ou que le provider n'est pas
Docker : changer `entrypoints` / ajouter
`traefik.http.routers.lidar-webapp.tls.certresolver=…`, ou déclarer le
service directement dans la config Traefik. Le webapp est transparent au
proxy (URLs relatives) et remonte l'IP réelle via `X-Forwarded-For` pour la
restriction `LIDAR_REGEN_CIDR` : l'IP du téléphone doit y figurer pour lancer
des générations.
> **Sans Traefik** — servir la carte en HTTPS avec un certificat auto-signé :
> `./make-tls-cert.sh` crée `tls/webapp.{crt,key}`, à monter dans le
> conteneur avec `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` (le chemin
> `serve-webapp.sh` le fait automatiquement). Le téléphone accepte alors
> l'avertissement « certificat non fiable » une fois.
## Performance réseau (HTTP/2)
Le serveur interne (uvicorn) ne parle que **HTTP/1.1** : le navigateur y
limite chaque origine à 6 connexions simultanées. Peu contraignant depuis
l'optimisation de la carte (URLs versionnées servies `Cache-Control:
immutable` — zéro revalidation une fois chargées — et mosaïque d'overview à
la place des centaines de vignettes en vue dézoomée), mais pour un premier
chargement ou un parcours intensif, **passer par Traefik (HTTPS)** active
HTTP/2 entre le navigateur et le proxy : toutes les requêtes se multiplexent
sur une seule connexion (fin de la file d'attente des 6 connexions).
- **Via l'entrée `websecure` de Traefik** (route `https://lidar.example.fr`…)
: HTTP/2 est déjà actif — comportement par défaut de Traefik sur TLS.
Vérification : `curl -sI --http2 https://<domaine>/ | head -1` → `HTTP/2 200`.
- **En direct sur `http://<ip-pi>:8973`** (LAN) : HTTP/1.1 — les navigateurs
ne négocient pas h2 en clair. Si le besoin s'en fait sentir, exposer une
seconde route Traefik vers le même service (h2 côté client, le proxy
parlant h1 à uvicorn en backend).
- Entre Traefik et le conteneur, rien à changer : la limite qui compte est
celle du navigateur vers le frontend.
## Mise à jour
Sur le Pi, après un `git pull` :
```bash
docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage
```
Avec `serve-webapp.sh` (l'override Traefik est chargé automatiquement s'il
existe) :
```bash
./serve-webapp.sh restart # rebuild de l'image + recréation du conteneur
```
Avec `run.sh --serve-webapp`, l'image est reconstruite automatiquement à
chaque lancement ; il suffit de relancer la commande. Le cache de tuiles
(`output/`) n'est pas affecté par les mises à jour d'image.
### Interface de la carte : version embarquée
Les CSS/JS de l'interface sont **bâchés dans l'image** au build
(`Dockerfile.webapp`) : une évolution de l'interface (`lidar_pipeline/index.py`)
ne s'applique sur le Pi qu'après rebuild de l'image. Le décalage est détecté
automatiquement plutôt que de rester silencieux (tuiles invisibles) :
- l'interface embarque sa version (`ui_version` = hash CSS+JS source) et
compare à celle du serveur de données (`/api/tiles` → `ui_version`,
publiée dans `index_tiles.json` par le worker) : en cas de divergence,
un **bandeau rouge « Interface périmée »** s'affiche en haut de la carte ;
- la webapp consigne un avertissement une fois par paire de versions :
`Interface webapp périmée (webapp X, serveur Y) — reconstruire l'image`.
Réparation : `git pull` + rebuild de l'image (ci-dessus).
## Dépannage
- **État d'un rebuild** : `curl http://<ip-pi>:8973/api/sync` → `running`,
`phase` (`index`) et `error` (dernière erreur). Les logs du conteneur :
`docker logs lidar-webapp` (ou `docker compose -f docker-compose.webapp.yml
logs -f webapp`).
- **`Permission denied (publickey)` en SSH** : refaire
`ssh-copy-id lidar@<ip-machine>` et une connexion manuelle pour renseigner
`known_hosts` ; vérifier que `~/.ssh` est monté dans le conteneur
(automatique avec `run.sh`, à décommenter en compose).
- **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du
client n'est dans aucun des réseaux de `LIDAR_REGEN_CIDR` (défaut :
localhost + plages privées). Adapter la variable ou la vider pour lever
la restriction.
- **« Machine de traitement injoignable »** : vérifier le service `worker`
(`docker compose -f docker-compose.worker.yml logs -f worker`) et
`LIDAR_GENERATION_URL` (IP + port 8973). La consultation de la carte
reste possible avec le cache local.
- **Carte vide après sync** : attendre la fin du rebuild (`GET /api/sync` →
`"running": false`, ou `./serve-webapp.sh status`) puis recharger la
page ; le bouton ↻ de la carte enchaîne sync + vignettes
automatiquement.
- **Bandeau « Interface périmée » en haut de la carte** (ou tuiles invisibles
sans explication) : l'image webapp est plus ancienne que l'interface du
worker (assets bâchés au build). Réparer : `git pull` +
`docker compose -f docker-compose.webapp.yml up -d --build` sur le Pi.
- **Port 8973 déjà pris** : `WEBAPP_PORT=9000 ./serve-webapp.sh` (ou la
variable dans `webapp.env`), `--serve-webapp 9000` avec `run.sh`, ou
changer le mapping `ports:` en compose (le conteneur écoute toujours sur
8973).
## Fonctionnement
- Dessiner une zone (bouton « + Zone ») sur la carte du Pi envoie la demande
à la machine de traitement, qui télécharge les dalles IGN puis les traite.
La file de génération (progression tuile par tuile) est lue depuis la
machine distante en direct.
- À la fin du run, le navigateur appelle `/api/sync` : les images produites
sont servies à la demande depuis la machine de traitement, le Pi régénère
vignettes/sous-tuiles/index.html, puis recharge la carte. Le bouton ↻
relance le même cycle à tout moment.
- Sans machine de traitement configurée, `./run.sh --serve-webapp` sert la
carte en lecture seule depuis `output/` (cache figé, aucun traitement
possible depuis l'interface).
- **Autonomie** : une fois les tuiles chargées dans le cache local, la
webapp fonctionne seule — Leaflet est vendorisé dans `assets/vendor/`
(aucun CDN), tuiles/vignettes/infos sont lues dans `output/`, et un
backend injoignable masque simplement les boutons de génération sans
affecter la consultation.
- Machine de traitement seule (sans Pi) : rien ne change, `--serve` /
`docker compose up -d --build serve` fonctionne comme avant en local (tout-en-un), et `docker-compose.worker.yml` installe le générateur seul.
- Si la machine de traitement est éteinte, la carte du Pi reste consultable
(données synchronisées) ; seuls les nouveaux traitements sont indisponibles
(message « machine de traitement injoignable »).
## Références
- `lidar_pipeline/webapp.py` : proxy distant (`LIDAR_GENERATION_URL`),
`/api/sync` (rebuild de l'index), cache local à la demande, jeton
`LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`, restriction des générations au
réseau local (`LIDAR_REGEN_CIDR`).
- `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli
affine), recharge après sync.
- `docker-compose.worker.yml` : générateur de tuiles (machine puissante).
- `Dockerfile.webapp`, `docker-compose.webapp.yml` : webapp légère ARM64
(FastAPI + Pillow AVIF natif + pyproj, ~200 Mo).
- `serve-webapp.sh`, `webapp.env.example` : gestion de la stack webapp sur
le Pi (démarrage/arrêt/sync/logs), configuration locale dans
`webapp.env` (ignoré par git).

View File

@ -5,8 +5,10 @@ Carte « slippy » classique — même schéma de tuiles que Google Maps et
deviennent ainsi un **fond d'imagerie réutilisable** dans JOSM, iD, QGIS, uMap, deviennent ainsi un **fond d'imagerie réutilisable** dans JOSM, iD, QGIS, uMap,
MapLibre ou OsmAnd, en plus de l'interface de consultation fournie. MapLibre ou OsmAnd, en plus de l'interface de consultation fournie.
Elle coexiste avec la webapp historique (port 8973), sans rien lui changer : C'est la SEULE interface web du projet (l'ancienne webapp historique a été
même dossier `output/`, génération et export restent là-bas. retirée) : elle embarque aussi la **génération de tuiles** — locale sur
l'image complète (worker, `./run.sh --serve`), déléguée via
`LIDAR_GENERATION_URL` sur la machine légère (cf. `docs/DEPLOY_WEBAPP.md`).
```bash ```bash
docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/ docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/
@ -14,6 +16,35 @@ docker compose -f docker-compose.maps.yml logs -f maps
./run.sh --serve-maps # équivalent en conteneur au premier plan ./run.sh --serve-maps # équivalent en conteneur au premier plan
``` ```
## Générer des tuiles depuis la carte
- **+ Zone** (barre d'outils) — dessiner un rectangle : les dalles LHD de 1 km
qui l'intersectent sont téléchargées depuis la géoplateforme IGN puis
traitées (0,2 m, options ci-dessous) ; les zones et clics successifs
s'additionnent ;
- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui
manquent au moins une des couches demandées ;
- **↻ Générer cette dalle** (fiche d'infos au clic) — une dalle précise,
même sans données existantes.
Options du run : couches visées (défaut : les couches du panneau),
classification du sol (`ign`/`auto`/`smrf`/`csf`), classes LAS IGN
(`sol,unclassified…`), régénération forcée, reclassification, raccord des
bords (bande 100 m). Une demande lancée pendant un run part en **file
d'attente** (persistée, jamais de coupure du travail en place) ; la
progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu =
en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis
SIGKILL). À la fin du run, la carte se rafraîchit et la maintenance de
pyramide repart automatiquement.
Les boutons sont masqués si le navigateur vient d'une IP hors
`LIDAR_REGEN_CIDR` (défaut : localhost + plages privées RFC1918) ou si aucun
backend de génération n'existe (image légère sans `LIDAR_GENERATION_URL`).
Sur la machine légère, tout est transmis au worker
(`LIDAR_GENERATION_URL`), qui exécute le pipeline complet ; ses dalles sont
rapatriées à la demande par l'inventaire `/api/tiles` + les statiques
versionnées (`LIDAR_SOURCE_URL`).
## Contrat de tuilage ## Contrat de tuilage
| Point | Valeur | | Point | Valeur |

View File

@ -259,18 +259,18 @@ def main():
parser.add_argument( parser.add_argument(
"--rebuild-index", "--rebuild-index",
action="store_true", action="store_true",
help="Régénérer uniquement la carte globale HTML des tuiles déjà traitées (sans retraiter)" help="Régénérer uniquement le catalogue des tuiles traitées (vignettes, sous-tuiles, inventaire — sans retraiter)"
) )
parser.add_argument( parser.add_argument(
"--no-index", "--no-index",
action="store_true", action="store_true",
help="Ne pas générer la carte globale à la fin du traitement" help="Ne pas générer le catalogue (vignettes + inventaire) à la fin du traitement"
) )
parser.add_argument( parser.add_argument(
"--incremental-index", "--incremental-index",
action="store_true", action="store_true",
help="Régénérer la carte après chaque tuile terminée (affichage en direct " help="Régénérer le catalogue après chaque tuile terminée (affichage en direct "
"dans la webapp, sans attendre la fin du run)" "sur la carte, sans attendre la fin du run)"
) )
args = parser.parse_args() args = parser.parse_args()
@ -304,7 +304,7 @@ def main():
# Kill orphan PDAL processes on interrupt or termination. Le motif est # Kill orphan PDAL processes on interrupt or termination. Le motif est
# borné à NOTRE répertoire temporaire (pdal pipeline <output>/temp/…) : un # borné à NOTRE répertoire temporaire (pdal pipeline <output>/temp/…) : un
# pkill global massacrerient les PDAL des autres runs de la machine (ex. # pkill global massacrerient les PDAL des autres runs de la machine (ex.
# job webapp concurrent). Pas de hook atexit : une sortie normale n'a # job de génération concurrent). Pas de hook atexit : une sortie normale n'a
# aucune raison de tuer quoi que ce soit. # aucune raison de tuer quoi que ce soit.
global _pdal_kill_pattern global _pdal_kill_pattern
_pdal_kill_pattern = f"pdal pipeline {args.output}/temp/" _pdal_kill_pattern = f"pdal pipeline {args.output}/temp/"
@ -314,14 +314,14 @@ def main():
log_gpu_status() log_gpu_status()
try: try:
# Mode --rebuild-index : régénère uniquement la carte globale, sans retraiter # Mode --rebuild-index : régénère uniquement le catalogue, sans retraiter
if args.rebuild_index: if args.rebuild_index:
from .index import build_index from .index import build_index
index_path = build_index(args.output, args.format) index_path = build_index(args.output, args.format)
if index_path: if index_path:
logger.info(f"Carte globale générée : {index_path}") logger.info(f"Catalogue généré : {index_path}")
else: else:
logger.warning("Aucune tuile traitée trouvée — carte globale non générée") logger.warning("Aucune tuile traitée trouvée — catalogue non généré")
return return
# Nouveau run : remise à zéro du journal d'événements (file de # Nouveau run : remise à zéro du journal d'événements (file de

View File

@ -1,474 +0,0 @@
"""Export multi-dalles : mosaïque d'images adjacentes pour consultation terrain.
Assemble les visualisations de plusieurs dalles LiDAR contiguës (1 km chacune,
Lambert 93) en une seule image (PNG/JPEG/WebP) ou un PDF à une page par
visualisation, avec habillage (bandeau titre, échelle, nord) et légende de la
couche exportée : titre, dégradé de la colormap avec bornes, lecture du rendu
couleurs ↔ terrain et méthode de calcul (textes de VIZ_LEGENDS, index.py).
Les dalles sont collées bord à bord, sans aucun trait ni espace entre elles :
les images sources (tif_to_crop) sont des carrés purs de 1 km qui se
joignent parfaitement.
Pensé pour un usage sur téléphone depuis la carte (webapp.py /api/export) :
Pillow seul, aucune dépendance au pipeline — fonctionne sur la webapp légère
(Raspberry Pi) depuis le cache local des tuiles.
"""
import logging
from datetime import datetime
from pathlib import Path
logger = logging.getLogger("lidar")
# Formats de sortie acceptés (clé API → format Pillow).
EXPORT_FORMATS = {"png": "PNG", "jpeg": "JPEG", "webp": "WEBP", "pdf": "PDF"}
# Extension de fichier par format.
EXPORT_EXTS = {"png": "png", "jpeg": "jpg", "webp": "webp", "pdf": "pdf"}
# Garde-fou mémoire : mosaïque RGB au-delà de ~1,6 Go (Raspberry Pi) refusée
# en pleine résolution — l'utilisateur choisit alors une taille réduite.
_MAX_CANVAS_BYTES = 1_600_000_000
# PDF multi-pages : toutes les pages vivent en mémoire au moment de
# l'enregistrement (save_all) — côté maximal ramené à cette valeur.
_PDF_PAGE_MAX_PX = 6000
def _load_font(size, bold=False):
"""Police TrueType si disponible, sinon police embarquée de Pillow."""
from PIL import ImageFont
names = ("DejaVuSans-Bold.ttf", "DejaVuSans.ttf") if bold else ("DejaVuSans.ttf",)
for name in names:
for directory in ("/usr/share/fonts/truetype/dejavu/",
"/usr/share/fonts/dejavu/",
"/usr/share/fonts/TTF/"):
path = directory + name
if Path(path).exists():
try:
return ImageFont.truetype(path, size)
except OSError:
pass
try:
return ImageFont.load_default(size=size) # Pillow >= 10.1
except TypeError:
return ImageFont.load_default()
def _hex_rgb(hx):
"""'#rrggbb' → (r, g, b)."""
hx = hx.lstrip('#')
return tuple(int(hx[i:i + 2], 16) for i in (0, 2, 4))
def _gradient_bar(gradient, width, height):
"""Barre de dégradé interpolée linéairement entre les arrêts de colormap."""
from PIL import Image
stops = [_hex_rgb(c) for c in gradient]
bar = Image.new("RGB", (width, height))
n = len(stops) - 1
for x in range(width):
t = x / max(width - 1, 1) * n
i = min(int(t), n - 1)
f = t - i
color = tuple(int(round(stops[i][k] + (stops[i + 1][k] - stops[i][k]) * f))
for k in range(3))
for y in range(height):
bar.putpixel((x, y), color)
return bar
def _wrap_text(measure, text, font, max_w):
"""Découpe un texte en lignes ≤ max_w px (les \\n explicites sont conservés)."""
lines = []
for para in text.split("\n"):
words = para.split()
if not words:
lines.append("")
continue
cur = words[0]
for word in words[1:]:
if measure.textlength(cur + " " + word, font=font) <= max_w:
cur += " " + word
else:
lines.append(cur)
cur = word
lines.append(cur)
return lines
def _find_tile_images(vis_dir, cells, viz_keys, resolution):
"""Chemins d'image par dalle : {(col, row): {viz_key: Path}}.
Raises:
ValueError: dalles absentes à la résolution demandée, ou visualisations
manquantes sur certaines dalles.
"""
from .index import scan_tiles
by_cell = {(t["col"], t["row"]): t for t in scan_tiles(vis_dir)
if t["resolution"] == resolution}
missing = [f"{c},{r}" for (c, r) in cells if (c, r) not in by_cell]
if missing:
raise ValueError(
f"dale(s) sans visualisation à {str(resolution).replace('.', ',')} m/px : "
f"{', '.join(missing)}")
images, missing_viz = {}, []
for cell in cells:
t = by_cell[cell]
per_viz = {}
for key in viz_keys:
info = t["viz"].get(key)
if not info:
missing_viz.append(f"{cell[0]},{cell[1]} ({key})")
continue
per_viz[key] = Path(t["dir_path"]) / info["filename"]
images[cell] = per_viz
if missing_viz:
raise ValueError("visualisation manquante sur : " + ", ".join(missing_viz))
return images
def _nice_scale_m(width_m):
"""Longueur d'échelle ronde (m) tenant dans ~25 % de la largeur."""
for cand in (25, 50, 100, 200, 250, 500, 1000, 2000, 5000, 10000):
if cand <= 0.25 * width_m:
return cand
return 10000
def _fmt_scale(v):
return f"{v / 1000:g} km" if v >= 1000 else f"{v} m"
def _build_mosaic_into(canvas, origin_y, images, viz_key, tile_px,
n_cols, n_rows, col_min, row_max):
"""Colle les dalles bord à bord dans un canevas existant (offset vertical).
Aucun trait, aucun espace entre dalles. Coller directement dans le canevas
final évite de garder deux mosaïques pleine résolution en mémoire.
"""
from PIL import Image
for (col, row), per_viz in images.items():
img = Image.open(str(per_viz[viz_key]))
if img.mode != "RGB":
img = img.convert("RGB")
if img.size != (tile_px, tile_px):
img = img.resize((tile_px, tile_px))
# Dalle (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
# Ligne 0 de l'image = nord → les rows décroissantes descendent.
x = (col - col_min) * tile_px
y = origin_y + (row_max - row) * tile_px
canvas.paste(img, (x, y))
img.close()
def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max):
"""Colle les dalles bord à bord dans un canevas neuf dédié à la mosaïque."""
from PIL import Image
canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white")
_build_mosaic_into(canvas, 0, images, viz_key, tile_px, n_cols, n_rows,
col_min, row_max)
return canvas
def _legend_layout(legend, mosaic, w):
"""Mise en page du bandeau légende (polices, lignes, hauteur), ou None.
Mesure le texte sur la mosaïque (sans la modifier) pour dimensionner le
bandeau avant la création du canevas final.
"""
from PIL import ImageDraw
measure = ImageDraw.Draw(mosaic)
# Police corps du bandeau : généreuse (lecture de terrain sur téléphone),
# bornée pour rester proportionnée aux petits exports réduits.
body = max(16, min(30, w // 100))
gap = max(8, int(body * 0.55))
line_h = int(body * 1.45)
x0 = max(18, w // 60)
max_w = max(40, w - 2 * x0)
text_font = _load_font(body)
lines = _wrap_text(measure, legend["legend"], text_font, max_w)
desc_lines = _wrap_text(measure, "Calcul : " + legend["description"],
text_font, max_w)
has_bar = bool(legend.get("gradient") and legend.get("ticks"))
bar_w = max(100, min(w // 3, 640))
bar_h = max(10, int(body * 0.9))
height = (gap + int(body * 1.75) + gap
+ (bar_h + line_h if has_bar else 0) + gap
+ len(lines) * line_h + gap + len(desc_lines) * line_h + gap)
return {"body": body, "gap": gap, "line_h": line_h, "x0": x0,
"title_font": _load_font(int(body * 1.35), bold=True),
"text_font": text_font, "lines": lines, "desc_lines": desc_lines,
"has_bar": has_bar, "bar_w": bar_w, "bar_h": bar_h, "height": height}
def _draw_legend(out, draw, legend, layout, y_base):
"""Dessine le bandeau légende sous le pied : titre, dégradé + bornes,
lecture du rendu (lignes de la légende) et méthode de calcul."""
y = y_base + layout["gap"]
draw.text((layout["x0"], y), legend["title"], fill=(20, 20, 20),
font=layout["title_font"], anchor="la")
y += int(layout["body"] * 1.75) + layout["gap"]
if layout["has_bar"]:
bar = _gradient_bar(legend["gradient"], layout["bar_w"], layout["bar_h"])
out.paste(bar, (layout["x0"], y))
# Cadre dessiné juste à l'extérieur de la barre : les pixels
# extrêmes du dégradé (bornes de l'échelle) restent visibles.
draw.rectangle((layout["x0"] - 1, y - 1,
layout["x0"] + layout["bar_w"], y + layout["bar_h"]),
outline=(90, 90, 90), width=1)
tick_y = y + layout["bar_h"] + 2
draw.text((layout["x0"], tick_y), legend["ticks"][0], fill=(20, 20, 20),
font=layout["text_font"], anchor="la")
draw.text((layout["x0"] + layout["bar_w"], tick_y), legend["ticks"][1],
fill=(20, 20, 20), font=layout["text_font"], anchor="ra")
y = tick_y + layout["line_h"]
y += layout["gap"]
for line in layout["lines"]:
draw.text((layout["x0"], y), line, fill=(20, 20, 20),
font=layout["text_font"], anchor="la")
y += layout["line_h"]
y += layout["gap"]
for line in layout["desc_lines"]:
draw.text((layout["x0"], y), line, fill=(90, 90, 90),
font=layout["text_font"], anchor="la")
y += layout["line_h"]
def _chrome_layout(w, h, legend):
"""Dimensions des bandeaux (titre, pied, légende) autour d'une mosaïque w×h."""
from PIL import Image
top_h = max(90, h // 16)
foot_h = max(84, h // 20)
# Image factice pour la mesure : seule la police compte, pas le contenu
layout = _legend_layout(legend, Image.new("RGB", (1, 1)), w) if legend else None
legend_h = layout["height"] if layout else 0
return top_h, foot_h, layout, legend_h
def _draw_chrome(out, mosaic_w, mosaic_h, top_h, foot_h, m_per_px, title,
subtitle, layout, legend):
"""Dessine sur `out` les bandeaux autour de la zone mosaïque (offset top_h)."""
from PIL import ImageDraw
w, h = mosaic_w, mosaic_h
d = ImageDraw.Draw(out)
title_font = _load_font(max(22, int(top_h * 0.34)))
sub_font = _load_font(max(14, int(top_h * 0.20)))
foot_font = _load_font(max(14, int(foot_h * 0.26)))
for text, font, cy in ((title, title_font, top_h * 0.40),
(subtitle, sub_font, top_h * 0.76)):
tw = d.textlength(text, font=font)
d.text(((w - tw) / 2, cy), text, fill=(20, 20, 20), font=font,
anchor="lm")
# Pied : échelle à gauche, nord à droite.
width_m = w * m_per_px
scale_m = _nice_scale_m(width_m)
bar_px = max(40, int(scale_m / m_per_px))
bar_y = h + top_h + foot_h * 0.42
x0, y0 = max(18, w // 60), bar_y - foot_h * 0.16
seg = bar_px / 4
for i in range(4):
cx = x0 + i * seg
d.rectangle((cx, y0, cx + seg, y0 + foot_h * 0.20),
fill=(15, 15, 15) if i % 2 == 0 else "white",
outline=(15, 15, 15), width=2)
d.text((x0 + bar_px / 2, y0 - foot_h * 0.22), _fmt_scale(scale_m),
fill=(20, 20, 20), font=foot_font, anchor="ms")
nx = w - max(30, w // 24)
ny = h + top_h + foot_h * 0.55
s = foot_h * 0.30
d.polygon([(nx, ny - s * 1.5), (nx - s, ny + s), (nx, ny + s * 0.4),
(nx + s, ny + s)], fill=(15, 15, 15))
d.text((nx, ny - s * 2.2), "N", fill=(20, 20, 20), font=foot_font,
anchor="ms")
if layout:
y_base = h + top_h + foot_h
d.line((0, y_base, w, y_base), fill=(210, 210, 210), width=1)
_draw_legend(out, d, legend, layout, y_base)
def _decorate(mosaic, m_per_px, title, subtitle, legend=None):
"""Ajoute un bandeau de titre au-dessus et un pied (échelle + nord) dessous.
Avec `legend` (entrée de VIZ_LEGENDS), un bandeau explicatif de la couche
est ajouté sous le pied : titre, dégradé de la colormap avec bornes,
lecture du rendu et méthode de calcul.
L'habillage ne touche jamais la mosaïque : les dalles restent jointives.
"""
from PIL import Image
w, h = mosaic.size
top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend)
out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white")
out.paste(mosaic, (0, top_h))
_draw_chrome(out, w, h, top_h, foot_h, m_per_px, title, subtitle,
layout, legend)
return out
def export_filename(cells, resolution, viz_keys, fmt):
"""Nom de fichier déterministe + horodatage (évite les collisions)."""
cols = [c for c, _ in cells]
rows = [r for _, r in cells]
res = str(resolution).replace(".", "p")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
viz = "-".join(viz_keys)[:60]
return (f"export_{min(cols)}-{max(cols)}_{min(rows)}-{max(rows)}"
f"_r{res}_{viz}_{stamp}.{EXPORT_EXTS[fmt]}")
def build_export(vis_dir, cells, viz_keys, resolution, fmt, out_dir,
max_side=0):
"""Assemble des dalles adjacentes en une image ou un PDF.
Args:
vis_dir: dossier output/visualisations (dalles scannées via index).
cells: liste (col, row) des dalles à assembler (km L93).
viz_keys: mots-clés de visualisation (ex: slope, hillshade_multi).
Plusieurs clés → un export par couche : PDF multi-pages (une page
par visualisation) ou une image par visualisation.
resolution: résolution des dalles en m/px (0.5 ou 0.2).
fmt: png, jpeg, webp ou pdf.
out_dir: dossier de sortie (output/exports, créé si besoin).
max_side: réduire le plus grand côté de la mosaïque à N pixels
(0 = pleine résolution).
Returns:
{file: Path du premier fichier, width, height, pages,
files: [{file, width, height, pages}, …] — 1 par couche en image,
1 entrée pour le PDF multi-pages}
Raises:
ValueError: dalles ou visualisations manquantes, format invalide,
zone trop grande en mémoire.
"""
from PIL import Image
resample = getattr(Image, 'LANCZOS', 1)
if fmt not in EXPORT_FORMATS:
raise ValueError(f"format invalide : {fmt!r} "
f"(attendus : {', '.join(EXPORT_FORMATS)})")
viz_keys = [str(k) for k in viz_keys if k]
if not viz_keys:
raise ValueError("aucune visualisation demandée")
images = _find_tile_images(vis_dir, cells, viz_keys, resolution)
cols = [c for c, _ in cells]
rows = [r for _, r in cells]
col_min, col_max = min(cols), max(cols)
row_min, row_max = min(rows), max(rows)
n_cols = col_max - col_min + 1
n_rows = row_max - row_min + 1
# Côté réel d'une dalle (2000 px à 0,5 m, 5000 à 0,2 m) : lu sur la
# première image plutôt que recalculé, pour coller aux fichiers réels.
first_path = next(iter(images.values()))[viz_keys[0]]
with Image.open(str(first_path)) as _im:
tile_px = max(_im.size)
tile_px = max(1, tile_px)
canvas_bytes = n_cols * tile_px * n_rows * tile_px * 3
if canvas_bytes > _MAX_CANVAS_BYTES:
raise ValueError(
f"zone trop grande en pleine résolution "
f"({n_cols}×{n_rows} dalles à {resolution} m/px ≈ "
f"{canvas_bytes / 1e9:.1f} Go) — choisissez une taille réduite")
from .index import VIZ_LABELS, VIZ_LEGENDS
res_txt = str(resolution).replace(".", ",")
date_txt = datetime.now().strftime("%d/%m/%Y")
subtitle = (f"Lambert 93 · X {col_min}–{col_max + 1} km · "
f"Y {row_min - 1}–{row_max} km · {res_txt} m/px · "
f"{len(cells)} dalle{'s' if len(cells) > 1 else ''} · {date_txt}")
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Export : {n_cols}×{n_rows} dalles à {res_txt} m/px, "
f"{' × '.join(viz_keys)} ({fmt})")
# PDF multi-pages : toutes les pages sont retenues pour save_all —
# côté plafonné pour rester en mémoire raisonnable.
page_cap = _PDF_PAGE_MAX_PX if fmt == "pdf" and len(viz_keys) > 1 else None
# Options d'enregistrement hétérogènes (str/int/float/bool)
save_kw: dict = {"format": EXPORT_FORMATS[fmt]}
if fmt == "jpeg":
save_kw["quality"] = 90
elif fmt == "webp":
save_kw["quality"] = 90
elif fmt == "pdf":
save_kw["resolution"] = 150.0
# Une couche = un export : page du PDF multi-pages, ou fichier image
# dédié (chacun porte la légende de SA couche).
pages = [] # pages PDF décorées (save_all)
files = [] # fichiers écrits (1 par couche en image, le PDF sinon)
try:
for key in viz_keys:
m_per_px = resolution
caps = [c for c in (int(max_side), page_cap) if c]
cap = min(caps) if caps else 0
w = n_cols * tile_px
h = n_rows * tile_px
if cap and max(w, h) > cap:
# Mosaïque réduite (téléphone / pages PDF) : taille bornée par
# le cap, le chemin mosaïque → resize → habillage reste simple
ratio = cap / max(w, h)
size = (max(1, round(w * ratio)), max(1, round(h * ratio)))
mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows,
col_min, row_max)
mosaic = mosaic.resize(size, resample)
m_per_px = resolution / ratio
decorated = _decorate(mosaic, m_per_px,
VIZ_LABELS.get(key, key), subtitle,
legend=VIZ_LEGENDS.get(key))
mosaic.close()
else:
# Pleine résolution : collage DIRECT dans le canevas habillé —
# jamais deux mosaïques entières en mémoire (la garde
# _MAX_CANVAS_BYTES correspond alors à la taille réelle)
legend = VIZ_LEGENDS.get(key)
top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend)
decorated = Image.new(
"RGB", (w, h + top_h + foot_h + legend_h), "white")
_build_mosaic_into(decorated, top_h, images, key, tile_px,
n_cols, n_rows, col_min, row_max)
_draw_chrome(decorated, w, h, top_h, foot_h, m_per_px,
VIZ_LABELS.get(key, key), subtitle, layout, legend)
if fmt != "pdf":
key_file = out_dir / export_filename(cells, resolution, [key], fmt)
decorated.save(str(key_file), **save_kw)
decorated.close()
files.append(key_file)
else:
pages.append(decorated)
if fmt == "pdf":
out_file = out_dir / export_filename(cells, resolution, viz_keys, fmt)
if len(pages) > 1:
save_kw["save_all"] = True
save_kw["append_images"] = pages[1:]
pages[0].save(str(out_file), **save_kw)
files.append(out_file)
finally:
for p in pages:
p.close()
result_files = []
for f in files:
entry = {"file": f, "width": 0, "height": 0, "pages": 1}
if fmt == "pdf":
# Pages de même dimension que la première mosaïque décorée.
first = pages[0] if pages else None
entry["width"], entry["height"] = first.size if first else (0, 0)
entry["pages"] = len(pages)
else:
with Image.open(str(f)) as img:
entry["width"], entry["height"] = img.size
result_files.append(entry)
head = result_files[0] if result_files else {
"file": None, "width": 0, "height": 0, "pages": 0}
result = {"file": head["file"], "width": head["width"],
"height": head["height"], "pages": head["pages"],
"files": result_files}
total = sum(f.stat().st_size for f in files)
logger.info(f"✓ Export terminé : {len(files)} fichier"
f"{'s' if len(files) > 1 else ''} ({total / 1e6:.1f} Mo)")
return result

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
"""Serveur de tuiles XYZ + carte « slippy » (image lidar-maps). """Serveur de tuiles XYZ + carte « slippy » + génération (image lidar-maps).
Sert la pyramide de tuiles décrite dans `tiles.py` selon le schéma Sert la pyramide de tuiles décrite dans `tiles.py` selon le schéma
OpenStreetMap/Google Maps, donc directement utilisable comme fond d'imagerie OpenStreetMap/Google Maps, donc directement utilisable comme fond d'imagerie
@ -19,6 +19,27 @@ Les tuiles sont rendues à la demande puis mises en cache sur disque
serveur amont : une tuile absente localement y est rapatriée (quelques dizaines serveur amont : une tuile absente localement y est rapatriée (quelques dizaines
de Ko) au lieu de rapatrier les dalles entières. de Ko) au lieu de rapatrier les dalles entières.
Génération de tuiles (reprise de la webapp historique) :
GET /api/status → état du run en cours (ou du dernier), file, tuiles
GET /api/tiles → inventaire des dalles (index_tiles.json) — sert de
serveur de dalles amont (LIDAR_SOURCE_URL)
POST /api/preview → cellules 1 km d'une bbox WGS84 à traiter
POST /api/generate → télécharge (géoplateforme IGN) puis traite
POST /api/stop → arrête le run en cours
POST /api/queue/clear → vide la file d'attente
GET /api/cell → dalle L93 sous un point (sélection à la main)
Sur l'image complète (worker, machine de traitement), la génération est
locale : `python -m lidar_pipeline` en sous-processus, un job à la fois, les
demandes concurrentes en file persistante. Sur l'image légère (Raspberry Pi),
`LIDAR_GENERATION_URL` délègue tout à la machine de traitement — les dalles
produites sont rapatriées à la demande via `LIDAR_SOURCE_URL` (inventaire
/api/tiles + montages statiques visualisations/, index_thumbs/,
index_subtiles/). `LIDAR_API_TOKEN` (worker) / `LIDAR_REMOTE_TOKEN` (léger)
protègent optionnellement les appels ; `LIDAR_REGEN_CIDR` réserve la
génération au réseau local (boutons masqués ailleurs).
Sur une petite machine (Raspberry Pi), deux réglages inversent la charge : Sur une petite machine (Raspberry Pi), deux réglages inversent la charge :
- `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile - `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile
@ -35,13 +56,17 @@ Sur une petite machine (Raspberry Pi), deux réglages inversent la charge :
import json import json
import logging import logging
import math
import os import os
import re import re
import signal
import subprocess
import sys import sys
import threading import threading
import time import time
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid
import xml.sax.saxutils as _xml import xml.sax.saxutils as _xml
from collections import deque from collections import deque
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -49,8 +74,9 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles as _StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
@ -58,6 +84,7 @@ from . import tiles as tiles_mod
from .mapui import ASSETS_DIRNAME, render_html, ui_version, write_map_assets from .mapui import ASSETS_DIRNAME, render_html, ui_version, write_map_assets
OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output")) OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output"))
INPUT_DIR = Path(os.environ.get("LIDAR_INPUT_DIR", "/data/input"))
PORT = int(os.environ.get("LIDAR_PORT", "8975")) PORT = int(os.environ.get("LIDAR_PORT", "8975"))
# Serveur de tuiles amont (machine de traitement) — mode deux machines. # Serveur de tuiles amont (machine de traitement) — mode deux machines.
MAPS_URL = (os.environ.get("LIDAR_MAPS_URL") or "").rstrip("/") MAPS_URL = (os.environ.get("LIDAR_MAPS_URL") or "").rstrip("/")
@ -75,6 +102,33 @@ ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("&copy;", "©").r
TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2)) TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2))
_render_sem = threading.Semaphore(TILE_WORKERS) _render_sem = threading.Semaphore(TILE_WORKERS)
# Machine de traitement distante (webapp légère type Raspberry Pi) : si
# définie, la génération (téléchargement IGN + traitement) y est déléguée.
GENERATION_URL = (os.environ.get("LIDAR_GENERATION_URL") or "").rstrip("/")
# Jeton partagé : la webapp légère le présente à la machine de traitement
# (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel.
REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None
API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
# Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) :
# liste de CIDR séparés par virgules, vide = restriction levée. Par défaut :
# boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte
# Docker (passerelles 172.x) et localhost, tout en refusant les clients
# venus d'Internet. L'IP du client est l'IP de connexion (conservée par le
# DNAT Docker) ; si elle est dans le réseau autorisé, X-Forwarded-For est
# honoré (reverse proxy local — un client distant ne peut pas le forger
# puisque sa connexion directe est déjà hors réseau).
REGEN_CIDR = (os.environ.get(
"LIDAR_REGEN_CIDR",
"127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") or "").strip()
# Résolution de génération (politique 0,2 m seule — cf. webapp historique).
GENERATE_RESOLUTIONS = (0.2,)
GROUND_CLASS_METHODS = ("auto", "ign", "smrf", "csf")
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées)
MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes)
LOG_FILE = OUTPUT_DIR / ".generation.log"
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
QUEUE_FILE = OUTPUT_DIR / ".generation.queue.json"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Maintenance de la pyramide en tâche de fond (petites machines) # Maintenance de la pyramide en tâche de fond (petites machines)
@ -294,11 +348,59 @@ if not (_assets_dir / "app.js").is_file():
_assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets" _assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets"
write_map_assets(_assets_dir) write_map_assets(_assets_dir)
_pipeline_import_cache = {}
def _pipeline_importable():
"""Vrai si le pipeline de traitement est importable (image complète)."""
if "ok" not in _pipeline_import_cache:
try:
import lidar_pipeline.pipeline # noqa: F401
_pipeline_import_cache["ok"] = True
except Exception:
_pipeline_import_cache["ok"] = False
return _pipeline_import_cache["ok"]
logger.info(f"Carte LiDAR (tuiles XYZ) — sortie {OUTPUT_DIR}" logger.info(f"Carte LiDAR (tuiles XYZ) — sortie {OUTPUT_DIR}"
+ (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local") + (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local")
+ f", {TILE_WORKERS} rendu(s) simultané(s)" + f", {TILE_WORKERS} rendu(s) simultané(s)"
+ f", {tiles_mod.FETCH_WORKERS} téléchargement(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") + f", cache sources {tiles_mod.SOURCE_CACHE_BYTES // (1024 * 1024)} Mo")
if GENERATION_URL:
logger.info(f"Backend de génération des tuiles : {GENERATION_URL} "
f"(LIDAR_GENERATION_URL)")
elif _pipeline_importable():
logger.info("Backend de génération des tuiles : local (pipeline complet)")
else:
logger.info("Backend de génération des tuiles : aucun (image légère sans "
"LIDAR_GENERATION_URL) — boutons de génération masqués")
# --- Dalles servies en statique (inventaire /api/tiles + rapatriement) -------
# Une machine légère (LIDAR_SOURCE_URL pointant ici) liste ces dalles via
# /api/tiles puis les télécharge depuis ces montages : vignettes, quadrants et
# dalles complètes. Cache navigateur immutable quand l'URL porte ?v= (mtime
# posée par build_index), revalidation courte sinon.
class _VersionedStaticFiles(_StaticFiles):
async def get_response(self, path, scope):
resp = await super().get_response(path, scope)
qs = urllib.parse.parse_qs(
scope.get("query_string", b"").decode("ascii", "replace"))
versioned = qs.get("v", [""])[0].isdigit()
resp.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if versioned
else "public, max-age=300, must-revalidate")
return resp
for _name in ("visualisations", "index_thumbs", "index_subtiles",
"index_overview"):
_dir = OUTPUT_DIR / _name
_dir.mkdir(parents=True, exist_ok=True)
app.mount(f"/{_name}", _VersionedStaticFiles(directory=str(_dir)),
name=_name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -341,13 +443,15 @@ def _layer_or_404(layer):
def _layer_infos(): def _layer_infos():
"""Couches disponibles + libellés/descriptions du registre partagé.""" """Couches disponibles + libellés/descriptions du registre partagé."""
from .index import VIZ_LABELS, VIZ_LEGENDS from .index import KEYWORD_TO_STEP, VIZ_LABELS, VIZ_LEGENDS
out = [] out = []
for key in tiles_mod.available_layers(OUTPUT_DIR): for key in tiles_mod.available_layers(OUTPUT_DIR):
legend = VIZ_LEGENDS.get(key, {}) legend = VIZ_LEGENDS.get(key, {})
out.append({"key": key, out.append({"key": key,
"label": VIZ_LABELS.get(key, key), "label": VIZ_LABELS.get(key, key),
"description": legend.get("description", "")}) "description": legend.get("description", ""),
# Nom d'étape --only du pipeline (sélecteur de génération)
"step": KEYWORD_TO_STEP.get(key, key)})
return out return out
@ -834,6 +938,749 @@ def healthz():
return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))} return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))}
# ---------------------------------------------------------------------------
# Génération de tuiles (portée de la webapp historique)
# ---------------------------------------------------------------------------
# Deux modes, mutuellement exclusifs :
# - LIDAR_GENERATION_URL défini (webapp légère) : /api/preview, /api/generate,
# /api/status, /api/stop et /api/queue/* sont transmis à la machine de
# traitement (elle-même servie par ce module, image complète) ;
# - sinon, si le pipeline est importable (image complète) : génération locale
# — `python -m lidar_pipeline` en sous-processus, un job à la fois, les
# demandes concurrentes part en file (persistée, survit aux redémarrages).
# Sur l'image légère sans LIDAR_GENERATION_URL, /api/status répond
# available=false : l'interface masque les boutons de génération.
def _generation_available():
return bool(GENERATION_URL) or _pipeline_importable()
def _require_token(x_lidar_token: str = Header(None)):
"""Protège les routes mutantes si LIDAR_API_TOKEN est défini (sinon no-op)."""
if API_TOKEN:
import hmac
presented = x_lidar_token or ""
if not hmac.compare_digest(presented, API_TOKEN):
raise HTTPException(401, "token d'API manquant ou invalide")
def _ip_in_regen_cidr(ip):
"""Vrai si l'IP appartient à un des CIDR autorisés (LIDAR_REGEN_CIDR)."""
if not REGEN_CIDR:
return True # restriction désactivée
if not ip:
return False
import ipaddress
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
for part in REGEN_CIDR.split(","):
part = part.strip()
if not part:
continue
try:
if addr in ipaddress.ip_network(part, strict=False):
return True
except ValueError:
continue
return False
def _client_ip(request):
"""IP d'origine du client pour la restriction de génération.
Le pair direct doit être dans le réseau autorisé ; dans ce cas seulement,
X-Forwarded-For (reverse proxy local) désigne le client réel derrière lui.
Un client externe connecté en direct est refusé sur son IP de connexion,
quel que soit le X-Forwarded-For qu'il annonce.
"""
if request is None:
return None
peer = getattr(request, "client", None)
peer_ip = getattr(peer, "host", None) if peer else None
if not _ip_in_regen_cidr(peer_ip):
return peer_ip # hors réseau (ou inconnu) : jugé sur cette IP
xff = request.headers.get("x-forwarded-for") if hasattr(request, "headers") else None
if xff:
first = xff.split(",")[0].strip()
if first:
return first
return peer_ip
def _require_lan_for_generation(request: Request):
"""Réserve /api/generate aux clients du réseau local (LIDAR_REGEN_CIDR)."""
ip = _client_ip(request)
if not _ip_in_regen_cidr(ip):
raise HTTPException(
403, f"génération de tuiles réservée au réseau local"
f"{f' ({REGEN_CIDR})' if REGEN_CIDR else ''} — "
f"client : {ip or 'IP inconnue'}")
def _proxy_generation(method, path, payload=None, timeout=30):
"""Transmet un appel de génération à la machine de traitement.
Retourne la réponse JSON distante. Les erreurs HTTP distantes (ex: 409 une
génération est déjà en cours) sont relayées telles quelles ; une machine
injoignable devient un 503 explicite pour l'interface.
"""
import urllib.error
import urllib.request
req = urllib.request.Request(
GENERATION_URL + path,
data=json.dumps(payload).encode("utf-8") if payload is not None else None,
method=method)
req.add_header("Content-Type", "application/json")
if REMOTE_TOKEN:
req.add_header("X-Lidar-Token", REMOTE_TOKEN)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode("utf-8")).get("detail", str(e))
except (ValueError, OSError):
detail = str(e)
raise HTTPException(e.code, detail)
except (urllib.error.URLError, OSError) as e:
raise HTTPException(503, f"machine de traitement injoignable "
f"({GENERATION_URL}) : {e}")
def _viz_step_names():
"""Noms des visualisations acceptées (étapes VIZ_STEPS du pipeline).
Sur l'image légère (sans dépendances de traitement), repli sur le
registre de l'index — le même ordre que VIZ_STEPS.
"""
try:
from .pipeline import VIZ_STEPS
return [name for name, _ in VIZ_STEPS]
except ImportError:
from .index import KEYWORD_TO_STEP
return list(KEYWORD_TO_STEP.values())
def _panel_viz_steps():
"""Couches réellement affichées par la carte, en noms d'étapes --only."""
from .index import PANEL_VIZ, KEYWORD_TO_STEP
keys = [k for k in PANEL_VIZ if k in KEYWORD_TO_STEP]
return [KEYWORD_TO_STEP[k] for k in keys] or _viz_step_names()
def _viz_step_labels():
"""Étapes → libellés (journal de progression lisible)."""
from .index import VIZ_LABELS, step_to_keyword
return {step: VIZ_LABELS.get(step_to_keyword(step), step)
for step in _viz_step_names()}
class PreviewRequest(BaseModel):
bbox: Optional[list] = Field(None, description="[ouest, sud, est, nord] en WGS84 "
"(requis sauf all_missing=true)")
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
viz: Optional[list] = Field(None,
description="Visualisations demandées, noms d'étapes du "
"pipeline (ex: aspect, wavelet) ; défaut : les "
"couches affichées dans le panneau. "
"Une tuile existante est considérée faite "
"seulement si elle les possède toutes")
all_missing: bool = Field(False,
description="Ignorer la bbox : toutes les dalles LHD "
"présentes dans input/ qui manquent au moins "
"une des visualisations demandées (sans "
"téléchargement)")
class GenerateRequest(BaseModel):
tiles: list = Field(..., description="liste [col, row] (entiers km L93)")
regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées "
"(visualisations refaites, classification conservée)")
reclassify: bool = Field(False,
description="Relancer la classification du sol même si "
"la méthode demandée est déjà en cache "
"(--force-classification). Défaut : conserver "
"la classification existante ; choisir une "
"autre méthode reclassifie de toute façon "
"les tuiles concernées")
ground_class: str = Field("ign",
description="Méthode de classification du sol : "
"auto, ign, smrf, csf")
ign_classes: str = Field("sol",
description="Classes LAS pour le MNT IGN : liste noms ou "
"codes séparés par virgules — sol(2), "
"unclassified(1), eau(9), virtuel(66), "
"pont(17), sursol(64). Mode pur, "
"aucune retouche. (défaut: sol)")
edge_buffer: bool = Field(False,
description="Raccord des bords : étendre le MNT d'une "
"bande de 100 m remplie avec les points sol "
"des 8 tuiles voisines — rendus continus "
"d'une tuile à l'autre (recadrés sur la "
"dalle 1 km exacte)")
viz: list = Field(None,
description="Visualisations à générer, noms d'étapes du "
"pipeline (ex: aspect, wavelet, slope) ; "
"défaut : les couches affichées dans le panneau")
all_missing: bool = Field(False,
description="Ignorer la liste de tuiles : traiter toutes "
"les dalles LHD présentes dans input/ qui "
"manquent au moins une des visualisations "
"demandées (sans téléchargement)")
def bbox_to_cells(w, s, e, n):
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.
Une cellule (col, row) couvre X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
Conversion via pyproj (présent dans toutes les images), repli sur
l'approximation affine de l'index.
"""
from .index import _approx_wgs84_to_l93
try:
from pyproj import Transformer
transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True)
xs, ys = transformer.transform([w, e, w, e], [s, s, n, n])
except ImportError:
pts = [_approx_wgs84_to_l93(lon, lat)
for lon, lat in ((w, s), (e, s), (w, n), (e, n))]
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
min_x, max_x = min(xs) + 0.5, max(xs) - 0.5 # rétrécit d'1 m : bords exclus
min_y, max_y = min(ys) + 0.5, max(ys) - 0.5
if max_x <= min_x or max_y <= min_y:
return []
cols = range(int(math.floor(min_x / 1000)), int(math.floor(max_x / 1000)) + 1)
rows = range(int(math.floor(min_y / 1000)) + 1, int(math.floor(max_y / 1000)) + 2)
return [(c, r) for r in rows for c in cols]
def point_to_cell(lon, lat):
"""Cellule L93 de 1 km (col, row) contenant un point WGS84."""
from .index import _approx_wgs84_to_l93
try:
from pyproj import Transformer
transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True)
x, y = transformer.transform(lon, lat)
except ImportError:
x, y = _approx_wgs84_to_l93(lon, lat)
return int(math.floor(x / 1000)), int(math.floor(y / 1000)) + 1
def processed_cells(output_dir):
"""Ensemble des cellules (col, row) ayant déjà des visualisations."""
from .index import scan_tiles
tiles = scan_tiles(Path(output_dir) / "visualisations")
return {(t["col"], t["row"]) for t in tiles}
def laz_cells(input_dir):
"""Coordonnées (col, row) des dalles LHD présentes dans input/."""
from .index import parse_basename_coords
cells = set()
for f in list(Path(input_dir).glob("*.laz")) + list(Path(input_dir).glob("*.las")):
name = f.name
for ext in ('.copc.laz', '.copc.las', '.laz', '.las'):
if name.lower().endswith(ext):
name = name[:-len(ext)]
break
coords = parse_basename_coords(name)
if coords:
cells.add(coords)
return sorted(cells)
def complete_cells(output_dir, viz_steps):
"""Cellules ayant TOUTES les visualisations demandées à 0,2 m."""
from .index import cells_with_all_viz, step_to_keyword
keys = [step_to_keyword(v) for v in viz_steps]
return cells_with_all_viz(Path(output_dir) / "visualisations", keys,
GENERATE_RESOLUTIONS)
def missing_cells_with_corners(cells, output_dir, include_done=False, viz=None):
"""Filtre les cellules à traiter et calcule leurs coins WGS84.
Retourne [{col, row, corners: [[lat, lon] × 4 SW,SE,NE,NW}].
"""
from .index import attach_gps_bounds
if viz:
done = complete_cells(output_dir, viz)
else:
done = processed_cells(output_dir)
todo = [{"col": c, "row": r} for (c, r) in cells
if include_done or (c, r) not in done]
if todo:
attach_gps_bounds(todo)
return todo
def _attach_frame_bounds(tiles):
"""Ajoute aux tuiles non terminées leurs coins WGS84 (cadre sur la carte).
L'interface dessine un cadre coloré autour des dalles du run en cours
(orange : rendu en cours, bleu : en attente, rouge : échec) ; les coins
viennent de la grille L93 (col, row) → bornes exactes via PROJ. Les
dalles terminées n'ont pas de cadre : coins omis pour alléger le sondage
/api/status.
"""
from .index import attach_gps_bounds, parse_basename_coords
todo = []
for tile in tiles:
if tile.get("state") == "done":
continue
coords = parse_basename_coords(tile.get("name") or "")
if coords:
todo.append({"col": coords[0], "row": coords[1]})
attach_gps_bounds(todo)
by_cell = {(t["col"], t["row"]): t for t in todo}
for tile in tiles:
if tile.get("state") == "done":
continue
coords = parse_basename_coords(tile.get("name") or "")
entry = by_cell.get(coords) if coords else None
if entry:
tile["corners"] = entry.get("corners") or []
def _tail_log(n_lines):
"""Dernières lignes du journal de génération (.generation.log)."""
try:
text = LOG_FILE.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
lines = [l for l in text.splitlines() if l.strip()]
return lines[-n_lines:]
# --- État du job + file d'attente (persistés sur disque) ---------------------
_job: dict = {"proc": None, "started": None, "returncode": None, "cmd": None,
"finished": None, "qid": None, "run_id": None}
# RLock (réentrant) : /api/status construit sa réponse SOUS verrou et appelle
# _queue_summary() qui le reprend — un Lock simple s'y bloquerait définitivement.
_job_lock = threading.RLock()
_queue = []
_queue_seq = 0
def _save_job_state():
"""Écrit l'état du job sur disque — survit au redémarrage du serveur."""
state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished",
"qid", "run_id")}
try:
JOB_FILE.write_text(json.dumps(state), encoding="utf-8")
except OSError:
pass
def _load_job_state():
"""Recharge l'état du dernier job au démarrage du serveur."""
try:
state = json.loads(JOB_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
with _job_lock:
_job["started"] = state.get("started")
_job["returncode"] = state.get("returncode")
_job["cmd"] = state.get("cmd")
_job["finished"] = state.get("finished")
_job["qid"] = state.get("qid")
_job["run_id"] = state.get("run_id")
_load_job_state()
def _save_queue():
"""Écrit la file d'attente sur disque (atomique, best-effort)."""
try:
tmp = QUEUE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_queue), encoding="utf-8")
os.replace(tmp, QUEUE_FILE)
except OSError:
pass
def _load_queue():
"""Recharge la file d'attente au démarrage (et son compteur d'ids)."""
global _queue_seq
try:
data = json.loads(QUEUE_FILE.read_text(encoding="utf-8"))
if isinstance(data, list):
_queue[:] = [it for it in data
if isinstance(it, dict) and isinstance(it.get("req"), dict)]
ids = [int(it.get("id") or 0) for it in _queue]
_queue_seq = max(ids) if ids else 0
except (OSError, ValueError):
pass
_load_queue()
def _queue_summary():
"""Aperçu de la file pour /api/status (requêtes + horodatages)."""
with _job_lock:
return [{"id": it.get("id"), "tuiles": len(it["req"].get("tiles") or []),
"en_file_depuis": it.get("queued_at")}
for it in _queue]
EDGE_BUFFER_METERS = 100.0 # raccord des bords : largeur de bande (m)
def _build_command(tiles, regenerate=False, ground_class="ign", ign_classes="sol",
viz=None, reclassify=False, edge_buffer=False):
"""Commande de génération : téléchargement IGN + traitement des fichiers.
Avec regenerate=True, les visualisations des tuiles déjà présentes sont
refaites (--force) mais leur classification est conservée. Avec
reclassify=True, --force-classification relance la classification même à
méthode inchangée. Avec edge_buffer=True, le MNT est étendu d'une bande de
EDGE_BUFFER_METERS remplie avec les points sol des tuiles voisines.
"""
from .fetch_ign import tile_filename
# -u : sortie non bufferisée — le journal .generation.log doit être
# lu en temps réel par /api/status (progression affichée dans l'UI).
cmd = [sys.executable, "-u", "-m", "lidar_pipeline", str(INPUT_DIR),
"-o", str(OUTPUT_DIR),
"-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
"--only", *(viz or _panel_viz_steps()),
"--ground-classification", ground_class,
"--ign-classes", ign_classes]
if edge_buffer:
cmd += ["--edge-buffer", f"{EDGE_BUFFER_METERS:g}"]
if regenerate:
cmd += ["--force"]
if reclassify:
cmd += ["--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1":
cmd += ["-g", "all"]
workers = os.environ.get("LIDAR_WORKERS", "auto").strip()
if workers.lower() not in ("auto", ""):
cmd += ["-w", workers]
# Inventaire régénéré après chaque tuile terminée : la carte le sonde
# (/api/tiles) et les nouvelles dalles apparaissent en direct.
cmd += ["--incremental-index"]
cmd += ["--fetch-tiles"]
cmd += [f"{c},{r}" for (c, r) in tiles]
cmd += ["--file"]
cmd += [tile_filename(c, r) for (c, r) in tiles]
return cmd
def _resolve_request(req):
"""Valide une demande de génération et résout sa liste de tuiles.
Retourne (tuiles [(col, row)], viz [noms d'étapes]). Réexécutée au
démarrage de chaque demande sortie de file : les dalles présentes dans
input/ peuvent avoir changé entre la mise en file et le départ du run.
"""
names = _viz_step_names()
viz = [v for v in (req.viz or []) if v] or _panel_viz_steps()
invalid = [v for v in viz if v not in names]
if invalid:
raise HTTPException(
400, f"visualisation invalide : {', '.join(invalid)} "
f"(attendues : {', '.join(names)})")
if req.all_missing:
done = set() if req.regenerate else complete_cells(OUTPUT_DIR, viz)
tiles = [c for c in laz_cells(INPUT_DIR)
if c not in done or req.regenerate]
if not tiles:
raise HTTPException(400, "aucune tuile à compléter dans input/")
if len(tiles) > MAX_CELLS_ALL:
raise HTTPException(
400, f"trop de tuiles à compléter ({len(tiles)}) — max "
f"{MAX_CELLS_ALL} par run ; relancez la passe globale à "
f"la fin de celui-ci")
else:
tiles = []
for pair in req.tiles:
if not (isinstance(pair, list) and len(pair) == 2):
raise HTTPException(400, f"tuile invalide : {pair!r} (attendu [col, row])")
tiles.append((int(pair[0]), int(pair[1])))
if not tiles:
raise HTTPException(400, "aucune tuile fournie")
if len(tiles) > MAX_CELLS:
raise HTTPException(400, f"trop de tuiles ({len(tiles)}) — max {MAX_CELLS}")
if req.ground_class not in GROUND_CLASS_METHODS:
raise HTTPException(
400, f"méthode de classification invalide : {req.ground_class!r} "
f"(attendu : {', '.join(GROUND_CLASS_METHODS)})")
# Génération du nord au sud (row décroissante, puis col croissante) : les
# workers prennent les tuiles dans l'ordre de soumission et la carte se
# remplit de haut en bas pendant le run.
tiles.sort(key=lambda cr: (-cr[1], cr[0]))
return tiles, viz
def _launch_job(tiles, viz, req, qid=None):
"""Démarre un run du pipeline. À appeler sous _job_lock, serveur libre."""
cmd = _build_command(tiles, regenerate=req.regenerate,
ground_class=req.ground_class,
ign_classes=req.ign_classes,
viz=viz, reclassify=req.reclassify,
edge_buffer=req.edge_buffer)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(LOG_FILE, "w", encoding="utf-8")
# Nouveau run : journal d'événements remis à zéro et identifiant de run
# transmis au pipeline (LIDAR_RUN_ID) : les événements tardifs d'un run
# annulé (workers survivants) sont filtrés à la lecture.
from .progress import reset_events
reset_events(OUTPUT_DIR)
run_id = uuid.uuid4().hex[:12]
_job.update({"proc": None, "started": time.time(), "returncode": None,
"cmd": cmd, "finished": None, "qid": qid, "run_id": run_id})
_save_job_state()
# start_new_session : le pipeline et ses workers/PDAL forment leur propre
# groupe de processus — /api/stop peut le tuer en bloc sans toucher le
# serveur, et le gestionnaire SIGTERM du pipeline (killpg) reste confiné.
p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
cwd="/app" if Path("/app").exists() else None,
env=dict(os.environ, LIDAR_RUN_ID=run_id),
start_new_session=True)
# proc posé AVANT le fil de veille : un run qui meurt instantanément
# déclencherait _watch → _start_next_queued avec _job["proc"] encore à
# None (serveur « libre ») et un second run partirait en parallèle.
_job["proc"] = p
def _watch():
rc = p.wait()
log_fh.close()
with _job_lock:
_job["returncode"] = rc
_job["finished"] = time.time()
_save_job_state()
# Le run est fini : la demande suivante de la file peut partir.
_start_next_queued()
threading.Thread(target=_watch, daemon=True).start()
return cmd
def _start_next_queued():
"""Démarre la première demande de la file dès que le serveur est libre."""
while True:
with _job_lock:
proc = _job["proc"]
if proc is not None and proc.poll() is None:
return False
if not _queue:
return False
item = _queue.pop(0)
_save_queue()
try:
req = GenerateRequest(**item["req"])
tiles, viz = _resolve_request(req)
except HTTPException as e:
logger.warning("Demande en file ignorée : %s", e.detail)
continue
with _job_lock:
proc = _job["proc"]
if proc is not None and proc.poll() is None:
_queue.insert(0, item) # un autre run est parti : remise en tête
_save_queue()
return False
_launch_job(tiles, viz, req, qid=item.get("id"))
return True
@app.get("/api/status")
def generation_status(request: Request = None):
"""État de la génération : run en cours, file, tuiles une par une.
available : ce serveur peut générer (pipeline local ou LIDAR_GENERATION_URL).
regen_allowed : l'IP du navigateur a le droit de lancer (LIDAR_REGEN_CIDR)
— l'interface masque les boutons sinon. Webapp légère : l'état vient de la
machine de traitement ; hors ligne : running=null (aucun faux « terminé »).
"""
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
available = _generation_available()
if GENERATION_URL:
try:
data = _proxy_generation("GET", "/api/status", timeout=10)
except HTTPException as e:
if e.status_code != 503:
raise
return {"running": None, "distant": True, "worker_offline": True,
"available": available, "regen_allowed": regen_allowed}
data["distant"] = True
data["available"] = available
data["regen_allowed"] = regen_allowed
return data
from .progress import progress_snapshot
tiles = progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels(),
run_id=_job.get("run_id"))
_attach_frame_bounds(tiles)
with _job_lock:
pending = bool(_queue)
if pending:
# File d'attente non vide et serveur libre (ex. redémarrage du
# conteneur en cours de file) : la demande suivante repart.
_start_next_queued()
with _job_lock:
proc = _job["proc"]
running = proc is not None and proc.poll() is None
return {
"running": running,
"started": _job["started"],
"returncode": _job["returncode"],
"cmd": _job["cmd"],
"qid": _job.get("qid"),
"queue": _queue_summary(),
"log": _tail_log(40),
"available": available,
"regen_allowed": regen_allowed,
"tiles": tiles,
}
@app.get("/api/tiles")
def tiles_inventory(stamp: Optional[float] = None):
"""Inventaire des dalles (index_tiles.json) pour les machines légères.
Écrit par le pipeline à chaque passe (et après chaque tuile en mode
--incremental-index) : vignettes, sous-tuiles et dalles référencées y
sont servies par les montages statiques homonymes. ?stamp=X → réponse
allégée si rien n'a changé.
"""
path = OUTPUT_DIR / "index_tiles.json"
try:
mtime = path.stat().st_mtime
if stamp is not None and abs(mtime - stamp) < 1e-4:
return {"stamp": mtime, "tiles": None}
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {"stamp": None, "tiles": None}
data["stamp"] = mtime
return data
@app.post("/api/preview", dependencies=[Depends(_require_token),
Depends(_require_lan_for_generation)])
def preview(req: PreviewRequest):
"""Cellules 1 km intersectant une bbox WGS84 (ou passe all_missing).
Sont incluses les tuiles absentes ET les tuiles existantes incomplètes
(il leur manque une des visualisations demandées). Retourne
{count, capped, cells:[{col, row, corners}]}.
"""
if GENERATION_URL:
# Webapp légère : le distant connaît input/ et output/ complets
return _proxy_generation("POST", "/api/preview", json.loads(req.json()))
names = _viz_step_names()
viz = [v for v in (req.viz or []) if v] or _panel_viz_steps()
invalid = [v for v in viz if v not in names]
if invalid:
raise HTTPException(
400, f"visualisation invalide : {', '.join(invalid)} "
f"(attendues : {', '.join(names)})")
if req.all_missing:
# Passe globale : dalles LHD déjà présentes dans input/ (aucun téléchargement)
cells = laz_cells(INPUT_DIR)
capped = len(cells) > MAX_CELLS_ALL
todo = missing_cells_with_corners(cells[:MAX_CELLS_ALL], OUTPUT_DIR,
include_done=req.regenerate, viz=viz)
else:
if not req.bbox or len(req.bbox) != 4:
raise HTTPException(400, "bbox attendue : [ouest, sud, est, nord]")
w, s, e, n = (float(v) for v in req.bbox)
cells = bbox_to_cells(w, s, e, n)
capped = len(cells) > MAX_CELLS
todo = missing_cells_with_corners(cells[:MAX_CELLS], OUTPUT_DIR,
include_done=req.regenerate, viz=viz)
return {"count": len(todo), "capped": capped, "cells": todo}
@app.get("/api/cell")
def cell_at_point(lat: float, lng: float):
"""Cellule L93 de 1 km contenant un point WGS84 + coins pour la carte."""
if GENERATION_URL:
return _proxy_generation("GET", f"/api/cell?lat={lat}&lng={lng}")
from .index import attach_gps_bounds
col, row = point_to_cell(lng, lat)
tile = {"col": col, "row": row}
attach_gps_bounds([tile])
return {"col": col, "row": row, "corners": tile.get("corners") or []}
@app.post("/api/generate", dependencies=[Depends(_require_token),
Depends(_require_lan_for_generation)])
def generate(req: GenerateRequest):
if GENERATION_URL:
# Webapp légère : téléchargement + traitement sur la machine distante,
# qui valide les paramètres, met en file et renvoie son propre état.
data = _proxy_generation("POST", "/api/generate",
json.loads(req.json()))
data["distant"] = True
return data
tiles, viz = _resolve_request(req)
with _job_lock:
proc = _job["proc"]
running = proc is not None and proc.poll() is None
if running:
# Un run est en cours : la demande part en file d'attente et
# démarrera à sa fin — jamais de coupure du travail en place.
global _queue_seq
_queue_seq += 1
item = {"id": _queue_seq, "req": json.loads(req.json()),
"queued_at": time.time()}
_queue.append(item)
_save_queue()
return {"demarré": False, "en_file": len(_queue), "qid": item["id"],
"tuiles": len(tiles)}
cmd = _launch_job(tiles, viz, req)
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
@app.post("/api/queue/clear", dependencies=[Depends(_require_token),
Depends(_require_lan_for_generation)])
def queue_clear():
"""Retire les demandes en attente (le run en cours n'est pas touché)."""
if GENERATION_URL:
return _proxy_generation("POST", "/api/queue/clear")
with _job_lock:
n = len(_queue)
_queue.clear()
_save_queue()
return {"retirées": n}
@app.post("/api/stop", dependencies=[Depends(_require_token),
Depends(_require_lan_for_generation)])
def stop_generation():
"""Arrête la génération en cours (SIGTERM au pipeline, SIGKILL en repli)."""
if GENERATION_URL:
return _proxy_generation("POST", "/api/stop")
with _job_lock:
proc = _job["proc"]
if proc is None or proc.poll() is not None:
raise HTTPException(409, "aucune génération en cours")
try:
proc.terminate() # SIGTERM → nettoyage propre du pipeline
except OSError:
pass
def _escalate():
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (OSError, ProcessLookupError):
try:
proc.kill()
except OSError:
pass
threading.Thread(target=_escalate, daemon=True).start()
logger.info("Arrêt de la génération demandé (SIGTERM au pipeline)")
return {"arrêt": "demandé"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Interface # Interface
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -101,7 +101,50 @@ _MAP_HTML = """<!DOCTYPE html>
<div class="card-body" id="useBody"></div> <div class="card-body" id="useBody"></div>
</div> </div>
<div id="gencard" class="card" hidden>
<div class="card-head">
<span class="card-title">Génération</span>
<span id="genBadge" class="card-stat"></span>
<button id="genClose" class="icon-btn" title="Fermer">×</button>
</div>
<div class="card-body">
<div id="genMsg" class="meta">Dessinez une zone (+) ou complétez les dalles existantes (⤒).</div>
<div id="genOpts">
<div class="gen-row"><span class="gen-lbl">Couches</span>
<select id="genViz" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
<div class="gen-row"><span class="gen-lbl">Classification</span>
<select id="genClass" title="Méthode de classification du sol">
<option value="ign">IGN (pré-classée)</option>
<option value="auto">Auto</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select></div>
<div class="gen-row"><span class="gen-lbl">Classes IGN</span>
<input id="genIgnClasses" class="gen-input" value="sol"
title="Classes LAS du MNT IGN : sol(2), unclassified(1), eau(9), virtuel(66), pont(17), sursol(64) — séparées par des virgules"></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<label class="chk"><input type="checkbox" id="genReclass"> Reclasser le sol (même méthode)</label>
<label class="chk"><input type="checkbox" id="genEdge"> Raccord des bords (bande 100 m)</label>
</div>
<div id="genClassInfo" class="meta"></div>
<div class="card-foot" id="genActions">
<button id="genGo" class="primary" title="Télécharge (géoplateforme IGN) puis traite les dalles sélectionnées">Générer</button>
<button id="genClear" title="Vider la sélection de dalles">Effacer la sélection</button>
</div>
<div id="genRun" hidden>
<div class="meta" id="genProgress"></div>
<pre id="genLog" class="gen-log"></pre>
<div class="card-foot">
<button id="genStop" title="SIGTERM au pipeline (nettoyage propre), SIGKILL en repli">Arrêter</button>
<button id="genQueueClear" title="Retirer les demandes en attente (le run en cours continue)">Vider la file</button>
</div>
</div>
</div>
</div>
<nav id="tools"> <nav id="tools">
<button id="btnZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+</button>
<button id="btnComplete" title="Compléter : traiter toutes les dalles déjà téléchargées qui manquent des couches choisies" hidden>⤒</button>
<button id="zoomIn" title="Zoomer">+</button> <button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button> <button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomFit" title="Recadrer sur les données">⤢</button> <button id="zoomFit" title="Recadrer sur les données">⤢</button>
@ -238,9 +281,41 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
.use-note { font-size: 11px; color: var(--muted); line-height: 1.5; } .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 { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; }
.leaflet-control-attribution a { color: var(--accent) !important; } .leaflet-control-attribution a { color: var(--accent) !important; }
/* --- génération de tuiles ------------------------------------------------ */
#gencard { right: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px); }
body.drawing { cursor: crosshair !important; }
body.drawing .leaflet-container { cursor: crosshair !important; }
.gen-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px;
align-items: center; margin: 4px 0; font-size: 11.5px; color: var(--muted); }
#genViz { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 4px; font: inherit; font-size: 11.5px; }
#genViz option { background-color: #10141b; }
#genClass, .gen-input { 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: 11.5px; width: 100%; height: 24px; }
.chk { display: flex; gap: 7px; align-items: center; font-size: 11.5px;
color: var(--muted); margin: 4px 0; cursor: pointer; }
.chk input { accent-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #0b0e13; font-weight: 600; }
button.primary:hover { filter: brightness(1.12); }
.gen-log { font: 10.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--muted);
background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap;
word-break: break-all; margin: 6px 0; }
#tools button.accent { color: var(--accent); border-color: var(--accent); }
#tools button.busy { color: var(--accent); border-color: var(--accent); }
/* Cadres des dalles du run : orange pulsant = rendu en cours, bleu = en
attente, rouge = échec. Les dalles faites n'ont pas de cadre. */
@keyframes genpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.gen-frame-running { stroke: #f4a261; fill: #f4a26122; animation: genpulse 1.4s ease-in-out infinite; }
.gen-frame-pending { stroke: #4cc9f0; fill: #4cc9f014; stroke-dasharray: 6 4; }
.gen-frame-failed { stroke: #e76f51; fill: #e76f5133; }
.gen-sel { stroke: var(--accent); fill: #5aa9e61f; cursor: pointer; }
@media (max-width: 720px) { @media (max-width: 720px) {
#layercard { width: calc(100vw - 24px); } #layercard { width: calc(100vw - 24px); }
#tilecard { width: calc(100vw - 24px); } #tilecard { width: calc(100vw - 24px); }
#gencard { width: calc(100vw - 24px); }
} }
""" """
@ -449,15 +524,18 @@ function buildLayers() {
// Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la // Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la
// pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers // 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 // les zones sans donnée. La fusion de la pile ENTIÈRE sur le fond reste
// réglable à part (STATE.stackBlend). // réglable à part (STATE.stackBlend). Les panneaux sont réutilisés si déjà
const stack = map.createPane('lidarStack'); // créés (rafraîchissement après un run de génération).
const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
stack.style.zIndex = 400; stack.style.zIndex = 400;
stack.style.isolation = 'isolate'; stack.style.isolation = 'isolate';
META.layers.forEach((info) => { META.layers.forEach((info) => {
const pane = 'lidar-' + info.key; const paneName = 'lidar-' + info.key;
map.createPane(pane, stack); const pane = map.getPane(paneName) || map.createPane(paneName, stack);
const existing = LAYERS.get(info.key);
if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); }
const layer = L.tileLayer(tileUrl(info.key), { const layer = L.tileLayer(tileUrl(info.key), {
pane: pane, pane: paneName,
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors — // Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
// pas de requêtes inutiles sur le reste du monde. // pas de requêtes inutiles sur le reste du monde.
bounds: META.bounds bounds: META.bounds
@ -664,11 +742,17 @@ function renderBaseRow() {
} }
// --- fiche dalle ---------------------------------------------------------- // --- fiche dalle ----------------------------------------------------------
function showTileInfo(data) { function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ Générer cette dalle</button></div>'
: '';
const card = el('tilecard'); const card = el('tilecard');
if (!data || !data.found) { if (!data || !data.found) {
el('tileTitle').textContent = 'Aucune dalle ici'; el('tileTitle').textContent = 'Aucune dalle ici';
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>'; el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false; card.hidden = false;
return; return;
} }
@ -682,7 +766,9 @@ function showTileInfo(data) {
'<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' + '<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' +
' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' + ' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' +
(data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') + (data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') +
'<div class="meta">Couches : ' + (viz || '—') + '</div>'; '<div class="meta">Couches : ' + (viz || '—') + '</div>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false; card.hidden = false;
} }
@ -720,6 +806,288 @@ function renderUseCard() {
el('usecard').hidden = false; el('usecard').hidden = false;
} }
// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
// Portée de la webapp historique : dessin de zone (+), passe « tout
// compléter » (⤒), sélection cumulée (clic fiche dalle), options de run,
// file d'attente et progression dalle par dalle (cadres sur la carte).
const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
const genSel = new Set(); // clés "col,row" des dalles retenues
const genCorners = new Map(); // clé → coins GPS [[lat, lon] × 4]
const genPolys = new Map(); // clé → polygone de surlignage
const genFrames = new Map(); // nom → cadre coloré de dalle en cours
let genDrawing = false, genDrawStart = null, genDrawRect = null;
let genIgnoreClickUntil = 0;
let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670]]) {
map.createPane(pane).style.zIndex = String(z);
}
function genKey(c, r) { return c + ',' + r; }
function genApi(method, url, body) {
return fetch(url, {
method: method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
}).then(r => {
if (!r.ok) {
return r.json().catch(() => ({})).then(d => {
throw new Error((d && d.detail) || ('HTTP ' + r.status));
});
}
return r.json();
});
}
function genOpenCard(msg) {
if (msg !== undefined) el('genMsg').textContent = msg;
el('gencard').hidden = false;
}
function genFillViz() {
el('genViz').innerHTML = META.layers.map(l =>
'<option value="' + (l.step || l.key) + '">' + l.label + '</option>').join('');
}
function genSelectedViz() {
return Array.from(el('genViz').selectedOptions).map(o => o.value);
}
function genRefreshSel() {
for (const poly of genPolys.values()) poly.remove();
genPolys.clear();
for (const [key, corners] of genCorners.entries()) {
if (!genSel.has(key) || !corners || corners.length < 4) continue;
const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
}).addTo(map);
poly.on('click', (e) => {
L.DomEvent.stopPropagation(e);
genRemoveCell(key);
toast('Dalle retirée de la sélection');
});
genPolys.set(key, poly);
}
el('genGo').disabled = !genSel.size;
if (genSel.size) {
el('genMsg').textContent = genSel.size + ' dalle(s) sélectionnée(s) — ' +
'cliquez un surlignage pour retirer une dalle, réglez les options puis « Générer ».';
}
}
function genRemoveCell(key) {
genSel.delete(key);
genCorners.delete(key);
genRefreshSel();
}
function genAddCells(cells) {
let added = 0;
for (const c of (cells || [])) {
const k = genKey(c.col, c.row);
if (!genSel.has(k)) { genSel.add(k); added++; }
if (c.corners) genCorners.set(k, c.corners);
}
genRefreshSel();
return added;
}
function genAddCellFromQuery() {
if (!lastTileQuery) return;
genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(d => {
genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
el('tilecard').hidden = true;
})
.catch(msg => toast('Échec : ' + msg.message, 6000));
}
function genSetDrawing(on) {
genDrawing = on;
document.body.classList.toggle('drawing', on);
el('btnZone').classList.toggle('accent', on);
if (on) map.dragging.disable(); else map.dragging.enable();
if (!on && genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
genDrawStart = null;
if (on) genOpenCard('Dessinez un rectangle sur la carte (re-cliquez + pour annuler).');
}
map.on('mousedown', (e) => {
if (!genDrawing) return;
genDrawStart = e.latlng;
if (genDrawRect) genDrawRect.remove();
genDrawRect = L.rectangle([genDrawStart, genDrawStart], {
pane: 'genDraw', color: '#4cc9f0', weight: 2, dashArray: '6 4',
fillOpacity: 0.05, interactive: false,
}).addTo(map);
});
map.on('mousemove', (e) => {
if (!genDrawing || !genDrawStart || !genDrawRect) return;
genDrawRect.setBounds(L.latLngBounds(genDrawStart, e.latlng));
});
map.on('mouseup', (e) => {
if (!genDrawing || !genDrawStart) return;
const b = L.latLngBounds(genDrawStart, e.latlng);
genDrawStart = null;
genIgnoreClickUntil = Date.now() + 400; // pas de fiche dalle après un dessin
if (Math.abs(b.getWest() - b.getEast()) < 1e-9 ||
Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
genSetDrawing(false);
if (genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
genOpenCard('Analyse de la zone…');
genApi('POST', 'api/preview', {
bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
genOpenCard((added ? added + ' nouvelle(s) dalle(s) retenue(s)'
: 'zone déjà couverte — cochez « Régénérer » pour forcer') +
(d.capped ? ' — zone plafonnée, resserrez le rectangle' : '') + '.');
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
});
function genComplete() {
genOpenCard('Analyse des dalles déjà téléchargées…');
genApi('POST', 'api/preview', {
all_missing: true,
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
genOpenCard((added || d.count || 0) + ' dalle(s) à compléter' +
(d.capped ? ' — plafonnées à 10 000 par run' : '') + '.');
if (genPolys.size) {
const bounds = L.latLngBounds([]);
genPolys.forEach(p => bounds.extend(p.getBounds()));
map.fitBounds(bounds, { padding: [40, 40] });
}
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
}
function genLaunch() {
const tiles = [...genSel].map(k => k.split(',').map(Number));
if (!tiles.length) return;
genApi('POST', 'api/generate', {
tiles: tiles,
regenerate: el('genRegen').checked,
reclassify: el('genReclass').checked,
ground_class: el('genClass').value,
ign_classes: el('genIgnClasses').value.trim() || 'sol',
edge_buffer: el('genEdge').checked,
viz: genSelectedViz(),
}).then(d => {
genSel.clear();
genCorners.clear();
genRefreshSel();
GEN.wasRunning = true;
genPollNow();
toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)')
: ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)'));
}).catch(msg => toast('Échec : ' + msg.message, 6000));
}
// Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en
// attente, rouge = échec) — redraw complet à chaque sondage, c'est peu.
function genDrawFrames(tiles) {
for (const poly of genFrames.values()) poly.remove();
genFrames.clear();
for (const t of (tiles || [])) {
if (t.state === 'done' || !t.corners || t.corners.length < 4) continue;
const cls = t.state === 'running' ? 'gen-frame-running'
: t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending';
const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), {
pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15,
interactive: false,
}).addTo(map);
genFrames.set(t.name || JSON.stringify(t.corners[0]), poly);
}
}
// Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de
// tuile neuf) et la maintenance de pyramide est relancée côté serveur.
function genAfterRun() {
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
buildLayers();
renderPanel();
renderBaseRow();
applyLayers();
}).catch(() => {});
fetch('api/map/background', { method: 'POST' }).catch(() => {});
}
function genUpdateStatus(d) {
const queued = (d.queue || []).length;
if (d.running || queued) {
el('genActions').hidden = true;
el('genOpts').hidden = true;
el('genRun').hidden = false;
const tiles = d.tiles || [];
const done = tiles.filter(t => t.state === 'done').length;
const failed = tiles.filter(t => t.state === 'failed').length;
let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)')
: 'Démarrage du run suivant…';
if (failed) txt += ' • ' + failed + ' échec(s)';
if (queued) txt += ' • ' + queued + ' demande(s) en file';
el('genProgress').textContent = txt;
el('genLog').textContent = (d.log || []).slice(-12).join('\n');
genDrawFrames(tiles);
GEN.wasRunning = true;
} else {
el('genRun').hidden = true;
el('genActions').hidden = !GEN.available;
el('genOpts').hidden = false;
genDrawFrames(null);
if (GEN.wasRunning) {
GEN.wasRunning = false;
toast(d.returncode === 0 ? 'Génération terminée'
: 'Génération interrompue (code ' + d.returncode + ')', 6000);
genAfterRun();
}
}
el('genBadge').textContent = d.running ? '● en cours'
: queued ? ('⏱ ' + queued) : '';
el('btnZone').classList.toggle('busy', !!d.running || !!queued);
}
function genPollNow() {
if (!GEN.available) return;
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
if (d) genUpdateStatus(d);
genPollSoon();
}).catch(() => genPollSoon());
}
function genPollSoon() {
clearTimeout(GEN.timer);
GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000);
}
el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
el('btnComplete').addEventListener('click', genComplete);
el('genClose').addEventListener('click', () => {
el('gencard').hidden = true;
if (genDrawing) genSetDrawing(false);
});
el('genClear').addEventListener('click', () => {
genSel.clear();
genCorners.clear();
genRefreshSel();
genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).');
});
el('genGo').addEventListener('click', genLaunch);
el('genStop').addEventListener('click', () =>
genApi('POST', 'api/stop')
.then(() => toast('Arrêt demandé'))
.catch(msg => toast('Échec : ' + msg.message, 6000)));
el('genQueueClear').addEventListener('click', () =>
genApi('POST', 'api/queue/clear')
.then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
.catch(msg => toast('Échec : ' + msg.message, 6000)));
// --- démarrage ------------------------------------------------------------ // --- démarrage ------------------------------------------------------------
function fitData() { function fitData() {
if (META.bounds) { if (META.bounds) {
@ -746,6 +1114,19 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
buildLayers(); buildLayers();
renderPanel(); renderPanel();
renderBaseRow(); renderBaseRow();
genFillViz();
// Droits de génération : boutons affichés si le serveur peut générer
// (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
// réseau autorisé (LIDAR_REGEN_CIDR) — sinon masqués silencieusement.
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
if (!d) return;
GEN.available = !!d.available;
GEN.allowed = d.regen_allowed !== false;
const show = GEN.available && GEN.allowed;
el('btnZone').hidden = !show;
el('btnComplete').hidden = !show;
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => toast('Serveur de tuiles injoignable.', 10000)); }).catch(() => toast('Serveur de tuiles injoignable.', 10000));
let applyingHash = false; let applyingHash = false;
@ -766,8 +1147,10 @@ window.addEventListener('hashchange', () => {
map.on('moveend zoomend', saveHashSoon); map.on('moveend zoomend', saveHashSoon);
map.on('click', (e) => { map.on('click', (e) => {
if (genDrawing || Date.now() < genIgnoreClickUntil) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng) fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null).then(showTileInfo).catch(() => {}); .then(r => r.ok ? r.json() : null)
.then(d => showTileInfo(d, e.latlng)).catch(() => {});
}); });
el('cardToggle').addEventListener('click', () => el('cardToggle').addEventListener('click', () =>
@ -851,6 +1234,9 @@ el('gps').addEventListener('click', () => {
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { el('tilecard').hidden = true; el('usecard').hidden = true; } if (e.key === 'Escape') {
el('tilecard').hidden = true; el('usecard').hidden = true;
if (genDrawing) genSetDrawing(false);
}
}); });
""" """

View File

@ -663,8 +663,8 @@ class LidarArchaeoPipeline:
def _rebuild_index_incremental(self): def _rebuild_index_incremental(self):
"""Régénère la carte juste après une tuile terminée (mode incrémental). """Régénère la carte juste après une tuile terminée (mode incrémental).
Réécrit index.html + index_tiles.json pour que la webapp affiche la Réécrit index_tiles.json (et rafraîchit vignettes/sous-tuiles) pour
tuile sans attendre la fin du run. Anti-rebond : 3 s minimum entre que la carte affiche la tuile sans attendre la fin du run. Anti-rebond : 3 s minimum entre
deux passes — les tuiles terminées pendant l'intervalle sont couvertes deux passes — les tuiles terminées pendant l'intervalle sont couvertes
par la passe suivante ou par la passe finale. Les logs de build_index par la passe suivante ou par la passe finale. Les logs de build_index
sont masqués pour ne pas noyer le journal du run. sont masqués pour ne pas noyer le journal du run.
@ -809,13 +809,13 @@ class LidarArchaeoPipeline:
logger.info(f" • DTM : {self.dtm_dir}") logger.info(f" • DTM : {self.dtm_dir}")
logger.info(f" • Visualisations: {self.vis_dir}") logger.info(f" • Visualisations: {self.vis_dir}")
# Génère la carte globale interactive des tuiles traitées # Génère le catalogue des tuiles traitées (vignettes + inventaire)
if not self.no_index: if not self.no_index:
try: try:
from .index import build_index from .index import build_index
index_path = build_index(self.output_dir, self.output_format) index_path = build_index(self.output_dir, self.output_format)
if index_path: if index_path:
logger.info(f" • Carte globale : {index_path}") logger.info(f" • Catalogue : {index_path}")
except Exception as e: except Exception as e:
logger.warning(f"Index global non généré: {e}") logger.warning(f"Index global non généré: {e}")

View File

@ -1,11 +1,11 @@
"""Progression structurée d'un run de génération (contrat pipeline → interface). """Progression structurée d'un run de génération (contrat pipeline → interface).
Le pipeline et le téléchargement IGN écrivent des événements JSON ligne à Le pipeline et le téléchargement IGN écrivent des événements JSON ligne à
ligne dans <sortie>/.generation.events.jsonl ; la webapp les agrège via ligne dans <sortie>/.generation.events.jsonl ; mapserve les agrège via
/api/status pour afficher les tuiles une par une (nom, type, état) dans la /api/status pour afficher les tuiles une par une (nom, type, état) dans la
file de génération, à la place du journal brut. file de génération, à la place du journal brut.
Module volontairement découplé de la webapp : le rendu des tuiles pourra être Module volontairement découplé du serveur : le rendu des tuiles pourra être
déporté hors du serveur web — le producteur d'événements ne dépend que du déporté hors du serveur web — le producteur d'événements ne dépend que du
dossier de sortie. Les workers parallèles écrivent chacun leur ligne en mode dossier de sortie. Les workers parallèles écrivent chacun leur ligne en mode
ajout (une ligne < 4 Ko avec O_APPEND est atomique sur POSIX). ajout (une ligne < 4 Ko avec O_APPEND est atomique sur POSIX).
@ -156,7 +156,7 @@ def aggregate_tiles(events, viz_labels=None):
def progress_snapshot(output_dir, viz_labels=None, run_id=None): def progress_snapshot(output_dir, viz_labels=None, run_id=None):
"""État consolidé des tuiles du dernier run (prêt pour /api/status). """État consolidé des tuiles du dernier run (prêt pour /api/status).
run_id (LIDAR_RUN_ID posé par la webapp) : ne garde que les événements de run_id (LIDAR_RUN_ID posé par mapserve) : ne garde que les événements de
CE run — les workers d'un run annulé peuvent continuer d'écrire (O_APPEND) CE run — les workers d'un run annulé peuvent continuer d'écrire (O_APPEND)
après la troncature du journal par le run suivant, et pollueraient sinon après la troncature du journal par le run suivant, et pollueraient sinon
la file affichée. Sans run_id (run CLI autonome) : tout le journal. la file affichée. Sans run_id (run CLI autonome) : tout le journal.

View File

@ -1,276 +0,0 @@
"""Tests de l'export multi-dalles (mosaïque jointive image/PDF) et de son API."""
def _make_tile_dir(tmp_path, col, row, viz_keys, resolution=0.5, tile_px=16,
color=(255, 0, 0)):
"""Crée le dossier de visualisations d'une dalle (WebP lossless unis).
Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent
le basename nu (cf. _expected_output_path dans le pipeline).
"""
from PIL import Image
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
suffix = "" if resolution == 0.5 else "_r" + str(resolution).replace(".", "p")
vis = tmp_path / "visualisations" / (base + suffix)
vis.mkdir(parents=True, exist_ok=True)
for key in viz_keys:
Image.new("RGB", (tile_px, tile_px), color).save(
str(vis / f"{base}_{key}.webp"), format="WEBP", lossless=True)
def _grid2x2(tmp_path):
"""Quatre dalles adjacentes de couleurs distinctes (row 20 au nord)."""
_make_tile_dir(tmp_path, 10, 20, ["slope"], color=(255, 0, 0)) # nord-ouest
_make_tile_dir(tmp_path, 11, 20, ["slope"], color=(0, 255, 0)) # nord-est
_make_tile_dir(tmp_path, 10, 19, ["slope"], color=(0, 0, 255)) # sud-ouest
_make_tile_dir(tmp_path, 11, 19, ["slope"], color=(255, 255, 0)) # sud-est
def test_export_places_tiles_and_is_seamless(tmp_path):
"""Grille 2×2 : placement exact (row haute au nord) et collage bord à bord.
Aucun trait ni espace entre les dalles : les pixels de part et d'autre de
chaque couture appartiennent aux dalles voisines, jamais au fond blanc.
"""
from PIL import Image
from lidar_pipeline.export import build_export
_grid2x2(tmp_path)
out = tmp_path / "exports"
res = build_export(tmp_path / "visualisations",
[(10, 19), (11, 19), (10, 20), (11, 20)],
["slope"], 0.5, "png", out)
with Image.open(str(res["file"])) as img:
# 2 dalles de 16 px + habillage → largeur = 32 exactement (le bandeau
# légende, comme bandeau/pied, n'élargit pas l'image)
assert img.width == 32
# Haut de la mosaïque : premier pixel rouge pur (dalles au-dessus des
# bandeaux texte blancs, insensible à la hauteur du bandeau légende)
def rgb(x, y):
return img.getpixel((x, y))
mosaic_top = next(y for y in range(img.height)
if rgb(4, y) == (255, 0, 0))
mid = mosaic_top + 16
# Nord : row 20 en haut (rouge à l'ouest, vert à l'est)
assert rgb(4, mosaic_top + 4) == (255, 0, 0)
assert rgb(28, mosaic_top + 4) == (0, 255, 0)
# Sud : row 19 en bas (bleu à l'ouest, jaune à l'est)
assert rgb(4, mosaic_top + 28) == (0, 0, 255)
assert rgb(28, mosaic_top + 28) == (255, 255, 0)
# Couture verticale (x=15|16) : couleurs des dalles, pas de blanc
assert rgb(15, mosaic_top + 8) == (255, 0, 0)
assert rgb(16, mosaic_top + 8) == (0, 255, 0)
assert rgb(15, mid) == (0, 0, 255)
assert rgb(16, mid) == (255, 255, 0)
# Couture horizontale (y=+15|+16) : couleurs des dalles, pas de blanc
assert rgb(4, mosaic_top + 15) == (255, 0, 0)
assert rgb(4, mosaic_top + 16) == (0, 0, 255)
def test_export_pdf_one_page_per_viz(tmp_path):
"""PDF multi-couches : une page par visualisation demandée."""
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
res = build_export(tmp_path / "visualisations", [(10, 20)],
["slope", "aspect"], 0.5, "pdf", tmp_path / "exports")
assert res["file"].suffix == ".pdf"
data = res["file"].read_bytes()
assert data.startswith(b"%PDF")
pages = data.count(b"/Type /Page") - data.count(b"/Type /Pages")
assert pages == 2
def test_export_image_one_file_per_viz(tmp_path):
"""Image + plusieurs couches : un fichier par couche, chacun avec SA
légende (comme le PDF fait une page par couche)."""
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
res = build_export(tmp_path / "visualisations", [(10, 20)],
["slope", "aspect"], 0.5, "png", tmp_path / "exports")
files = res["files"]
assert len(files) == 2
names = [e["file"].name for e in files]
assert any("_slope_" in n for n in names)
assert any("_aspect_" in n for n in names)
for e in files:
assert e["file"].is_file() and e["file"].suffix == ".png"
assert e["pages"] == 1
# Clés de compatibilité : premier fichier
assert res["file"] == files[0]["file"]
assert res["width"] == files[0]["width"] and res["height"] == files[0]["height"]
def test_export_missing_tile_or_viz(tmp_path):
"""Dalle absente à la résolution demandée ou visualisation manquante."""
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope"])
try:
build_export(tmp_path / "visualisations", [(10, 20), (12, 20)],
["slope"], 0.5, "png", tmp_path / "exports")
assert False, "une ValueError était attendue"
except ValueError as e:
assert "12,20" in str(e)
try:
build_export(tmp_path / "visualisations", [(10, 20)],
["wavelet"], 0.5, "png", tmp_path / "exports")
assert False, "une ValueError était attendue"
except ValueError as e:
assert "wavelet" in str(e)
def test_export_includes_layer_legend(tmp_path):
"""Chaque export porte la légende de sa couche : dégradé de la colormap
(extrémités exactes) dans un bandeau sous le pied, en plus de l'habillage."""
from PIL import Image
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=256)
res = build_export(tmp_path / "visualisations", [(10, 20)],
["slope"], 0.5, "png", tmp_path / "exports")
with Image.open(str(res["file"])) as img:
# Bandeau légende présent : hauteur au-delà de mosaïque + bandeau + pied
assert img.height > 256 + 90 + 84
zone = [c for c in (img.getpixel((x, y))
for y in range(256 + 90 + 84, img.height)
for x in range(img.width))
if isinstance(c, tuple)]
# Extrémités du dégradé inferno de la pente (0° → 30°) : #000004 → #fcffa4
assert any(abs(c[0]) <= 1 and abs(c[1]) <= 1 and abs(c[2] - 4) <= 1
for c in zone)
assert any(abs(c[0] - 252) <= 1 and abs(c[1] - 255) <= 1
and abs(c[2] - 164) <= 1 for c in zone)
def test_export_legend_per_pdf_page():
"""PDF multi-couches : chaque page porte la légende de SA couche."""
from lidar_pipeline.export import _legend_layout
from lidar_pipeline.index import VIZ_LEGENDS
from PIL import Image
layout_slope = _legend_layout(VIZ_LEGENDS["slope"], Image.new("RGB", (600, 600)), 600)
layout_aspect = _legend_layout(VIZ_LEGENDS["aspect"], Image.new("RGB", (600, 600)), 600)
# Bandeaux distincts (dégradés/textes différents) et dimensionnés
assert layout_slope["height"] > 0 and layout_aspect["height"] > 0
assert layout_slope["has_bar"] and layout_aspect["has_bar"]
def test_viz_legends_registry_complete():
"""VIZ_LEGENDS couvre toutes les couches proposées (VIZ_LABELS)."""
from lidar_pipeline.index import VIZ_LABELS, VIZ_LEGENDS
assert set(VIZ_LABELS) == set(VIZ_LEGENDS)
for key, info in VIZ_LEGENDS.items():
assert info["title"], key
assert info["legend"], key
assert info["description"], key
if info.get("gradient") is not None:
assert len(info["gradient"]) >= 2, key
assert info["ticks"] and len(info["ticks"]) == 2, key
else: # couches photo/carte : pas de dégradé, pas de bornes
assert info["ticks"] is None, key
def test_viz_legends_gradients_match_colormaps():
"""Les dégradés de VIZ_LEGENDS restent calés sur les colormaps réelles
(un cmap changé dans rendering.py doit rafraîchir le dégradé)."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from lidar_pipeline.index import VIZ_LEGENDS
from lidar_pipeline.rendering import COLORMAPS, RGB_LEGENDS
for key, info in VIZ_LEGENDS.items():
cmap_name = info.get("cmap")
if not cmap_name:
continue
ref = COLORMAPS.get(key) or RGB_LEGENDS.get(key)
assert ref is not None, key
assert ref["cmap"] == cmap_name, key
cm = plt.get_cmap(cmap_name)
stops = info["gradient"]
for i, hx in enumerate(stops):
r, g, b = (int(hx[j:j + 2], 16) for j in (1, 3, 5))
mr, mg, mb = (round(v * 255) for v in cm(i / (len(stops) - 1))[:3])
assert abs(r - mr) <= 1 and abs(g - mg) <= 1 and abs(b - mb) <= 1, key
def test_export_max_side_downsizes(tmp_path):
"""max_side réduit le plus grand côté de la mosaïque."""
from PIL import Image
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=64)
_make_tile_dir(tmp_path, 11, 20, ["slope"], tile_px=64)
res = build_export(tmp_path / "visualisations", [(10, 20), (11, 20)],
["slope"], 0.5, "png", tmp_path / "exports",
max_side=64)
with Image.open(str(res["file"])) as img:
assert img.width == 64 # mosaïque 128 px ramenée à 64
def test_export_endpoint(tmp_path, monkeypatch):
"""/api/export assemble et renvoie un fichier téléchargeable."""
import lidar_pipeline.webapp as webapp
_grid2x2(tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
req = webapp.ExportRequest(tiles=[[10, 20], [11, 20], [10, 19], [11, 19]],
viz=["slope"], resolution=0.5,
format="jpeg", max_side=0)
d = webapp.export_tiles(req)
files = d["fichiers"]
assert len(files) == 1
f = files[0]
assert f["url"].startswith("/api/export/file/")
assert f["nom"].endswith(".jpg")
assert (tmp_path / "exports" / f["nom"]).is_file()
assert f["taille"] > 0
def test_export_endpoint_multi_viz_image(tmp_path, monkeypatch):
"""/api/export en image avec 2 couches → 2 fichiers téléchargeables."""
import lidar_pipeline.webapp as webapp
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
req = webapp.ExportRequest(tiles=[[10, 20]], viz=["slope", "aspect"],
resolution=0.5, format="jpeg", max_side=0)
d = webapp.export_tiles(req)
assert len(d["fichiers"]) == 2
for f in d["fichiers"]:
assert f["url"].startswith("/api/export/file/")
assert (tmp_path / "exports" / f["nom"]).is_file()
def test_export_endpoint_rejects_bad_requests(tmp_path, monkeypatch):
"""/api/export valide format, visualisations et tuiles."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
_grid2x2(tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
def expect_400(req):
try:
webapp.export_tiles(req)
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 400
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"],
resolution=0.5, format="tiff", max_side=0))
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=[],
resolution=0.5, format="png", max_side=0))
# Dalle inexistante à cette résolution
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"],
resolution=0.2, format="png", max_side=0))
def test_export_file_route(tmp_path, monkeypatch):
"""Le fichier exporté est servi en pièce jointe ; traversée refusée."""
from fastapi import HTTPException
from fastapi.responses import FileResponse
import lidar_pipeline.webapp as webapp
exports = tmp_path / "exports"
exports.mkdir(parents=True)
(exports / "ok.png").write_bytes(b"x")
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
resp = webapp.export_file("ok.png")
assert isinstance(resp, FileResponse)
for bad in ("../visualisations/secret", "nope.png", "a/b.png"):
try:
webapp.export_file(bad)
assert False, f"une HTTPException était attendue pour {bad}"
except HTTPException as e:
assert e.status_code == 404

View File

@ -207,8 +207,8 @@ def test_collect_tile_metadata_fallback_date(tmp_path):
assert meta['generated'] is not None assert meta['generated'] is not None
def test_build_index_generates_html(tmp_path): def test_build_index_generates_inventory(tmp_path):
"""build_index génère index.html et les vignettes.""" """build_index génère l'inventaire index_tiles.json et les vignettes."""
from lidar_pipeline.index import build_index from lidar_pipeline.index import build_index
output_dir = tmp_path / "output" output_dir = tmp_path / "output"
@ -221,105 +221,20 @@ def test_build_index_generates_html(tmp_path):
result = build_index(output_dir) result = build_index(output_dir)
assert result is not None assert result is not None
html_path = Path(result) inv_path = Path(result)
assert html_path.exists() assert inv_path.exists()
assert html_path.name == "index.html" assert inv_path.name == "index_tiles.json"
# L'ancienne interface HTML a été retirée : plus de coquille ni d'assets
assert not (output_dir / "index.html").exists()
assert not (output_dir / "assets").exists()
content = html_path.read_text(encoding='utf-8') # Inventaire servi par /api/tiles aux machines légères : tuiles avec coins,
# Vérifie la présence des éléments clés # libellés de couches, compteur.
assert "Carte LiDAR" in content data = json.loads(inv_path.read_text(encoding='utf-8'))
assert "LHD_FXX_1000_6881" in content
assert "LHD_FXX_1001_6881" in content
# Vérifie que le JSON intégré est valide
assert "const TILES" in content
# Vérifie les assets de l'interface (CSS/JS séparés)
assets = output_dir / "assets"
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
app_js = (assets / "app.js").read_text(encoding='utf-8')
assert "Couches" in app_js or "layers" in app_js
# Barre de génération : sélecteur restreint au panneau (PANEL_VIZ =
# DEFAULT_LAYERS ; valeurs = noms d'étapes --only). Les couches hors
# panneau (svf, hillshade, wavelet, roughness) ne sont plus proposées.
assert 'id="genViz"' in content
assert 'value="slope"' in content
assert 'value="pos_open"' in content
assert 'value="aspect"' in content
assert 'value="roughness"' not in content
assert 'value="wavelet"' not in content
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content
assert 'value="aniso_open"' not in content
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
# Adoption du dernier run au chargement (redémarrage du serveur) :
# un run terminé réapparaît dans la file avec ses tuiles, sans doublon.
assert "Dernière génération" in app_js
assert "t.started === s.started" in app_js
# Vidage de la file : les runs retirés sont mémorisés (par horodatage
# serveur) pour que le rechargement ne réadopte pas la « dernière
# génération » du serveur et ne refasse pas réapparaître les tâches finies.
assert "dismissedRuns" in app_js
assert "saveDismissed()" in app_js
# Bouton « Compléter » : visualisations manquantes des dalles présentes
# dans input/ (scope all_missing envoyé au preview et au generate).
# « + Zone » et « Compléter » ne vivent que dans l'onglet Génération,
# jamais dans la barre d'outils flottante de la carte.
assert 'id="btnComplete"' in content
map_tools = content.split('id="mapTools"', 1)[1].split('</nav>', 1)[0]
assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools
tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0]
assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
# Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs
# Leaflet, pane 600) pour rectangle et cellules fantômes.
assert "createPane('genDraw')" in app_js
assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules
assert 'assets/app.css' in content
# Bouton d'actualisation des couches (détection des nouveautés sur disque) :
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/sync" in app_js
# Préconfigurations de couches (onglet Couches) : le sélecteur applique
# des presets intégrés (couches visibles, niveaux de transparence —
# visualisations + fond de carte OSM —, ordre de pile) et 💾/✕ gèrent
# les presets personnels persistés en localStorage.
assert 'id="presetSelect"' in content
assert 'id="presetSave"' in content
assert "BUILTIN_PRESETS" in app_js and "applyPreset" in app_js
assert "presetDetail" in app_js and "lidarPresets_v1" in app_js
assert "osm: { on: true, opacity: 0.3, dark: true }" in app_js # preset porte le fond OSM
assert "p.osm" in app_js # l'application y inclut le fond
# Partage de la vue : bouton « Partager » + lien contenant vue, couches
# et transparences (hash #zoom,lat,lng&L=...&O=...) — le destinataire
# reçoit l'état exact, indépendamment de son localStorage local.
assert 'id="btnShare"' in content
assert "buildShareHash" in app_js and "parseSharedHash" in app_js
assert 'assets/app.js' in content
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
assert 'assets/vendor/leaflet/leaflet.js' in content
assert 'assets/vendor/leaflet/leaflet.css' in content
assert 'unpkg.com' not in content
assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file()
# Fin d'un run de génération : synchronisation puis rechargement (le
# rechargement direct reste en secours si /api/sync est absent).
assert "syncThenReload" in app_js
# Tuiles en direct : la carte sonde /api/tiles pendant un run et fusionne
# les tuiles terminées sans recharger (mode incrémental du pipeline).
assert "/api/tiles" in app_js and "pollLiveTiles" in app_js
assert "registerTile" in app_js and "mergeTilesData" in app_js
# Garde anti-page périmée : un onglet ouvert depuis plus de 15 min
# (JS ancien après un déploiement) se recharge au retour au premier
# plan — l'ancienne interface n'est jamais affichée.
assert "reloadStalePage" in app_js and "visibilitychange" in app_js
# Export des données de carte (servi par /api/tiles)
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
assert data['tiles'] and data['tiles'][0]['corners'] assert data['tiles'] and data['tiles'][0]['corners']
assert 'aspect' in data['viz_meta'] assert 'aspect' in data['viz_meta']
assert data['viz_meta']['aspect']['label']
assert data['stats']['n_tiles'] == len(data['tiles']) assert data['stats']['n_tiles'] == len(data['tiles'])
# Version de l'interface exportée : la webapp légère détecte une image
# périmée (assets bâchés au build, non reconstruite après évolution)
from lidar_pipeline.index import ui_version
assert data['stats']['ui_version'] == ui_version()
# Vérifie les vignettes générées # Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs" thumb_dir = output_dir / "index_thumbs"
assert thumb_dir.is_dir() assert thumb_dir.is_dir()
@ -327,27 +242,6 @@ def test_build_index_generates_html(tmp_path):
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
def test_write_ui_assets(tmp_path):
"""_write_ui_assets écrit app.js/app.css dans le dossier cible (bâchés image).
app.js a __UI_VERSION__ substitué par la version réelle (ui_version) :
le JS compare cette version à celle du serveur de données et signale
une webapp périmée (bandeau, cf. checkUiVersion).
"""
from lidar_pipeline.index import _write_ui_assets, _APP_JS, ui_version
dst = tmp_path / "ui"
_write_ui_assets(dst)
v = ui_version()
assert v == ui_version() and len(v) == 10
js = (dst / "app.js").read_text(encoding="utf-8")
assert js == _APP_JS.replace("__UI_VERSION__", v)
assert v in js and "__UI_VERSION__" not in js
# Le placeholder et le check sont embarqués dans la source
assert '"__UI_VERSION__"' in _APP_JS and "checkUiVersion" in _APP_JS
assert (dst / "app.css").read_text(encoding="utf-8").startswith("/*")
def test_mid_thumbnails_generated(tmp_path): def test_mid_thumbnails_generated(tmp_path):
"""Vignette intermédiaire 640 px générée et référencée dans l'index.""" """Vignette intermédiaire 640 px générée et référencée dans l'index."""
from lidar_pipeline.index import build_index from lidar_pipeline.index import build_index
@ -410,25 +304,6 @@ def test_panel_restricted_to_kept_layers():
assert KEYWORD_TO_STEP.get(key, key) in steps assert KEYWORD_TO_STEP.get(key, key) in steps
def test_panel_selection_lists_kept_layers():
"""Le panneau propose uniquement les couches conservées (PANEL_VIZ).
Même sans données sur disque, le panneau affiche les couches conservées ;
une couche absente du panneau n'est plus proposée, même présente sur
disque.
"""
from lidar_pipeline.index import _panel_selection, PANEL_VIZ
ordered, default = _panel_selection({'aspect'})
assert set(ordered) == set(PANEL_VIZ)
assert default == 'aspect'
ordered, default = _panel_selection(set())
assert set(ordered) == set(PANEL_VIZ)
assert default # toujours une couche par défaut
# Une couche présente sur disque mais hors panneau est exclue
ordered, default = _panel_selection({'aniso_open'})
assert 'aniso_open' not in ordered
def test_build_index_merges_cross_resolution_viz(tmp_path): def test_build_index_merges_cross_resolution_viz(tmp_path):
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m. """Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
@ -602,35 +477,6 @@ def test_build_index_empty_returns_none(tmp_path):
assert result is None assert result is None
def test_build_index_embeds_valid_json(tmp_path):
"""Le JSON embarqué dans le HTML est valide et contient les tuiles."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
build_index(output_dir)
content = (output_dir / "index.html").read_text(encoding='utf-8')
# Extrait le JSON entre "const TILES = " et la fin de déclaration
start = content.index("const TILES = ") + len("const TILES = ")
# Trouve le ; de fin de déclaration
depth = 0
end = start
for i, ch in enumerate(content[start:], start):
if ch in ('{', '['):
depth += 1
elif ch in ('}', ']'):
depth -= 1
if depth == 0:
end = i + 1
break
data = json.loads(content[start:end])
assert len(data) > 0
assert data[0]['col'] == 1000
assert data[0]['row'] == 6881
def test_attach_gps_bounds(): def test_attach_gps_bounds():
"""attach_gps_bounds ajoute des bounds GPS ordonnées (France métropolitaine).""" """attach_gps_bounds ajoute des bounds GPS ordonnées (France métropolitaine)."""
@ -718,74 +564,7 @@ def test_approx_wgs84_to_l93_roundtrip():
x2, y2 = _approx_wgs84_to_l93(lon, lat) x2, y2 = _approx_wgs84_to_l93(lon, lat)
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6 assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6
def test_overview_generated_for_large_grids(tmp_path):
"""Grille >= 6 dalles : une mosaïque d'overview par couche + le payload."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
for col in (1000, 1001, 1002):
for row in (6881, 6882):
_make_fake_viz_dir(vis_dir, "t", col, row, ('hillshade_multi', 'svf'))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
ov = data['overview']
assert 'hillshade_multi' in ov and 'svf' in ov
items = ov['hillshade_multi']['items']
assert len(items) == 1 # une seule zone géographique
meta = items[0]
assert (output_dir / meta['url'].split('?')[0]).is_file()
assert meta['km_w'] == 3 and meta['km_h'] == 2
assert meta['w'] == 3 * meta['px_per_km']
assert meta['h'] == 2 * meta['px_per_km']
assert len(meta['corners']) == 4
# La coquille HTML embarque la constante pour l'interface
html = (output_dir / "index.html").read_text(encoding='utf-8')
assert "const OVERVIEW" in html
assert "index_overview/hillshade_multi_" in html
def test_overview_one_mosaic_per_zone(tmp_path):
"""Zones géographiques distantes : une mosaïque PAR ZONE (pas une grille
globale étirée sur des centaines de km aux coins introuvables)."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
# Deux clusters de 2×3 dalles, à ~400 km l'un de l'autre
for col in (1000, 1001, 1002):
for row in (6881, 6882):
_make_fake_viz_dir(vis_dir, "a", col, row, ('hillshade_multi',))
for col in (1400, 1401, 1402):
for row in (6481, 6482):
_make_fake_viz_dir(vis_dir, "b", col, row, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
items = data['overview']['hillshade_multi']['items']
assert len(items) == 2
assert items[0]['km_w'] == 3 and items[0]['km_h'] == 2
assert items[1]['km_w'] == 3 and items[1]['km_h'] == 2
# Les deux mosaïques sont distinctes (coins différents)
assert items[0]['corners'] != items[1]['corners']
def test_overview_skipped_for_small_grids(tmp_path):
"""Grille < 6 dalles : pas d'overview, les vignettes suffisent."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
assert data['overview'] == {}
def test_subtile_corners_shared_edges_exact(): def test_subtile_corners_shared_edges_exact():

View File

@ -258,7 +258,7 @@ def test_ui_offers_reorder_and_blend_modes():
# Pile isolée + fusion par couche et fusion de la pile sur le fond # Pile isolée + fusion par couche et fusion de la pile sur le fond
assert "createPane('lidarStack')" in _MAP_JS assert "createPane('lidarStack')" in _MAP_JS
assert "isolation = 'isolate'" in _MAP_JS assert "isolation = 'isolate'" in _MAP_JS
assert "map.createPane(pane, stack)" in _MAP_JS assert "map.createPane(paneName, stack)" in _MAP_JS
assert "pane.style.mixBlendMode" in _MAP_JS assert "pane.style.mixBlendMode" in _MAP_JS
assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML
# Le lien partagé transporte la fusion (4e champ, rétrocompatible) # Le lien partagé transporte la fusion (4e champ, rétrocompatible)
@ -595,3 +595,231 @@ def test_background_scan_detects_and_fills(tmp_path, monkeypatch):
assert mapserve.background_scan(tmp_path) > 0 assert mapserve.background_scan(tmp_path) > 0
finally: finally:
_reset_bg(mapserve) _reset_bg(mapserve)
# ---------------------------------------------------------------------------
# Génération de tuiles (portée de la webapp historique)
# ---------------------------------------------------------------------------
import types
from fastapi import HTTPException
import pytest
class _ClientRequest:
"""Requête minimale avec IP de connexion (restriction LIDAR_REGEN_CIDR)."""
def __init__(self, ip, xff=None):
self.client = types.SimpleNamespace(host=ip)
self.headers = {"x-forwarded-for": xff} if xff else {}
def _gen_setup(tmp_path, monkeypatch):
import lidar_pipeline.mapserve as mapserve
_make_dalle(tmp_path, 1054, 6882, ["aspect", "slope"])
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(mapserve, "INPUT_DIR", tmp_path / "input")
monkeypatch.setattr(mapserve, "GENERATION_URL", "")
monkeypatch.setattr(mapserve, "LOG_FILE", tmp_path / ".generation.log")
monkeypatch.setattr(mapserve, "JOB_FILE", tmp_path / ".generation.job.json")
monkeypatch.setattr(mapserve, "QUEUE_FILE", tmp_path / ".generation.queue.json")
mapserve._queue.clear()
mapserve._pipeline_import_cache.clear()
return mapserve
def test_bbox_to_cells_covers_1km():
"""Une bbox d'1 km² WGS84 sélectionne la cellule L93 correspondante."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline.tiles import _transformer
x, y = 1054500, 6881500
w, n = _transformer("EPSG:2154", "EPSG:4326").transform(x - 200, y + 200)
e, s = _transformer("EPSG:2154", "EPSG:4326").transform(x + 200, y - 200)
cells = mapserve.bbox_to_cells(w, s, e, n)
assert (1054, 6882) in cells
# Cellule (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km
assert all(1053 <= c <= 1055 and 6881 <= r <= 6883 for c, r in cells)
def test_preview_bbox_lists_cells_with_corners(tmp_path, monkeypatch):
"""Le preview retourne les cellules à traiter avec leurs coins GPS."""
mapserve = _gen_setup(tmp_path, monkeypatch)
from lidar_pipeline.tiles import _transformer
x, y = 1054500, 6881500
w, n = _transformer("EPSG:2154", "EPSG:4326").transform(x - 100, y + 100)
e, s = _transformer("EPSG:2154", "EPSG:4326").transform(x + 100, y - 100)
req = mapserve.PreviewRequest(bbox=[w, s, e, n], viz=["aspect"])
d = mapserve.preview(req)
assert d["capped"] is False
cells = {(c["col"], c["row"]) for c in d["cells"]}
assert (1054, 6882) in cells
first = d["cells"][0]
assert first["corners"] and len(first["corners"]) == 4
def test_preview_rejects_unknown_viz(tmp_path, monkeypatch):
mapserve = _gen_setup(tmp_path, monkeypatch)
req = mapserve.PreviewRequest(bbox=[0.0, 44.0, 0.01, 44.01],
viz=["inconnue"])
with pytest.raises(HTTPException) as e:
mapserve.preview(req)
assert e.value.status_code == 400
def test_preview_all_missing_without_laz(tmp_path, monkeypatch):
"""Passe globale sans dalle dans input/ : liste vide (rien à compléter).
L'erreur 400 « aucune tuile à compléter » ne concerne que le lancement
(/api/generate → _resolve_request) : le preview reste informatif.
"""
mapserve = _gen_setup(tmp_path, monkeypatch)
(tmp_path / "input").mkdir()
req = mapserve.PreviewRequest(all_missing=True, viz=["aspect"])
d = mapserve.preview(req)
assert d["count"] == 0 and d["cells"] == []
def test_generate_validates_request(tmp_path, monkeypatch):
mapserve = _gen_setup(tmp_path, monkeypatch)
for kwargs in ({"tiles": []},
{"tiles": [[1054]]},
{"tiles": [[1054, 6882]], "ground_class": "inconnue"},
{"tiles": [[c, 6882] for c in range(500)]}):
req = mapserve.GenerateRequest(**kwargs)
with pytest.raises(HTTPException) as e:
mapserve._resolve_request(req)
assert e.value.status_code == 400
# Tri nord → sud (row décroissante) puis col croissante
req = mapserve.GenerateRequest(tiles=[[1055, 6882], [1054, 6883],
[1054, 6882]])
tiles, _viz = mapserve._resolve_request(req)
assert tiles == [(1054, 6883), (1054, 6882), (1055, 6882)]
def test_generate_launches_and_queues(tmp_path, monkeypatch):
mapserve = _gen_setup(tmp_path, monkeypatch)
launched = []
monkeypatch.setattr(mapserve, "_launch_job",
lambda tiles, viz, req, qid=None:
launched.append((tiles, qid)) or ["fake", "cmd"])
req = mapserve.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"])
d = mapserve.generate(req)
assert d["demarré"] is True and d["tuiles"] == 1
# Run en cours : la demande part en file (jamais de coupure du travail)
import threading
with mapserve._job_lock:
mapserve._job["proc"] = types.SimpleNamespace(
poll=lambda: None, pid=4242, terminate=lambda: None, wait=lambda: None)
d = mapserve.generate(req)
assert d["demarré"] is False and d["en_file"] == 1
assert mapserve.generate(req)["en_file"] == 2
assert len(mapserve._queue_summary()) == 2
d = mapserve.queue_clear()
assert d["retirées"] == 2
with mapserve._job_lock:
mapserve._job["proc"] = None
mapserve._queue.clear()
def test_status_reports_rights_and_availability(tmp_path, monkeypatch):
"""/api/status annonce available (pipeline local) et regen_allowed (IP)."""
mapserve = _gen_setup(tmp_path, monkeypatch)
monkeypatch.setattr(mapserve, "_pipeline_import_cache", {"ok": True})
d = mapserve.generation_status(_ClientRequest("192.168.3.10"))
assert d["available"] is True and d["regen_allowed"] is True
assert d["running"] is False and d["tiles"] == []
# Client hors réseau local : génération interdite (boutons masqués)
d = mapserve.generation_status(_ClientRequest("203.0.113.7"))
assert d["regen_allowed"] is False
# X-Forwarded-For honoré seulement depuis un pair de confiance
d = mapserve.generation_status(
_ClientRequest("172.25.0.1", xff="203.0.113.7"))
assert d["regen_allowed"] is False
d = mapserve.generation_status(
_ClientRequest("172.25.0.1", xff="192.168.3.10"))
assert d["regen_allowed"] is True
# Image légère sans pipeline ni LIDAR_GENERATION_URL : indisponible
monkeypatch.setattr(mapserve, "_pipeline_import_cache", {"ok": False})
d = mapserve.generation_status(_ClientRequest("192.168.3.10"))
assert d["available"] is False
def test_status_proxies_to_worker(tmp_path, monkeypatch):
"""Webapp légère : /api/status relaie l'état du worker, 503 hors ligne."""
mapserve = _gen_setup(tmp_path, monkeypatch)
monkeypatch.setattr(mapserve, "GENERATION_URL", "http://worker:8973")
monkeypatch.setattr(mapserve, "_pipeline_import_cache", {"ok": False})
def fake_proxy(method, path, payload=None, timeout=30):
assert (method, path) == ("GET", "/api/status")
return {"running": True, "tiles": []}
monkeypatch.setattr(mapserve, "_proxy_generation", fake_proxy)
d = mapserve.generation_status(_ClientRequest("192.168.3.10"))
assert d["distant"] is True and d["running"] is True
assert d["available"] is True and d["regen_allowed"] is True
def offline(method, path, payload=None, timeout=30):
raise HTTPException(503, "machine de traitement injoignable")
monkeypatch.setattr(mapserve, "_proxy_generation", offline)
d = mapserve.generation_status(_ClientRequest("192.168.3.10"))
assert d["worker_offline"] is True and d["running"] is None
def test_generate_requires_lan(tmp_path, monkeypatch):
"""Un client hors LIDAR_REGEN_CIDR ne peut pas lancer de génération."""
mapserve = _gen_setup(tmp_path, monkeypatch)
with pytest.raises(HTTPException) as e:
mapserve._require_lan_for_generation(_ClientRequest("203.0.113.7"))
assert e.value.status_code == 403
def test_cell_at_point(tmp_path, monkeypatch):
"""/api/cell donne la dalle sous un point (sélection manuelle)."""
mapserve = _gen_setup(tmp_path, monkeypatch)
from lidar_pipeline.tiles import _transformer
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1054500, 6881500)
d = mapserve.cell_at_point(lat=lat, lng=lon)
assert (d["col"], d["row"]) == (1054, 6882)
assert d["corners"] and len(d["corners"]) == 4
def test_tiles_inventory_endpoint(tmp_path, monkeypatch):
"""/api/tiles sert index_tiles.json (machines légères LIDAR_SOURCE_URL)."""
mapserve = _gen_setup(tmp_path, monkeypatch)
payload = {"tiles": [{"col": 1054, "row": 6882,
"viz": {"aspect": {"full": "visualisations/x.avif?v=1"}}}],
"viz_meta": {}, "stats": {"n_tiles": 1}}
(tmp_path / "index_tiles.json").write_text(
__import__("json").dumps(payload), encoding="utf-8")
d = mapserve.tiles_inventory()
assert d["tiles"] == payload["tiles"] and d["stamp"] > 0
# ?stamp inchangé → réponse allégée
light = mapserve.tiles_inventory(stamp=d["stamp"])
assert light["tiles"] is None
def test_ui_offers_generation_controls():
"""L'interface embarque les contrôles de génération (+ Zone, options, run)."""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
assert 'id="btnZone"' in _MAP_HTML and 'id="btnComplete"' in _MAP_HTML
assert 'id="gencard"' in _MAP_HTML and 'id="genGo"' in _MAP_HTML
assert 'id="genStop"' in _MAP_HTML and 'id="genQueueClear"' in _MAP_HTML
assert 'id="genViz"' in _MAP_HTML and 'id="genEdge"' in _MAP_HTML
# Boutons masqués par défaut : révélés seulement si /api/status autorise
assert "GEN.available && GEN.allowed" in _MAP_JS
# Dessin de zone + cadres de run + sondage d'état
assert "genSetDrawing" in _MAP_JS and "pane: 'genDraw'" in _MAP_JS
assert "gen-frame-running" in _MAP_CSS and "genDrawFrames" in _MAP_JS
assert "api/status" in _MAP_JS and "api/preview" in _MAP_JS
assert "api/generate" in _MAP_JS and "api/stop" in _MAP_JS
assert "all_missing: true" in _MAP_JS
# Fin de run : rafraîchissement des couches + relance de la maintenance
assert "genAfterRun" in _MAP_JS and "api/map/background" in _MAP_JS

View File

@ -405,7 +405,7 @@ def test_png_palette_option_shrinks_tiles(tmp_path, monkeypatch):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _remote_payload(): def _remote_payload():
"""Charge utile /api/tiles telle que la sert la webapp du pipeline.""" """Charge utile /api/tiles telle que la sert mapserve (serveur de dalles)."""
base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2" base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2"
tiles = [] tiles = []
for i in range(2): for i in range(2):

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ les dalles qui l'intersectent (transformation projective par dalle, erreur très
inférieure au pixel), puis mise en cache sur disque. inférieure au pixel), puis mise en cache sur disque.
Volontairement sans GDAL ni numpy : Pillow + pyproj suffisent, l'image légère Volontairement sans GDAL ni numpy : Pillow + pyproj suffisent, l'image légère
(Dockerfile.maps / Dockerfile.webapp) reste petite et portable ARM64. (Dockerfile.maps) reste petite et portable ARM64.
""" """
import logging import logging
@ -47,7 +47,7 @@ _MID_RES = 1000.0 / 640
_SUBTILE_RE = re.compile(r"_(\d+)_(\d+)\.avif$") _SUBTILE_RE = re.compile(r"_(\d+)_(\d+)\.avif$")
# Serveur de dalles amont (webapp du pipeline) : quand il est défini, l'index # Serveur de dalles amont (mapserve du worker) : quand il est défini, l'index
# des sources vient de son /api/tiles et les images manquantes sont rapatriées # 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 # à la demande dans le cache local — le conteneur carte n'a alors besoin
# d'aucune donnée locale au démarrage. # d'aucune donnée locale au démarrage.

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Génère un certificat auto-signé pour servir la webapp en HTTPS.
#
# Pourquoi : l'API Geolocation des navigateurs (bouton ⌖ « centrer sur la
# position GPS du téléphone ») n'est disponible qu'en contexte sécurisé
# (HTTPS). Servir la carte en https://<hôte>:PORT débloque le GPS sur
# téléphone ; le certificat auto-signé affiche un avertissement « non fiable »
# que l'on accepte une fois (l'usage est sur le LAN, pas exposé).
#
# Usage :
# ./make-tls-cert.sh # crée tls/webapp.crt + tls/webapp.key
# ./make-tls-cert.sh --force # régénère même si le certificat existe
#
# Le certificat est monté dans le conteneur (./tls → /app/tls) et activé par
# les variables LIDAR_SSL_CERTFILE / LIDAR_SSL_KEYFILE (cf. serve-webapp.sh
# et docs/DEPLOY_WEBAPP.md).
set -euo pipefail
cd "$(dirname "$0")"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
mkdir -p tls
# Garde-fou : si docker compose a déjà monté ./tls, Docker l'a créé root:root.
# En tant qu'utilisateur (non root), on ne peut pas écrire dedans.
if [ ! -w tls ]; then
echo "Erreur : tls/ n'est pas writable par $(id -un) (souvent créé root par Docker)." >&2
echo "Corriger : sudo chown \"$(id -un)\" tls puis relancer $0" >&2
exit 1
fi
if [ "$FORCE" -eq 0 ] && [ -f tls/webapp.crt ] && [ -f tls/webapp.key ]; then
echo "Certificat auto-signé déjà présent : tls/webapp.crt + tls/webapp.key"
echo "Régénérer : $0 --force"
else
CN="${LIDAR_CERT_CN:-lidar-webapp}"
# SAN : nom local du conteneur + IP générique. Le téléphone se connecte
# par l'IP du Pi ; le SAN IP ne couvre pas cette IP dynamique d'où
# l'avertissement de nom, mais le certificat reste valable (10 ans).
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout tls/webapp.key -out tls/webapp.crt \
-subj "/CN=${CN}" \
-addext "subjectAltName=DNS:${CN},DNS:lidar-webapp,DNS:*.local,IP:0.0.0.0" \
> /dev/null
chmod 600 tls/webapp.key
echo "Certificat auto-signé créé : tls/webapp.crt + tls/webapp.key (valable 10 ans, CN=${CN})"
fi
echo
echo "Activation (webapp.env) :"
echo " LIDAR_SSL_CERTFILE=/app/tls/webapp.crt"
echo " LIDAR_SSL_KEYFILE=/app/tls/webapp.key"
echo "serve-webapp.sh monte ./tls → /app/tls et passe ces variables automatiquement."
echo "Puis : ./serve-webapp.sh restart — la carte répond sur https://<hôte>:PORT."

98
run.sh
View File

@ -21,11 +21,11 @@
# --skip VIZ... Exclure ces visualisations # --skip VIZ... Exclure ces visualisations
# --file NOM... Traiter un ou plusieurs fichiers LAZ # --file NOM... Traiter un ou plusieurs fichiers LAZ
# --test Exécuter les tests unitaires # --test Exécuter les tests unitaires
# --serve [PORT] Carte + API de génération (image complète, défaut: 8973) # --serve [PORT] Carte + tuiles XYZ + génération (image complète, défaut: 8973)
# --serve-webapp [PORT] # --serve-maps [PORT]
# Conteneur webapp seul (image légère) : carte servie depuis # Conteneur carte seul (image légère lidar-maps) : tuiles
# le cache local de tuiles, génération déléguée à la machine # servies depuis le cache local, génération déléguée via
# de traitement (LIDAR_GENERATION_URL), tuiles à la demande # LIDAR_GENERATION_URL, dalles rapatriées via LIDAR_SOURCE_URL
# -h Afficher l'aide complète # -h Afficher l'aide complète
set -e set -e
@ -106,9 +106,6 @@ NO_INDEX_FLAG=""
FETCH_TILES_ARGS="" FETCH_TILES_ARGS=""
SERVE_FLAG=0 SERVE_FLAG=0
SERVE_PORT=8973 SERVE_PORT=8973
WEBAPP_FLAG=0
WEBAPP_SERVE_PORT=8973
WEBAPP_IMAGE="lidar-webapp"
MAPS_FLAG=0 MAPS_FLAG=0
MAPS_SERVE_PORT=8975 MAPS_SERVE_PORT=8975
MAPS_IMAGE="lidar-maps" MAPS_IMAGE="lidar-maps"
@ -153,10 +150,6 @@ while [ $# -gt 0 ]; do
SERVE_FLAG=1; shift SERVE_FLAG=1; shift
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then SERVE_PORT="$1"; shift; fi if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then SERVE_PORT="$1"; shift; fi
;; ;;
--serve-webapp)
WEBAPP_FLAG=1; shift
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then WEBAPP_SERVE_PORT="$1"; shift; fi
;;
--serve-maps) --serve-maps)
MAPS_FLAG=1; shift MAPS_FLAG=1; shift
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then MAPS_SERVE_PORT="$1"; shift; fi if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then MAPS_SERVE_PORT="$1"; shift; fi
@ -189,15 +182,12 @@ while [ $# -gt 0 ]; do
echo " --file NOM... Traiter un ou plusieurs fichiers LAZ" echo " --file NOM... Traiter un ou plusieurs fichiers LAZ"
echo " --fetch-tiles COL,ROW..." echo " --fetch-tiles COL,ROW..."
echo " Télécharger ces dalles depuis l'IGN puis les traiter" echo " Télécharger ces dalles depuis l'IGN puis les traiter"
echo " --serve [PORT] Servir la carte + API de génération de zones (défaut: 8973)" echo " --serve [PORT] Carte + tuiles XYZ + API de génération (image complète,"
echo " --serve-webapp [PORT]" echo " défaut 8973) — cf. docker-compose.yml (service serve)"
echo " Conteneur webapp seul (image légère, sans PDAL/GPU) :"
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 " --serve-maps [PORT]"
echo " Carte à tuiles XYZ (image légère lidar-maps, défaut 8975) :" echo " Carte à tuiles XYZ (image légère lidar-maps, défaut 8975) :"
echo " schéma OpenStreetMap, réutilisable dans JOSM/QGIS/iD" echo " schéma OpenStreetMap, réutilisable dans JOSM/QGIS/iD ;"
echo " génération déléguée via LIDAR_GENERATION_URL"
echo " (cf. docs/MAPS.md)" echo " (cf. docs/MAPS.md)"
echo " --test Exécuter les tests unitaires" echo " --test Exécuter les tests unitaires"
echo " -h Afficher cette aide" echo " -h Afficher cette aide"
@ -254,17 +244,6 @@ build_image() {
rm -f "$log" rm -f "$log"
} }
# Image webapp légère (conteneur webapp seul — cf. Dockerfile.webapp)
build_webapp_image() {
local log="/tmp/lidar_webapp_build_$$.log"
echo "Mise à jour de l'image Docker webapp (cache)..."
if ! docker build -f Dockerfile.webapp -t "$WEBAPP_IMAGE" "$SCRIPT_DIR" >"$log" 2>&1; then
cat "$log" >&2
echo "Échec du build de l'image webapp (journal : $log)" >&2
exit 1
fi
}
# Image carte à tuiles XYZ (cf. Dockerfile.maps) # Image carte à tuiles XYZ (cf. Dockerfile.maps)
build_maps_image() { build_maps_image() {
local log="/tmp/lidar_maps_build_$$.log" local log="/tmp/lidar_maps_build_$$.log"
@ -294,10 +273,12 @@ build_image
# Créer les répertoires s'ils n'existent pas # Créer les répertoires s'ils n'existent pas
mkdir -p "$INPUT_DIR" "$OUTPUT_DIR" mkdir -p "$INPUT_DIR" "$OUTPUT_DIR"
# Mode --serve : carte + API de génération (input/ en écriture) # Mode --serve : carte à tuiles XYZ + API de génération (image complète,
# input/ en écriture pour le téléchargement IGN). Équivalent conteneurisé :
# docker compose up -d --build serve (port 8973).
if [ "$SERVE_FLAG" -eq 1 ]; then if [ "$SERVE_FLAG" -eq 1 ]; then
echo "============================================" echo "============================================"
echo " Carte LiDAR + API de génération" echo " Carte LiDAR — tuiles XYZ + génération"
echo "============================================" echo "============================================"
echo " http://127.0.0.1:${SERVE_PORT}/" echo " http://127.0.0.1:${SERVE_PORT}/"
echo " GPU : $([ -n "$GPU_FLAG" ] && echo 'OUI' || echo 'non')" echo " GPU : $([ -n "$GPU_FLAG" ] && echo 'OUI' || echo 'non')"
@ -310,7 +291,7 @@ if [ "$SERVE_FLAG" -eq 1 ]; then
fi fi
# Réseau autorisé à lancer les générations (/api/generate) : la valeur # Réseau autorisé à lancer les générations (/api/generate) : la valeur
# vient de l'environnement hôte LIDAR_REGEN_CIDR ; sans elle, le défaut # vient de l'environnement hôte LIDAR_REGEN_CIDR ; sans elle, le défaut
# de la webapp s'applique (localhost + RFC1918). Chaîne VIDE pour lever # du serveur s'applique (localhost + RFC1918). Chaîne VIDE pour lever
# complètement la restriction. # complètement la restriction.
REGEN_ENV_FLAG="-e LIDAR_REGEN_CIDR=${LIDAR_REGEN_CIDR-127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" REGEN_ENV_FLAG="-e LIDAR_REGEN_CIDR=${LIDAR_REGEN_CIDR-127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
exec docker run --rm --init $GPU_FLAG \ exec docker run --rm --init $GPU_FLAG \
@ -319,9 +300,10 @@ if [ "$SERVE_FLAG" -eq 1 ]; then
-v "${INPUT_DIR}:/data/input" \ -v "${INPUT_DIR}:/data/input" \
-v "${OUTPUT_DIR}:/data/output" \ -v "${OUTPUT_DIR}:/data/output" \
-e LIDAR_INPUT_DIR=/data/input -e LIDAR_OUTPUT_DIR=/data/output \ -e LIDAR_INPUT_DIR=/data/input -e LIDAR_OUTPUT_DIR=/data/output \
-e LIDAR_PORT=8973 \
$GPU_ENV_FLAG $REGEN_ENV_FLAG \ $GPU_ENV_FLAG $REGEN_ENV_FLAG \
"$IMAGE_NAME" \ "$IMAGE_NAME" \
python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 python3 -m uvicorn lidar_pipeline.mapserve:app --host 0.0.0.0 --port 8973
fi fi
# Mode --serve-maps : carte à tuiles XYZ (image légère lidar-maps). # Mode --serve-maps : carte à tuiles XYZ (image légère lidar-maps).
@ -329,6 +311,8 @@ fi
# consultable ici et les mêmes URLs servent de fond d'imagerie dans JOSM, iD, # 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 : # QGIS ou uMap. Variables optionnelles depuis l'environnement hôte :
# LIDAR_MAPS_URL serveur de tuiles amont (carte déportée) # LIDAR_MAPS_URL serveur de tuiles amont (carte déportée)
# LIDAR_SOURCE_URL serveur de dalles amont (inventaire /api/tiles)
# LIDAR_GENERATION_URL machine de traitement pour la génération déléguée
# LIDAR_ATTRIBUTION mention d'attribution servie aux clients # LIDAR_ATTRIBUTION mention d'attribution servie aux clients
if [ "$MAPS_FLAG" -eq 1 ]; then if [ "$MAPS_FLAG" -eq 1 ]; then
build_maps_image build_maps_image
@ -339,10 +323,16 @@ if [ "$MAPS_FLAG" -eq 1 ]; then
echo " Carte : http://127.0.0.1:${MAPS_SERVE_PORT}/" 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 " 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 " JOSM / QGIS : /tiles/josm.imagery.xml, /tiles/wmts.xml"
echo " Amont : ${LIDAR_MAPS_URL:-aucun (rendu local)}" echo " Amont tuiles : ${LIDAR_MAPS_URL:-aucun (rendu local)}"
echo " Amont dalles : ${LIDAR_SOURCE_URL:-aucun (cache local seul)}"
echo " Génération : ${LIDAR_GENERATION_URL:-locale si pipeline présent}"
echo "============================================" echo "============================================"
MAPS_ENV=() MAPS_ENV=()
[ -n "${LIDAR_MAPS_URL:-}" ] && MAPS_ENV+=(-e LIDAR_MAPS_URL="$LIDAR_MAPS_URL") [ -n "${LIDAR_MAPS_URL:-}" ] && MAPS_ENV+=(-e LIDAR_MAPS_URL="$LIDAR_MAPS_URL")
[ -n "${LIDAR_SOURCE_URL:-}" ] && MAPS_ENV+=(-e LIDAR_SOURCE_URL="$LIDAR_SOURCE_URL")
[ -n "${LIDAR_GENERATION_URL:-}" ] && MAPS_ENV+=(-e LIDAR_GENERATION_URL="$LIDAR_GENERATION_URL")
[ -n "${LIDAR_REMOTE_TOKEN:-}" ] && MAPS_ENV+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN")
[ -n "${LIDAR_REGEN_CIDR:-}" ] && MAPS_ENV+=(-e LIDAR_REGEN_CIDR="$LIDAR_REGEN_CIDR")
[ -n "${LIDAR_ATTRIBUTION:-}" ] && MAPS_ENV+=(-e LIDAR_ATTRIBUTION="$LIDAR_ATTRIBUTION") [ -n "${LIDAR_ATTRIBUTION:-}" ] && MAPS_ENV+=(-e LIDAR_ATTRIBUTION="$LIDAR_ATTRIBUTION")
exec docker run --rm --init \ exec docker run --rm --init \
--user 1000:1000 \ --user 1000:1000 \
@ -353,44 +343,6 @@ if [ "$MAPS_FLAG" -eq 1 ]; then
"$MAPS_IMAGE" "$MAPS_IMAGE"
fi 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
# servies à la demande depuis LIDAR_GENERATION_URL (plus de rsync).
# Variables passées telles quelles depuis l'environnement hôte :
# LIDAR_GENERATION_URL machine de traitement (ex: http://192.168.1.50:8973)
# LIDAR_REMOTE_TOKEN jeton si la machine exige LIDAR_API_TOKEN
# LIDAR_REGEN_CIDR réseau autorisé à lancer les générations
# (défaut 192.168.1.0/24, vide = restriction levée)
if [ "$WEBAPP_FLAG" -eq 1 ]; then
build_webapp_image
mkdir -p "$OUTPUT_DIR"
echo "============================================"
echo " Carte LiDAR — webapp seule (image légère)"
echo "============================================"
echo " http://127.0.0.1:${WEBAPP_SERVE_PORT}/"
echo " Génération distante : ${LIDAR_GENERATION_URL:-non configurée (LIDAR_GENERATION_URL)}"
echo " Cache local tuiles : à la demande (LIDAR_GENERATION_URL)"
echo "============================================"
WEBAPP_ENV=()
[ -n "$LIDAR_GENERATION_URL" ] && WEBAPP_ENV+=(-e LIDAR_GENERATION_URL="$LIDAR_GENERATION_URL")
[ -n "$LIDAR_REMOTE_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN")
[ -n "$LIDAR_API_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_API_TOKEN="$LIDAR_API_TOKEN")
[ -n "${LIDAR_REGEN_CIDR:-}" ] && WEBAPP_ENV+=(-e LIDAR_REGEN_CIDR="$LIDAR_REGEN_CIDR")
# Clé SSH du hôte (déploiement, git pull distant) : montée en lecture seule
# si présente. LIDAR_MOUNT_SSH=0 pour ne PAS l'exposer au conteneur.
WEBAPP_SSH=()
[ "${LIDAR_MOUNT_SSH:-1}" = "1" ] && [ -d "${HOME}/.ssh" ] && WEBAPP_SSH+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro")
exec docker run --rm --init \
--user 1000:1000 \
-p "${WEBAPP_SERVE_PORT}:8973" \
-v "${OUTPUT_DIR}:/data/output" \
"${WEBAPP_SSH[@]}" \
-e LIDAR_OUTPUT_DIR=/data/output \
"${WEBAPP_ENV[@]}" \
"$WEBAPP_IMAGE"
fi
# Lancer le pipeline # Lancer le pipeline
echo "============================================" echo "============================================"
echo " Pipeline LiDAR Archéologique" echo " Pipeline LiDAR Archéologique"

View File

@ -1,180 +0,0 @@
#!/bin/bash
# Stack webapp LiDAR — gestion de la stack compose sur la machine légère
# (Raspberry Pi). Cf. docs/DEPLOY_WEBAPP.md.
#
# Pilote docker compose (jamais docker run direct, qui ignorerait labels et
# volumes de l'override) :
# - docker-compose.webapp.yml (image légère Dockerfile.webapp, port 8973) ;
# - docker-compose.webapp.override.yml s'il existe (non versionné : labels
# Traefik, volume réel, environnement local) — chargé automatiquement :
# la route publique HTTPS et le cache de tuiles réels sont préservés.
#
# Configuration : variables dans webapp.env (modèle : webapp.env.example,
# non versionné) ou exportées — elles alimentent l'interpolation compose
# (${LIDAR_WEBAPP_HOST} des labels Traefik, etc.) ; l'environnement du
# CONTENEUR vit dans les fichiers compose / override.
#
# HTTPS : terminé par Traefik (override). Sans Traefik, monter ./tls via un
# override local avec LIDAR_SSL_CERTFILE / LIDAR_SSL_KEYFILE (cf.
# make-tls-cert.sh) — ce script ne gère plus le certificat auto-signé.
#
# Usage :
# ./serve-webapp.sh [start] démarrer (build + arrière-plan, redémarrage auto)
# ./serve-webapp.sh stop arrêter et supprimer la stack (cache conservé)
# ./serve-webapp.sh restart reconstruire l'image et recréer le conteneur
# ./serve-webapp.sh redeploir git pull + rebuild de l'image + redémarrage du conteneur
# ./serve-webapp.sh status état du conteneur et de la dernière sync
# ./serve-webapp.sh sync forcer un rebuild + régénération des vignettes
# ./serve-webapp.sh logs suivre les logs du conteneur
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTAINER="lidar-webapp"
COMPOSE_BASE="docker-compose.webapp.yml"
COMPOSE_OVERRIDE="docker-compose.webapp.override.yml"
# Configuration locale (non versionnée) : webapp.env surcharge l'environnement
# (sert à l'interpolation ${...} des fichiers compose).
if [ -f "${SCRIPT_DIR}/webapp.env" ]; then
set -a
# shellcheck disable=SC1091
. "${SCRIPT_DIR}/webapp.env"
set +a
fi
WEBAPP_PORT="${WEBAPP_PORT:-8973}"
COMPOSE_FILES=(-f "${SCRIPT_DIR}/${COMPOSE_BASE}")
if [ -f "${SCRIPT_DIR}/${COMPOSE_OVERRIDE}" ]; then
COMPOSE_FILES+=(-f "${SCRIPT_DIR}/${COMPOSE_OVERRIDE}")
fi
compose() {
docker compose "${COMPOSE_FILES[@]}" "$@"
}
# Port hôte réellement publié par le conteneur (mapping compose) ; repli sur
# WEBAPP_PORT tant que le conteneur est arrêté.
published_port() {
local p
p="$(docker port "$CONTAINER" 8973/tcp 2>/dev/null | head -1 | sed 's/.*://')"
echo "${p:-$WEBAPP_PORT}"
}
lan_ip() {
hostname -I 2> /dev/null | awk '{print $1}'
}
wait_up() {
echo -n "Attente du serveur sur le port $1..."
local up=0 i
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$1/api/status" > /dev/null 2>&1; then
up=1
break
fi
sleep 1
done
if [ "$up" != "1" ]; then
echo " KO (voir ./serve-webapp.sh logs)" >&2
exit 1
fi
echo " OK"
}
cmd_start() {
compose up -d --build
wait_up "$(published_port)"
local port
port="$(published_port)"
echo "============================================"
echo " Carte LiDAR — webapp (${CONTAINER})"
echo "============================================"
echo " URL : http://127.0.0.1:${port}/"
if [ -n "$(lan_ip)" ]; then
echo " URL (LAN) : http://$(lan_ip):${port}/"
fi
if [ -f "${SCRIPT_DIR}/${COMPOSE_OVERRIDE}" ]; then
echo " Override compose : ${COMPOSE_OVERRIDE} chargé (Traefik/volume local)"
else
echo " Override compose : absent (HTTP direct, sans Traefik)"
fi
echo " Génération distante: ${LIDAR_GENERATION_URL:-non configurée (override / webapp.env)}"
echo "============================================"
}
cmd_stop() {
compose down
echo "Stack webapp arrêtée (conteneur ${CONTAINER} supprimé, cache conservé)."
}
cmd_restart() {
compose up -d --build --force-recreate
wait_up "$(published_port)"
echo "Stack webapp recréée (image reconstruite, relit webapp.env)."
}
# Mise à jour complète du checkout git + rebuild de l'image + redémarrage du
# conteneur. À lancer sur le Pi après un push vers origin : récupère les
# derniers commits, reconstruit l'image légère et recrée le conteneur. Le
# cache de tuiles (output/) est conservé — les vignettes déjà présentes ne
# sont pas régénérées (voir sync pour forcer un rebuild des vignettes).
cmd_redeploy() {
if [ ! -d "${SCRIPT_DIR}/.git" ]; then
echo "Échec : ${SCRIPT_DIR} n'est pas un dépôt git — impossible de faire git pull." >&2
exit 1
fi
echo "============================================"
echo " Redéploiement webapp (${CONTAINER})"
echo "============================================"
echo " Checkout git : ${SCRIPT_DIR}"
echo " Récupération des derniers commits (git pull)..."
git -C "${SCRIPT_DIR}" pull --ff-only
echo " Image reconstruite, conteneur recréé..."
compose up -d --build --force-recreate
wait_up "$(published_port)"
local port
port="$(published_port)"
echo "============================================"
echo " Webapp redéployée — http://127.0.0.1:${port}/"
echo "============================================"
}
cmd_status() {
compose ps
local port
port="$(published_port)"
if curl -fsS "http://127.0.0.1:${port}/api/sync" 2> /dev/null; then
echo ""
else
echo "Webapp : injoignable sur http://127.0.0.1:${port}" >&2
fi
}
cmd_sync() {
local port code
port="$(published_port)"
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:${port}/api/sync" || true)"
case "$code" in
200) echo "Sync lancée (suivi : ./serve-webapp.sh status, logs : ./serve-webapp.sh logs)" ;;
409) echo "Une sync est déjà en cours (suivi : ./serve-webapp.sh status)" ;;
*)
echo "Échec : HTTP ${code:-pas de réponse} (webapp injoignable sur http://127.0.0.1:${port} ?)" >&2
exit 1
;;
esac
}
CMD="${1:-start}"
case "$CMD" in
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
redeploir) cmd_redeploy ;;
status) cmd_status ;;
sync) cmd_sync ;;
logs) compose logs -f webapp ;;
*)
echo "Usage : $0 [start|stop|restart|redeploir|status|sync|logs]" >&2
exit 1
;;
esac

View File

@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Point d'entrée du conteneur webapp : sert la carte via uvicorn.
#
# HTTPS optionnel (requis pour la géolocalisation GPS depuis un téléphone) :
# si LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE sont définis ET que les fichiers
# existent (certificat auto-signé, cf. ./make-tls-cert.sh), la carte est servie
# en https://<hôte>:PORT et le bouton ⌖ fonctionne sur téléphone. Si les
# variables sont définies mais les fichiers absents (cert non encore généré),
# repli en HTTP avec un avertissement — la carte reste consultable, seul le
# GPS est indisponible.
set -euo pipefail
PORT="${LIDAR_PORT:-8973}"
SSL_ARGS=()
if [ -n "${LIDAR_SSL_CERTFILE:-}" ] && [ -n "${LIDAR_SSL_KEYFILE:-}" ]; then
if [ -f "${LIDAR_SSL_CERTFILE}" ] && [ -f "${LIDAR_SSL_KEYFILE}" ]; then
echo "Webapp servie en HTTPS (certificat ${LIDAR_SSL_CERTFILE}) — GPS actif sur téléphone"
SSL_ARGS=(--ssl-certfile "${LIDAR_SSL_CERTFILE}" --ssl-keyfile "${LIDAR_SSL_KEYFILE}")
else
echo "Avertissement : certificats TLS absents (${LIDAR_SSL_CERTFILE} / ${LIDAR_SSL_KEYFILE}) — service en HTTP." >&2
echo "Générer le certificat puis relancer : ./make-tls-cert.sh && docker compose up -d --build" >&2
fi
fi
exec python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port "${PORT}" ${SSL_ARGS[@]+"${SSL_ARGS[@]}"}

View File

@ -1,43 +0,0 @@
# Configuration de la stack webapp — copier en webapp.env et adapter.
# webapp.env est ignoré par git (jetons, chemins locaux).
# Syntaxe shell simple : KEY=valeur (les valeurs avec espaces entre guillemets).
# Port des sondes locales de serve-webapp.sh quand le conteneur est arrêté
# (le port publié vient du mapping docker-compose.webapp.yml ; défaut 8973)
#WEBAPP_PORT=8973
# Machine de traitement (service worker de docker-compose.worker.yml).
# Commenté : consultation autonome du cache local, aucune génération.
#LIDAR_GENERATION_URL=http://192.168.1.50:8973
# Jeton partagé si la machine de traitement définit LIDAR_API_TOKEN
#LIDAR_REMOTE_TOKEN=un-secret-à-partager
# Les tuiles sont servies à la demande depuis LIDAR_GENERATION_URL (cache
# local peuplé au fil des consultations) : plus de rsync ni de sync périodique.
# Réseaux autorisés à lancer les générations depuis la carte
# (liste de CIDR séparés par virgules ; défaut : localhost + plages
# privées 127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 ;
# vide pour lever la restriction)
#LIDAR_REGEN_CIDR=192.168.1.0/24
# --- GPS sur téléphone ---------------------------------------------------
# L'API Geolocation (bouton ⌖ « centrer sur la position GPS ») exige un
# contexte sécurisé (HTTPS). Deux façons de l'activer :
#
# a. Traefik (recommandé) : le webapp sert du HTTP, Traefik expose
# https://<domaine> avec un certificat valable. La config Traefik est
# dans l'override local docker-compose.webapp.override.yml (non
# versionné) — cf. docker-compose.webapp.override.yml.example et
# docs/DEPLOY_WEBAPP.md. Rien à définir dans ce fichier.
#
# b. Sans Traefik : certificat auto-signé monté via l'override compose
# local (docker-compose.webapp.override.yml) :
# 1. ./make-tls-cert.sh (crée tls/webapp.crt + tls/webapp.key)
# 2. ajouter à l'override le montage ./tls:/app/tls:ro et les deux
# variables ci-dessous, puis ./serve-webapp.sh restart.
# Accepter l'avertissement « certificat non fiable » sur le téléphone :
#LIDAR_SSL_CERTFILE=/app/tls/webapp.crt
#LIDAR_SSL_KEYFILE=/app/tls/webapp.key