From 0a2ccee95856c6466eb593b1b5844a02be0921ba Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Wed, 23 Sep 2026 23:21:05 +0200 Subject: [PATCH] =?UTF-8?q?Porter=20la=20g=C3=A9n=C3=A9ration=20de=20tuile?= =?UTF-8?q?s=20dans=20lidar-maps=20et=20retirer=20l'ancienne=20webapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .gitignore | 7 +- AGENTS.md | 35 +- Dockerfile | 13 +- Dockerfile.webapp | 58 - docker-compose.local-2m.yml | 64 - docker-compose.maps.yml | 18 +- docker-compose.webapp.override.yml.example | 37 - docker-compose.webapp.yml | 51 - docker-compose.worker.yml | 20 +- docker-compose.yml | 11 +- docs/DEPLOY_WEBAPP.md | 429 +-- docs/MAPS.md | 35 +- lidar_pipeline/cli.py | 16 +- lidar_pipeline/export.py | 474 --- lidar_pipeline/index.py | 3626 +------------------- lidar_pipeline/mapserve.py | 855 ++++- lidar_pipeline/mapui.py | 406 ++- lidar_pipeline/pipeline.py | 8 +- lidar_pipeline/progress.py | 6 +- lidar_pipeline/tests/test_export.py | 276 -- lidar_pipeline/tests/test_index.py | 245 +- lidar_pipeline/tests/test_mapserve.py | 230 +- lidar_pipeline/tests/test_tiles.py | 2 +- lidar_pipeline/tests/test_webapp.py | 1244 ------- lidar_pipeline/tiles.py | 4 +- lidar_pipeline/webapp.py | 1668 --------- make-tls-cert.sh | 53 - run.sh | 102 +- serve-webapp.sh | 180 - webapp-entrypoint.sh | 23 - webapp.env.example | 43 - 31 files changed, 1774 insertions(+), 8465 deletions(-) delete mode 100644 Dockerfile.webapp delete mode 100644 docker-compose.local-2m.yml delete mode 100644 docker-compose.webapp.override.yml.example delete mode 100644 docker-compose.webapp.yml delete mode 100644 lidar_pipeline/export.py delete mode 100644 lidar_pipeline/tests/test_export.py delete mode 100644 lidar_pipeline/tests/test_webapp.py delete mode 100644 lidar_pipeline/webapp.py delete mode 100755 make-tls-cert.sh delete mode 100755 serve-webapp.sh delete mode 100755 webapp-entrypoint.sh delete mode 100644 webapp.env.example diff --git a/.gitignore b/.gitignore index 4e3cc0f..10300f9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,7 @@ build/ .coverage htmlcov/ -# Interface bâchée dans l'image (régénérée au build, cf. Dockerfile.webapp) -lidar_pipeline/webapp_assets/ +# Interface bâchée dans l'image (régénérée au build, cf. Dockerfile.maps) lidar_pipeline/mapserve_assets/ # Docker @@ -48,10 +47,7 @@ lidar_pipeline/mapserve_assets/ # Fichiers de configuration locaux .env .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) data/ @@ -61,5 +57,4 @@ notebooks/ # Éventuels fichiers de cache matplotlib matplotlibrc -docker-compose.webapp.override.yml output-test/ diff --git a/AGENTS.md b/AGENTS.md index 84218d4..2be9c2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,30 +2,30 @@ - install: `docker build -t lidar-lidar .` (deps baked into image) - 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 générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes) -- simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` peuplé à la demande depuis le worker. Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS). -- carte à tuiles XYZ (style OSM/Google Maps, image légère `lidar-maps`, port 8975) : `docker compose -f docker-compose.maps.yml up -d --build` ou `./run.sh --serve-maps` — cf. `docs/MAPS.md`. Coexiste avec la webapp historique (8973) ; lecture seule (génération/export restent sur `webapp.py`). -- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git) ; mise à jour = `git pull` puis `restart` (rebuild inclus). Le script pilote `docker compose` et charge automatiquement `docker-compose.webapp.override.yml` s'il existe — utilisable sur le Pi de prod (192.168.3.10) : labels Traefik `lidar.example.fr` + volume réel `/srv/lidar/output` de l'override préservés. Mise à jour distante du Pi depuis ce poste : `ssh` (checkout `/srv/lidar_rendu`, procédure dans `docs/DEPLOY_WEBAPP.md` §2). +- 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` = mapserve sur l'image complète, API + tuiles + inventaire pour les cartes distantes) - 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.` - test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.::::` - lint: not configured - format: not configured - 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. -- 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` +- 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 -- **Generation is 0.2 m only** (policy): `/api/generate` (`GENERATE_RESOLUTIONS` in `webapp.py`), the compose `process` command and the CLI `-r` default all produce 0.2 m exclusively; 0.5 m stays available via explicit `-r 0.5`. Completeness detection (`complete_cells`) requires the viz at 0.2 m only. -- **Génération du nord au sud** : les tuiles sont traitées par ligne décroissante (row = nord en km), colonnes croissantes — `find_laz_files` (pipeline.py) pour les passes batch et `_resolve_request` (webapp.py) pour les runs lancés depuis la carte. Les workers prennent les fichiers dans l'ordre de soumission : la carte se remplit de haut en bas pendant un run (`--file` explicite au CLI = ordre utilisateur préservé). Parallélisme de génération : `LIDAR_WORKERS` (10 dans les compose ; fallback 10 dans `webapp.py`). +- **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. +- **`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. - **Tuiles XYZ réutilisables hors du projet** : `/tiles/{layer}/{z}/{x}/{y}.png` suit le schéma OpenStreetMap (256 px, EPSG:3857, PNG RGBA transparent hors emprise, CORS `*`) — JOSM, iD, QGIS, uMap et MapLibre le consomment tel quel, avec découverte via TileJSON / WMTS / `josm.imagery.xml`. Le 512 px (`@2x.webp`) est réservé à l'interface interne (moitié moins de requêtes en HTTP/1.1). Toute évolution du gabarit d'URL casse des configurations clientes : la changer demande une décision explicite. - **Bilingual naming**: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French. -- **Adding a visualization requires 4 edits**: (1) `generate_X()` in `visualizations.py`, (2) entry in `VIZ_STEPS` in `pipeline.py`, (3) entry in `COLORMAPS` in `rendering.py`, (4) entry in `VIZ_LEGENDS` in `index.py` (title/legend/description + sampled cmap gradient — single text source merged into `COLORMAPS` at import, also used by the export mosaic legend in `export.py`). Missing any one breaks the pipeline. +- **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`. - **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. @@ -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). - **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`. -- **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. -- **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). +- **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 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. - **`_`-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) ### 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. -- `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). -- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress. -- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp. +- `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 mapserve for live progress (frames on the map). ### 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. - **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. - **`_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. diff --git a/Dockerfile b/Dockerfile index 137b1ce..42ccd28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,13 +38,12 @@ COPY setup.py . COPY lidar_pipeline/ ./lidar_pipeline/ RUN pip3 install --no-cache-dir . -# CSS/JS de l'interface (app.js/app.css + Leaflet) bâchés dans l'image : -# la webapp les sert depuis webapp_assets/ du paquet installé, plus depuis -# le volume (cf. Dockerfile.webapp). cd /tmp : sans lui, python3 -c met le -# répertoire courant (/app, copie source) sur sys.path et le bake atterrirait -# dans la copie au lieu du paquet installé (dist-packages) qu'utilise le -# conteneur au runtime. -RUN cd /tmp && python3 -c "import pathlib, lidar_pipeline.index as i; i._write_ui_assets(pathlib.Path(i.__file__).resolve().parent / 'webapp_assets')" +# CSS/JS de l'interface de la carte (app.js/app.css + Leaflet) bâchés dans +# l'image : mapserve les sert depuis mapserve_assets/ du paquet installé. +# cd /tmp : sans lui, python3 -c met le répertoire courant (/app, copie +# source) sur sys.path et le bake atterrirait dans la copie au lieu du +# paquet installé (dist-packages) qu'utilise le 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)" # Copy backward-compatible entry point COPY process_lidar.py /usr/local/bin/ diff --git a/Dockerfile.webapp b/Dockerfile.webapp deleted file mode 100644 index daa9fc5..0000000 --- a/Dockerfile.webapp +++ /dev/null @@ -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"] diff --git a/docker-compose.local-2m.yml b/docker-compose.local-2m.yml deleted file mode 100644 index 219bb39..0000000 --- a/docker-compose.local-2m.yml +++ /dev/null @@ -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 diff --git a/docker-compose.maps.yml b/docker-compose.maps.yml index c9dea0b..79fa7e8 100644 --- a/docker-compose.maps.yml +++ b/docker-compose.maps.yml @@ -1,7 +1,9 @@ # Carte à tuiles XYZ (style OpenStreetMap / Google Maps) — cf. docs/MAPS.md. # -# Coexiste avec la webapp historique (port 8973) : même dossier output/, aucune -# interférence. Les tuiles sont rendues à la demande et mises en cache dans +# Sert la pyramide de tuiles + l'interface de consultation + la génération +# (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/. # # docker compose -f docker-compose.maps.yml up -d --build @@ -24,7 +26,17 @@ services: environment: - TZ=Europe/Paris - 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 à # la demande dans ./output — de quoi servir tout un catalogue distant. # - LIDAR_SOURCE_URL=http://192.168.1.50:8973 diff --git a/docker-compose.webapp.override.yml.example b/docker-compose.webapp.override.yml.example deleted file mode 100644 index 5583037..0000000 --- a/docker-compose.webapp.override.yml.example +++ /dev/null @@ -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:// — 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" diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml deleted file mode 100644 index 297adde..0000000 --- a/docker-compose.webapp.yml +++ /dev/null @@ -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://: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 diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 6be3c12..0ded456 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -1,10 +1,10 @@ # 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 -# webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent : -# dessin d'une zone → téléchargement IGN + traitement GPU ici, les tuiles -# sont servies à la demande par la webapp (cache peuplé au fil des -# consultations). +# cartes distantes (Raspberry Pi, docker-compose.maps.yml + LIDAR_GENERATION_URL) +# appellent : dessin d'une zone → téléchargement IGN + traitement GPU ici, +# dalles et tuiles servies aux machines légères (/api/tiles + statiques, +# /tiles/XYZ). # # docker compose -f docker-compose.worker.yml up -d --build # docker compose -f docker-compose.worker.yml logs -f worker @@ -13,7 +13,7 @@ # 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] 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: build: . image: lidar-lidar @@ -22,7 +22,8 @@ services: user: "1000:1000" gpus: all # retirer cette ligne sur une machine sans GPU 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: # input/ en écriture : l'API y télécharge les dalles IGN manquantes - ./input:/data/input @@ -31,7 +32,8 @@ services: - TZ=Europe/Paris - LIDAR_INPUT_DIR=/data/input - 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_WORKERS=auto # Une ligne par worker : BLAS/OpenMP mono-thread, sinon 12 workers × N @@ -41,9 +43,9 @@ services: - MKL_NUM_THREADS=1 - NUMEXPR_NUM_THREADS=1 # 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 - 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 # Traitement ponctuel des dalles input/ (une passe puis arrêt) diff --git a/docker-compose.yml b/docker-compose.yml index a677aa8..51f7aca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ # Lancement du pipeline LiDAR — TOUJOURS via docker compose : # docker compose up -d --build serve # TOUJOURS --build : le code est baké dans # # 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 down # arrêt # Traitement ponctuel (sans serveur) — --build pour la même raison : @@ -10,7 +10,7 @@ # docker compose --profile interactive up -d jupyter 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: build: . image: lidar-lidar @@ -28,18 +28,19 @@ services: - TZ=Europe/Paris - LIDAR_INPUT_DIR=/data/input - LIDAR_OUTPUT_DIR=/data/output + - LIDAR_PORT=8973 # Les générations lancées depuis la carte utilisent le GPU - LIDAR_GPU=1 - 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 - # (protège /api/generate, /api/preview, /api/rebuild, /api/sync). + # (protège /api/generate, /api/preview, /api/stop). # - LIDAR_API_TOKEN=change-moi # 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 # virgules, vide = tout autoriser). # - 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 # Traitement ponctuel des dalles input/ (une passe puis arrêt) diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index 2821c2a..fc7066d 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -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 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) - │ sert carte + vignettes (régénérées localement) +Navigateur ──HTTP──▶ Raspberry Pi (image légère lidar-maps, port 8975) + │ sert la carte + la pyramide (cache seule + maintenance + │ de fond, LIDAR_TILE_CACHE_ONLY / LIDAR_TILE_BACKGROUND) │ /api/generate, /api/preview, /api/status │ └─ 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, docker-compose.worker.yml service `worker`) : 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) -Le service `worker` joue le rôle de générateur : il accepte les demandes -de génération envoyées par les webapp distantes (téléchargement IGN + -traitement PDAL/GPU) et expose les tuiles produites, servies à la demande par les webapp. +Le service `worker` joue le rôle de générateur : il accepte les demandes de +génération envoyées par les cartes distantes (téléchargement IGN + traitement +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 docker compose -f docker-compose.worker.yml up -d --build # API sur http://: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/` : ```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, @@ -44,21 +50,18 @@ environment: - LIDAR_API_TOKEN=un-secret-à-partager ``` -## Raspberry Pi (webapp légère) +## Raspberry Pi (carte légère) ### 0. Prérequis sur le Pi - **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 - (`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 [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) (tester avec `docker compose version`). -- **Clé SSH côté hôte** : montée dans le conteneur pour le déploiement et le - `git pull` distant (le rsync de tuiles, supprimé, n'en avait pas besoin). -- **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). +- **Espace disque** : prévoir la taille du cache (dalles rapatriées + tuiles + rendues ; compter la taille de `output/` sur la machine de traitement). ### 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 ``` -Le clone SSH utilise la clé de l'hôte (`~/.ssh`, section 2). Si le serveur -git n'est pas encore connu, faire une première connexion pour accepter son -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). +`output/` et `input/` sont ignorés par git : le dépôt ne contient que le +code, le cache se remplit à la demande depuis la machine de traitement. -### 2. Accès SSH (déploiement) +### 2. Lancer la carte -Le rsync ayant été retiré, le SSH ne sert plus à ramener les tuiles (elles -sont servies à la demande, section 4). Il sert au déploiement et au support -: se connecter à la machine de traitement pour y inspecter `output/` ou y -déposer des tuiles manuellement. +`docker-compose.maps.yml` (versionné) sert de base ; la configuration locale +du Pi vit dans un **override** `docker-compose.maps.override.yml` (non +versionné) : -```bash -ssh-keygen -t ed25519 # si pas encore de clé -ssh-copy-id lidar@ # compte sur la machine de traitement -ssh lidar@ exit # 1re connexion : enregistre known_hosts +```yaml +name: lidar-maps +services: + 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 -non interactive (le conteneur ne peut pas répondre). +```bash +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 -traitement ; ce compte SSH sert à l'inspecter ou à y déposer des tuiles -manuellement si besoin. +La carte est sur `http://:8975/` (et via Traefik si configuré). -Avec `serve-webapp.sh` et `run.sh`, la clé SSH de l'hôte (`~/.ssh`, clé + -known_hosts) est montée automatiquement en lecture seule dans le conteneur ; -avec `docker compose`, le montage équivalent est à décommenter dans -`docker-compose.webapp.yml` (voir l'option c ci-dessous). +`LIDAR_TILE_CACHE_ONLY=1` + `LIDAR_TILE_BACKGROUND=1` inversent la charge sur +un petit Pi : la navigation ne rend rien (tuile absente = transparente, +`X-Tile-Pending`), une tâche de fond surveille les dalles nouvelles ou +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 -`ssh`, créé une fois (puis `chmod +x ssh`), -fournit l'accès autorisé avec transfert d'agent (`-A`) — les `git pull` -distants utilisent la clé locale — et refuse toute interaction (BatchMode) : +Les boutons **+ Zone** (dessiner un rectangle → téléchargement IGN + run sur +le worker), **⤒ Compléter** (dalles déjà téléchargées incomplètes) et +**↻ Générer cette dalle** (fiche d'infos au clic) sont masqués si : + +- 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 && 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 #!/bin/bash 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 exec ssh -A -o BatchMode=yes -o ConnectTimeout=5 \ -o StrictHostKeyChecking=accept-new "$HOST" "$@" ``` -Usage : `ssh` (Pi par défaut), `ssh [commande]`. Mise à jour -complète du Pi (checkout git en `/srv/lidar_rendu`, avec -l'override Traefik — jamais `serve-webapp.sh` en prod) : +Mise à jour complète du Pi (checkout git en `/srv/lidar_rendu`, +avec l'override Traefik) : ```bash 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 - -Trois façons, au choix : - -**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://: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://: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://:8973/api/sync -``` - -(puis attendre la fin : `curl http://: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://: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://` 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:/// | head -1` → `HTTP/2 200`. -- **En direct sur `http://: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://: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@` 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). +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 +tourne. diff --git a/docs/MAPS.md b/docs/MAPS.md index 0411dfb..0a538aa 100644 --- a/docs/MAPS.md +++ b/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, MapLibre ou OsmAnd, en plus de l'interface de consultation fournie. -Elle coexiste avec la webapp historique (port 8973), sans rien lui changer : -même dossier `output/`, génération et export restent là-bas. +C'est la SEULE interface web du projet (l'ancienne webapp historique a été +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 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 ``` +## 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 | Point | Valeur | diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 5b70320..5d34fc9 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -259,18 +259,18 @@ def main(): parser.add_argument( "--rebuild-index", 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( "--no-index", 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( "--incremental-index", action="store_true", - help="Régénérer la carte après chaque tuile terminée (affichage en direct " - "dans la webapp, sans attendre la fin du run)" + help="Régénérer le catalogue après chaque tuile terminée (affichage en direct " + "sur la carte, sans attendre la fin du run)" ) args = parser.parse_args() @@ -304,7 +304,7 @@ def main(): # Kill orphan PDAL processes on interrupt or termination. Le motif est # borné à NOTRE répertoire temporaire (pdal pipeline /temp/…) : un # 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. global _pdal_kill_pattern _pdal_kill_pattern = f"pdal pipeline {args.output}/temp/" @@ -314,14 +314,14 @@ def main(): log_gpu_status() 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: from .index import build_index index_path = build_index(args.output, args.format) if index_path: - logger.info(f"Carte globale générée : {index_path}") + logger.info(f"Catalogue généré : {index_path}") 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 # Nouveau run : remise à zéro du journal d'événements (file de diff --git a/lidar_pipeline/export.py b/lidar_pipeline/export.py deleted file mode 100644 index dbe6d9a..0000000 --- a/lidar_pipeline/export.py +++ /dev/null @@ -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 diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index eee7a3b..3ba8e12 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -1,33 +1,24 @@ -"""Carte continue interactive des tuiles LiDAR traitées. +"""Catalogue des tuiles traitées : registres partagés, vignettes, inventaire. -Génère l'interface web de consultation (servie par webapp.py via --serve) : - - output/index.html : coquille HTML minimaliste embarquant les données - - output/assets/app.css : styles (panneaux flottants type SIG) - - output/assets/app.js : logique carte (Leaflet), couches, infos tuiles +Anciennement l'interface web historique (index.html + assets) : celle-ci a été +remplacée par la carte à tuiles XYZ (mapserve.py/mapui.py, image lidar-maps). +Ce module produit aujourd'hui les artefacts dont le pipeline et la carte ont +besoin : -Fonctionnalités : - - Carte continue zoom/pan (Leaflet), tuiles rotées selon la projection - Lambert 93, vignettes ↔ images pleine résolution selon le zoom. - - Panneau de couches superposables avec opacité individuelle et - réordonnancement par glisser-déposer (persisté en localStorage). - - Panneau d'infos par tuile : résolution, méthode de classification du sol - (lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers. - - Génération via l'API web (./run.sh --serve) : dessin d'un rectangle - (téléchargement IGN + traitement) ou sélection cumulative au clic — - chaque tuile individuelle, avec ou sans données existantes ; les zones - et les clics s'additionnent, et une demande lancée pendant un run part - en file d'attente côté serveur (jamais de coupure du travail en place). - - Onglet Export : sélection de dalles sur la carte → mosaïque jointive - (image ou PDF multi-couches) téléchargeable pour consultation sur - téléphone (/api/export de la webapp, module export.py). + - registres partagés : VIZ_LABELS/VIZ_LEGENDS (libellés, légendes), défauts + d'affichage (DEFAULT_LAYERS/OPACITY/BLEND), correspondances mot-clé ↔ + étape du pipeline (KEYWORD_TO_STEP) ; + - vignettes (index_thumbs/) et sous-tuiles (index_subtiles/) servant de + paliers sources à la pyramide XYZ (tiles.py) ; + - inventaire output/index_tiles.json : dalles, couches et URLs versionnées, + servi par /api/tiles de mapserve aux machines légères (LIDAR_SOURCE_URL) + et reconstruit après chaque tuile en mode --incremental-index. -Intégration: - - Appelé automatiquement à la fin de process_all() dans pipeline.py - - Régénération solo via --rebuild-index dans cli.py +Intégration : + - appelé automatiquement à la fin de process_all() dans pipeline.py ; + - régénération solo via --rebuild-index dans cli.py. """ -import hashlib -import html import json import logging import re @@ -226,7 +217,7 @@ DEFAULT_VIZ = 'positive_openness' # proposée (couches conservées ; les autres ne sont plus ni affichées ni # référencées). None = toutes les visualisations présentes sur disque. # Le sélecteur de génération/régénération est piloté par le même registre -# (voir _render_html). La pente y reste proposée mais éteinte par défaut. +# La pente y reste proposée mais éteinte par défaut. PANEL_VIZ = ('slope', 'aspect', 'positive_openness') # Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline @@ -311,12 +302,6 @@ _MID_THUMB_SIZE = 640 # 1 requête et ~30 Mo décodés par couche au lieu de centaines. La résolution # est plafonnée : l'overview n'est affiché que tant qu'il reste au moins 1:1 # avec l'écran (jamais flou). -_OVERVIEW_DIR = 'index_overview' -_OVERVIEW_MIN_DALLES = 6 # grille plus petite : les vignettes suffisent -_OVERVIEW_MAX_PX = 3072 # côté max de la mosaïque (~28 Mo décodés) -_OVERVIEW_PX_PER_KM = 256 # résolution cible (1:1 avec les vignettes) -_OVERVIEW_QUALITY = 80 - # Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli. _VIZ_FALLBACK_ORDER = [ 'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness', @@ -932,129 +917,6 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): return usable -def _l93_points_to_wgs84_corners(xs, ys): - """Transforme des points L93 en [[lat, lon], …] (ordre conservé). - - rasterio.warp (PROJ exact) si disponible, sinon pyproj, sinon - l'approximation affine (webapp légère sans GDAL — précision ~km). - Même transformation que attach_gps_bounds : les coins produits - coïncident exactement avec ceux des dalles. - """ - try: - from rasterio.warp import transform as warp_transform - lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', list(xs), list(ys)) - except ImportError: - try: - from pyproj import Transformer - tr = Transformer.from_crs('EPSG:2154', 'EPSG:4326', always_xy=True) - lons, lats = tr.transform(list(xs), list(ys)) - except ImportError: - pairs = [_approx_l93_to_wgs84(x, y) for x, y in zip(xs, ys)] - lons = [p[0] for p in pairs] - lats = [p[1] for p in pairs] - return [[la, lo] for la, lo in zip(lats, lons)] - - -def _build_overview(zone_records, output_dir): - """Assemble les mosaïques d'overview par couche pour la vue dézoomée. - - Une image WebP par (zone géographique × couche), couvrant le bbox L93 - des dalles de la zone (cases sans dalle transparentes), assemblée depuis - les vignettes index_thumbs (256 px/km) : au dézoom l'interface l'affiche - à la place des vignettes individuelles — 1 requête et ~30 Mo décodés par - couche et par zone au lieu de centaines. Une mosaïque par zone : les - grilles distantes (plusieurs secteurs, parfois à des centaines de km) - n'ont pas de coins partagés et surtout pas la même échelle utile. Les - coins du bbox sont projetés par la même transformation que les dalles — - la mosaïque s'emboîte exactement sur les vignettes, même si la zone - n'est pas un rectangle plein. Résolution plafonnée à 256 px/km et - 3072 px de côté : l'overview n'est affiché que tant qu'il reste au - moins 1:1 avec l'écran, jamais étiré donc jamais flou. - - Args: - zone_records: enregistrements par zone ({'tiles': [dalle...]}) avec - col/row, corners WGS84 et viz[k]['thumb'] (URL relative). - output_dir: dossier de sortie racine. - - Returns: - {viz_key: {'items': [{url, w, h, px_per_km, km_w, km_h, corners}]}} - ou {} si aucune zone assez grande ou PIL indisponible. - """ - try: - from PIL import Image as PILImage - except ImportError: - logger.warning("PIL indisponible — overview non générée") - return {} - try: - resample = PILImage.Resampling.LANCZOS - except AttributeError: - resample = getattr(PILImage, 'LANCZOS', 1) - out_dir = output_dir / _OVERVIEW_DIR - out_dir.mkdir(parents=True, exist_ok=True) - overview = {} - for zone in zone_records: - have = [t for t in zone['tiles'] if t.get('corners')] - if len(have) < _OVERVIEW_MIN_DALLES: - continue - min_col = min(t['col'] for t in have) - max_col = max(t['col'] for t in have) - min_row = min(t['row'] for t in have) - max_row = max(t['row'] for t in have) - km_w = max_col + 1 - min_col - km_h = max_row + 1 - min_row - px_per_km = max(4, min(_OVERVIEW_PX_PER_KM, - _OVERVIEW_MAX_PX // max(km_w, km_h))) - w, h = km_w * px_per_km, km_h * px_per_km - # Coins SW, SE, NE, NW du bbox L93 (dalle (col,row) : Y ∈ [(row−1), - # row] km — cf. attach_gps_bounds), projetés comme les coins de dalles. - xs = [min_col * 1000, (max_col + 1) * 1000, - (max_col + 1) * 1000, min_col * 1000] - ys = [(min_row - 1) * 1000, (min_row - 1) * 1000, - max_row * 1000, max_row * 1000] - corners = _l93_points_to_wgs84_corners(xs, ys) - viz_keys = [] - for t in have: - for k in t['viz']: - if k not in viz_keys: - viz_keys.append(k) - for viz_key in viz_keys: - canvas = PILImage.new('RGBA', (w, h), (0, 0, 0, 0)) - pasted = 0 - for t in have: - info = t['viz'].get(viz_key) - if not info: - continue - src = output_dir / info['thumb'].split('?')[0] - try: - with PILImage.open(str(src)) as im: - cell = im.convert('RGBA') - except Exception as e: - logger.debug(f"Overview : vignette illisible {src.name} : {e}") - continue - if cell.size != (px_per_km, px_per_km): - cell = cell.resize((px_per_km, px_per_km), resample) - # Image ligne 0 = nord : la dalle de row max est en haut. - canvas.paste(cell, ((t['col'] - min_col) * px_per_km, - (max_row - t['row']) * px_per_km)) - pasted += 1 - if not pasted: - continue - stem = f"{viz_key}_{min_col}_{min_row}" - out = out_dir / f"{stem}.webp" - try: - canvas.save(str(out), format='WEBP', quality=_OVERVIEW_QUALITY) - except Exception as e: - logger.warning(f"Overview {stem} non écrite : {e}") - continue - overview.setdefault(viz_key, {'items': []})['items'].append({ - 'url': f"{_OVERVIEW_DIR}/{out.name}{_url_version(_mtime(out))}", - 'w': w, 'h': h, 'px_per_km': px_per_km, - 'km_w': km_w, 'km_h': km_h, - 'corners': corners, - }) - return overview - - def _viz_src_dir(tile, info): """Dossier réel du fichier d'une visualisation. @@ -1115,23 +977,20 @@ def _collect_tile_metadata(tile, dtm_dir): return meta -def build_index(output_dir, output_format='avif', generate_overview=True): - """Génère la carte interactive HTML des tuiles traitées, organisée par zones. +def build_index(output_dir, output_format='avif'): + """Régénère le catalogue des tuiles traitées : vignettes + inventaire. Scanne output_dir/visualisations/, collecte les métadonnées de génération, - génère les vignettes JPEG, écrit les assets (CSS/JS) puis - output_dir/index.html. + génère les vignettes JPEG (index_thumbs/) et les sous-tuiles + (index_subtiles/) — paliers sources de la pyramide XYZ (tiles.py) — puis + écrit l'inventaire output/index_tiles.json (servi par /api/tiles). Args: output_dir: dossier de sortie racine (contient visualisations/). output_format: format des images ('avif' ou 'webp') — pour info. - generate_overview: False sur la webapp légère (LIDAR_GENERATION_URL) : - ses mosaïques, bâties sur un cache partiel, auraient une géométrie - différente de celles du worker sous le même nom de fichier — - l'index distant fusionné après coup fournit les bonnes. Returns: - Path vers index.html si succès, None si échec ou aucune tuile. + Path vers index_tiles.json si succès, None si échec ou aucune tuile. """ output_dir = Path(output_dir) vis_dir = output_dir / 'visualisations' @@ -1296,3436 +1155,25 @@ def build_index(output_dir, output_format='avif', generate_overview=True): if n_sous: logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)") - # Mosaïques d'overview (vue dézoomée) : une image par zone et par couche, - # assemblée depuis les vignettes des dalles de la zone. Webapp légère : - # générées par la machine de traitement (cf. generate_overview). - overview = _build_overview(zone_records, output_dir) if generate_overview else {} - if overview: - n_items = sum(len(v['items']) for v in overview.values()) - first = next(iter(overview.values()))['items'][0] - logger.info(f" Overview : {len(overview)} couche(s) × zones, " - f"{n_items} mosaïque(s) (ex. {first['w']}×{first['h']} px, " - f"{first['px_per_km']} px/km)") - - # Export des données de carte (index_tiles.json) : la webapp les sert via - # /api/tiles pour afficher les tuiles terminées en direct, sans recharger - # la page — le pipeline le réécrit après chaque tuile en mode incrémental. - ordered_viz, default_viz = _panel_selection(all_viz_keys) - (output_dir / 'index_tiles.json').write_text(json.dumps({ + # Inventaire des dalles (index_tiles.json) : mapserve le sert via + # /api/tiles aux machines légères (LIDAR_SOURCE_URL) ; le pipeline le + # réécrit après chaque tuile en mode incrémental (--incremental-index). + viz_meta = {k: {'label': VIZ_LABELS.get(k, k)} + for k in _VIZ_FALLBACK_ORDER if k in all_viz_keys} + for k in sorted(all_viz_keys): + viz_meta.setdefault(k, {'label': VIZ_LABELS.get(k, k)}) + inventory_path = output_dir / 'index_tiles.json' + inventory_path.write_text(json.dumps({ 'tiles': display_tiles, - 'overview': overview, - 'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz}, - 'stats': {'default_viz': default_viz, - 'default_layers': default_layers_present(all_viz_keys), - 'default_opacity': dict(DEFAULT_OPACITY), - 'default_blend': dict(DEFAULT_BLEND), - 'n_tiles': len(display_tiles), - 'ui_version': ui_version()}, + 'viz_meta': viz_meta, + 'stats': {'n_tiles': len(display_tiles)}, }, ensure_ascii=False), encoding='utf-8') - _write_assets(output_dir) - html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format, - overview) - html_path = output_dir / 'index.html' - html_path.write_text(html, encoding='utf-8') - - logger.info(f"Index global généré : {html_path} ({time.time() - t_start:.1f}s)") + logger.info(f"Inventaire généré : {inventory_path} " + f"({time.time() - t_start:.1f}s)") logger.info(f" {len(tiles)} tuile(s) • {thumbs_generated} vignette(s) générée(s)" + (f" • {thumbs_failed} échec(s)" if thumbs_failed else "")) logger.info(f" Grille : {global_bbox['min_col']}-{global_bbox['max_col']} km E × " f"{global_bbox['min_row']}-{global_bbox['max_row']} km N") - return html_path - - -def ui_version(): - """Version de l'interface : hash court du contenu source CSS+JS. - - Embarqué dans le app.js servi (__UI_VERSION__ remplacé par - _write_ui_assets) et dans index_tiles.json (stats) : le navigateur - compare sa propre version à celle annoncée par le serveur de données - (/api/tiles) et signale une webapp périmée (assets bâchés au build de - l'image, cf. Dockerfile.webapp — l'image doit être reconstruite après - toute évolution de l'interface). - """ - return hashlib.sha1((_APP_CSS + _APP_JS).encode('utf-8')).hexdigest()[:10] - - -def _write_ui_assets(assets_dir): - """Écrit les fichiers statiques de l'interface (CSS + JS) dans `assets_dir`. - - Inclut Leaflet vendorisé (vendor/leaflet) : la carte reste fonctionnelle - hors ligne, sans dépendance au CDN unpkg. La webapp bâche ces fichiers - dans l'image (cf. Dockerfile.webapp) ; le mode serveur statique les écrit - dans output/assets/ (cf. _write_assets). - """ - import shutil - assets_dir = Path(assets_dir) - assets_dir.mkdir(parents=True, exist_ok=True) - (assets_dir / 'app.css').write_text(_APP_CSS, encoding='utf-8') - # __UI_VERSION__ : hash du contenu source (ui_version), substitué à - # l'écriture — le JS compare cette version à celle du serveur de données - # et affiche un bandeau si la webapp est périmée. - (assets_dir / 'app.js').write_text( - _APP_JS.replace('__UI_VERSION__', ui_version()), encoding='utf-8') - src_vendor = Path(__file__).parent / 'assets' / 'vendor' - dst_vendor = assets_dir / 'vendor' - if src_vendor.is_dir(): - shutil.rmtree(dst_vendor, ignore_errors=True) - shutil.copytree(src_vendor, dst_vendor) - - -def _write_assets(output_dir): - """Écrit les fichiers statiques de l'interface dans output/assets/.""" - _write_ui_assets(Path(output_dir) / 'assets') - -def _panel_selection(all_viz_keys): - """Couches proposées dans le panneau + couche activée par défaut. - - Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit - décrire le panneau exactement comme la page au moment du rebuild). - """ - # Panneau complet : toutes les couches du registre (VIZ_LABELS), même - # absentes du disque — activables dès leur apparition — plus les couches - # historiques éventuellement trouvées sur disque (libellé = clé brute). - # PANEL_VIZ permet toujours de restreindre volontairement la sélection. - panel_keys = set(VIZ_LABELS) | set(all_viz_keys) - if PANEL_VIZ is not None: - panel_keys &= set(PANEL_VIZ) - # Ordre des couches dans le panneau (selon ordre préféré puis alpha). - ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in panel_keys] - for v in sorted(panel_keys): - if v not in ordered_viz: - ordered_viz.append(v) - # Couche activée par défaut : préférence globale puis aspect, à condition - # d'exister sur le disque ; sinon la 1re proposée présente, sinon la 1re. - default_viz = next( - (v for v in (DEFAULT_VIZ, 'aspect') if v in all_viz_keys), - next((v for v in ordered_viz if v in all_viz_keys), - ordered_viz[0] if ordered_viz else DEFAULT_VIZ)) - return ordered_viz, default_viz - - -def _json_for_script(obj): - """JSON sûr à embarquer dans un - - - - -""" - - -_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */ - -:root { - /* Le color-scheme impose au navigateur le rendu sombre des contrôles - natifs (popup des ' + - BLEND_MODES.map(m => - '').join('') + - ''; - row.innerHTML = - '⠿' + - '' + - '' + escapeHtml(meta.label) + '' + - (state.layers.on[key] ? blendSel : '') + - '' + - '' + Math.round(state.layers.opacity[key] * 100) + '%'; - - const eye = row.querySelector('.eye'); - const range = row.querySelector('input[type="range"]'); - const pct = row.querySelector('.layer-pct'); - - eye.addEventListener('click', () => { - state.layers.on[key] = !state.layers.on[key]; - // Réactivation sur une opacité nulle : on remonte à 100 % - if (state.layers.on[key] && state.layers.opacity[key] === 0) state.layers.opacity[key] = 1; - saveLayers(); - renderLayerPanel(); - syncTileLayers(); - }); - range.addEventListener('input', () => { - const v = Number(range.value) / 100; - state.layers.opacity[key] = v; - pct.textContent = range.value + '%'; - // Applique directement sans reconstruire le DOM - for (const entry of markers.values()) { - const img = entry.imgs[key]; - if (img) img.style.opacity = String(v); - } - updateImages(); - }); - range.addEventListener('change', saveLayers); - - const sel = row.querySelector('.layer-blend'); - if (sel) sel.addEventListener('change', () => { - state.layers.blend[key] = sel.value; - saveLayers(); - // Applique directement sans reconstruire le DOM - for (const entry of markers.values()) { - const img = entry.imgs[key]; - if (img) img.style.mixBlendMode = sel.value; - } - }); - - row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); }); - row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; }); - row.addEventListener('dragover', e => e.preventDefault()); - row.addEventListener('drop', e => { - e.preventDefault(); - if (!dragKey || dragKey === key) return; - const ord = state.layers.order; - ord.splice(ord.indexOf(dragKey), 1); - ord.splice(ord.indexOf(key), 0, dragKey); - saveLayers(); - renderLayerPanel(); - syncTileLayers(); - }); - vizLayersEl.appendChild(row); - } -} - -document.getElementById('osmToggle').addEventListener('click', () => { - state.layers.osm.on = !state.layers.osm.on; - saveLayers(); renderOsmRow(); -}); -document.getElementById('osmOpacity').addEventListener('input', e => { - state.layers.osm.opacity = Number(e.target.value) / 100; - osmLayer.setOpacity(state.layers.osm.opacity); - document.getElementById('osmPct').textContent = e.target.value + '%'; -}); -document.getElementById('osmOpacity').addEventListener('change', saveLayers); -// Clic sur le nom du fond = bascule mode sombre / clair -document.getElementById('osmRow').querySelector('.layer-name') - .addEventListener('click', () => { - state.layers.osm.dark = !state.layers.osm.dark; - saveLayers(); renderOsmRow(); - }); -document.getElementById('osmRow').querySelector('.layer-name').title = - 'Cliquer : fond de carte clair / assombri'; - -// ============================================================ -// Préconfigurations de couches : un preset définit les couches -// visibles, leurs niveaux de transparence (opacités des -// visualisations + fond de carte OSM) et l'ordre de pile -// (bas → haut). Un seul preset intégré sert de configuration de -// référence ; les presets personnels sont stockés sur le serveur -// (/api/presets, partagés entre tous les navigateurs connectés) -// et en écho dans le localStorage du navigateur (repli si l'interface -// est servie sans webapp). -// ============================================================ -const PRESETS_LS = 'lidarPresets_v1'; -const PRESETS_URL = '/api/presets'; -// Écho local des presets : s'il n'y a pas de webapp (serveur statique), -// ils ne vivent que dans le localStorage du navigateur. -let customPresets = (() => { - try { - const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]'); - return Array.isArray(p) ? p : []; - } catch (e) { return []; } -})(); -function persistCustomPresets() { - try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ } -} - -// Les presets stockés sur le serveur sont partagés entre tous les -// navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux -// déjà en local (même id = la version serveur l'emporte). Webapp -// injoignable : la liste locale est conservée telle quelle. -function upsertCustomPreset(p) { - const i = customPresets.findIndex(x => x.id === p.id); - if (i === -1) customPresets.push(p); else customPresets[i] = p; -} -function removeCustomPreset(id) { - customPresets = customPresets.filter(p => p.id !== id); -} -async function syncCustomPresets() { - try { - const r = await fetch(PRESETS_URL, { cache: 'no-store' }); - if (!r.ok) return; - const data = await r.json(); - const server = Array.isArray(data.presets) ? data.presets : []; - const byId = new Map(customPresets.map(p => [p.id, p])); - for (const p of server) if (p && p.id) byId.set(p.id, p); - customPresets = [...byId.values()]; - persistCustomPresets(); - renderPresetSelect(presetSelectEl.value); - } catch (e) { /* pas de webapp : presets locaux uniquement */ } -} -// Les presets portent les niveaux de transparence : opacité de chaque -// visualisation + fond de carte OSM (visibilité, opacité, mode sombre). -const BUILTIN_PRESETS = [ - { label: 'Défaut', on: ['aspect', 'positive_openness'], - opacity: { aspect: 0.74, positive_openness: 1 }, - osm: { on: true, opacity: 0.3, dark: true } }, -]; - -// Niveaux de transparence d'un preset, lisibles dans la liste -// (opacité de chaque visualisation + fond de carte OSM). -function presetDetail(p) { - const parts = (p.on || []).filter(k => VIZ_META[k]) - .map(k => (VIZ_META[k].label || k) + ' ' + - Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%' + - ((p.blend && validBlend(p.blend[k]) && p.blend[k] !== 'normal') - ? ' (' + blendLabel(p.blend[k]) + ')' : '')); - if (p.osm) parts.push('Fond ' + (p.osm.on - ? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%' - : 'masqué')); - return parts.join(' · '); -} - -function applyPreset(p) { - const want = (p.on || []).filter(k => VIZ_META[k]); - const L = state.layers; - const off = L.order.filter(k => !want.includes(k)); - L.order = want.slice(); // ordre du preset (bas → haut) - L.order.push(...off); // les autres gardent leur ordre relatif - for (const k of L.order) { - const on = want.includes(k); - L.on[k] = on; - if (on) { - L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1; - if (p.blend && validBlend(p.blend[k])) L.blend[k] = p.blend[k]; - } - } - // Niveaux de transparence du fond de carte (visibilité, opacité, sombre) - if (p.osm) { - L.osm.on = !!p.osm.on; - if (p.osm.opacity != null) L.osm.opacity = Number(p.osm.opacity); - if (p.osm.dark != null) L.osm.dark = !!p.osm.dark; - } - saveLayers(); - renderLayerPanel(); - renderOsmRow(); - syncTileLayers(); -} - -const presetSelectEl = document.getElementById('presetSelect'); -const presetSaveEl = document.getElementById('presetSave'); -const presetDeleteEl = document.getElementById('presetDelete'); - -function renderPresetSelect(selectedValue) { - presetSelectEl.innerHTML = ''; - const none = document.createElement('option'); - none.value = ''; - none.textContent = 'Préconfigurations…'; - presetSelectEl.appendChild(none); - for (const p of BUILTIN_PRESETS) { - const o = document.createElement('option'); - o.value = 'builtin:' + p.label; - o.textContent = p.label + ' — ' + presetDetail(p); - presetSelectEl.appendChild(o); - } - const customs = customPresets; - if (customs.length) { - const g = document.createElement('optgroup'); - g.label = 'Mes presets'; - for (const p of customs) { - const o = document.createElement('option'); - o.value = 'custom:' + p.id; - o.textContent = p.label + ' — ' + presetDetail(p); - g.appendChild(o); - } - presetSelectEl.appendChild(g); - } - if (selectedValue) presetSelectEl.value = selectedValue; - presetDeleteEl.hidden = !presetSelectEl.value.startsWith('custom:'); -} - -presetSelectEl.addEventListener('change', () => { - const v = presetSelectEl.value; - if (!v) return; - let p = null; - if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8)); - else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7)); - if (p) applyPreset(p); -}); - -presetSaveEl.addEventListener('click', () => { - const name = prompt('Nom du preset :'); - if (!name || !name.trim()) return; - const enabled = state.layers.order.filter(k => state.layers.on[k]); - const osm = state.layers.osm; - const p = { - id: Date.now().toString(36), - label: name.trim(), - on: enabled, - opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])), - blend: Object.fromEntries(enabled.map(k => [k, state.layers.blend[k] || 'normal'])), - // Niveaux de transparence du fond de carte - osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark }, - }; - upsertCustomPreset(p); - persistCustomPresets(); - renderPresetSelect('custom:' + p.id); - // Persistance serveur (partagée entre navigateurs) ; en échec - // (pas de webapp), le preset reste dans le stockage local. - fetch(PRESETS_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(p), - }).then(r => { - if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status); - }).catch(() => {}); -}); - -presetDeleteEl.addEventListener('click', () => { - const v = presetSelectEl.value; - if (!v.startsWith('custom:')) return; - if (!confirm('Supprimer ce preset ?')) return; - const id = v.slice(7); - removeCustomPreset(id); - persistCustomPresets(); - renderPresetSelect(''); - fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' }) - .catch(() => {}); -}); - -renderPresetSelect(); -syncCustomPresets(); -const sidebar = document.getElementById('sidebar'); -function setSidebarCollapsed(collapsed) { - sidebar.classList.toggle('collapsed', collapsed); - document.body.classList.toggle('sb-collapsed', collapsed); - try { localStorage.setItem('lidarSidebarCollapsed', collapsed ? '1' : ''); } catch (e) {} -} -document.getElementById('sidebarCollapse').addEventListener('click', () => - setSidebarCollapsed(!sidebar.classList.contains('collapsed'))); -try { - if (localStorage.getItem('lidarSidebarCollapsed') === '1') setSidebarCollapsed(true); -} catch (e) {} - -const tabButtons = document.querySelectorAll('.tabbar .tab'); -let activeTab = 'layers'; -function switchTab(name) { - activeTab = name; - for (const b of tabButtons) { - b.classList.toggle('active', b.dataset.tab === name); - } - document.querySelectorAll('.tab-page').forEach(p => { p.hidden = p.id !== 'tab-' + name; }); - // L'onglet Tuile est contextuel : sa sélection n'est pas mémorisée - if (name === 'layers' || name === 'gen') { - try { localStorage.setItem('lidarTab', name); } catch (e) {} - } -} -for (const b of tabButtons) b.addEventListener('click', () => switchTab(b.dataset.tab)); -try { - const savedTab = localStorage.getItem('lidarTab'); - if (savedTab === 'layers' || savedTab === 'gen') switchTab(savedTab); -} catch (e) {} - -document.getElementById('btnMenu').addEventListener('click', () => - document.body.classList.toggle('menu-open')); - -// ============================================================ -// Vue : zoom / pan / persistance et partage. -// Hash simple : #zoom,lat,lng. Hash partagé (bouton « Partager ») : -// #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark — L liste -// TOUTES les couches de bas en haut (opacité en %, visible 0/1), O -// décrit le fond OSM (affiché : opacité en % : mode sombre). -// ============================================================ -function parseSharedHash() { - const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)(.*)$/.exec(location.hash || ''); - if (!m || isNaN(parseFloat(m[1])) || isNaN(parseFloat(m[2])) || isNaN(parseFloat(m[3]))) return null; - const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]), - layers: null, osm: null }; - const seg = {}; - for (const p of m[4].split('&')) { - const i = p.indexOf('='); - if (i > 0) seg[p.slice(0, i)] = p.slice(i + 1); - } - if (seg.L) { - const found = {}, order = []; - for (const part of seg.L.split(',')) { - const f = part.split(':'); - if (f.length < 3 || !VIZ_META[f[0]]) continue; - const op = Number(f[1]); - if (isNaN(op)) continue; - found[f[0]] = { on: f[2] === '1', op: Math.min(100, Math.max(0, op)) }; - order.push(f[0]); - } - if (order.length) { - for (const k of Object.keys(VIZ_META)) if (!order.includes(k)) order.push(k); - const on = {}, opacity = {}; - for (const k of order) { - const e = found[k]; - on[k] = e ? e.on : false; - opacity[k] = e ? e.op / 100 : 1; - } - out.layers = { order, on, opacity }; - } - } - if (seg.O) { - const f = seg.O.split(':'); - if (f.length === 3) { - const op = Number(f[1]); - if (!isNaN(op)) { - out.osm = { on: f[0] === '1', opacity: Math.min(100, Math.max(0, op)) / 100, dark: f[2] === '1' }; - } - } - } - return out; -} -function readViewHash() { return parseSharedHash(); } -function saveViewHash() { - const c = map.getCenter(); - history.replaceState(null, '', '#' + - map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6)); -} -// Construit le lien partageable : vue courante + état complet des couches -// (pile, visibilité, opacités) + fond OSM. -function buildShareHash() { - const c = map.getCenter(); - const L = state.layers.order.map(k => - k + ':' + Math.round(state.layers.opacity[k] * 100) + ':' + (state.layers.on[k] ? '1' : '0') - ).join(','); - const o = state.layers.osm; - return '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6) + - '&L=' + L + - '&O=' + (o.on ? '1' : '0') + ':' + Math.round(o.opacity * 100) + ':' + (o.dark ? '1' : '0'); -} -// Copie du lien : l'API presse-papiers n'existe qu'en contexte sécurisé -// (https/localhost) — sur http:// on repasse par -// execCommand, puis par un prompt de repli. -const shareBtn = document.getElementById('btnShare'); -function copyShareLink() { - const url = location.href; - const feedback = () => { - if (!shareBtn) return; - shareBtn.textContent = 'Lien copié ✓'; - shareBtn.disabled = true; - setTimeout(() => { shareBtn.textContent = '⎘ Partager'; shareBtn.disabled = false; }, 2000); - }; - const legacy = () => { - const inp = document.createElement('input'); - inp.value = url; - inp.style.cssText = 'position:fixed;left:-9999px;top:0'; - document.body.appendChild(inp); - inp.select(); - inp.setSelectionRange(0, url.length); - let ok = false; - try { ok = document.execCommand('copy'); } catch (e) { ok = false; } - inp.remove(); - if (ok) feedback(); - else window.prompt('Copier le lien de la vue :', url); - }; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(url).then(feedback, legacy); - } else legacy(); -} -if (shareBtn) shareBtn.addEventListener('click', () => { - history.replaceState(null, '', location.pathname + location.search + buildShareHash()); - copyShareLink(); -}); - -function fitView() { - // Si une vue a été sauvegardée dans l'URL (F5 / lien partagé), on la - // restaure au lieu de recadrer sur l'ensemble des tuiles. - const v = readViewHash(); - if (v) { map.setView([v.lat, v.lng], v.zoom); return; } - // Fallback : vue sauvegardée en sessionStorage (après une génération). - const saved = sessionStorage.getItem('lidarMap'); - if (saved) { - try { - const v = JSON.parse(saved); - if (v.lat != null && v.lng != null && v.zoom != null) { - map.setView([v.lat, v.lng], v.zoom); - return; - } - } catch (e) { /* stockage illisible : on retombe sur fitBounds */ } - } - if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); -} - -const zoomInd = document.getElementById('zoomIndicator'); -document.getElementById('zoomIn').addEventListener('click', () => map.zoomIn(1)); -document.getElementById('zoomOut').addEventListener('click', () => map.zoomOut(1)); -document.getElementById('zoomReset').addEventListener('click', () => { - history.replaceState(null, '', location.pathname + location.search); - if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); -}); - -// ============================================================ -// Position GPS du téléphone : centrer la carte dessus -// ============================================================ -const gpsBtn = document.getElementById('btnGps'); -const gpsToast = document.getElementById('gpsToast'); -let gpsToastTimer = null; -let gpsMarker = null; -function gpsMsg(text, ms) { - gpsToast.textContent = text; - gpsToast.hidden = false; - clearTimeout(gpsToastTimer); - gpsToastTimer = setTimeout(() => { gpsToast.hidden = true; }, ms || 4000); -} -gpsBtn.addEventListener('click', () => { - if (!('geolocation' in navigator)) { - gpsMsg('Géolocalisation non prise en charge par ce navigateur ' + - '(connexion sécurisée HTTPS requise).', 6000); - return; - } - gpsBtn.classList.add('busy'); - try { - navigator.geolocation.getCurrentPosition(pos => { - gpsBtn.classList.remove('busy'); - const ll = [pos.coords.latitude, pos.coords.longitude]; - map.setView(ll, Math.max(map.getZoom(), 16)); - if (gpsMarker) gpsMarker.remove(); - gpsMarker = L.circleMarker(ll, { - radius: 5, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9 - }).addTo(map); - if (pos.coords.accuracy) { - gpsMarker.bindTooltip('Précision GPS ~' + Math.round(pos.coords.accuracy) + ' m'); - } - gpsMsg('Carte centrée sur votre position GPS.'); - }, err => { - gpsBtn.classList.remove('busy'); - let why = 'Aucune position reçue'; - if (err && err.code === 1) why = 'Autorisation de localisation refusée'; - else if (err && err.code === 2) why = 'Position momentanément indisponible'; - else if (err && err.code === 3) why = 'Délai de localisation dépassé'; - gpsMsg(why + '.', 6000); - }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }); - } catch (e) { - gpsBtn.classList.remove('busy'); - gpsMsg('La géolocalisation exige une connexion sécurisée (HTTPS).', 6000); - } -}); -// will-change pendant l'animation de zoom uniquement : le scaling passe sur -// le GPU, et la couche est libérée juste après (mémoire vidéo). -let zoomGpuTimer = null; -// Animation de zoom des tuiles calée sur le fond de carte : les marqueurs -// Leaflet glissent (transition de position) sans changer de taille — les -// tuiles LiDAR « nageaient » donc sur le fond pendant l'animation puis -// sautaient à la fin. On applique au pane entier la mise à l'échelle que -// Leaflet applique aux couches de dalles : translate + scale autour de -// l'origine de la vue cible. La cible coïncide exactement avec la position -// que Marker._animateZoom donnerait à chaque icône (project(latlng, z1) − -// _getNewPixelOrigin(center, z1)) : le swap à la fin du zoom est sans saut. -map.on('zoomanim', (e) => { - const s = map.getZoomScale(e.zoom, map.getZoom()); - const po0 = map.getPixelOrigin(); - const po1 = map._getNewPixelOrigin(e.center, e.zoom); - lidarTilesPane.style.transform = - 'translate3d(' + (s * po0.x - po1.x) + 'px,' + (s * po0.y - po1.y) + 'px,0) scale(' + s + ')'; -}); -map.on('zoomend', () => { lidarTilesPane.style.transform = ''; }); -map.on('zoomstart', () => { - clearTimeout(zoomGpuTimer); - document.body.classList.add('zooming'); -}); -map.on('zoomend', () => { - zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1); - layoutTiles(); - zoomGpuTimer = setTimeout(() => document.body.classList.remove('zooming'), 1200); -}); -// L'URL (#zoom,lat,lng) est mise à jour en débouncé : un replaceState à chaque -// moveend coûte plus cher qu'il n'y paraît. -let viewHashTimer = null; -function saveViewHashSoon() { - clearTimeout(viewHashTimer); - viewHashTimer = setTimeout(saveViewHash, 400); -} -window.addEventListener('pagehide', () => { clearTimeout(viewHashTimer); saveViewHash(); }); -map.on('moveend', () => { updateImages(); saveViewHashSoon(); }); -map.on('click', e => { - if (genSelMode) { pickGenSelCell(e.latlng); return; } - hideTileInfo(); -}); -map.whenReady(() => layoutTiles()); - -// ============================================================ -// Fiche tuile (onglet Tuile) : métadonnées + couches de la tuile -// ============================================================ -const tileBody = document.getElementById('tileBody'); -let currentTile = null; - -function methodLabel(m) { - if (!m) return 'inconnue'; - if (m === 'ign') return 'IGN — sol (2)'; - if (m.startsWith('ign_')) return 'IGN — classes ' + m.split('_').slice(1).join(', '); - return { smrf: 'SMRF', csf: 'CSF', auto: 'Auto (détection)' }[m] || m; -} - -function escapeHtml(s) { - // Noms de dalles/couches insérés en innerHTML : jamais de HTML brut, - // même si un fichier renommé dans input/ tente d'injecter du markup. - return String(s).replace(/[&<>"']/g, c => - ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); -} - -function humanSize(bytes) { - if (!Number.isFinite(bytes)) return ''; - if (bytes < 1024) return bytes + ' o'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' Ko'; - return (bytes / (1024 * 1024)).toFixed(1) + ' Mo'; -} - -function layerPos(key) { - const i = state.layers.order.indexOf(key); - return i === -1 ? 999 : i; -} - -function fmtShortDate(s) { - return (s && s.length >= 16) - ? s.slice(8, 10) + '/' + s.slice(5, 7) + ' ' + s.slice(11, 16) - : (s || ''); -} - -function regenDisabledAttr() { - if (!apiAvailable) return ' disabled title="API indisponible — lancez ./run.sh --serve"'; - if (!regenAllowed) return ' disabled title="Génération réservée au réseau local"'; - return ''; -} - -function tileInfoContent(t) { - const meta = t.meta || {}; - const res = String(t.resolution).replace('.', ','); - const el = document.createElement('div'); - el.className = 'tile-info'; - el.innerHTML = - '
' + - 'Tuile ' + t.col + ' · ' + t.row + '' + - '' + res + ' m/px' + - '' + - '
' + - '
' + escapeHtml(t.name) + '
' + - '
' + - (t.size_km || 1) + ' × ' + (t.size_km || 1) + ' km · ' + methodLabel(meta.method) + - (meta.generated ? ' · généré le ' + fmtShortDate(meta.generated) : '') + - '
' + - '
Visualisations
' + - '
' + - '
' + - '' + - '
'; - - const pills = el.querySelector('.vpills'); - Object.keys(t.viz) - .sort((a, b) => layerPos(a) - layerPos(b)) - .forEach(k => { - const vm = meta.viz && meta.viz[k]; - const label = VIZ_META[k] ? VIZ_META[k].label : k; - const tip = vm ? [label, vm.date, vm.size != null ? humanSize(vm.size) : ''] - .filter(Boolean).join(' — ') : label; - const pill = document.createElement('span'); - pill.className = 'vpill' + (state.layers.on[k] ? ' on' : ''); - pill.title = tip; - pill.innerHTML = - '' + - '' + - '⤓'; - pills.appendChild(pill); - }); - - // Œil ou nom : bascule de la couche, reflet immédiat dans la puce - el.querySelectorAll('button[data-viz]').forEach(btn => { - btn.addEventListener('click', () => { - const k = btn.dataset.viz; - if (!VIZ_META[k]) return; - state.layers.on[k] = !state.layers.on[k]; - if (state.layers.on[k] && state.layers.opacity[k] === 0) state.layers.opacity[k] = 1; - saveLayers(); - renderLayerPanel(); - syncTileLayers(); - const pill = btn.closest('.vpill'); - if (pill) { - pill.classList.toggle('on', state.layers.on[k]); - const eye = pill.querySelector('.vpill-eye'); - if (eye) eye.innerHTML = state.layers.on[k] ? SVG_EYE : SVG_EYE_OFF; - } - }); - }); - const regen = el.querySelector('.tile-info-regen'); - if (regen) regen.addEventListener('click', () => regenerateCurrentTile()); - const closeBtn = el.querySelector('.tile-info-close'); - if (closeBtn) closeBtn.addEventListener('click', hideTileInfo); - return el; -} - -function showTileInfo(t) { - currentTile = t; - // Surlignage de la tuile sélectionnée - for (const entry of markers.values()) { - const div = entryDiv(entry); - if (div) div.classList.toggle('selected', entry.t === t); - } - tileBody.replaceChildren(tileInfoContent(t)); - switchTab('tile'); -} - -function hideTileInfo() { - currentTile = null; - for (const entry of markers.values()) { - const div = entryDiv(entry); - if (div) div.classList.remove('selected'); - } - const empty = document.createElement('div'); - empty.className = 'tile-empty'; - empty.textContent = 'Cliquez sur une tuile de la carte pour afficher ses informations de génération.'; - tileBody.replaceChildren(empty); - if (activeTab === 'tile') switchTab('layers'); -} - -document.addEventListener('keydown', e => { - if (e.key === 'Escape') { - hideTileInfo(); - if (exportMode) setExportMode(false); - if (genSelMode) setGenSelMode(false); - } -}); - -function regenerateCurrentTile() { - if (!currentTile || !apiAvailable || !regenAllowed) return; - const t = currentTile; - hideTileInfo(); - pendingTiles = [[t.col, t.row]]; - pendingRegen = true; - pendingCompleteAll = false; - document.getElementById('genRegen').checked = true; - genGo.style.display = ''; - showGenBar('1 tuile à régénérer — choisissez les options puis lancez'); - updateGenClassInfo(); -} - -// ============================================================ -// Génération de nouvelles zones via l'API web (./run.sh --serve) -// ============================================================ -let apiAvailable = false; -let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR) -const genBtn = document.getElementById('btnGenZone'); -const btnGenSel = document.getElementById('btnGenSel'); -const btnComplete = document.getElementById('btnComplete'); -const genBar = document.getElementById('genBar'); -const genMsg = document.getElementById('genMsg'); -const genGo = document.getElementById('genGo'); -const genCancel = document.getElementById('genCancel'); -const genRegen = document.getElementById('genRegen'); -const genReclass = document.getElementById('genReclass'); -const genClassInfo = document.getElementById('genClassInfo'); -const genClass = document.getElementById('genClass'); -const genIgnClasses = document.getElementById('genIgnClasses'); -const genViz = document.getElementById('genViz'); -const genEdgeBuffer = document.getElementById('genEdgeBuffer'); -const genQueue = document.getElementById('genQueue'); -const genBadge = document.getElementById('genBadge'); -const genStop = document.getElementById('genStop'); -const genQueueBody = document.getElementById('genQueueBody'); -const genQueueClear = document.getElementById('genQueueClear'); -const genQueueDrop = document.getElementById('genQueueDrop'); -const genQueueCount = document.getElementById('genQueueCount'); -let genMode = false, drawStart = null, drawRect = null; -let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false; -const ghostCells = []; -let genSelMode = false; -const genSel = new Set(); // clés "col,row" des dalles retenues (clics + zones cumulés) -const genSelCorners = new Map(); // clé "col,row" → coins GPS (dalles sans données) -const genSelPolys = new Map(); // clé "col,row" → polygone Leaflet de surlignage -const renderFrames = new Map(); // clé "col,row" → cadre coloré de la dalle en cours de rendu -const GEN_MAX_CELLS = 400; // garde-fou serveur : ~400 km² max par demande - -// Affiche la barre de confirmation de génération, dans l'onglet Génération -// (bascule automatique sur cet onglet). -function showGenBar(msg) { - if (msg !== undefined) genMsg.textContent = msg; - switchTab('gen'); - genBar.classList.add('show'); -} - -// Classification déjà présente sur les tuiles visées : guide le choix -// « Reclasser le sol » (décoché = la classification existante est conservée). -function updateGenClassInfo() { - if (!genClassInfo) return; - const methods = new Set(); - for (const pair of (pendingTiles || [])) { - for (const t of TILES) { - if (t.col === pair[0] && t.row === pair[1] && t.meta && t.meta.method) - methods.add(t.meta.method); - } - } - if (!methods.size) { genClassInfo.hidden = true; return; } - genClassInfo.textContent = 'Classification existante : ' + - [...methods].map(methodLabel).join(', ') + - ' — conservée si « Reclasser le sol » est décoché et que la méthode ne change pas'; - genClassInfo.hidden = false; -} - -fetch('/api/status').then(r => { - apiAvailable = r.ok; - if (!apiAvailable) hideExportTab(); - return r.ok ? r.json() : null; -}).then(d => { - const genBtns = [genBtn, btnGenSel, btnComplete]; - if (!d) { if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = false; }); return; } - // Génération réservée au réseau local (LIDAR_REGEN_CIDR, cf. webapp.py) : - // les boutons de traitement restent masqués pour les autres clients. - regenAllowed = d.regen_allowed !== false; - if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = !regenAllowed; }); -}).catch(() => { if (!apiAvailable) hideExportTab(); }); - -function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; } - -// Visualisations choisies dans la barre de génération (noms d'étapes --only). -// Sert au preview (détection des tuiles incomplètes) et au lancement : une -// tuile existante mais privée d'une de ces visualisations est incluse. -// Rien de sélectionné → toutes les couches du sélecteur (= panneau affiché). -function selectedGenViz() { - const all = genViz ? Array.from(genViz.options).map(o => o.value) : []; - const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : []; - return sel.length ? sel : (all.length ? all : ['aspect']); -} - -function exitGenMode() { - genMode = false; drawStart = null; - document.body.classList.remove('drawing'); - if (drawRect) { drawRect.remove(); drawRect = null; } - map.dragging.enable(); -} - -genBtn.addEventListener('click', () => { - if (genMode) { exitGenMode(); return; } - clearGhosts(); - pendingCompleteAll = false; // nouvelle zone : le scope global « Compléter » est levé - genBar.classList.remove('show'); - genMode = true; - map.dragging.disable(); - document.body.classList.add('drawing'); - genGo.style.display = 'none'; - showGenBar('Dessinez un rectangle sur la carte (re-cliquez le bouton pour annuler)'); -}); - -map.on('mousedown', e => { - if (!genMode) return; - drawStart = e.latlng; - if (drawRect) drawRect.remove(); - drawRect = L.rectangle([drawStart, drawStart], - { color: '#4cc9f0', weight: 2, dashArray: '6 4', fillOpacity: 0.05, pane: 'genDraw' }).addTo(map); -}); -map.on('mousemove', e => { - if (!genMode || !drawStart || !drawRect) return; - drawRect.setBounds(L.latLngBounds(drawStart, e.latlng)); -}); -map.on('mouseup', e => { - if (!genMode || !drawStart) return; - const b = L.latLngBounds(drawStart, e.latlng); - drawStart = null; - if (Math.abs(b.getWest() - b.getEast()) < 1e-9 || Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return; - exitGenMode(); - genGo.style.display = 'none'; - showGenBar('Analyse de la zone...'); - fetch('/api/preview', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], - regenerate: genRegen.checked, - viz: selectedGenViz() }) - }).then(r => r.json()).then(d => { - if (drawRect) { drawRect.remove(); drawRect = null; } - pendingCompleteAll = false; // zone dessinée : le scope global est levé - // La zone S'AJOUTE à la sélection en cours (zones successives, clics - // individuels) : les tuiles déjà retenues sont conservées, jamais perdues. - let added = 0; - for (const c of (d.cells || [])) { - const k = genSelKey(c.col, c.row); - if (!genSel.has(k)) { genSel.add(k); added++; } - if (c.corners) genSelCorners.set(k, c.corners); - } - refreshGenSelHighlights(); - pendingTiles = genSelCells(); - updateGenClassInfo(); - if (!genSel.size) { - genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' + - (genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)'); - genGo.style.display = 'none'; - return; - } - genMsg.textContent = 'Zone : ' + added + ' tuile(s) ajoutée(s)' + - (d.capped ? ' (zone tronquée à 400)' : '') + - ' — ' + genSel.size + ' tuile(s) sélectionnée(s) au total' + - (genSel.size > GEN_MAX_CELLS - ? ' (maximum ' + GEN_MAX_CELLS + ' par lancement : retirez des tuiles)' : ''); - genGo.style.display = genSel.size > GEN_MAX_CELLS ? 'none' : ''; - }).catch(() => { showGenBar('API indisponible'); }); -}); - -// « Sélectionner des tuiles » : sélection cumulative, tuile par tuile — le -// clic sur la carte ajoute ou retire la dalle de 1 km sous le curseur, avec -// ou sans données existantes (téléchargement + génération à la validation ; -// les sous-tuiles 0,2 m comptent pour une seule dalle). Les zones dessinées -// s'ajoutent à la même sélection. La barre de confirmation permet de choisir -// les couches et les options avant de lancer (--force implicite sur une -// sélection clic par clic : retraitement ciblé des tuiles choisies). -function genSelKey(col, row) { return col + ',' + row; } - -function refreshGenSelHighlights() { - const markerCells = new Set(); - for (const entry of markers.values()) { - const k = genSelKey(entry.t.col, entry.t.row); - markerCells.add(k); - const div = entryDiv(entry); - if (div) div.classList.toggle('genSel', genSel.has(k)); - } - // Dalles sans données (aucun marqueur) : surlignage en polygone - for (const [k, poly] of genSelPolys) { - if (!genSel.has(k) || markerCells.has(k)) { poly.remove(); genSelPolys.delete(k); } - } - for (const k of genSel) { - if (markerCells.has(k) || genSelPolys.has(k)) continue; - const corners = genSelCorners.get(k); - if (!corners) continue; - genSelPolys.set(k, L.polygon(corners, - { color: '#5aa9e6', weight: 1.5, fillOpacity: 0.15, pane: 'genDraw' }).addTo(map)); - } -} - -function genSelCells() { - return [...genSel].map(k => k.split(',').map(Number)); -} - -// Cadres colorés sur la carte autour des dalles du run en cours : orange -// pulsant pour la dalle en cours de rendu, bleu pointillé pour celles qui -// attendent, rouge pour les échecs. Les coins viennent de /api/status -// (attachés aux dalles non terminées, y compris sans données existantes) ; -// un sondage sans coins n'affiche rien — la file de l'onglet Génération -// reste la référence. -const RENDER_FRAME_STYLES = { - running: { color: '#ffb454', weight: 3, fillOpacity: 0.08, - className: 'render-frame render-running' }, - pending: { color: '#5aa9e6', weight: 1.5, dashArray: '6 5', fill: false, - className: 'render-frame render-pending' }, - failed: { color: '#e94560', weight: 2, dashArray: '2 4', fillOpacity: 0.08, - className: 'render-frame render-failed' } -}; - -function updateRenderFrames(tiles) { - const wanted = new Map(); - for (const t of (tiles || [])) { - if (t.state === 'done' || !t.corners || t.col == null || t.row == null) continue; - if (!RENDER_FRAME_STYLES[t.state]) continue; - wanted.set(genSelKey(t.col, t.row), t); - } - for (const [k, frame] of renderFrames) { - if (wanted.has(k) && wanted.get(k).state === frame.state) continue; - frame.rect.remove(); - renderFrames.delete(k); - } - for (const [k, t] of wanted) { - if (renderFrames.has(k)) continue; - const rect = L.polygon(t.corners, Object.assign( - { pane: 'genDraw', interactive: false }, RENDER_FRAME_STYLES[t.state])).addTo(map); - renderFrames.set(k, { rect: rect, state: t.state }); - } -} - -function clearRenderFrames() { - renderFrames.forEach(f => f.rect.remove()); - renderFrames.clear(); -} - -function updateGenSelBar() { - showGenBar(!genSel.size - ? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile' - : genSel.size > GEN_MAX_CELLS - ? genSel.size + ' tuile(s) sélectionnée(s) — maximum ' + GEN_MAX_CELLS + ' par lancement' - : genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'); - genGo.style.display = genSel.size && genSel.size <= GEN_MAX_CELLS ? '' : 'none'; -} - -function toggleGenSelCell(col, row, corners) { - const key = genSelKey(col, row); - if (genSel.has(key)) genSel.delete(key); - else { - genSel.add(key); - if (corners) genSelCorners.set(key, corners); - } - refreshGenSelHighlights(); - pendingTiles = genSelCells(); - pendingRegen = true; - pendingCompleteAll = false; - updateGenSelBar(); - updateGenClassInfo(); -} - -// Clic sur la carte hors dalle affichée : le serveur convertit le point -// WGS84 en dalle L93 de 1 km — permet de sélectionner des tuiles qui -// n'existent pas encore (aucun marqueur, aucune donnée). -function pickGenSelCell(latlng) { - fetch('/api/cell?lat=' + latlng.lat + '&lng=' + latlng.lng) - .then(r => r.ok ? r.json() : null) - .then(d => { - if (d && d.col !== null && d.col !== undefined && d.row !== undefined) - toggleGenSelCell(d.col, d.row, d.corners); - }).catch(() => { /* hors ligne : le clic suivant réessaiera */ }); -} - -function setGenSelMode(on) { - genSelMode = on; - document.body.classList.toggle('genSel', on); - if (btnGenSel) { - btnGenSel.classList.toggle('primary', on); - btnGenSel.innerHTML = on - ? '✓ Sélection en cours — cliquer sur la carte' - : 'Sélectionner des tuiles'; - } - // Mobile : le tiroir latéral couvre la carte — on le referme pour laisser - // la place aux clics (l'utilisateur le rouvre via ☰ pour valider). - if (on && document.body.classList.contains('menu-open')) { - document.body.classList.remove('menu-open'); - } - if (hintChip) { - hintChip.innerHTML = on - ? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile
La sélection se cumule avec les zones dessinées — Échap pour terminer' - : hintDefaultHtml; - } - if (on) { - if (genMode) exitGenMode(); - clearGhosts(); - pendingCompleteAll = false; - genRegen.checked = true; // régénération ciblée : retraitement forcé - switchTab('gen'); - updateGenSelBar(); - } -} - -// Quitte le mode sélection et vide la sélection (changement de mode). -function clearGenSel() { - if (genSelMode) setGenSelMode(false); - if (genSel.size) { - genSel.clear(); - genSelCorners.clear(); - refreshGenSelHighlights(); - } -} - -if (btnGenSel) btnGenSel.addEventListener('click', () => { - setGenSelMode(!genSelMode); -}); - -// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au -// moins une des visualisations choisies (pas de téléchargement) — la barre de -// confirmation reste utilisable (choix des couches, classification, Lancer). -btnComplete.addEventListener('click', () => { - if (!apiAvailable) return; - clearGhosts(); - clearGenSel(); - genGo.style.display = 'none'; - showGenBar('Analyse des tuiles présentes dans input/...'); - fetch('/api/preview', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ all_missing: true, regenerate: genRegen.checked, - viz: selectedGenViz() }) - }).then(r => r.json()).then(d => { - pendingTiles = (d.cells || []).map(c => [c.col, c.row]); - pendingCompleteAll = true; - updateGenClassInfo(); - (d.cells || []).forEach(c => ghostCells.push( - L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map) - )); - if (!pendingTiles.length) { - genMsg.textContent = 'Aucune visualisation manquante pour les couches choisies'; - genGo.style.display = 'none'; - return; - } - genMsg.textContent = pendingTiles.length + ' tuile(s) à compléter (dalles présentes dans input/)' + - (d.capped ? ' (liste tronquée)' : ''); - genGo.style.display = ''; - }).catch(() => { showGenBar('API indisponible'); }); -}); - -genGo.addEventListener('click', () => { - if (!pendingTiles.length) return; - genBar.classList.remove('show'); - clearGhosts(); - const nTiles = pendingTiles.length; - const isRegen = genRegen.checked || pendingRegen; - const fromSel = genSel.size > 0; - fetch('/api/generate', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tiles: pendingTiles, - regenerate: isRegen, - viz: selectedGenViz(), - all_missing: pendingCompleteAll, - ground_class: genClass ? genClass.value : 'ign', - ign_classes: genIgnClasses ? genIgnClasses.value : 'sol', - edge_buffer: genEdgeBuffer ? genEdgeBuffer.checked : false, - reclassify: genReclass ? genReclass.checked : false }) - }).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) - .then(d => { - const verb = isRegen ? 'régénération' - : pendingCompleteAll ? 'complétion' : 'génération'; - pendingCompleteAll = false; - pendingRegen = false; - if (fromSel) clearGenSel(); - const label = nTiles + ' tuile(s) — ' + verb; - // Un run est déjà en cours : la demande est partie en file d'attente - // côté serveur et démarrera à sa fin, sans couper le travail en place. - if (d && d['demarré'] === false && d.en_file) - return startGenTask(label, { qid: d.qid, position: d.en_file }); - return startGenTask(label); - }) - .catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); }); -}); - -genCancel.addEventListener('click', () => { - genBar.classList.remove('show'); - clearGhosts(); - pendingTiles = []; pendingRegen = false; pendingCompleteAll = false; - updateGenClassInfo(); - clearGenSel(); - if (genMode) exitGenMode(); -}); - -// ============================================================ -// Export : mosaïque de dalles adjacentes (image ou PDF téléphone) -// ============================================================ -// Mode sélection : le clic sur une dalle l'ajoute ou la retire (surlignage -// vert), les 4 sous-tuiles 0,2 m comptant pour une seule dalle. La -// validation assemble les images pleine résolution côté serveur -// (/api/export) : collage bord à bord, aucun trait entre les dalles. -const btnExportSel = document.getElementById('btnExportSel'); -const exportClearBtn = document.getElementById('exportClear'); -const exportBarEl = document.getElementById('exportBar'); -const exportCountEl = document.getElementById('exportCount'); -const exportResSel = document.getElementById('exportRes'); -const exportVizSel = document.getElementById('exportViz'); -const exportFormatSel = document.getElementById('exportFormat'); -const exportSizeSel = document.getElementById('exportSize'); -const exportGoBtn = document.getElementById('exportGo'); -const exportMsgEl = document.getElementById('exportMsg'); -const exportDoneEl = document.getElementById('exportDone'); -const exportDoneList = document.getElementById('exportDoneList'); -const hintChip = document.getElementById('hint'); -const hintDefaultHtml = hintChip ? hintChip.innerHTML : ''; -let exportMode = false; -const exportSel = new Set(); // clés "col,row,résolution" des dalles retenues - -// L'export passe par l'API : sans serveur (carte statique), l'onglet disparaît. -function hideExportTab() { - const b = document.querySelector('.tab[data-tab="export"]'); - if (b) b.style.display = 'none'; -} - -function exportKey(col, row, res) { return col + ',' + row + ',' + res; } - -function refreshExportHighlights() { - for (const entry of markers.values()) { - const div = entryDiv(entry); - if (!div) continue; - const t = entry.t; - div.classList.toggle('exportSel', - exportSel.has(exportKey(t.col, t.row, t.resolution))); - } -} - -function toggleExportSel(t) { - const key = exportKey(t.col, t.row, t.resolution); - if (exportSel.has(key)) exportSel.delete(key); - else exportSel.add(key); - refreshExportHighlights(); - updateExportPanel(); -} - -function setExportMode(on) { - exportMode = on; - document.body.classList.toggle('exporting', on); - btnExportSel.classList.toggle('primary', on); - btnExportSel.innerHTML = on - ? '✓ Sélection en cours — cliquer des dalles' - : '⤓ Sélectionner des dalles'; - // Mobile : le tiroir latéral couvre la carte — on le referme pour laisser - // la place aux clics (l'utilisateur le rouvre via ☰ pour valider l'export). - if (on && document.body.classList.contains('menu-open')) { - document.body.classList.remove('menu-open'); - } - if (hintChip) { - hintChip.innerHTML = on - ? 'Mode export : clic sur dalle = ajouter / retirer
Échap pour terminer la sélection' - : hintDefaultHtml; - } -} - -// Dalles retenues pour une résolution donnée -function exportSelCells(res) { - const cells = []; - for (const key of exportSel) { - const p = key.split(','); - if (Number(p[2]) === res) cells.push([Number(p[0]), Number(p[1])]); - } - return cells; -} - -// Couches communes aux dalles sélectionnées (à la résolution choisie) : -// seules celles proposées par le panneau (VIZ_META) sont exportables. -function exportCommonViz(res) { - let common = null; - for (const key of exportSel) { - const p = key.split(','); - if (Number(p[2]) !== res) continue; - const keys = new Set(); - for (const t of TILES) { - if (t.col === Number(p[0]) && t.row === Number(p[1]) - && t.resolution === res) { - for (const k in t.viz) if (VIZ_META[k]) keys.add(k); - } - } - common = common === null ? keys : new Set([...common].filter(k => keys.has(k))); - } - return common || new Set(); -} - -function updateExportResOptions() { - const resList = [...new Set([...exportSel].map(k => Number(k.split(',')[2])))].sort(); - const prev = exportResSel.value !== '' ? Number(exportResSel.value) : null; - exportResSel.innerHTML = ''; - for (const r of resList) { - const o = document.createElement('option'); - o.value = String(r); - o.textContent = String(r).replace('.', ',') + ' m/px'; - exportResSel.appendChild(o); - } - // Résolution conservée si encore valide, sinon la plus fine - exportResSel.value = (prev !== null && resList.includes(prev)) - ? String(prev) : String(resList[resList.length - 1]); -} - -function updateExportVizOptions() { - const res = Number(exportResSel.value); - const common = [...exportCommonViz(res)].sort((a, b) => layerPos(a) - layerPos(b)); - const prev = Array.from(exportVizSel.selectedOptions).map(o => o.value) - .filter(k => common.includes(k)); - exportVizSel.innerHTML = ''; - for (const k of common) { - const o = document.createElement('option'); - o.value = k; - o.textContent = VIZ_META[k] ? VIZ_META[k].label : k; - exportVizSel.appendChild(o); - } - // Défaut : la couche active la plus haute, sinon la première proposée - const sel = prev.length ? prev - : [activeLayers().filter(k => common.includes(k)).pop() || common[0]].filter(Boolean); - [...exportVizSel.options].forEach(o => { o.selected = sel.includes(o.value); }); -} - -function updateExportPanel() { - const has = exportSel.size > 0; - exportBarEl.classList.toggle('show', has); - exportClearBtn.hidden = !has; - exportMsgEl.textContent = ''; - if (!has) return; - updateExportResOptions(); - updateExportVizOptions(); - updateExportCount(); -} - -function updateExportCount() { - const res = Number(exportResSel.value); - const n = exportSelCells(res).length; - const cols = exportSelCells(res).map(c => c[0]); - const rows = exportSelCells(res).map(c => c[1]); - let sub = ''; - if (n) { - sub = ' · X ' + Math.min(...cols) + '–' + (Math.max(...cols) + 1) - + ' · Y ' + (Math.min(...rows) - 1) + '–' + Math.max(...rows) + ' km'; - } - exportCountEl.innerHTML = n + ' dalle' + (n > 1 ? 's' : '') - + ' sélectionnée' + (n > 1 ? 's' : '') - + ' ' + sub + ''; -} - -btnExportSel.addEventListener('click', () => { - setExportMode(!exportMode); - if (exportMode) switchTab('export'); -}); -exportClearBtn.addEventListener('click', () => { - exportSel.clear(); - refreshExportHighlights(); - updateExportPanel(); -}); -exportResSel.addEventListener('change', () => { updateExportVizOptions(); updateExportCount(); }); - -// Plusieurs couches sélectionnées : une image par couche (comme le PDF fait -// une page par couche) — chaque fichier porte la légende de SA couche. - -exportGoBtn.addEventListener('click', () => { - const res = Number(exportResSel.value); - const cells = exportSelCells(res); - const viz = Array.from(exportVizSel.selectedOptions).map(o => o.value); - if (!cells.length || !viz.length) { - exportMsgEl.textContent = 'Sélectionnez au moins une dalle et une visualisation.'; - return; - } - exportGoBtn.disabled = true; - exportMsgEl.textContent = 'Assemblage en cours… (' + cells.length + ' dalle' + (cells.length > 1 ? 's' : '') + ')'; - fetch('/api/export', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tiles: cells, viz: viz, resolution: res, - format: exportFormatSel.value, - max_side: Number(exportSizeSel.value) }) - }).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) - .then(d => { - const files = d.fichiers || []; - exportMsgEl.textContent = files.length - ? '✓ Export prêt' + (files.length > 1 - ? ' — ' + files.length + ' fichiers' - : (files[0].pages > 1 ? ' — ' + files[0].pages + ' pages' : '')) - : '✓ Export prêt'; - files.forEach((f, i) => { - const a = document.createElement('a'); - a.href = f.url; - a.download = f.nom; - a.title = 'Télécharger (' + (f.largeur || '?') + '×' + (f.hauteur || '?') + ' px)'; - a.textContent = '⤓ ' + f.nom + ' · ' + humanSize(f.taille); - exportDoneList.prepend(a); - if (i === 0) a.click(); // premier fichier : téléchargement direct - }); - exportDoneEl.hidden = false; - }) - .catch(msg => { exportMsgEl.textContent = 'Erreur : ' + msg; }) - .finally(() => { exportGoBtn.disabled = false; }); -}); - -// ============================================================ -// File de génération : panneau latéral persistant (sessionStorage). -// Les tâches survivent au rechargement de la carte : une tâche finie -// reste affichée « Terminé » jusqu'au vidage manuel de la liste. -// Chaque tâche montre ses tuiles une par une (nom court + types -// d'étapes) ; le journal brut reste accessible via le bouton ⌄. -// ============================================================ -const GEN_QKEY = 'lidarGenQueue'; -let genTasks = []; -try { genTasks = JSON.parse(sessionStorage.getItem(GEN_QKEY) || '[]'); } catch (e) { genTasks = []; } -// Migration des tâches créées avant l'affichage par tuiles : sans champ -// tiles, le journal brut pouvait rester ouvert d'office — on le replie. -genTasks.forEach(t => { if (!Array.isArray(t.tiles)) { t.tiles = []; t.logOpen = false; } }); -let genPollTimer = null; - -function saveGenTasks() { - try { sessionStorage.setItem(GEN_QKEY, JSON.stringify(genTasks)); } catch (e) { /* privé */ } -} - -// Runs dont l'utilisateur a retiré la tâche « finie » de la liste : on les -// mémorise (par horodatage serveur `started`) pour que le rechargement de la -// page — qui réadopte la « dernière génération » depuis .generation.job.json -// — ne les re-crée pas à chaque rafraîchissement. -const GEN_DKEY = 'lidarGenDismissed'; -let dismissedRuns = new Set(); -try { dismissedRuns = new Set(JSON.parse(sessionStorage.getItem(GEN_DKEY) || '[]')); } catch (e) { dismissedRuns = new Set(); } -function saveDismissed() { - try { sessionStorage.setItem(GEN_DKEY, JSON.stringify([...dismissedRuns])); } catch (e) { /* privé */ } -} - -function genStepEl(s) { - const el = document.createElement('span'); - el.className = 'gen-step ' + s.state; - el.textContent = s.label; - if (s.state === 'skip') el.title = 'Déjà existant — ignoré'; - return el; -} - -function genTileEl(t) { - const el = document.createElement('div'); - el.className = 'gen-tile ' + t.state; - const name = document.createElement('span'); - name.className = 'gen-tile-name'; - name.textContent = t.short; name.title = t.name; - const steps = document.createElement('span'); - steps.className = 'gen-tile-steps'; - (t.steps || []).forEach(s => steps.appendChild(genStepEl(s))); - const state = document.createElement('span'); - state.className = 'gen-tile-state'; - state.textContent = t.state === 'done' ? '✓' : t.state === 'failed' ? '✗' : ''; - el.append(name, steps, state); - return el; -} - -function genTaskEl(t) { - const el = document.createElement('div'); - el.className = 'gen-task ' + t.state; - const head = document.createElement('div'); - head.className = 'gen-task-head'; - const dot = document.createElement('span'); dot.className = 'gen-task-dot'; - const label = document.createElement('span'); - label.className = 'gen-task-label'; - label.title = t.label; label.textContent = t.label; - const state = document.createElement('span'); state.className = 'gen-task-state'; - const tiles = t.tiles || []; - if (t.state === 'queued') { - state.textContent = t.position - ? 'En file — position ' + t.position - : 'En file d\'attente'; - } else if (t.state === 'running') { - const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length; - state.textContent = tiles.length - ? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)' - : 'En cours…'; - } else if (t.state === 'done') { - state.textContent = 'Terminé'; - } else if (t.state === 'stopped') { - state.textContent = 'Interrompu'; - } else { - state.textContent = 'Échec (code ' + t.returncode + ')'; - } - const time = document.createElement('span'); - time.className = 'gen-task-time'; time.textContent = t.time; - const toggle = document.createElement('button'); - toggle.className = 'icon-btn gen-task-toggle'; - toggle.textContent = t.logOpen ? '⌃' : '⌄'; - toggle.title = 'Afficher / masquer le journal brut'; - head.append(dot, label, state, time, toggle); - el.appendChild(head); - // Corps : tuiles une par une (nom + types d'étapes), en cours et échecs - // en tête, terminées en dessous — le journal brut reste replié sous ⌄. - if (tiles.length) { - const list = document.createElement('div'); - list.className = 'gen-task-tiles'; - const rank = s => s === 'running' ? 0 : s === 'failed' ? 1 : s === 'done' ? 3 : 2; - [...tiles].sort((a, b) => rank(a.state) - rank(b.state)) - .forEach(x => list.appendChild(genTileEl(x))); - el.appendChild(list); - } - const log = document.createElement('pre'); - log.className = 'gen-task-log'; - log.hidden = !t.logOpen; - log.textContent = (t.log || []).join('\n') || 'Démarrage...'; - log.scrollTop = log.scrollHeight; - el.appendChild(log); - toggle.addEventListener('click', () => { - t.logOpen = !t.logOpen; saveGenTasks(); renderGenQueue(); - }); - return el; -} - -function renderGenQueue() { - // Ne reconstruit le DOM que si les tâches ont changé : le sondage de 2 s - // ne doit pas faire vibrer la liste ni consommer du CPU pour rien. - const sig = JSON.stringify(genTasks); - if (sig === renderGenQueue.sig) { genQueue.hidden = genTasks.length === 0; return; } - renderGenQueue.sig = sig; - genQueueBody.textContent = ''; - // Plus récentes en haut - for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t)); - const running = genTasks.filter(t => t.state === 'running').length; - const queued = genTasks.filter(t => t.state === 'queued').length; - genBadge.hidden = running === 0; - genBadge.textContent = running > 0 ? String(running) : ''; - if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; } - const active = running + queued; - genQueueCount.textContent = genTasks.length - ? (active ? active + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)') - : ''; - genQueue.hidden = genTasks.length === 0; -} - -function startGenTask(labelText, opts) { - const now = new Date(); - const qid = opts ? opts.qid : undefined; - genTasks.push({ - id: Date.now(), label: labelText, - time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }), - // Demande partie en file d'attente serveur : suivie jusqu'à son tour - state: qid !== undefined ? 'queued' : 'running', - qid: qid, position: opts ? opts.position : undefined, - returncode: null, log: [], logOpen: false, tiles: [], - }); - saveGenTasks(); renderGenQueue(); pollGenStatus(); -} - -function finishRunningTasks(d) { - let changed = false; - for (const t of genTasks) { - if (t.state !== 'running') continue; - // returncode négatif = tué par /api/stop ; null = serveur relancé en plein - // run (job disparu) : dans les deux cas la tâche est interrompue, pas échouée. - t.state = d.returncode === 0 ? 'done' - : (d.returncode === null || d.returncode < 0) ? 'stopped' : 'failed'; - t.returncode = d.returncode; - // Sans identité de run capturée (tâche lancée immédiatement, jamais en - // file) : on mémorise l'horodatage du run pour pouvoir la retirer sans - // qu'elle soit réadoptée au rechargement de la page. - if (t.started === undefined) t.started = d.started; - changed = true; - } - if (changed) saveGenTasks(); - renderGenQueue(); -} - -// ============================================================ -// Tuiles en direct : chaque tuile terminée apparaît sans recharger -// ============================================================ -// Pendant un run, le pipeline régénère l'index après chaque tuile terminée -// (--incremental-index) et la webapp sert ces données via /api/tiles. On -// fusionne les nouveautés dans la carte : la tuile est visible dès la fin -// de son rendu, sans attendre la fin de la file. Les URLs images portent -// un suffixe ?v= (mtime de la source) : une tuile régénérée change d'URL, -// l' se recharge et le nouveau rendu remplace l'ancien à l'écran — -// cache navigateur compris. -let liveTilesStamp = null; -// Le panneau est figé au chargement (VIZ_META initial) : le flux /api/tiles -// fusionne l'index du worker, qui peut encore proposer des couches retirées -// (index obsolète) — on n'ajoute au menu que des clés déjà connues. -const PANEL_KEYS = new Set(Object.keys(VIZ_META)); -function mergeTilesData(data) { - if (!data || !Array.isArray(data.tiles)) return; - const byKey = new Map(); - for (const t of data.tiles) { - if (t.corners) byKey.set(tileKey(t), t); - } - const curByKey = new Map(TILES.map(t => [tileKey(t), t])); - let changed = false; - // Retire les tuiles disparues (ex. dalle 0,5 m remplacée par ses - // sous-tuiles 0,2 m plus fines) - for (let i = TILES.length - 1; i >= 0; i--) { - const key = tileKey(TILES[i]); - if (byKey.has(key)) continue; - const entry = markers.get(key); - if (entry) { entry.mk.remove(); markers.delete(key); } - TILES.splice(i, 1); - changed = true; - } - byKey.forEach((t, key) => { - const cur = curByKey.get(key); - if (!cur) { - TILES.push(t); - registerTile(t); - changed = true; - } else if (cur !== t) { - Object.assign(cur, t); // tuile enrichie (nouvelles couches en cours de run) - changed = true; - } - }); - // Couches nouvellement proposées : ajoutées au panneau, éteintes par défaut - let metaChanged = false; - if (data.viz_meta) { - for (const k of Object.keys(data.viz_meta)) { - if (!PANEL_KEYS.has(k) || VIZ_META[k]) continue; - VIZ_META[k] = data.viz_meta[k]; - if (!state.layers.order.includes(k)) { - state.layers.order.push(k); - state.layers.on[k] = false; - state.layers.opacity[k] = 1; - } - metaChanged = true; - } - } - // Overview neuve (fin de rebuild pendant un run) : mosaïque recalculée → - // nouvelle URL ?v= → l' de la couche se recharge. - let ovChanged = false; - if (data.overview) { - for (const k of Object.keys(data.overview)) { - if (JSON.stringify(OVM[k]) === JSON.stringify(data.overview[k])) continue; - OVM[k] = data.overview[k]; - ovChanged = true; - } - if (ovChanged) { - registerOverview(); // (re)crée les marqueurs si la géométrie a changé - layoutOverview(); - } - } - if (changed) { - recomputeGridBounds(); - const statTiles = document.getElementById('statTiles'); - if (statTiles) { - const cols = [...new Set(TILES.map(t => t.col))].sort((a, b) => a - b); - const rows = [...new Set(TILES.map(t => t.row))].sort((a, b) => a - b); - const gw = cols.length ? cols[cols.length - 1] - cols[0] + 1 : 0; - const gh = rows.length ? rows[rows.length - 1] - rows[0] + 1 : 0; - statTiles.textContent = TILES.length + ' tuile(s) · ' + gw + '×' + gh + ' km · ' + - statTiles.textContent.split('·').pop().trim(); - } - } - if (changed || metaChanged) renderLayerPanel(); - if (changed || ovChanged) { - syncTileLayers(); layoutTiles(); - // Une dalle qui vient d'apparaître prend le relais du polygone de - // sélection (surlignage du marqueur désormais possible) - refreshGenSelHighlights(); - } -} -function pollLiveTiles() { - const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : ''; - fetch('/api/tiles' + q).then(r => r.ok ? r.json() : null).then(d => { - if (!d) return; - checkUiVersion(d); - liveTilesStamp = d.stamp; - if (d.tiles) mergeTilesData(d); - }).catch(() => {}); -} - -function pollGenStatus() { - if (genPollTimer) return; // un seul sondage global pour toute la file - genPollTimer = setInterval(() => { - if (document.hidden) return; // onglet en arrière-plan : reprend au retour - fetch('/api/status').then(r => r.json()).then(d => { - // Réponse d'erreur (ex: machine de traitement injoignable) : ignorer, - // le sondage reprendra — ne pas marquer les tâches en échec. - if (typeof d.running !== 'boolean') return; - // Demandes en file côté serveur : bouton de retrait - if (genQueueDrop) genQueueDrop.hidden = !(d.queue && d.queue.length); - // Tâches en file : démarrent quand le serveur attaque leur run (qid), - // « Interrompu » si la demande est retirée sans avoir tourné. - const serverQids = new Set((d.queue || []).map(q => q.id)); - for (const t of genTasks) { - if (t.state !== 'queued') continue; - const gone = !serverQids.has(t.qid); - if (d.running && d.qid === t.qid) { - // Le run de cette demande a démarré : les tâches « en cours » plus - // anciennes appartenaient au run précédent, forcément terminé (le - // serveur n'exécute qu'un run à la fois). - for (const o of genTasks) { - if (o !== t && o.state === 'running') { o.state = 'done'; o.returncode = 0; } - } - t.state = 'running'; t.started = d.started; t.position = undefined; - } else if (d.running && d.qid == null && gone) { - t.state = 'running'; t.started = d.started; t.position = undefined; - } else if (!d.running && gone) { - t.state = 'stopped'; - } - } - const running = genTasks.filter(t => t.state === 'running'); - const queued = genTasks.filter(t => t.state === 'queued'); - if (!running.length && !queued.length) { - clearInterval(genPollTimer); genPollTimer = null; - return; - } - const lines = d.log || []; - for (const t of running) { - t.log = lines.slice(-60); - // Tuiles une par une (nom court + types d'étapes) depuis /api/status - if (d.tiles && d.tiles.length) t.tiles = d.tiles; - } - if (!d.running) { - clearRenderFrames(); - finishRunningTasks(d); - if (genTasks.some(t => t.state === 'queued')) { - // Des demandes attendent encore leur tour : poursuivre la veille - // (le rechargement de fin de run attendra la fin de la file). - saveGenTasks(); renderGenQueue(); - return; - } - clearInterval(genPollTimer); genPollTimer = null; - // Sauvegarde la vue courante pour qu'elle survive au rechargement - // (sinon fitView() recadre sur l'ensemble des tuiles et le zoom part à zéro). - try { - const c = map.getCenter(); - sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() })); - } catch (e) { /* sessionStorage indisponible : pas grave */ } - syncThenReload(); - } else { - saveGenTasks(); renderGenQueue(); - updateRenderFrames(d.tiles); - pollLiveTiles(); - } - }).catch(() => {}); - }, 2000); -} - -// Après un run : reconstruire vignettes et carte depuis le cache à la demande -// (/api/sync — sur la webapp légère, les tuiles proviennent de la machine de -// traitement), puis recharger. /api/sync se réduit à un rebuild local : même -// flux partout. Le rechargement attend la fin de CE rebuild (done >= started, -// horodatage serveur retourné au POST) : sans cela, un sondage trop tôt -// lirait le done du rebuild précédent et rechargerait une carte périmée. -// Rechargement de secours après 30 min. -function syncThenReload() { - fetch('/api/sync', { method: 'POST' }) - .then(r => r.ok ? r.json() : null) - .then(d => { - const started = (d && d.started) || 0; - const t0 = Date.now(); - const check = setInterval(() => { - fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => { - const finished = s && !s.running - && (!started || (s.done || 0) >= started); - if (finished || (s && s.error) || Date.now() - t0 > 30 * 60 * 1000) { - clearInterval(check); - location.reload(); - } - }).catch(() => { clearInterval(check); location.reload(); }); - }, 2000); - }).catch(() => location.reload()); -} - -genQueueClear.addEventListener('click', () => { - // Les tâches retirées (finies, interrompues, échecs) sont mémorisées : sans - // cela le rechargement réadopterait la « dernière génération » du serveur - // (.generation.job.json) et les ferait réapparaître. - for (const t of genTasks) { - if (t.state === 'running' || t.state === 'queued') continue; - if (t.started !== undefined) dismissedRuns.add(t.started); - } - genTasks = genTasks.filter(t => t.state === 'running' || t.state === 'queued'); - saveGenTasks(); saveDismissed(); renderGenQueue(); -}); - -// Arrêt de la génération en cours : SIGTERM au pipeline côté serveur, qui -// nettoie ses workers et ses processus PDAL ; le sondage marque ensuite la -// tâche « Interrompu » (returncode négatif). -if (genStop) genStop.addEventListener('click', () => { - genStop.disabled = true; - fetch('/api/stop', { method: 'POST' }) - .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) - .catch(msg => { - genStop.disabled = false; - showGenBar('Erreur arrêt : ' + msg); - }); -}); - -// Retrait des demandes en file d'attente côté serveur : le run en cours -// continue, les demandes non démarrées sont abandonnées. -if (genQueueDrop) genQueueDrop.addEventListener('click', () => { - genQueueDrop.disabled = true; - fetch('/api/queue/clear', { method: 'POST' }) - .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) - .then(() => { - genQueueDrop.disabled = false; - genQueueDrop.hidden = true; - if (!genPollTimer) pollGenStatus(); // marquer les tâches « Interrompu » - }) - .catch(msg => { - genQueueDrop.disabled = false; - showGenBar('Erreur file d\'attente : ' + msg); - }); -}); - -renderGenQueue(); -// Reprend le suivi si une tâche tournait avant un rechargement de la page, -// adopte une génération lancée hors interface (API directe), ou réaffiche le -// dernier run terminé après un redémarrage du serveur (état rechargé du -// fichier .generation.job.json). La clé `started` évite les doublons au -// rechargement : un même run n'est adopté qu'une fois. -if (genTasks.some(t => t.state === 'running' || t.state === 'queued')) pollGenStatus(); -else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => { - if (!s || genTasks.some(t => t.started === s.started)) return; - // Déjà retirée manuellement de la liste : ne pas réadopter ce run. - if (s.started != null && dismissedRuns.has(s.started)) return; - // Déjà suivie par une tâche (en cours ou en file côté serveur) : pas - // d'adoption en doublon d'un run lancé depuis cet onglet. - const trackedQids = new Set(genTasks.filter(t => t.qid !== undefined).map(t => t.qid)); - if (s.qid != null && trackedQids.has(s.qid)) return; - if ((s.queue || []).some(q => trackedQids.has(q.id))) return; - const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); - if (s.running) { - genTasks.push({ - id: Date.now(), label: 'Génération en cours (lancée hors interface)', - time: now, started: s.started, - state: 'running', returncode: null, log: [], logOpen: false, tiles: s.tiles || [], - }); - saveGenTasks(); renderGenQueue(); pollGenStatus(); - updateRenderFrames(s.tiles); - } else if (s.returncode !== null && s.tiles && s.tiles.length) { - genTasks.push({ - id: Date.now(), label: 'Dernière génération', - time: now, started: s.started, - state: s.returncode === 0 ? 'done' : s.returncode < 0 ? 'stopped' : 'failed', - returncode: s.returncode, - log: [], logOpen: false, tiles: s.tiles, - }); - saveGenTasks(); renderGenQueue(); - } -}).catch(() => {}); - -// ============================================================ -// Initialisation -// ============================================================ -renderOsmRow(); -renderLayerPanel(); -fitView(); -syncTileLayers(); -layoutTiles(); -zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1); - -// Veille permanente : même sans file affichée dans cet onglet (génération -// lancée depuis un autre onglet, un autre appareil, ou index reconstruit -// côté serveur), la carte sonde /api/tiles toutes les 15 s — la réponse -// allégée par ?stamp= la rend quasi gratuite quand rien n'a changé — et -// fusionne les tuiles nouvelles ou régénérée dès que l'index bouge. Pendant -// un run suivi par cet onglet, pollGenStatus() sonde déjà toutes les 2 s. -setInterval(() => { - if (!document.hidden && !genPollTimer) pollLiveTiles(); -}, 15000); - -// ============================================================ -// Garde anti-page périmée (garantie : l'ancienne interface n'est -// jamais affichée) -// ============================================================ -// Un onglet laissé ouvert pendant un déploiement webapp continuerait -// d'exécuter le JS ancien. Dès que la page a plus de 15 min, son retour -// au premier plan (onglet mobile, fenêtre) déclenche le rechargement : -// le document étant servi no-store, le nouvel index.html amène le -// ?v= neuf et donc le JS à jour. Vue et file de génération sont -// conservées ; pas de rechargement pendant un run suivi (genPollTimer). -const loadedAt = Date.now(); -function reloadStalePage() { - if (genPollTimer || Date.now() - loadedAt < 15 * 60 * 1000) return; - try { - const c = map.getCenter(); - sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() })); - } catch (e) { /* sessionStorage indisponible : pas grave */ } - console.info("Page chargée depuis plus de 15 min — rechargement automatique pour l'interface à jour"); - location.reload(); -} -document.addEventListener('visibilitychange', - () => { if (!document.hidden) reloadStalePage(); }); -window.addEventListener('focus', reloadStalePage); - -// ============================================================ -// Détection de nouvelles couches (run de génération en cours) -// ============================================================ -// Le panneau et les URLs par tuile sont figés au rebuild de l'index : -// /api/layers liste ce qui existe sur disque, et le bouton ↻ resynchronise -// puis reconstruit la carte (vignettes + sous-tuiles) et recharge la page. -const layerRefreshBtn = document.getElementById('layerRefresh'); -function checkNewLayers() { - fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => { - if (!d || !layerRefreshBtn) return; - const news = Object.keys(d).filter(k => !VIZ_META[k]); - layerRefreshBtn.hidden = news.length === 0; - if (news.length) { - layerRefreshBtn.title = 'Nouvelles couches : ' + - news.map(k => d[k]).join(', ') + ' — cliquer pour actualiser la carte'; - } - }).catch(() => {}); -} -checkNewLayers(); -setInterval(() => { if (!document.hidden) checkNewLayers(); }, 60000); -if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => { - layerRefreshBtn.textContent = '…'; - layerRefreshBtn.disabled = true; - fetch('/api/sync', { method: 'POST' }) - .then(r => r.ok ? r.json() : Promise.reject('rejeté')) - .then(() => new Promise(resolve => { - const poll = setInterval(() => { - fetch('/api/sync').then(r => r.json()).then(s => { - if (!s.running) { clearInterval(poll); resolve(s.error); } - }).catch(() => { clearInterval(poll); resolve(null); }); - }, 3000); - })) - .then(err => { - if (err) { - layerRefreshBtn.textContent = '↻'; - layerRefreshBtn.disabled = false; - layerRefreshBtn.title = 'Erreur synchronisation/rebuild : ' + err; - } else { - location.reload(); - } - }) - .catch(() => { - layerRefreshBtn.textContent = '↻'; - layerRefreshBtn.disabled = false; - }); -}); - -} // fin guard Leaflet -""" + return inventory_path diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index f6d3240..83fa791 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -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 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 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 : - `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 logging +import math import os import re +import signal +import subprocess import sys import threading import time import urllib.parse import urllib.request +import uuid import xml.sax.saxutils as _xml from collections import deque from contextlib import asynccontextmanager @@ -49,8 +74,9 @@ from functools import lru_cache from pathlib import Path 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.staticfiles import StaticFiles as _StaticFiles from pydantic import BaseModel, Field 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 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")) # Serveur de tuiles amont (machine de traitement) — mode deux machines. 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)) _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) @@ -294,11 +348,59 @@ if not (_assets_dir / "app.js").is_file(): _assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets" 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}" + (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local") + f", {TILE_WORKERS} rendu(s) simultané(s)" + f", {tiles_mod.FETCH_WORKERS} téléchargement(s) simultané(s)" + f", cache sources {tiles_mod.SOURCE_CACHE_BYTES // (1024 * 1024)} Mo") +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(): """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 = [] for key in tiles_mod.available_layers(OUTPUT_DIR): legend = VIZ_LEGENDS.get(key, {}) out.append({"key": key, "label": VIZ_LABELS.get(key, key), - "description": legend.get("description", "")}) + "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 @@ -834,6 +938,749 @@ def healthz(): 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 # --------------------------------------------------------------------------- diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py index 0d2dfd3..3a545d8 100644 --- a/lidar_pipeline/mapui.py +++ b/lidar_pipeline/mapui.py @@ -101,7 +101,50 @@ _MAP_HTML = """
+ + ', 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')) + # Inventaire servi par /api/tiles aux machines légères : tuiles avec coins, + # libellés de couches, compteur. + data = json.loads(inv_path.read_text(encoding='utf-8')) assert data['tiles'] and data['tiles'][0]['corners'] assert 'aspect' in data['viz_meta'] + assert data['viz_meta']['aspect']['label'] 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 thumb_dir = output_dir / "index_thumbs" 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 -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): """Vignette intermédiaire 640 px générée et référencée dans l'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 -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): """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 -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(): """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) 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(): diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 0ade8c1..cd67a06 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -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 assert "createPane('lidarStack')" 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 "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML # 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 finally: _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 diff --git a/lidar_pipeline/tests/test_tiles.py b/lidar_pipeline/tests/test_tiles.py index 31acb62..a57d8fe 100644 --- a/lidar_pipeline/tests/test_tiles.py +++ b/lidar_pipeline/tests/test_tiles.py @@ -405,7 +405,7 @@ def test_png_palette_option_shrinks_tiles(tmp_path, monkeypatch): # --------------------------------------------------------------------------- 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" tiles = [] for i in range(2): diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py deleted file mode 100644 index 2489834..0000000 --- a/lidar_pipeline/tests/test_webapp.py +++ /dev/null @@ -1,1244 +0,0 @@ -"""Tests du serveur web de génération de zones (webapp).""" - - -def test_bbox_to_cells_single_km_cell(): - """Une bbox couvrant ~1 km² retourne la cellule L93 correspondante.""" - from lidar_pipeline.webapp import bbox_to_cells - # Cellule 1054,6882 : X∈[1054000,1055000], Y∈[6881000,6882000] (L93) - from rasterio.warp import transform as warp_transform - lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', - [1054100, 1054900], [6881100, 6881900]) - cells = bbox_to_cells(min(lons), min(lats), max(lons), max(lats)) - assert (1054, 6882) in cells - # La sélection reste locale : pas de cellule lointaine - for (c, r) in cells: - assert abs(c - 1054) <= 1 and abs(r - 6882) <= 1 - - -def test_bbox_to_cells_empty_for_tiny_bbox(): - """Une bbox quasi ponctuelle ne sélectionne rien (rétrécie sous 1 m).""" - from lidar_pipeline.webapp import bbox_to_cells - assert bbox_to_cells(7.850000, 48.930000, 7.850001, 48.930001) == [] - - -def test_processed_cells(tmp_path): - """processed_cells lit les dossiers de visualisations.""" - from lidar_pipeline.webapp import processed_cells - vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2" - vis.mkdir(parents=True) - (vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x") - assert processed_cells(tmp_path) == {(1054, 6882)} - - -def test_missing_cells_filters_processed(tmp_path): - """Les cellules déjà traitées sont exclues, les autres gardent leurs coins.""" - from lidar_pipeline.webapp import missing_cells_with_corners - vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69" - vis.mkdir(parents=True) - (vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x") - todo = missing_cells_with_corners([(1054, 6882), (1055, 6882)], tmp_path) - assert len(todo) == 1 - assert todo[0]['col'] == 1055 and todo[0]['row'] == 6882 - assert len(todo[0]['corners']) == 4 # SW, SE, NE, NW - - -def test_missing_cells_include_done(tmp_path): - """include_done=True conserve les cellules déjà traitées (régénération).""" - from lidar_pipeline.webapp import missing_cells_with_corners - vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69" - vis.mkdir(parents=True) - (vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x") - todo = missing_cells_with_corners([(1054, 6882), (1055, 6882)], tmp_path, - include_done=True) - assert {(t['col'], t['row']) for t in todo} == {(1054, 6882), (1055, 6882)} - - -def _make_tile(tmp_path, col, row, viz_keys, resolutions=(0.5, 0.2)): - """Crée les dossiers de visualisations d'une tuile (une entrée par résolution). - - Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent - le basename nu (cf. _expected_output_path dans le pipeline). - """ - base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69" - for res in resolutions: - suffix = "" if res == 0.5 else "_r" + str(res).replace('.', 'p') - vis = tmp_path / "visualisations" / (base + suffix) - vis.mkdir(parents=True, exist_ok=True) - for key in viz_keys: - (vis / f"{base}_{key}.avif").write_bytes(b"x") - - -def test_missing_cells_includes_incomplete_tile(tmp_path): - """Une tuile existante mais incomplète reste incluse (visualisation manquante).""" - from lidar_pipeline.webapp import missing_cells_with_corners - _make_tile(tmp_path, 1054, 6882, ["aspect"]) - # aspect aux deux résolutions → complète pour ['aspect'] - assert missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"]) == [] - # slope manquante → la tuile est incluse pour être complétée - todo = missing_cells_with_corners([(1054, 6882)], tmp_path, - viz=["aspect", "slope"]) - assert [(t['col'], t['row']) for t in todo] == [(1054, 6882)] - - -def test_missing_cells_requires_generation_resolution(tmp_path): - """Une tuile complète à 0.5 mais sans 0.2 reste à traiter (seule 0.2 est générée).""" - from lidar_pipeline.webapp import missing_cells_with_corners - _make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.5,)) - assert len(missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"])) == 1 - _make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.2,)) - assert missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"]) == [] - - -def test_complete_cells_maps_step_names(tmp_path): - """Les noms d'étapes sont convertis en mots-clés de fichiers (hillshade → hillshade_multi).""" - from lidar_pipeline.webapp import complete_cells - _make_tile(tmp_path, 1054, 6882, ["hillshade_multi"]) - assert complete_cells(tmp_path, ["hillshade"]) == {(1054, 6882)} - # 'pos_open' → 'positive_openness' : absent → tuile non complète - assert complete_cells(tmp_path, ["pos_open"]) == set() - - -def test_preview_rejects_unknown_viz(): - """/api/preview refuse un nom de visualisation inconnu (HTTPException 400).""" - from fastapi import HTTPException - from lidar_pipeline.webapp import preview, PreviewRequest - req = PreviewRequest(bbox=[7.85, 48.93, 7.86, 48.94], viz=["inconnu"]) - try: - preview(req) - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 400 - assert "inconnu" in e.detail - - -def test_preset_sanitize_and_upsert(tmp_path, monkeypatch): - """Presets de couches : valeurs assainies, remplacées par identifiant.""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json") - req = webapp.PresetRequest(id="Relief v2!", label=" Reliefs ", - on=["slope", "aspect"], - opacity={"slope": 0.5, "aspect": "0.8", "lrm": "x"}, - osm={"on": True, "opacity": 1.5, "dark": True}) - p = webapp._sanitize_preset(req) - assert p["id"] == "reliefv2" - assert p["label"] == "Reliefs" - assert p["opacity"] == {"slope": 0.5, "aspect": 0.8} - assert p["osm"] == {"on": True, "opacity": 1.0, "dark": True} - webapp.upsert_preset(req) - assert [x["label"] for x in webapp.list_presets()["presets"]] == ["Reliefs"] - # Même identifiant : remplacement, pas de doublon - webapp.upsert_preset(webapp.PresetRequest(id="reliefv2", label="Reliefs v2", - on=["slope"], opacity={})) - got = webapp.list_presets()["presets"] - assert len(got) == 1 and got[0]["label"] == "Reliefs v2" - # Suppression inconnue : 404 - from fastapi import HTTPException - webapp.delete_preset(got[0]["id"]) - assert webapp.list_presets()["presets"] == [] - try: - webapp.delete_preset("reliefv2") - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 404 - - -def test_preset_file_corrupted_is_ignored(tmp_path, monkeypatch): - """Un fichier de presets corrompu est ignoré (liste vide, pas d'exception).""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json") - webapp.PRESETS_FILE.write_text("{pas du json", encoding="utf-8") - assert webapp._load_presets() == [] - - -def test_build_command_resolutions_match_completeness(): - """La commande -r et la détection de complétude utilisent les mêmes résolutions.""" - from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command - cmd = _build_command([(1054, 6882)]) - i = cmd.index("-r") - assert cmd[i + 1] == ",".join(str(r) for r in GENERATE_RESOLUTIONS) - - -def test_resolve_request_orders_tiles_north_to_south(): - """Les tuiles d'une demande partent du nord vers le sud (row décroissante).""" - from types import SimpleNamespace - from lidar_pipeline.webapp import _resolve_request - req = SimpleNamespace(viz=[], all_missing=False, regenerate=False, - tiles=[[1055, 6881], [1054, 6882], [1054, 6880], [1053, 6882]], - ground_class="ign") - tiles, _viz = _resolve_request(req) - assert tiles == [(1053, 6882), (1054, 6882), (1055, 6881), (1054, 6880)] - - -def test_job_state_persisted_across_reload(tmp_path, monkeypatch): - """L'état du job survit au redémarrage du serveur (file .generation.job.json).""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "JOB_FILE", tmp_path / "job.json") - saved = {k: webapp._job.get(k) for k in ("proc", "started", "returncode", "cmd", "finished")} - try: - webapp._job.update({"started": 123.0, "returncode": 0, - "cmd": ["python", "-m", "lidar_pipeline"], "finished": 456.0}) - webapp._save_job_state() - assert (tmp_path / "job.json").exists() - # Simulation du redémarrage : état mémoire vide, rechargé depuis disque - webapp._job.update({"proc": None, "started": None, "returncode": None, - "cmd": None, "finished": None}) - webapp._load_job_state() - assert webapp._job["started"] == 123.0 - assert webapp._job["returncode"] == 0 - assert webapp._job["finished"] == 456.0 - finally: - webapp._job.update(saved) - - -def test_laz_cells_parses_input(tmp_path): - """laz_cells repère les dalles LHD présentes dans input/ (les autres ignorés).""" - from lidar_pipeline.webapp import laz_cells - (tmp_path / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x") - (tmp_path / "LHD_FXX_1055_6881_PTS_LAMB93_IGN69.laz").write_bytes(b"x") - (tmp_path / "LHD_FXX_1056_6880_PTS_LAMB93_IGN69.copc.las").write_bytes(b"x") - (tmp_path / "autre_fichier.laz").write_bytes(b"x") - assert laz_cells(tmp_path) == [(1054, 6882), (1055, 6881), (1056, 6880)] - - -def test_attach_frame_bounds_only_unfinished(): - """Cadres de rendu : coins WGS84 sur les dalles non terminées seulement.""" - from lidar_pipeline.webapp import _attach_frame_bounds - tiles = [ - {"short": "1054-6882", "name": "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", - "state": "running", "steps": []}, - {"short": "1054-6881", "name": "LHD_FXX_1054_6881_PTS_LAMB93_IGN69", - "state": "done", "steps": []}, - {"short": "inconnu", "name": "pas_une_dalle", "state": "failed", "steps": []}, - ] - _attach_frame_bounds(tiles) - assert tiles[0]["col"] == 1054 and tiles[0]["row"] == 6882 - assert len(tiles[0]["corners"]) == 4 # SW, SE, NE, NW - lat = [c[0] for c in tiles[0]["corners"]] - lon = [c[1] for c in tiles[0]["corners"]] - assert abs(max(lat) - min(lat)) < 0.02 # ~1 km en degrés - assert abs(max(lon) - min(lon)) < 0.03 - assert "corners" not in tiles[1] # terminée : pas de cadre - assert "corners" not in tiles[2] # nom hors grille LHD - - -def test_preview_all_missing(tmp_path, monkeypatch): - """all_missing: seulement les dalles présentes dans input/ et incomplètes.""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - (tmp_path / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x") - (tmp_path / "LHD_FXX_1055_6881_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x") - # 1054,6882 : aspect aux deux résolutions → complète pour ['aspect'] - base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69" - for suffix in ("", "_r0p2"): - vis = tmp_path / "visualisations" / (base + suffix) - vis.mkdir(parents=True) - (vis / f"{base}_aspect.avif").write_bytes(b"x") - d = webapp.preview(webapp.PreviewRequest(all_missing=True, viz=["aspect"])) - assert d["count"] == 1 - assert d["cells"][0]["col"] == 1055 and d["cells"][0]["row"] == 6881 - - -def test_generate_all_missing(tmp_path, monkeypatch): - """all_missing: traite toutes les dalles présentes (au-delà de la limite 400).""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - for i in range(1054, 1455): # 401 dalles → au-delà de MAX_CELLS - (tmp_path / f"LHD_FXX_{i:04d}_6882_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x") - - captured = {} - - def fake_popen(cmd, **kwargs): - captured["cmd"] = cmd - - class _P: - def wait(self): - return 0 - - def poll(self): - return 0 - return _P() - - # Stub du module subprocess : patcher le vrai module global casserait ses - # autres usages (matplotlib l'appelle à l'import, ex. fc-list) et la - # restauration serait un no-op (valeur déjà falsifiée). - import subprocess as _real_subprocess - - class _SubprocessStub: - Popen = staticmethod(fake_popen) - run = staticmethod(_real_subprocess.run) - STDOUT = _real_subprocess.STDOUT - - monkeypatch.setattr(webapp, "subprocess", _SubprocessStub) - req = webapp.GenerateRequest(tiles=[], all_missing=True, viz=["aspect"]) - result = webapp.generate(req) - assert result["demarré"] is True and result["tuiles"] == 401 - i = captured["cmd"].index("--file") - assert len(captured["cmd"][i + 1:]) == 401 - assert "LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz" in captured["cmd"] - - -def test_generate_all_missing_empty(tmp_path, monkeypatch): - """all_missing sans dalle présente → 400.""" - from fastapi import HTTPException - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - try: - webapp.generate(webapp.GenerateRequest(tiles=[], all_missing=True)) - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 400 - - -def test_build_command_regenerate(): - """regenerate --force seul (classification conservée) ; reclassify ajoute --force-classification.""" - from lidar_pipeline.webapp import _build_command - cmd = " ".join(_build_command([(1054, 6882)], regenerate=True)) - assert "--force" in cmd - assert "--force-classification" not in cmd - cmd = " ".join(_build_command([(1054, 6882)], regenerate=True, reclassify=True)) - assert "--force" in cmd - assert "--force-classification" in cmd - cmd = " ".join(_build_command([(1054, 6882)])) - assert "--force" not in cmd - assert "--force-classification" not in cmd - - -def test_build_command_ground_classification(): - """La commande utilise la méthode de classification demandée (défaut : ign).""" - from lidar_pipeline.webapp import _build_command, GROUND_CLASS_METHODS - # Défaut : ign (pré-classification) - cmd = _build_command([(1054, 6882)]) - i = cmd.index("--ground-classification") - assert cmd[i + 1] == "ign" - # Chaque méthode valide est transmise telle quelle ; --force suit - # regenerate, --force-classification suit reclassify (indépendants) - for method in GROUND_CLASS_METHODS: - for regenerate in (False, True): - for reclassify in (False, True): - cmd = _build_command([(1054, 6882)], regenerate=regenerate, - ground_class=method, reclassify=reclassify) - i = cmd.index("--ground-classification") - assert cmd[i + 1] == method - assert ("--force" in cmd) == regenerate - assert ("--force-classification" in cmd) == reclassify - - -def test_rebuild_flag_synchronous(monkeypatch): - """Le rebuild est marqué running dès le POST (pas après coup). - - Un GET /api/sync juste après le POST doit voir le rebuild en cours : - sinon il lirait le done du rebuild précédent et l'interface - rechargerait une carte périmée (bug de rafraîchissement). - """ - import threading - import time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - - go = threading.Event() - - def slow_build(out, **kw): - go.wait(2) # rebuild artificiellement long - - monkeypatch.setattr(index_mod, "build_index", slow_build) - saved = dict(webapp._rebuild) - try: - d = webapp._start_rebuild() - assert d["demarré"] is True - assert d["started"] <= time.time() - # Déjà « running » au retour du POST, avant la fin du fil - assert webapp.rebuild_status()["running"] is True - assert webapp.rebuild_status()["done"] is None - go.set() - for _ in range(100): - if not webapp.rebuild_status()["running"]: - break - time.sleep(0.05) - s = webapp.rebuild_status() - assert s["running"] is False - assert s["done"] is not None and s["done"] >= d["started"] - finally: - webapp._rebuild.update(saved) - - -def test_merge_remote_index_merges_files(tmp_path, monkeypatch): - """Le rebuild fusionne l'index du worker dans les fichiers, pas seulement /api/tiles. - - Sans fusion, la coquille HTML embarque un sous-ensemble des tuiles (le - cache local seul) : au 1er chargement, les tuiles récentes du worker - manquent et n'arrivent qu'au sondage suivant. - """ - import json as _json - import lidar_pipeline.webapp as webapp - - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - locale = {"col": 10, "row": 20, "resolution": 0.2, "name": "locale", "viz": {}} - perimee = {"col": 11, "row": 20, "resolution": 0.2, "name": "locale-perimee", "viz": {}} - dist_neuve = {"col": 11, "row": 20, "resolution": 0.2, "name": "distante-neuve", "viz": {}} - dist_seule = {"col": 12, "row": 20, "resolution": 0.2, "name": "distante-seule", "viz": {}} - (tmp_path / "index_tiles.json").write_text(_json.dumps( - {"tiles": [locale, perimee], - "viz_meta": {"aspect": {"label": "Aspect"}}, - "stats": {"n_tiles": 2}}), encoding="utf-8") - (tmp_path / "index.html").write_text( - "\nconst OVERVIEW = {};\n", encoding="utf-8") - monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: { - "tiles": [dist_neuve, dist_seule], - "overview": {"hillshade_multi": {"url": "index_overview/x.webp?v=2"}}, - "viz_meta": {"aspect": {"label": "Aspect distant"}}, - "stats": {"n_tiles": 2}, - }) - webapp._merge_remote_index() - data = _json.loads((tmp_path / "index_tiles.json").read_text(encoding="utf-8")) - names = [t["name"] for t in data["tiles"]] - # Le distant prime sur la même position, le local complète les autres. - assert "distante-neuve" in names and "locale" in names and "distante-seule" in names - assert "locale-perimee" not in names - assert data["viz_meta"]["aspect"]["label"] == "Aspect distant" - # La coquille embarque la même liste fusionnée (et l'overview distante). - html = (tmp_path / "index.html").read_text(encoding="utf-8") - assert "distante-neuve" in html and "distante-seule" in html - assert "locale-perimee" not in html - assert "const OVERVIEW = {\"hillshade_multi\"" in html - assert data["overview"]["hillshade_multi"]["url"].endswith("?v=2") - # Worker injoignable : fichiers laissés intacts. - before = (tmp_path / "index_tiles.json").read_text(encoding="utf-8") - monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None) - webapp._merge_remote_index() - assert (tmp_path / "index_tiles.json").read_text(encoding="utf-8") == before - - -def test_static_mounts_cache_headers(tmp_path): - """Montages d'images : no-cache par défaut, immutable si l'URL porte ?v=. - - ?v= suit la mtime du fichier servi (posée par build_index) : le contenu - désigné ne change jamais → cache navigateur 1 an sans revalidation. Sans - ?v= (index.html, rejeu manuel), no-cache force la revalidation 304 — un - rendu régénéré ne reste jamais périmé à l'écran. - """ - import asyncio - from starlette.routing import Mount - import lidar_pipeline.webapp as webapp - - assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles) - mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)} - for name in ("index_thumbs", "index_subtiles", "index_overview", - "visualisations", "DTM"): - route = mounted.get(f"/{name}") - assert route is not None, f"montage /{name} absent" - assert isinstance(route.app, webapp._NoCacheStaticFiles) - - # L'en-tête est bien posé sur la réponse servie - (tmp_path / "x.jpg").write_bytes(b"1") - srv = webapp._NoCacheStaticFiles(directory=str(tmp_path)) - - async def fetch(query): - scope = {"type": "http", "method": "GET", "path": "/x.jpg", - "headers": [], "query_string": query} - return await srv.get_response("x.jpg", scope) - - resp = asyncio.run(fetch(b"")) - assert resp.headers["cache-control"] == "no-cache, must-revalidate" - resp = asyncio.run(fetch(b"v=1712345678901")) - assert resp.headers["cache-control"] == "public, max-age=31536000, immutable" - - -def test_stop_generation(monkeypatch): - """/api/stop termine le processus en cours (SIGTERM) ou refuse (409).""" - import time - from fastapi import HTTPException - import lidar_pipeline.webapp as webapp - terminated = [] - - class FakeProc: - pid = 4242 - def poll(self): - return None # en cours - def terminate(self): - terminated.append("SIGTERM") - def wait(self, timeout=None): - terminated.append("wait") - - saved = {k: webapp._job.get(k) for k in ("proc", "started", "returncode", "cmd", "finished")} - try: - webapp._job.update({"proc": FakeProc(), "started": time.time(), - "returncode": None, "cmd": ["x"], "finished": None}) - assert webapp.stop_generation() == {"arrêt": "demandé"} - assert "SIGTERM" in terminated - # Le fil d'escalade peut mettre un instant avant son wait : laisser - # la file de threads vider avant de restaurer l'état du job. - for _ in range(50): - if "wait" in terminated: - break - time.sleep(0.02) - # Sans job en cours : refus propre - webapp._job.update({"proc": None}) - try: - webapp.stop_generation() - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 409 - finally: - webapp._job.update(saved) - - -def test_point_to_cell_matches_bbox_cells(): - """point_to_cell désigne la cellule contenant le point (cohérent bbox_to_cells).""" - from lidar_pipeline.webapp import point_to_cell, bbox_to_cells - from rasterio.warp import transform as warp_transform - # Centre de la dalle 1054,6882 en L93 → WGS84 - lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500]) - lon, lat = lons[0], lats[0] - assert point_to_cell(lon, lat) == (1054, 6882) - # Une petite bbox autour du même point donne la même cellule, elle seule - cells = bbox_to_cells(lon - 1e-4, lat - 1e-4, lon + 1e-4, lat + 1e-4) - assert cells == [(1054, 6882)] - # Point vers le bord est de la dalle suivante - lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1055800], [6881500]) - assert point_to_cell(lons[0], lats[0]) == (1055, 6882) - - -def test_api_cell_returns_corners(): - """/api/cell (clic carte) renvoie la dalle L93 et ses coins GPS.""" - from lidar_pipeline.webapp import cell_at_point - from rasterio.warp import transform as warp_transform - lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500]) - d = cell_at_point(lat=lats[0], lng=lons[0]) - assert d["col"] == 1054 and d["row"] == 6882 - assert len(d["corners"]) == 4 # SW, SE, NE, NW - - -def test_generate_enqueues_while_running(tmp_path, monkeypatch): - """Une demande pendant un run part en file (plus de 409, rien n'est coupé).""" - import time - import lidar_pipeline.webapp as webapp - - class FakeProc: - pid = 4242 - def poll(self): - return None # en cours - def wait(self, timeout=None): - return None - - saved_job = dict(webapp._job) - saved_queue = list(webapp._queue) - monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json") - try: - webapp._job.update({"proc": FakeProc(), "started": time.time(), - "returncode": None, "cmd": ["x"], "finished": None, - "qid": None}) - webapp._queue.clear() - req = webapp.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"]) - d = webapp.generate(req) - assert d["demarré"] is False - assert d["en_file"] == 1 and d["tuiles"] == 1 and d["qid"] is not None - assert len(webapp._queue) == 1 - assert webapp._queue[0]["req"]["tiles"] == [[1054, 6882]] - # La file survit à un « redémarrage » du serveur - webapp._queue.clear() - webapp._load_queue() - assert len(webapp._queue) == 1 - # Retrait des demandes en attente (le run en cours n'est pas touché) - assert webapp.queue_clear() == {"retirées": 1} - assert webapp._queue == [] - finally: - webapp._job.update(saved_job) - webapp._queue[:] = saved_queue - - -def test_start_next_queued_launches_after_run(tmp_path, monkeypatch): - """Serveur libre + file non vide : la demande suivante démarre (fidélité qid).""" - import time - import lidar_pipeline.webapp as webapp - - captured = {} - - def fake_popen(cmd, **kwargs): - captured["cmd"] = cmd - - class _P: - def wait(self): - return 0 - - def poll(self): - return 0 - return _P() - - import subprocess as _real_subprocess - - class _SubprocessStub: - Popen = staticmethod(fake_popen) - run = staticmethod(_real_subprocess.run) - STDOUT = _real_subprocess.STDOUT - - monkeypatch.setattr(webapp, "subprocess", _SubprocessStub) - monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "LOG_FILE", tmp_path / "gen.log") - monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json") - saved_job = dict(webapp._job) - saved_queue = list(webapp._queue) - try: - webapp._job.update({"proc": None, "started": None, "returncode": 0, - "cmd": None, "finished": time.time(), "qid": None}) - webapp._queue[:] = [{"id": 7, "queued_at": 1.0, - "req": {"tiles": [[1054, 6882]], "viz": ["aspect"]}}] - assert webapp._start_next_queued() is True - assert webapp._queue == [] # la demande est consommée - assert webapp._job["qid"] == 7 # le run porte l'identifiant de file - assert "--fetch-tiles" in captured["cmd"] - assert "1054,6882" in captured["cmd"] - finally: - webapp._job.update(saved_job) - webapp._queue[:] = saved_queue - - -def test_build_command_default_viz_panel(): - """Sans choix de visualisation, la commande génère les couches affichées.""" - from lidar_pipeline.webapp import _build_command, _panel_viz_steps - cmd = _build_command([(1054, 6882)]) - i = cmd.index("--only") - expected = _panel_viz_steps() - n = len(expected) - assert cmd[i + 1:i + 1 + n] == expected - assert "pos_open" in expected # panneau : slope, aspect, positive_openness - - -def test_build_command_custom_viz(): - """Les visualisations demandées sont passées à --only dans l'ordre.""" - from lidar_pipeline.webapp import _build_command - cmd = _build_command([(1054, 6882)], viz=["aspect", "wavelet", "slope"]) - i = cmd.index("--only") - assert cmd[i + 1:i + 4] == ["aspect", "wavelet", "slope"] - - -def test_build_command_incremental_index(): - """La génération web reconstruit l'index après chaque tuile (affichage en direct).""" - from lidar_pipeline.webapp import _build_command - cmd = _build_command([(1054, 6882)]) - assert "--incremental-index" in cmd - - -def test_tiles_endpoint_stamp_diffing(tmp_path, monkeypatch): - """/api/tiles sert index_tiles.json, allégé quand le stamp correspond.""" - import json - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - - # Fichier absent (aucune tuile encore indexée) → réponse vide, avec la - # version de l'interface (le JS embarqué détecte une webapp périmée) - r = webapp.tiles_data() - assert r["stamp"] is None and r["tiles"] is None - assert r["ui_version"] == webapp._ui_version_local() - - # Fichier présent → données complètes puis réponse allégée au stamp - (tmp_path / "index_tiles.json").write_text( - json.dumps({"tiles": [{"col": 1054, "corners": []}], - "viz_meta": {}, "stats": {}}), encoding="utf-8") - full = webapp.tiles_data() - assert full["tiles"][0]["col"] == 1054 - light = webapp.tiles_data(stamp=full["stamp"]) - assert light["tiles"] is None and light["stamp"] == full["stamp"] - # Un stamp périmé redonne les données complètes - assert webapp.tiles_data(stamp=full["stamp"] - 10)["tiles"] is not None - - -def test_tiles_endpoint_merges_remote_index(tmp_path, monkeypatch): - """L'index du worker prime ; le cache local complète les positions perdues. - - viz_meta est filtré aux couches du panneau (PANEL_VIZ) : une couche - retirée (absente de PANEL_VIZ) proposée par un index obsolète n'est - pas réintroduite. - """ - import json - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - (tmp_path / "index_tiles.json").write_text(json.dumps({ - "tiles": [ - {"col": 1054, "row": 6882, "resolution": 0.2, "viz": {}}, # commun - {"col": 1050, "row": 6882, "resolution": 0.2, "viz": {}}, # local seul - ], - "viz_meta": { - "aspect": {"label": "Aspect local"}, - "slope": {"label": "Pente locale"}, - }, - "stats": {}, - }), encoding="utf-8") - import os - remote_stamp = os.stat(tmp_path / "index_tiles.json").st_mtime + 1000 - monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: { - "stamp": remote_stamp, - "tiles": [ - # 1054 régénéré côté worker : sa version (viz remplie) doit primer - {"col": 1054, "row": 6882, "resolution": 0.2, "viz": {"slope": {}}}, - {"col": 1055, "row": 6882, "resolution": 0.2, "viz": {}}, # distant seul - ], - "viz_meta": { - "slope": {"label": "Pente"}, - "wavelet": {"label": "Ondulets"}, # hors panneau : filtrée - }, - }) - d = webapp.tiles_data() - entries = {(t["col"], json.dumps(t["viz"], sort_keys=True)) for t in d["tiles"]} - assert (1054, '{"slope": {}}') in entries # version distante retenue - assert (1055, '{}') in entries # tuile non cachée visible - assert (1050, '{}') in entries # historique local conservé - assert d["viz_meta"]["slope"]["label"] == "Pente" # distant prime - assert d["viz_meta"]["aspect"]["label"] == "Aspect local" # local complété - assert "wavelet" not in d["viz_meta"] # couche hors panneau filtrée - assert d["stamp"] == remote_stamp # max des deux stamps - - -def test_tiles_endpoint_advertises_ui_version(tmp_path, monkeypatch): - """/api/tiles publie ui_version : la version du serveur de données prime. - - Le JS embarqué (checkUiVersion) la compare à sa propre version et - affiche un bandeau quand la webapp est périmée (assets bâchés dans - l'image au build, image non reconstruite après une évolution). - """ - import lidar_pipeline.webapp as webapp - from lidar_pipeline.index import ui_version - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - # Pas de distant (mode mono-machine) : la version de cette webapp - d = webapp.tiles_data() - assert d["ui_version"] == ui_version() - # Distant (mode deux machines) : sa version prime, réponse allégée comprise - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: { - "stamp": 1.0, "tiles": [], - "stats": {"ui_version": "remote0000"}, - }) - d = webapp.tiles_data() - assert d["ui_version"] == "remote0000" - light = webapp.tiles_data(stamp=d["stamp"]) - assert light["tiles"] is None and light["ui_version"] == "remote0000" - - -def test_warn_ui_drift_once_per_version_pair(tmp_path, monkeypatch, caplog): - """Avertissement de drift : une fois par paire de versions, sans faux positif. - - Le navigateur affiche lui aussi un bandeau ; ce log aide l'opérateur - (cf. Dockerfile.webapp : l'interface est bâchée au build de l'image). - """ - import logging - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "_UI_DRIFT_WARNED", set()) - local = webapp._ui_version_local() - with caplog.at_level(logging.WARNING): - webapp._warn_ui_drift({"stats": {"ui_version": "autrev0000"}}) - webapp._warn_ui_drift({"stats": {"ui_version": "autrev0000"}}) - # Versions alignées (mono-machine) : pas d'avertissement - webapp._warn_ui_drift({"stats": {"ui_version": local}}) - # Index antérieur à l'ajout de la version : pas d'avertissement - webapp._warn_ui_drift({"stats": {}}) - warned = [r for r in caplog.records - if r.getMessage().startswith("Interface webapp périmée")] - assert len(warned) == 1 - - -def test_assets_never_immutable(): - """/assets revalidé systématiquement (no-cache), même avec ?v=. - - ?v= ne garantit plus l'immuabilité : les assets suivent l'IMAGE, pas le - cache de tuiles — un rebuild change le contenu SOUS le même ?v= de - l'index.html en attente de régénération. Un cache immutable bloquerait - alors l'ancien JS chez les navigateurs qui détiennent encore l'index - antérieur (premier affichage après déploiement) ; la revalidation - ETag/mtime les fait se mettre à jour. - """ - import lidar_pipeline.webapp as webapp - assert (webapp._assets_dir / "app.js").is_file() - for v in (None, "c1d46e3abc"): - resp = webapp.assets("app.js", v=v) - assert resp.headers["Cache-Control"] == "no-cache, must-revalidate" - import pytest - from fastapi import HTTPException - with pytest.raises(HTTPException): - webapp.assets("introuvable.js", v=None) - - -def test_tiles_endpoint_worker_offline_serves_local(tmp_path, monkeypatch): - """Worker injoignable : index local servi seul, sans erreur.""" - import json - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - (tmp_path / "index_tiles.json").write_text( - json.dumps({"tiles": [{"col": 1054}], "viz_meta": {}, "stats": {}}), - encoding="utf-8") - monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None) - d = webapp.tiles_data() - assert d["tiles"][0]["col"] == 1054 - assert d["stamp"] is not None - - -def test_worker_offline_breaker(monkeypatch): - """3 échecs consécutifs → tentatives suspendues ; un succès réarme.""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "_WORKER_HEALTH", {"fails": 0, "until": 0.0}) - webapp._worker_mark(False) - webapp._worker_mark(False) - assert webapp._worker_offline() is False # pas encore armé - webapp._worker_mark(False) - assert webapp._worker_offline() is True # disjoncteur armé - until = webapp._WORKER_HEALTH["until"] - webapp._worker_mark(False) - assert webapp._WORKER_HEALTH["until"] == until # fenêtre non étendue - webapp._worker_mark(True) - assert webapp._worker_offline() is False # réarmé immédiatement - - -def test_safe_rel_path(): - """Seuls les chemins images des préfixes autorisés sont rapatrieables.""" - from lidar_pipeline.webapp import _safe_rel_path - assert _safe_rel_path("/visualisations/dalle/img.avif") == \ - "visualisations/dalle/img.avif" - assert _safe_rel_path("/index_thumbs/a_b.jpg") == "index_thumbs/a_b.jpg" - assert _safe_rel_path("/index_subtiles/x_0_1.avif") == "index_subtiles/x_0_1.avif" - assert _safe_rel_path("/DTM/x.tif") is None # préfixe interdit - assert _safe_rel_path("/assets/app.js") is None # préfixe interdit - assert _safe_rel_path("/visualisations/../x.avif") is None # traversal - assert _safe_rel_path("/visualisations/a%20b.avif") is None # caractères - - -def test_fetch_remote_to_cache(tmp_path, monkeypatch): - """Rapatriement atomique depuis le worker ; échec sans fichier ni erreur.""" - import io - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - monkeypatch.setattr(webapp, "_WORKER_HEALTH", {"fails": 0, "until": 0.0}) - - class _Resp(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - captured = {} - - def fake_urlopen(req, timeout=None): - captured["url"] = req.full_url - return _Resp(b"IMAGEDATA") - - monkeypatch.setattr(webapp.urllib.request, "urlopen", fake_urlopen) - assert webapp._fetch_remote_to_cache("visualisations/dalle/img.avif") is True - p = tmp_path / "visualisations" / "dalle" / "img.avif" - assert p.read_bytes() == b"IMAGEDATA" - assert captured["url"] == "http://distant:8973/visualisations/dalle/img.avif" - assert not list(tmp_path.rglob("*.part")) # pas de résidu temporaire - assert webapp._WORKER_HEALTH["fails"] == 0 # succès → disjoncteur sain - - def boom(req, timeout=None): - raise OSError("injoignable") - - monkeypatch.setattr(webapp.urllib.request, "urlopen", boom) - assert webapp._fetch_remote_to_cache("visualisations/dalle/x.avif") is False - assert not (tmp_path / "visualisations" / "dalle" / "x.avif").exists() - assert webapp._WORKER_HEALTH["fails"] == 1 - - -def test_status_worker_offline_no_error(monkeypatch): - """Worker hors ligne : running=null (ignoré par l'UI), pas de 503.""" - from fastapi import HTTPException - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - - def unreachable(method, path, payload=None, timeout=30): - raise HTTPException(503, "machine de traitement injoignable") - - monkeypatch.setattr(webapp, "_proxy_api", unreachable) - d = webapp.status(_FakeRequest(host="192.168.1.7")) - assert d["running"] is None - assert d["worker_offline"] is True - assert d["regen_allowed"] is True - - -def test_viz_step_names_match_pipeline(): - """Les noms acceptés par l'API sont exactement les étapes VIZ_STEPS du pipeline.""" - from lidar_pipeline.webapp import _viz_step_names - from lidar_pipeline.pipeline import VIZ_STEPS - assert _viz_step_names() == [n for n, _ in VIZ_STEPS] - - -def test_generate_rejects_unknown_viz(): - """L'API refuse un nom de visualisation inconnu (HTTPException 400).""" - from fastapi import HTTPException - from lidar_pipeline.webapp import generate, GenerateRequest - req = GenerateRequest(tiles=[[1054, 6882]], viz=["wavelet", "inconnu"]) - try: - generate(req) - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 400 - assert "inconnu" in e.detail - - -def test_generate_accepts_wavelet_and_slope(monkeypatch): - """L'API accepte wavelet + slope et les transmet à --only.""" - import lidar_pipeline.webapp as webapp - captured = {} - - def fake_popen(cmd, **kwargs): - captured["cmd"] = cmd - - class _P: - def wait(self): - return 0 - - def poll(self): - return 0 - return _P() - - # Stub du module subprocess (voir test_generate_all_missing) : ne pas - # patcher le vrai module global. - import subprocess as _real_subprocess - - class _SubprocessStub: - Popen = staticmethod(fake_popen) - run = staticmethod(_real_subprocess.run) - STDOUT = _real_subprocess.STDOUT - - monkeypatch.setattr(webapp, "subprocess", _SubprocessStub) - req = webapp.GenerateRequest(tiles=[[1054, 6882]], - viz=["wavelet", "slope"]) - result = webapp.generate(req) - assert result["demarré"] is True - i = captured["cmd"].index("--only") - assert captured["cmd"][i + 1:i + 3] == ["wavelet", "slope"] - - -def test_available_layers_scans_disk(tmp_path, monkeypatch): - """/api/layers retourne les couches présentes (clé → label).""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69" - vis.mkdir(parents=True) - (vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_wavelet.avif").write_bytes(b"x") - layers = webapp.available_layers() - assert layers.get("wavelet") - assert "aspect" not in layers - - -def test_local_rebuild_skips_overview(tmp_path, monkeypatch): - """Webapp légère (GENERATION_URL) : pas de mosaïques d'overview locales. - - Bâties sur un cache partiel, leur géométrie diffère (autre px/km, autre - étendue) de celles du worker sous le MÊME nom de fichier : plus récentes - (mtime), elles seraient servies à la place et désaligneraient le payload. - """ - import time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - seen = {} - monkeypatch.setattr(index_mod, "build_index", - lambda out, **kw: seen.update(kw) or None) - webapp._rebuild.update({"running": False}) - assert webapp._start_rebuild()["demarré"] is True - for _ in range(200): - if not webapp._rebuild["running"]: - break - time.sleep(0.02) - assert seen.get("generate_overview") is False - - -def test_rebuild_index_background(tmp_path, monkeypatch): - """POST /api/rebuild lance build_index en arrière-plan puis termine.""" - import time as _time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - calls = [] - monkeypatch.setattr(index_mod, "build_index", lambda out, **kw: calls.append(out)) - assert webapp.rebuild_index()["demarré"] is True - for _ in range(100): - if not webapp.rebuild_status()["running"]: - break - _time.sleep(0.05) - assert calls # build_index a bien été appelé - assert webapp.rebuild_status()["running"] is False - assert webapp.rebuild_status()["done"] is not None - - -def test_status_exposes_tiles_from_events(tmp_path, monkeypatch): - """/api/status agrège les événements en tuiles (nom court + étapes). - - Les événements sont filtrés par identifiant de run : ceux d'un run - annulé (workers survivants écrivant après la troncature) n'apparaissent - pas dans la file du run courant.""" - import lidar_pipeline.webapp as webapp - from lidar_pipeline.progress import report_event, reset_events - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - with monkeypatch.context() as m: - m.setitem(webapp._job, "run_id", "run-courant") - reset_events(tmp_path) - m.setenv("LIDAR_RUN_ID", "run-courant") - report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "viz", - "start", "aspect", res=0.5) - # Worker retardataire d'un run annulé : réappend après le reset - m.setenv("LIDAR_RUN_ID", "run-annule") - report_event(tmp_path, "LHD_FXX_1055_6882_PTS_LAMB93_IGN69", "viz", - "start", "aspect", res=0.5) - m.delenv("LIDAR_RUN_ID") - s = webapp.status() - match = [t for t in s["tiles"] if t["short"] == "1054-6882"] - assert len(match) == 1 - assert match[0]["state"] == "running" - assert any(step["label"] == "Aspect" for step in match[0]["steps"]) - assert all(t["short"] != "1055-6882" for t in s["tiles"]) - - -def test_generate_resets_progress_events(tmp_path, monkeypatch): - """Un nouveau run repart d'un journal d'événements vide.""" - import lidar_pipeline.webapp as webapp - from lidar_pipeline.progress import read_events, report_event - - def fake_popen(cmd, **kwargs): - class _P: - def wait(self): - return 0 - - def poll(self): - return 0 - return _P() - - import subprocess as _real_subprocess - - class _SubprocessStub: - Popen = staticmethod(fake_popen) - run = staticmethod(_real_subprocess.run) - STDOUT = _real_subprocess.STDOUT - - monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - monkeypatch.setattr(webapp, "subprocess", _SubprocessStub) - report_event(tmp_path, "ancienne_tuile", "tile", "ok") - req = webapp.GenerateRequest(tiles=[[1054, 6882]]) - assert webapp.generate(req)["demarré"] is True - assert read_events(tmp_path) == [] - - -# ============================================================ -# Mode webapp légère (Raspberry Pi) : génération déléguée à une -# machine de traitement distante via LIDAR_GENERATION_URL. -# ============================================================ - -def test_generate_forwards_to_remote(monkeypatch): - """/api/generate transmet la demande à la machine de traitement.""" - import lidar_pipeline.webapp as webapp - calls = [] - - def fake_proxy(method, path, payload=None, timeout=30): - calls.append((method, path, payload)) - return {"demarré": True, "tuiles": 1} - - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - monkeypatch.setattr(webapp, "_proxy_api", fake_proxy) - req = webapp.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"]) - result = webapp.generate(req) - assert result["demarré"] is True and result["distant"] is True - assert calls == [("POST", "/api/generate", - {"tiles": [[1054, 6882]], "regenerate": False, - "reclassify": False, - "ground_class": "ign", "ign_classes": "sol", - "edge_buffer": False, - "viz": ["aspect"], - "all_missing": False})] - - -def test_status_proxies_remote(monkeypatch): - """/api/status rapporte l'état du job distant (file de génération).""" - import lidar_pipeline.webapp as webapp - calls = [] - - def fake_proxy(method, path, payload=None, timeout=30): - calls.append((method, path, timeout)) - return {"running": True, "tiles": []} - - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - monkeypatch.setattr(webapp, "_proxy_api", fake_proxy) - data = webapp.status() - assert data["running"] is True and data["distant"] is True - assert calls == [("GET", "/api/status", 10)] # timeout court : sondage UI - - -def test_preview_forwards_to_remote(monkeypatch): - """/api/preview interroge le distant (il seul connaît input/ complet).""" - import lidar_pipeline.webapp as webapp - calls = [] - - def fake_proxy(method, path, payload=None, timeout=30): - calls.append((method, path, payload)) - return {"count": 0, "cells": []} - - monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") - monkeypatch.setattr(webapp, "_proxy_api", fake_proxy) - req = webapp.PreviewRequest(bbox=[7.85, 48.93, 7.86, 48.94], viz=["aspect"]) - assert webapp.preview(req)["count"] == 0 - assert calls[0][0] == "POST" and calls[0][1] == "/api/preview" - assert calls[0][2]["bbox"] == [7.85, 48.93, 7.86, 48.94] - - -def test_proxy_translates_unreachable_host(monkeypatch): - """Une machine de traitement injoignable devient un 503 explicite.""" - from fastapi import HTTPException - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "GENERATION_URL", "http://localhost:1") - try: - webapp._proxy_api("GET", "/api/status", timeout=1) - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 503 - assert "injoignable" in e.detail - - -def test_fallback_viz_step_names_match_pipeline(): - """Le repli léger (sans pipeline) liste les mêmes étapes, même ordre.""" - from lidar_pipeline.pipeline import VIZ_STEPS - from lidar_pipeline.webapp import _fallback_viz_step_names - assert _fallback_viz_step_names() == [name for name, _ in VIZ_STEPS] - - -def test_viz_step_labels_without_pipeline(): - """Les libellés d'étapes restent corrects même sans importer le pipeline.""" - import lidar_pipeline.webapp as webapp - labels = webapp._viz_step_labels() - assert labels["hillshade"] == "Hillshade multidirectionnel" - assert labels["pos_open"] == "Openness positive" - - -def test_sync_is_rebuild_only(tmp_path, monkeypatch): - """/api/sync régénère l'index en arrière-plan (plus de rsync).""" - import time as _time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - calls = [] - monkeypatch.setattr(index_mod, "build_index", lambda out, **kw: calls.append(out)) - webapp._rebuild["done"] = None # ignore l'état d'un test précédent - assert webapp.sync_and_rebuild()["demarré"] is True - for _ in range(200): - s = webapp.rebuild_status() - if s["done"] is not None and not s["running"]: - break - _time.sleep(0.05) - assert calls # l'index a été reconstruit - assert webapp.rebuild_status()["error"] is None - assert webapp.rebuild_status()["done"] is not None - - -def test_require_token(): - """Le jeton (si défini) bloque les appels sans ou avec mauvais mauvais header.""" - from fastapi import HTTPException - import lidar_pipeline.webapp as webapp - old = webapp.API_TOKEN - webapp.API_TOKEN = "secret" - try: - for bad in (None, "faux"): - try: - webapp._require_token(bad) - assert False, "une HTTPException était attendue" - except HTTPException as e: - assert e.status_code == 401 - assert webapp._require_token("secret") is None - finally: - webapp.API_TOKEN = old - # Sans jeton configuré : tout passe (LAN de confiance) - webapp.API_TOKEN = None - assert webapp._require_token(None) is None - - -class _FakeRequest: - """Requête minimale pour tester la restriction par IP (peer + XFF).""" - - def __init__(self, host=None, xff=None): - self.client = type("C", (), {"host": host})() if host else None - self.headers = {"x-forwarded-for": xff} if xff else {} - - -def test_generate_restricted_to_lan_cidr(): - """/api/generate n'est autorisé que depuis LIDAR_REGEN_CIDR (si défini).""" - from fastapi import HTTPException - import lidar_pipeline.webapp as webapp - old = webapp.REGEN_CIDR - webapp.REGEN_CIDR = "192.168.1.0/24" - try: - # IP du réseau autorisé - webapp._require_lan_for_generation(_FakeRequest(host="192.168.1.42")) - # Reverse proxy local (pair dans le réseau) : X-Forwarded-For désigne - # le client réel derrière lui — refusé s'il est hors réseau - webapp._require_lan_for_generation( - _FakeRequest(host="192.168.1.50", xff="192.168.1.10, 192.168.1.50")) - for host, xff in (("10.0.0.5", None), ("8.8.8.8", None), - ("192.168.3.1", None), ("2001:db8::1", None), - # XFF forgé par un client externe : ignoré (pair hors réseau) - ("8.8.8.8", "192.168.1.5"), - # Client réel externe derrière le proxy local - ("192.168.1.50", "8.8.8.8"), - # IP absente - (None, None)): - try: - webapp._require_lan_for_generation(_FakeRequest(host=host, xff=xff)) - assert False, f"403 attendu pour host={host!r} xff={xff!r}" - except HTTPException as e: - assert e.status_code == 403 - assert "réseau local" in e.detail - finally: - webapp.REGEN_CIDR = old - - -def test_generate_cidr_disabled(monkeypatch): - """LIDAR_REGEN_CIDR vide = restriction levée (tout client autorisé).""" - import lidar_pipeline.webapp as webapp - monkeypatch.setattr(webapp, "REGEN_CIDR", "") - webapp._require_lan_for_generation(_FakeRequest(host="8.8.8.8")) - webapp._require_lan_for_generation(_FakeRequest()) - - -def test_status_exposes_regen_allowed(): - """/api/status indique à l'interface si le client peut générer.""" - import lidar_pipeline.webapp as webapp - assert webapp.status(_FakeRequest(host="192.168.1.7"))["regen_allowed"] is True - assert webapp.status(_FakeRequest(host="8.8.8.8"))["regen_allowed"] is False - - -def test_regen_cidr_default_covers_local_and_private(): - """Défaut : localhost + plages privées (LAN, hôte Docker), pas Internet.""" - import lidar_pipeline.webapp as webapp - # Miroir du défaut de webapp.REGEN_CIDR (multidiffusion interdite ici : - # on le fixe explicitement pour tester la valeur de liste multi-CIDR). - old = webapp.REGEN_CIDR - webapp.REGEN_CIDR = "127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" - try: - for host in ("127.0.0.1", "::1", # boucle locale - "172.17.0.1", "172.26.0.5", # ponts Docker - "192.168.1.42", "192.168.122.7", # LAN privé - "10.0.0.5"): - assert webapp._ip_in_regen_cidr(host), host - for host in ("8.8.8.8", "2001:db8::1", "169.254.1.1", None): - assert not webapp._ip_in_regen_cidr(host), host - finally: - webapp.REGEN_CIDR = old - - -def test_regen_cidr_accepts_comma_separated_list(): - """LIDAR_REGEN_CIDR accepte plusieurs réseaux (entrées invalides ignorées).""" - import lidar_pipeline.webapp as webapp - old = webapp.REGEN_CIDR - webapp.REGEN_CIDR = "192.168.1.0/24, 10.0.0.0/8, pas-un-cidr" - try: - assert webapp._ip_in_regen_cidr("192.168.1.9") - assert webapp._ip_in_regen_cidr("10.1.2.3") - assert not webapp._ip_in_regen_cidr("192.168.3.9") - assert not webapp._ip_in_regen_cidr("172.17.0.1") - finally: - webapp.REGEN_CIDR = old - - - diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py index 7a7e88a..cfcab9d 100644 --- a/lidar_pipeline/tiles.py +++ b/lidar_pipeline/tiles.py @@ -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. 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 @@ -47,7 +47,7 @@ _MID_RES = 1000.0 / 640 _SUBTILE_RE = re.compile(r"_(\d+)_(\d+)\.avif$") -# Serveur de dalles amont (webapp du pipeline) : quand il est défini, l'index +# 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 # à la demande dans le cache local — le conteneur carte n'a alors besoin # d'aucune donnée locale au démarrage. diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py deleted file mode 100644 index 5bb34ab..0000000 --- a/lidar_pipeline/webapp.py +++ /dev/null @@ -1,1668 +0,0 @@ -"""Serveur web de la carte LiDAR : sert l'index et expose l'API de génération. - -Lancé via `./run.sh --serve [PORT]` (input/ monté en écriture pour permettre -le téléchargement IGN). Endpoints : - - GET / → carte interactive (output/index.html) - GET /api/status → état de la génération en cours (ou dernière terminée) - GET /api/tiles → données de carte (index_tiles.json) pour l'affichage - en direct ; ?stamp=X → réponse allégée si inchangé - POST /api/sync → régénérer l'index local (vignettes + carte) en - arrière-plan (les tuiles sont servies à la demande) - POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option - all_missing=true) toutes les dalles LHD présentes dans - input/ qui manquent au moins une visualisation demandée. - Sont incluses les tuiles absentes ET les tuiles - existantes incomplètes ; option viz = noms d'étapes - demandées, option regenerate=true pour inclure les - tuiles complètes - POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules - (option regenerate=true ajoute --force : les visualisations - sont refaites mais la classification existante est - conservée ; option reclassify=true ajoute en plus - --force-classification pour relancer la classification - du sol — sinon changer simplement ground_class - reclassifie déjà les tuiles dont la méthode diffère ; - option all_missing=true traite toutes les dalles présentes - dans input/ qui manquent les visualisations demandées, sans - téléchargement ; sans --force, le pipeline ne génère que - les visualisations manquantes des tuiles existantes) - POST /api/stop → arrête la génération en cours (SIGTERM au pipeline, qui - nettoie ses workers et processus PDAL ; SIGKILL du groupe - en repli après 15 s) - POST /api/export → assemble des dalles adjacentes en une image (PNG/JPEG/ - WebP) ou un PDF multi-couches, consultable sur téléphone - (module export.py — mosaïque sans couture, habillage - titre/échelle/nord) ; GET /api/export/file/{nom} sert le - fichier en téléchargement. Marche aussi sur la webapp - légère : les tuiles viennent du cache local, aucune - délégation à la machine de traitement. - GET /api/presets → presets de couches personnalisés (couches visibles, - opacités, ordre, fond OSM) stockés sur le serveur et - partagés par tous les navigateurs connectés - POST /api/presets → créer/remplacer un preset de couches (même identifiant - = remplacement) - DELETE /api/presets/{id} → supprimer un preset de couches - -Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles, -/visualisations, /DTM. -Un seul job à la fois : la génération lance `python -m lidar_pipeline` en -sous-processus avec --fetch-tiles + --file, journalisé dans .generation.log. - -Architecture deux machines (cf. docs/DEPLOY_WEBAPP.md) : la webapp légère -(sans PDAL/GPU, ex. Raspberry Pi) délègue la génération à la machine de -traitement via LIDAR_GENERATION_URL — /api/generate, /api/preview et -/api/status sont transmis tels quels au service distant, qui exécute la même -webapp avec le pipeline complet. Les images produites sont servies à la -demande depuis GENERATION_URL (cache local peuplé au fil des consultations) -puis /api/sync régénère vignettes et carte localement. LIDAR_API_TOKEN -(machine de traitement) + LIDAR_REMOTE_TOKEN (webapp légère) protègent -optionnellement les appels distants. -""" - -import json -import logging -import math -import os -import re -import signal -import subprocess -import sys -import threading -import time -import urllib.parse -import urllib.request -import uuid -from pathlib import Path - -from typing import Optional - -from fastapi import Depends, FastAPI, Header, HTTPException, Request -from fastapi.responses import FileResponse, JSONResponse -from pydantic import BaseModel, Field - -INPUT_DIR = Path(os.environ.get("LIDAR_INPUT_DIR", "/data/input")) -OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output")) -PORT = int(os.environ.get("LIDAR_PORT", "8973")) -LOG_FILE = OUTPUT_DIR / ".generation.log" -JOB_FILE = OUTPUT_DIR / ".generation.job.json" -QUEUE_FILE = OUTPUT_DIR / ".generation.queue.json" -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) -MAX_EXPORT_TILES = 64 # garde-fou : dalles assemblables par export (mémoire) - -# 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 (connexions via la passerelle 172.x) et localhost, tout en refusant -# les clients venus d'Internet (IP publiques non routables vers le privé). -# 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 qui expose la carte au-dehors — 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 générée par /api/generate (cf. _build_command) : 0,2 m seule, -# la 0,5 m n'est plus produite. La détection des tuiles à compléter exige -# cette même résolution : une tuile n'est complète que si elle possède les -# visualisations demandées en 0,2 m. -GENERATE_RESOLUTIONS = (0.2,) - -app = FastAPI(title="Carte LiDAR — génération de zones") - -# Visibilité de la configuration au démarrage (docker compose logs) : le -# backend de génération est TOUJOURS défini par variable d'environnement. -logger = logging.getLogger("lidar") -if not logger.handlers: - # Hors cli.py (uvicorn seul), le logger 'lidar' n'a aucun handler — en - # ajouter un pour que la config soit visible dans les logs du conteneur. - _h = logging.StreamHandler(sys.stdout) - _h.setFormatter(logging.Formatter("%(message)s")) - logger.addHandler(_h) - logger.setLevel(logging.INFO) -if GENERATION_URL: - logger.info(f"Backend de génération des tuiles : {GENERATION_URL} " - f"(LIDAR_GENERATION_URL)") -else: - logger.info("Backend de génération des tuiles : local — consultation " - "autonome du cache, sans LIDAR_GENERATION_URL") - -@app.middleware("http") -async def _log_client_ip(request: Request, call_next): - xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip() - peer = request.client.host if request.client else None - ip = xff or peer - path = request.url.path - # Ne logguer que les requêtes API (pas les fichiers statiques) - if path.startswith("/api/") or path == "/": - logger.info(f"{request.method} {path} — client : {ip or '?'}") - return await call_next(request) - -# CSS/JS de l'interface : servis depuis l'IMAGE (bâchés au build, cf. -# Dockerfile / Dockerfile.webapp), plus depuis le volume — le code de -# l'interface suit l'image, pas le cache de tuiles. En repli (dev, source -# non bâchée), on les régénère depuis les constantes de l'index ; si le -# dossier du paquet n'est pas inscriptible (install système sans bake), -# repli dans un répertoire temporaire pour ne pas casser l'import. -_assets_dir = Path(__file__).resolve().parent / "webapp_assets" -if not (_assets_dir / "app.js").is_file(): - from .index import _write_ui_assets - try: - _write_ui_assets(_assets_dir) - except OSError: - import tempfile - _assets_dir = Path(tempfile.gettempdir()) / "lidar_webapp_assets" - _write_ui_assets(_assets_dir) - -# --- Cache à la demande : la visualisation peuple le cache local ------------- -# Les images viennent de la machine de traitement par HTTP : une image absente -# du disque — ou plus ancienne que la version ?v= référencée (mtime ms de la -# source sur le worker) — est téléchargée depuis GENERATION_URL, écrite sur -# disque puis servie. Requêtes concurrentes dédupliquées (verrou par fichier), -# flux réseau plafonné (sémaphore). -from fastapi.staticfiles import StaticFiles as _StaticFiles -from starlette.concurrency import run_in_threadpool - -_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles", - "index_overview", "index_xyz") -_FETCH_SEMAPHORE = threading.Semaphore(4) -_FETCH_LOCKS = {} -_FETCH_LOCKS_GUARD = threading.Lock() -_MAX_CACHE_BYTES = 256 * 1024 * 1024 # garde-fou par fichier (dalle AVIF ~35 Mo) - -# Disjoncteur : la machine de traitement n'est pas toujours allumée. Après -# quelques échecs consécutifs, les tentatives (index + images) sont suspendues -# 2 min — réponses locales instantanées, aucun martèlement ni erreur cliente. -_WORKER_HEALTH = {"fails": 0, "until": 0.0} -_WORKER_OFFLINE_AFTER = 3 -_WORKER_OFFLINE_SECONDS = 120.0 - - -def _worker_offline(): - return time.time() < _WORKER_HEALTH["until"] - - -def _worker_mark(ok): - if ok: - _WORKER_HEALTH.update(fails=0, until=0.0) - else: - _WORKER_HEALTH["fails"] += 1 - if (_WORKER_HEALTH["fails"] >= _WORKER_OFFLINE_AFTER - and _WORKER_HEALTH["until"] == 0.0): - _WORKER_HEALTH["until"] = time.time() + _WORKER_OFFLINE_SECONDS - logger.info("Machine de traitement injoignable — " - "rapatriement à la demande suspendu 2 min") - - -def _version_from_query(scope): - """Version ?v= (mtime ms de la source) demandée par le client, ou None.""" - try: - qs = urllib.parse.parse_qs(scope.get("query_string", b"").decode("ascii")) - v = qs.get("v", [None])[0] - return int(v) if v and v.isdigit() else None - except Exception: - return None - - -def _safe_rel_path(url_path): - """Chemin relatif validé (préfixe autorisé, composants sains), ou None.""" - rel = url_path.lstrip("/") - prefix = rel.split("/", 1)[0] - if prefix not in _ONDEMAND_PREFIXES: - return None - parts = [p for p in rel.split("/") if p not in ("", ".")] - if not parts or any(p == ".." or not re.fullmatch(r"[A-Za-z0-9_.-]+", p) - for p in parts): - return None - return "/".join(parts) - - -def _fetch_remote_to_cache(rel_path): - """Télécharge GENERATION_URL/rel_path vers OUTPUT_DIR/rel_path (atomique).""" - if _worker_offline(): - return False - dest = OUTPUT_DIR / rel_path - dest.parent.mkdir(parents=True, exist_ok=True) - tmp = dest.with_name(dest.name + ".part") - url = f"{GENERATION_URL}/{urllib.parse.quote(rel_path)}" - try: - with _FETCH_SEMAPHORE: - req = urllib.request.Request(url, headers={"User-Agent": "lidar-webapp-cache"}) - with urllib.request.urlopen(req, timeout=60) as r, open(tmp, "wb") as f: - total = 0 - while True: - chunk = r.read(1 << 20) - if not chunk: - break - total += len(chunk) - if total > _MAX_CACHE_BYTES: - raise IOError(f"fichier trop volumineux : {rel_path}") - f.write(chunk) - os.replace(tmp, dest) - _worker_mark(True) - logger.info(f"Cache à la demande : {rel_path} rapatrié " - f"({total / 1e6:.1f} Mo)") - return True - except Exception as e: - _worker_mark(False) - tmp.unlink(missing_ok=True) - logger.debug(f"Cache à la demande : échec {rel_path} : {e}") - return False - - -class _NoCacheStaticFiles(_StaticFiles): - """Fichiers statiques : cache immutable si l'URL est versionnée (?v=). - - Les URLs des images portent ?v= (mtime du fichier servi, posée par - build_index) : le contenu qu'elles désignent ne change jamais → cache - immutable (1 an, jamais revalidé). Sans ?v= (index.html vu directement, - rejeu manuel d'URL), on garde no-cache : revalidation 304 quasi gratuite - via ETag/Last-Modified, et jamais de rendu périmé après régénération. - """ - - async def get_response(self, path, scope): - resp = await super().get_response(path, scope) - resp.headers["Cache-Control"] = ( - "public, max-age=31536000, immutable" - if _version_from_query(scope) is not None - else "no-cache, must-revalidate") - return resp - - -class _OnDemandStaticFiles(_NoCacheStaticFiles): - """Montage statique qui peuple le cache depuis la machine de traitement. - - Fichier local absent, ou périmé (?v= plus récent que sa mtime) → téléchar- - gement puis service ; sans GENERATION_URL ou en cas d'échec, comportement - statique normal (404, ou version locale périmée plutôt que rien). - - url_prefix : préfixe du montage (ex. visualisations) — dans une app - montée, scope["path"] est le sous-chemin sans préfixe, on le reconstruit. - """ - - def __init__(self, *, directory, url_prefix): - super().__init__(directory=directory) - self.url_prefix = url_prefix - - async def get_response(self, path, scope): - want = None - if GENERATION_URL: - rel = _safe_rel_path(f"/{self.url_prefix}/{path}") - if rel is not None: - local = OUTPUT_DIR / rel - if not local.is_file(): - want = rel - else: - v = _version_from_query(scope) - if v is not None and int(local.stat().st_mtime * 1000) + 500 < v: - want = rel # tuile régénérée sur le worker depuis le cache - if want is not None: - with _FETCH_LOCKS_GUARD: - lock = _FETCH_LOCKS.setdefault(want, threading.Lock()) - # Téléchargement bloquant hors de la boucle d'événements ; le - # verrou par fichier déduplique les requêtes concurrentes. - await run_in_threadpool(self._fetch_under_lock, want, lock) - return await super().get_response(path, scope) - - @staticmethod - def _fetch_under_lock(rel, lock): - with lock: - return _fetch_remote_to_cache(rel) - - -for name in ("index_thumbs", "index_subtiles", "index_overview", - "index_xyz", "visualisations", "DTM"): - _dir = OUTPUT_DIR / name - _dir.mkdir(parents=True, exist_ok=True) - if name in _ONDEMAND_PREFIXES and GENERATION_URL: - app.mount(f"/{name}", - _OnDemandStaticFiles(directory=str(_dir), url_prefix=name), - name=name) - else: - app.mount(f"/{name}", _NoCacheStaticFiles(directory=str(_dir)), name=name) - - -@app.get("/assets/{file_path:path}") -def assets(file_path: str, v: str = None): - """Sert les fichiers de l'interface en revalidation (jamais immutable). - - ?v= est le hash de contenu posé par build_index — mais les assets - suivent l'IMAGE, pas le cache de tuiles : un rebuild de l'image change - le contenu SOUS le même ?v= de l'index.html en attente de régénération. - Un cache immutable les bloquerait alors chez les navigateurs qui - détiennent encore l'index.html antérieur (premier affichage après - déploiement = ancien JS). no-cache → revalidation ETag/mtime quasi - gratuite (304) ; si le fichier a changé (mtime neuve après rebuild), - le navigateur récupère le contenu frais même depuis un index.html périmé. - """ - base = _assets_dir.resolve() - p = (_assets_dir / file_path).resolve() - if base not in p.parents or not p.is_file(): - raise HTTPException(404, f"asset introuvable : {file_path}") - return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"}) - - -# Méthodes de classification du sol acceptées (mêmes valeurs que --ground-classification). -GROUND_CLASS_METHODS = ("auto", "ign", "smrf", "csf") - - -def _require_token(x_lidar_token: str = Header(None)): - """Protège les routes mutantes si LIDAR_API_TOKEN est défini (sinon no-op). - - Utilisé par la machine de traitement pour n'accepter que la webapp légère - autorisée (qui présente LIDAR_REMOTE_TOKEN) sur le réseau local. - """ - 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). - - LIDAR_REGEN_CIDR accepte plusieurs réseaux séparés par des virgules - (ex. « 192.168.1.0/24,10.0.0.0/8 ») ; les entrées invalides sont ignorées. - """ - 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_api(method, path, payload=None, timeout=30): - """Transmet un appel d'API à la machine de traitement (mode webapp légère). - - 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 la webapp 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: - return _fallback_viz_step_names() - - -def _fallback_viz_step_names(): - """Noms d'étapes dérivés du registre léger de l'index (sans pipeline).""" - from .index import KEYWORD_TO_STEP, VIZ_LABELS - return [KEYWORD_TO_STEP.get(k, k) for k in VIZ_LABELS] - - -def _panel_viz_steps(): - """Couches réellement affichées par la webapp, en noms d'étapes --only. - - Source unique : PANEL_VIZ (index.py) — le panneau de couches et le - sélecteur de génération sont pilotés par ce registre, les valeurs par - défaut de /api/preview et /api/generate doivent donc l'être aussi, sans - quoi une tuile générée « par défaut » serait incomplète pour le panneau. - """ - from .index import PANEL_VIZ, KEYWORD_TO_STEP - if not PANEL_VIZ: - return ["aspect"] - return [KEYWORD_TO_STEP.get(v, v) for v in PANEL_VIZ] - - -def _viz_step_labels(): - """Libellés français des étapes de visualisation (clé = nom d'étape). - - VIZ_LABELS est indexée par mot-clé de nom de fichier ; trois étapes ont un - nom de sortie différent (cf. _expected_output_path dans le pipeline). - """ - from .index import VIZ_LABELS, step_to_keyword - return {name: VIZ_LABELS.get(step_to_keyword(name), name) - for name 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)") - - -class ExportRequest(BaseModel): - tiles: list = Field(..., description="liste [col, row] des dalles adjacentes " - "à assembler (entiers km L93)") - viz: list = Field(..., description="mots-clés de visualisation (ex: slope, " - "hillshade_multi) ; plusieurs possibles en PDF " - "(une page par visualisation), une seule pour une image") - resolution: float = Field(0.5, description="résolution des dalles en m/px " - "(0.5 ou 0.2)") - format: str = Field("jpeg", description="format de sortie : png, jpeg, " - "webp ou pdf") - max_side: int = Field(0, description="réduire le plus grand côté de la " - "mosaïque à N pixels (0 = pleine résolution) — " - "recommandé sur téléphone (4096)") - - -# --- État du job de génération ------------------------------------------- -_job: dict = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None, "qid": None, "run_id": None} -_job_lock = threading.Lock() - - -def _save_job_state(): - """Écrit l'état du job sur disque — survit au redémarrage du serveur. - - Sans cela, un restart du conteneur perd tout : /api/status ne rapporte - plus le dernier run et la file de génération reste vide au rechargement. - Best-effort : n'acquiert pas le verrou (appelé aussi sous verrou). - """ - 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() - -# --- File d'attente des demandes de génération ----------------------------- -# Une demande arrivée pendant un run n'est plus rejetée (ancien 409) : elle -# part en file et démarre automatiquement à la fin du run en cours — le -# travail déjà lancé n'est jamais coupé. La file survit aux redémarrages du -# serveur (fichier .generation.queue.json) et est vidable via /api/queue/clear. -_queue = [] -_queue_seq = 0 - - -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)] - except (OSError, ValueError): - pass - _queue_seq = max((it.get("id", 0) for it in _queue), default=0) - - -def _queue_summary(): - """Aperçu de la file pour /api/status (sans la requête brute).""" - return [{"id": it.get("id"), - "tuiles": len(it["req"].get("tiles") or []), - "toutes": bool(it["req"].get("all_missing")), - "en_attente_depuis": it.get("queued_at")} - for it in _queue] - - -_load_queue() - - -# --- Presets de couches (partagés entre navigateurs) -------------------------- -# Stockés dans OUTPUT_DIR/.presets.json : un preset est un jeu nommé de -# couches visibles + opacités + ordre de pile + fond OSM, créé depuis -# l'interface (bouton 💾) et partagé par tous les navigateurs connectés à -# cette webapp (y compris le Pi en mode deux machines). Le localStorage du -# navigateur reste le repli quand l'interface est servie sans webapp. -PRESETS_FILE = OUTPUT_DIR / ".presets.json" -_presets_lock = threading.Lock() - - -def _load_presets(): - """Lis la liste des presets depuis disque (vide si absent/corrompu).""" - try: - data = json.loads(PRESETS_FILE.read_text(encoding="utf-8")) - if isinstance(data, list): - return data - except (OSError, ValueError): - pass - return [] - - -def _save_presets(presets): - """Écrit la liste des presets atomiquement (fichier temporaire + replace).""" - tmp = PRESETS_FILE.with_suffix(".tmp") - tmp.write_text(json.dumps(presets, ensure_ascii=False), encoding="utf-8") - os.replace(tmp, PRESETS_FILE) - - -class PresetRequest(BaseModel): - id: str = Field(..., description="identifiant du preset (attribué par l'interface)") - label: str = Field(..., description="nom affiché dans l'interface") - on: list = Field(default_factory=list, description="couches visibles (clés, ordre de pile)") - opacity: dict = Field(default_factory=dict, description="opacité de chaque couche, 0–1") - osm: dict = Field(default_factory=dict, description="fond de carte OSM : on, opacity, dark") - - -def _sanitize_preset(req: PresetRequest) -> dict: - """Preset à stocker : label assaini, opacités bornées entre 0 et 1.""" - on = [str(k) for k in (req.on or []) if str(k).strip()] - opacity = {} - for k, v in (req.opacity or {}).items(): - try: - opacity[str(k)] = min(1.0, max(0.0, float(v))) - except (TypeError, ValueError): - continue - osm_in = req.osm if isinstance(req.osm, dict) else {} - osm = {"on": bool(osm_in.get("on", False))} - try: - osm["opacity"] = min(1.0, max(0.0, float(osm_in["opacity"]))) - except (KeyError, TypeError, ValueError): - pass - if "dark" in osm_in: - osm["dark"] = bool(osm_in["dark"]) - pid = re.sub(r"[^a-z0-9_-]", "", str(req.id).lower()) - return { - "id": pid or "p" + str(int(time.time() * 1000)), - "label": (str(req.label).strip() or "Preset")[:80], - "on": on, - "opacity": opacity, - "osm": osm, - } - - -@app.get("/api/presets") -def list_presets(): - """Presets de couches personnalisés (partagés entre tous les navigateurs).""" - with _presets_lock: - return {"presets": _load_presets()} - - -@app.post("/api/presets", dependencies=[Depends(_require_token)]) -def upsert_preset(req: PresetRequest): - """Crée ou remplace un preset de couches (même identifiant = remplacement).""" - p = _sanitize_preset(req) - with _presets_lock: - presets = [x for x in _load_presets() if x.get("id") != p["id"]] - presets.append(p) - _save_presets(presets) - return p - - -@app.delete("/api/presets/{preset_id}", dependencies=[Depends(_require_token)]) -def delete_preset(preset_id: str): - """Supprime un preset de couches (404 s'il est inconnu).""" - with _presets_lock: - presets = _load_presets() - kept = [x for x in presets if x.get("id") != preset_id] - if len(kept) == len(presets): - raise HTTPException(404, "preset inconnu") - _save_presets(kept) - return {"ok": True} - - -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 rasterio/PROJ si disponible, sinon pyproj, sinon - l'approximation affine de l'index (webapp légère sans GDAL). - """ - from .index import _approx_wgs84_to_l93 - try: - from rasterio.warp import transform as warp_transform - xs, ys = warp_transform("EPSG:4326", "EPSG:2154", [w, e, w, e], [s, s, n, n]) - except ImportError: - 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] - # transform renvoie (xs, ys) dans la CRS cible - 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. - - Cohérent avec bbox_to_cells : une cellule (col, row) couvre - X ∈ [col, col+1] km, Y ∈ [row-1, row] km — sert au clic carte pour - sélectionner une dalle précise, même sans données existantes. - """ - from .index import _approx_wgs84_to_l93 - try: - from rasterio.warp import transform as warp_transform - x, y = warp_transform("EPSG:4326", "EPSG:2154", [lon], [lat]) - x, y = x[0], y[0] - except ImportError: - 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/. - - Repère les fichiers dont le nom est LHD_FXX_{col}_{row}_... (extensions - .copc.laz/.copc.las/.laz/.las) ; les autres fichiers sont ignorés. - """ - 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 à la résolution de - génération (0,2 m). - - viz_steps : noms d'étapes du pipeline (ex: 'aspect', 'pos_open'), convertis - en mots-clés de fichiers de sortie avant comparaison avec le disque. - """ - 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. - - Sans viz : une cellule ayant des visualisations est considérée faite. - Avec viz (noms d'étapes) : seules les cellules disposant de TOUTES les - visualisations demandées (aux résolutions de la génération) sont faites — - les tuiles existantes mais incomplètes restent incluses, afin de générer - les visualisations manquantes sans --force (le pipeline ignore l'existant). - - 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 (toutes les ~2 s pendant un run). - """ - 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 not coords: - continue - tile["col"], tile["row"] = coords - todo.append(tile) - if todo: - attach_gps_bounds(todo) - - -@app.get("/") -def root(): - index = OUTPUT_DIR / "index.html" - if not index.exists(): - return JSONResponse({"erreur": "index.html introuvable — lancez d'abord le pipeline"}, - status_code=404) - # index.html est régénéré à chaque passe du pipeline : on interdit le cache - # navigateur pour ne pas servir une version périmée (ex. menu de génération). - return FileResponse( - str(index), media_type="text/html", - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - }) - - -@app.get("/api/status") -def status(request: Request = None): - # Droit de lancer une génération depuis cette IP (guide l'interface) - regen_allowed = _ip_in_regen_cidr(_client_ip(request)) - if GENERATION_URL: - # Webapp légère : l'état vient de la machine de traitement ; le droit - # de génération reste jugé sur l'IP du navigateur (localement). Hors - # ligne : running=null (l'interface ignore, aucun faux « terminé »). - try: - data = _proxy_api("GET", "/api/status", timeout=10) - except HTTPException as e: - if e.status_code != 503: - raise - return {"running": None, "distant": True, "worker_offline": True, - "regen_allowed": regen_allowed} - data["distant"] = True - 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), - # L'interface masque les boutons de génération hors réseau local - "regen_allowed": regen_allowed, - # Tuiles une par une (nom court, type, état) — agrégées depuis les - # événements écrits par le pipeline (.generation.events.jsonl) ; - # les non terminées portent leurs coins pour le cadre sur la carte - "tiles": tiles, - } - - -@app.get("/api/layers") -def available_layers(): - """Couches proposées dans le panneau (clé → label), sans rebuild de l'index. - - Toutes les couches connues du registre (VIZ_LABELS) détectées sur disque. - Permet à la carte de détecter celles apparues depuis le dernier - build_index et de proposer une actualisation. - """ - from .index import VIZ_LABELS - vis = OUTPUT_DIR / "visualisations" - found = {} - if vis.is_dir(): - for key in VIZ_LABELS: - for ext in ("avif", "webp"): - if next(vis.glob(f"*/*_{key}.{ext}"), None) is not None: - found[key] = VIZ_LABELS.get(key, key) - break - return found - - -_REMOTE_INDEX = {"data": None, "fetched": 0.0} -_LOCAL_UI_VERSION = None -_UI_DRIFT_WARNED = set() - - -def _ui_version_local(): - """Version du JS/CSS bâti dans cette webapp (index.ui_version), en cache.""" - global _LOCAL_UI_VERSION - if _LOCAL_UI_VERSION is None: - from .index import ui_version - _LOCAL_UI_VERSION = ui_version() - return _LOCAL_UI_VERSION - - -def _warn_ui_drift(remote_data): - """Dérive de version webapp/serveur de données : avertit une fois par paire. - - L'interface de la webapp est bâchée au build de l'image (cf. - Dockerfile.webapp) : sans rebuild, les tuiles produites par un worker - plus récent ne s'affichent pas. Le navigateur signale le décalage lui - aussi (bandeau « interface périmée ») ; ce log aide l'opérateur. - """ - remote_ver = ((remote_data or {}).get("stats") or {}).get("ui_version") - local_ver = _ui_version_local() - if not remote_ver or remote_ver == local_ver: - return - key = (local_ver, remote_ver) - if key in _UI_DRIFT_WARNED: - return - _UI_DRIFT_WARNED.add(key) - logger.warning( - f"Interface webapp périmée (webapp {local_ver}, " - f"serveur {remote_ver}) — reconstruire l'image : " - f"git pull && docker compose up -d --build") -_REMOTE_INDEX_TTL = 60.0 - - -def _tile_entry_key(t): - """Clé d'unicité d'une tuile affichée (position, résolution, quadrant).""" - return (t.get("col"), t.get("row"), t.get("resolution"), - t.get("sub_i"), t.get("sub_j")) - - -def _remote_tiles_data(): - """Index de la machine de traitement (cache 60 s), ou None si injoignable. - - Une panne ne remonte pas : dernière réponse connue servie telle quelle - (stale-while-error), et le disjoncteur suspend les tentatives. - """ - if not GENERATION_URL or _worker_offline(): - return _REMOTE_INDEX["data"] - now = time.time() - if _REMOTE_INDEX["fetched"] > now - _REMOTE_INDEX_TTL: - return _REMOTE_INDEX["data"] - try: - with urllib.request.urlopen(f"{GENERATION_URL}/api/tiles", timeout=4) as r: - data = json.loads(r.read().decode("utf-8")) - _worker_mark(True) - _REMOTE_INDEX.update({"data": data, "fetched": now}) - _warn_ui_drift(data) - except Exception as e: - _worker_mark(False) - _REMOTE_INDEX["fetched"] = now # ne pas marteler à chaque requête - logger.debug(f"Index distant injoignable : {e}") - return _REMOTE_INDEX["data"] - - -@app.get("/api/tiles") -def tiles_data(stamp: Optional[float] = None): - """Données de carte — cache local + index de la machine de traitement. - - Le pipeline lancé par /api/generate tourne avec --incremental-index : il - réécrit index_tiles.json après chaque tuile terminée. La carte sonde cet - endpoint et fusionne les nouveautés. Sur la webapp légère, l'index du - worker (cache 60 s) fait autorité : les tuiles absentes du cache local y - figurent quand même — leurs images sont rapatriées à la visualisation par - le montage statique à la demande. Worker hors ligne : cache local seul, - sans erreur. Avec ?stamp=X : réponse allégée ({tiles: null}) si rien n'a - changé (le stamp combine les deux index). - """ - path = OUTPUT_DIR / "index_tiles.json" - try: - local_stamp = path.stat().st_mtime - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - local_stamp = None - data = None - remote = _remote_tiles_data() - remote_stamp = (remote or {}).get("stamp") - stamps = [s for s in (local_stamp, remote_stamp) if s is not None] - current = max(stamps) if stamps else None - # Version de l'interface du SERVEUR DE DONNÉES (le worker en mode deux - # machines, soi-même sinon) : le JS embarqué compare sa propre version - # (index.ui_version, __UI_VERSION__) et affiche un bandeau si la webapp - # est périmée (assets bâchés au build, image non reconstruite). - ui_ver = (((remote or {}).get("stats") - or (data or {}).get("stats") or {}).get("ui_version") - or _ui_version_local()) - if stamp is not None and current is not None and abs(current - stamp) < 1e-4: - return {"stamp": current, "tiles": None, "ui_version": ui_ver} - if data is None and remote is None: - return {"stamp": None, "tiles": None, "ui_version": ui_ver} - # L'index distant prime (tuiles régénérées : ?v= neuf → re-téléchargement) ; - # le local complète les positions que le worker n'a plus (historique). - merged = list((remote or {}).get("tiles") or []) - keys = {_tile_entry_key(t) for t in merged} - for t in (data or {}).get("tiles") or []: - k = _tile_entry_key(t) - if k not in keys: - merged.append(t) - keys.add(k) - viz_meta = dict((data or {}).get("viz_meta") or {}) - viz_meta.update((remote or {}).get("viz_meta") or {}) - # Le panneau de couches est figé (PANEL_VIZ) : un index obsolète (ex. - # worker antérieur à la restriction) ne doit pas réintroduire de couches - # retirées dans le menu au rechargement de la page. - from .index import PANEL_VIZ - if PANEL_VIZ is not None: - allowed = set(PANEL_VIZ) - viz_meta = {k: v for k, v in viz_meta.items() if k in allowed} - merged_overview = dict((data or {}).get("overview") or {}) - merged_overview.update((remote or {}).get("overview") or {}) - return { - "tiles": merged, - # Mosaïques d'overview : le distant prime (grille recalculée), - # le local complète les couches que le worker n'a plus. - "overview": merged_overview, - "viz_meta": viz_meta, - "stats": (remote or data or {}).get("stats") or {}, - # Version de l'interface du serveur de données (cf. plus haut). - "ui_version": ui_ver, - "stamp": current, - } - - -# --- Rebuild de l'index en arrière-plan -------------------------------------- -# Les tuiles sont servies à la demande (cache peuplé depuis la machine de -# traitement) : /api/sync ne fait plus qu'en régénérer l'index local -# (vignettes + carte). -_rebuild = {"running": False, "error": None, "done": None, "phase": None} - - -def _merge_remote_index(): - """Fusionne l'index du worker dans index_tiles.json et la coquille HTML. - - Le rebuild local ne voit que le cache : sans fusion, la coquille embarque - un sous-ensemble des tuiles (celles déjà rapatriées) et le premier - chargement de la carte n'affiche pas les tuiles récentes du worker — - elles n'arrivent qu'au sondage /api/tiles qui suit. Mêmes règles que - tiles_data : l'index distant prime (tuiles régénérées), le local complète - les positions que le worker n'a plus (historique). - """ - remote = _remote_tiles_data() # frais : _start_rebuild a vidé le cache - if not remote or not remote.get("tiles"): - return - path = OUTPUT_DIR / "index_tiles.json" - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - data = {} - merged = list(remote["tiles"]) - keys = {_tile_entry_key(t) for t in merged} - for t in data.get("tiles") or []: - k = _tile_entry_key(t) - if k not in keys: - merged.append(t) - keys.add(k) - overview = dict(data.get("overview") or {}) - overview.update(remote.get("overview") or {}) - viz_meta = dict(data.get("viz_meta") or {}) - viz_meta.update(remote.get("viz_meta") or {}) - from .index import PANEL_VIZ - if PANEL_VIZ is not None: - allowed = set(PANEL_VIZ) - viz_meta = {k: v for k, v in viz_meta.items() if k in allowed} - path.write_text(json.dumps({ - "tiles": merged, - "overview": overview, - "viz_meta": viz_meta, - # Les stats décrivent les réglages de l'INTERFACE (couches par - # défaut) : elles viennent du rebuild local, fait avec le code de - # cette webapp — celles du worker ne servent que de repli tant - # qu'aucun index local n'existe (sinon une machine mise à jour avant - # l'autre afficherait les vieux réglages du worker). - "stats": data.get("stats") or remote.get("stats") or {}, - }, ensure_ascii=False), encoding="utf-8") - # Coquille HTML : la liste embarquée doit être la même (fusionnée). Le - # JSON ne contient jamais de « ; » : la première séquence « ]; » après - # « const TILES = [ » termine forcément l'instruction (idem « }; » pour - # const OVERVIEW : à l'intérieur d'un objet JSON, « } » n'est suivi que - # de « , » « ] » ou « } »). - html_path = OUTPUT_DIR / "index.html" - try: - html = html_path.read_text(encoding="utf-8") - new_html = re.sub(r"const TILES = \[.*?\];", - "const TILES = " + json.dumps(merged, ensure_ascii=False) - .replace("= started). - """ - if _rebuild["running"]: - raise HTTPException(409, "un rebuild est déjà en cours") - started = time.time() - _rebuild.update({"running": True, "error": None, "done": None, - "phase": "index"}) - # Index distant (mode deux machines) : cache 60 s vidé pour que le - # premier /api/tiles suivant reflète l'état du worker sans attendre. - _REMOTE_INDEX["fetched"] = 0.0 - - def _run(): - try: - from .index import build_index - # Overview : générée par la machine de traitement — une mosaïque - # locale (cache partiel, autre géométrie) sous le même nom de - # fichier serait servie à la place de la sienne (mtime plus - # récente) et désalignerait le payload distant. - build_index(OUTPUT_DIR, generate_overview=not GENERATION_URL) - # Mode deux machines : le rebuild local ne voit que le cache — - # on y fusionne l'index du worker pour que coquille embarquée et - # /api/tiles servent la même vue complète dès le 1er chargement. - if GENERATION_URL: - try: - _merge_remote_index() - except Exception as e: # noqa: BLE001 — index local valable - logger.warning(f"Fusion de l'index distant ignorée : {e}") - except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état - _rebuild["error"] = str(e) - finally: - _rebuild["running"] = False - _rebuild["phase"] = None - _rebuild["done"] = time.time() - - threading.Thread(target=_run, daemon=True).start() - return {"demarré": True, "started": started} - - -@app.post("/api/sync", dependencies=[Depends(_require_token)]) -def sync_and_rebuild(): - """Régénère l'index local (vignettes + carte) en arrière-plan. - - Les tuiles étant servies à la demande, il n'y a plus de synchronisation à - exécuter : /api/sync se réduit à un rebuild de l'index (même - comportement que /api/rebuild). - """ - return _start_rebuild() - - -@app.get("/api/sync") -def sync_status(): - return rebuild_status() - - -@app.post("/api/rebuild", dependencies=[Depends(_require_token)]) -def rebuild_index(): - """Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan.""" - return _start_rebuild() - - -@app.get("/api/rebuild") -def rebuild_status(): - return {"running": _rebuild["running"], "error": _rebuild["error"], - "done": _rebuild["done"], "phase": _rebuild["phase"]} - - -def _tail_log(n_lines): - """Dernières lignes du journal — lecture depuis la fin uniquement. - - Le journal peut atteindre plusieurs Mo et /api/status est sondé toutes - les ~2 s par l'interface : relire tout le fichier à chaque sondage - coûte cher, surtout sur Raspberry Pi. - """ - try: - with open(LOG_FILE, "rb") as fh: - fh.seek(0, os.SEEK_END) - size = fh.tell() - fh.seek(max(0, size - 64 * 1024)) - chunk = fh.read().decode("utf-8", errors="replace") - return chunk.splitlines()[-n_lines:] - except OSError: - return [] - - -@app.post("/api/preview", dependencies=[Depends(_require_token)]) -def preview(req: PreviewRequest): - if GENERATION_URL: - # Webapp légère : le distant connaît input/ et output/ complets - return _proxy_api("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): - """Dalle L93 de 1 km contenant un point WGS84 (clic sur la carte). - - Retourne col, row et les coins GPS de la dalle — permet de sélectionner - une tuile individuelle même sans données existantes (téléchargement + - génération à la validation). - """ - if GENERATION_URL: - # Webapp légère : la conversion précise vit sur la machine de traitement - return _proxy_api("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 []} - - -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. - - La classification du sol est choisie via `ground_class` (défaut : "ign", - pré-classification IGN ; le pipeline bascule sur SMRF si un fichier ne la - contient pas). Avec ign_classes, on choisit les classes LAS extraites - pour le MNT (mode pur, ex. "sol,unclassified" pour combler les trous - sans retouche). Avec regenerate=True, les visualisations des tuiles déjà - présentes sont refaites (--force) mais leur classification est conservée : - le pipeline réutilise le DTM en cache quand la méthode ne change pas. - Avec reclassify=True, --force-classification relance la classification - même à méthode inchangée (sinon, choisir une méthode différente via - ground_class/ign_classes reclassifie déjà les tuiles concernées). - Avec edge_buffer=True, le MNT est étendu d'une bande de - EDGE_BUFFER_METERS remplie avec les points sol des tuiles voisines - (raccord des bords : rendus continus entre tuiles, images recadrées sur - la dalle 1 km). Avec viz, on choisit les visualisations générées - (noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ; - défaut : aspect). - """ - 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 : entier explicite via LIDAR_WORKERS, sinon « auto » — - # résolu par le pipeline au départ de chaque run (cœurs de la - # machine au moment du lancement). - workers = os.environ.get("LIDAR_WORKERS", "auto").strip() - if workers.lower() not in ("auto", ""): - cmd += ["-w", workers] - # Carte régénérée après chaque tuile terminée : la webapp l'affiche en - # direct via /api/tiles pendant le run. - 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: - # Passe globale : les dalles présentes dans input/ qui manquent les - # visualisations demandées (les complètes incluses seulement si regenerate) - 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/") - # Même garde-fou que /api/preview : un run parti sur tout input/ sans - # plafond peut durer des jours sans moyen de l'interrompre proprement - 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. - - Retourne la commande lancée. qid : identifiant de file d'attente quand - la demande vient de la file (suivi côté interface). - """ - 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 (les tuiles affichées - # dans la file correspondent au run qui démarre, pas au précédent) 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 la webapp, et le gestionnaire SIGTERM du pipeline (killpg) - # reste confiné à son groupe. - 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. - - Les demandes devenues invalides (ex. plus aucune tuile à compléter) - sont ignorées ; une demande repoussée en tête de file si un run part - entre-temps. Ne coupe jamais le run en cours. - """ - 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.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_api("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: - # Webapp légère : la file vit sur la machine de traitement - return _proxy_api("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 : son gestionnaire de signal nettoie ses workers et - ses processus PDAL (killpg sur son propre groupe, cf. cli.py). Si le - processus ne meurt pas dans les 15 s (worker bloqué), SIGKILL du groupe - entier. L'état final (returncode négatif) est enregistré par le fil de - surveillance du job. - """ - if GENERATION_URL: - # Webapp légère : l'arrêt concerne le pipeline de la machine distante. - return _proxy_api("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é"} - - -# --- Export multi-dalles (mosaïque image/PDF pour téléphone) --------------- -_export_lock = threading.Lock() - - -@app.post("/api/export") -def export_tiles(req: ExportRequest): - """Assemble des dalles adjacentes en image ou PDF (export.py). - - Local par nature : les tuiles assemblées sont celles du cache output/ - (la webapp légère les possède via le cache à la demande) — aucune - délégation à la machine de traitement. Un seul export à la fois - (CPU/mémoire limités sur Raspberry Pi). - """ - import re as _re - from .export import EXPORT_FORMATS, build_export - fmt = (req.format or "").lower() - if fmt not in EXPORT_FORMATS: - raise HTTPException(400, f"format invalide : {req.format!r} " - f"(attendus : {', '.join(EXPORT_FORMATS)})") - viz = [str(v) for v in (req.viz or []) if v] - if not viz: - raise HTTPException(400, "aucune visualisation demandée") - cells = [] - 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])") - cell = (int(pair[0]), int(pair[1])) - if cell not in cells: - cells.append(cell) - if not cells: - raise HTTPException(400, "aucune tuile fournie") - if len(cells) > MAX_EXPORT_TILES: - raise HTTPException(400, f"trop de dalles ({len(cells)}) — max {MAX_EXPORT_TILES}") - if not _export_lock.acquire(blocking=False): - raise HTTPException(409, "un export est déjà en cours — réessayez dans un instant") - try: - out_dir = OUTPUT_DIR / "exports" - result = build_export(OUTPUT_DIR / "visualisations", cells, viz, - float(req.resolution), fmt, out_dir, - max_side=int(req.max_side)) - except ValueError as e: - raise HTTPException(400, str(e)) - finally: - _export_lock.release() - # Une couche = un fichier image (ou une page du PDF) : la réponse liste - # tous les fichiers générés, chacun avec SA légende. - return {"fichiers": [{ - "url": f"/api/export/file/{e['file'].name}", - "nom": e["file"].name, - "taille": e["file"].stat().st_size, - "largeur": e["width"], - "hauteur": e["height"], - "pages": e["pages"], - } for e in result["files"]]} - - -@app.get("/api/export/file/{name}") -def export_file(name: str): - """Sert un fichier exporté en téléchargement (pièce jointe).""" - import re as _re - if not _re.fullmatch(r"[A-Za-z0-9_.-]+", name): - raise HTTPException(404, "nom de fichier invalide") - base = (OUTPUT_DIR / "exports").resolve() - path = (base / name).resolve() - if base not in path.parents or not path.is_file(): - raise HTTPException(404, f"export introuvable : {name}") - return FileResponse(str(path), filename=name, - headers={"Cache-Control": "no-cache"}) - - -if __name__ == "__main__": - import uvicorn - # HTTPS optionnel : la géolocalisation du navigateur (bouton ⌖, centrage - # GPS depuis un téléphone) n'est disponible qu'en contexte sécurisé. - # Définir LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE (certificat auto-signé) - # pour servir la carte en https://:PORT ; le téléphone accepte - # l'avertissement « certificat non fiable » puis le GPS fonctionne. - _ssl_cert = os.environ.get("LIDAR_SSL_CERTFILE") - _ssl_key = os.environ.get("LIDAR_SSL_KEYFILE") - _ssl_kw = ({"ssl_certfile": _ssl_cert, "ssl_keyfile": _ssl_key} - if _ssl_cert and _ssl_key else {}) - uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl_kw) diff --git a/make-tls-cert.sh b/make-tls-cert.sh deleted file mode 100755 index e96bfc8..0000000 --- a/make-tls-cert.sh +++ /dev/null @@ -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://: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://:PORT." diff --git a/run.sh b/run.sh index 0ff7df2..14b75ae 100755 --- a/run.sh +++ b/run.sh @@ -21,11 +21,11 @@ # --skip VIZ... Exclure ces visualisations # --file NOM... Traiter un ou plusieurs fichiers LAZ # --test Exécuter les tests unitaires -# --serve [PORT] Carte + API de génération (image complète, défaut: 8973) -# --serve-webapp [PORT] -# Conteneur webapp seul (image légère) : carte servie depuis -# le cache local de tuiles, génération déléguée à la machine -# de traitement (LIDAR_GENERATION_URL), tuiles à la demande +# --serve [PORT] Carte + tuiles XYZ + génération (image complète, défaut: 8973) +# --serve-maps [PORT] +# Conteneur carte seul (image légère lidar-maps) : tuiles +# servies depuis le cache local, génération déléguée via +# LIDAR_GENERATION_URL, dalles rapatriées via LIDAR_SOURCE_URL # -h Afficher l'aide complète set -e @@ -106,9 +106,6 @@ NO_INDEX_FLAG="" FETCH_TILES_ARGS="" SERVE_FLAG=0 SERVE_PORT=8973 -WEBAPP_FLAG=0 -WEBAPP_SERVE_PORT=8973 -WEBAPP_IMAGE="lidar-webapp" MAPS_FLAG=0 MAPS_SERVE_PORT=8975 MAPS_IMAGE="lidar-maps" @@ -153,10 +150,6 @@ while [ $# -gt 0 ]; do SERVE_FLAG=1; shift 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) MAPS_FLAG=1; shift 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 " --fetch-tiles COL,ROW..." 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-webapp [PORT]" - 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 [PORT] Carte + tuiles XYZ + API de génération (image complète," + echo " défaut 8973) — cf. docker-compose.yml (service serve)" echo " --serve-maps [PORT]" echo " Carte à tuiles XYZ (image légère lidar-maps, défaut 8975) :" - echo " schéma OpenStreetMap, réutilisable dans JOSM/QGIS/iD" + echo " 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 " --test Exécuter les tests unitaires" echo " -h Afficher cette aide" @@ -254,17 +244,6 @@ build_image() { 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) build_maps_image() { local log="/tmp/lidar_maps_build_$$.log" @@ -294,10 +273,12 @@ build_image # Créer les répertoires s'ils n'existent pas 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 echo "============================================" - echo " Carte LiDAR + API de génération" + echo " Carte LiDAR — tuiles XYZ + génération" echo "============================================" echo " http://127.0.0.1:${SERVE_PORT}/" echo " GPU : $([ -n "$GPU_FLAG" ] && echo 'OUI' || echo 'non')" @@ -310,7 +291,7 @@ if [ "$SERVE_FLAG" -eq 1 ]; then fi # 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 - # 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. 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 \ @@ -319,17 +300,20 @@ if [ "$SERVE_FLAG" -eq 1 ]; then -v "${INPUT_DIR}:/data/input" \ -v "${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 \ "$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 # Mode --serve-maps : carte à tuiles XYZ (image légère lidar-maps). # Les tuiles suivent le schéma OpenStreetMap ({z}/{x}/{y}.png) : la carte est # consultable ici et les mêmes URLs servent de fond d'imagerie dans JOSM, iD, # QGIS ou uMap. Variables optionnelles depuis l'environnement hôte : -# LIDAR_MAPS_URL serveur de tuiles amont (carte déportée) -# LIDAR_ATTRIBUTION mention d'attribution servie aux clients +# 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 if [ "$MAPS_FLAG" -eq 1 ]; then build_maps_image mkdir -p "$OUTPUT_DIR" @@ -339,10 +323,16 @@ if [ "$MAPS_FLAG" -eq 1 ]; then echo " Carte : http://127.0.0.1:${MAPS_SERVE_PORT}/" echo " Tuiles XYZ : http://127.0.0.1:${MAPS_SERVE_PORT}/tiles/{couche}/{z}/{x}/{y}.png" echo " JOSM / QGIS : /tiles/josm.imagery.xml, /tiles/wmts.xml" - echo " Amont : ${LIDAR_MAPS_URL:-aucun (rendu local)}" + echo " 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 "============================================" MAPS_ENV=() [ -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") exec docker run --rm --init \ --user 1000:1000 \ @@ -353,44 +343,6 @@ if [ "$MAPS_FLAG" -eq 1 ]; then "$MAPS_IMAGE" fi -# Mode --serve-webapp : conteneur webapp SEUL (image légère, sans PDAL/GPU). -# La carte est servie depuis le cache local de tuiles (output/) ; la -# génération est déléguée à la machine de traitement et les tuiles sont -# 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 echo "============================================" echo " Pipeline LiDAR Archéologique" diff --git a/serve-webapp.sh b/serve-webapp.sh deleted file mode 100755 index 14095a1..0000000 --- a/serve-webapp.sh +++ /dev/null @@ -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 diff --git a/webapp-entrypoint.sh b/webapp-entrypoint.sh deleted file mode 100755 index e2f9877..0000000 --- a/webapp-entrypoint.sh +++ /dev/null @@ -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://: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[@]}"} diff --git a/webapp.env.example b/webapp.env.example deleted file mode 100644 index 74a04eb..0000000 --- a/webapp.env.example +++ /dev/null @@ -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:// 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 -