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:
7
.gitignore
vendored
7
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
35
AGENTS.md
35
AGENTS.md
@ -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.
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@ -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/
|
||||||
|
|||||||
@ -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"]
|
|
||||||
@ -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
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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).
|
|
||||||
|
|||||||
35
docs/MAPS.md
35
docs/MAPS.md
@ -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 |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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("©", "©").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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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():
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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
@ -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
98
run.sh
@ -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"
|
||||||
|
|||||||
180
serve-webapp.sh
180
serve-webapp.sh
@ -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
|
|
||||||
@ -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[@]}"}
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user