From 422f58d77244969e0e85ebe05067478f1eb1a95c Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Wed, 2 Sep 2026 19:44:39 +0200 Subject: [PATCH] Split webapp for Raspberry Pi deployment, remote generation API and sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La webapp (carte + vignettes) et la génération de tuiles se déploient sur deux machines : image légère Dockerfile.webapp (FastAPI + Pillow AVIF natif + pyproj) sur Raspberry Pi, pipeline complet sur la machine de traitement. LIDAR_GENERATION_URL délègue /api/generate, /api/preview et /api/status ; /api/sync ramène les tuiles par rsync puis régénère vignettes et index localement. Token partagé optionnel (LIDAR_API_TOKEN/LIDAR_REMOTE_TOKEN). Retire du dépôt les journaux internes (.swival, audit-findings) et les données (data/, notebooks/). Doc : docs/DEPLOY_WEBAPP.md. --- .dockerignore | 17 + .gitignore | 6 + AGENTS.md | 7 +- Dockerfile.webapp | 49 ++ docker-compose.webapp.yml | 37 ++ docker-compose.yml | 17 +- docs/DEPLOY_WEBAPP.md | 115 +++++ lidar_pipeline/cli.py | 47 +- lidar_pipeline/fetch_ign.py | 54 +- lidar_pipeline/gpu.py | 65 ++- lidar_pipeline/index.py | 520 +++++++++++++++++--- lidar_pipeline/pipeline.py | 63 ++- lidar_pipeline/progress.py | 154 ++++++ lidar_pipeline/rendering.py | 100 ++-- lidar_pipeline/tests/test_fetch_ign.py | 79 +++ lidar_pipeline/tests/test_index.py | 101 +++- lidar_pipeline/tests/test_pipeline.py | 27 +- lidar_pipeline/tests/test_progress.py | 127 +++++ lidar_pipeline/tests/test_rendering.py | 24 + lidar_pipeline/tests/test_visualizations.py | 71 +++ lidar_pipeline/tests/test_webapp.py | 442 ++++++++++++++++- lidar_pipeline/visualizations.py | 140 +++--- lidar_pipeline/webapp.py | 432 ++++++++++++++-- run.sh | 25 +- 24 files changed, 2390 insertions(+), 329 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.webapp create mode 100644 docker-compose.webapp.yml create mode 100644 docs/DEPLOY_WEBAPP.md create mode 100644 lidar_pipeline/progress.py create mode 100644 lidar_pipeline/tests/test_progress.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b268f2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Données volumineuses montées au runtime, jamais bakées dans l'image +input/ +output/ +data/ + +# Divers non nécessaire au build +.git/ +audit-findings/ +docs/ +download_lidar.sh +run.sh +docker-compose.yml + +# Caches Python +**/__pycache__/ +*.pyc +.pytest_cache/ diff --git a/.gitignore b/.gitignore index 0871d3e..6d3150c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,11 @@ htmlcov/ .env .env.local +# Données et artefacts internes (jamais dans le dépôt) +data/ +.swival/ +audit-findings/ +notebooks/ + # Éventuels fichiers de cache matplotlib matplotlibrc diff --git a/AGENTS.md b/AGENTS.md index 3ca4bf3..f1d64a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,15 @@ - install: `docker build -t lidar-lidar .` (deps baked into image) - build: `docker build -t lidar-lidar .` -- test all: `./run.sh --test` +- 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) +- 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 serve` (port 8973) ; traitement ponctuel → `docker compose run --rm process [options]` ; logs → `docker compose logs -f serve` ; arrêt → `docker compose down`. -- **RÈGLE 2 — après chaque édition de code : rebuild de l'image puis relance du conteneur.** Le code est baké dans l'image (jamais monté) : sans `docker compose build` suivi d'un `docker compose up -d serve` (recrée le conteneur), l'ANCIEN code continue de tourner. Toujours reconstruire avant de faire tester/valider une modif par l'utilisateur. +- **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 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` - 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` diff --git a/Dockerfile.webapp b/Dockerfile.webapp new file mode 100644 index 0000000..46f37e6 --- /dev/null +++ b/Dockerfile.webapp @@ -0,0 +1,49 @@ +# 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 + +# rsync + client SSH : synchronisation des tuiles depuis la machine de +# traitement (LIDAR_SYNC_CMD). La clé se monte dans /home/lidar/.ssh. +RUN apt-get update && apt-get install -y --no-install-recommends \ + rsync \ + 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/ + +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 + +ENV LIDAR_INPUT_DIR=/data/input \ + LIDAR_OUTPUT_DIR=/data/output \ + LIDAR_PORT=8973 + +USER lidar + +EXPOSE 8973 + +CMD ["python3", "-m", "uvicorn", "lidar_pipeline.webapp:app", "--host", "0.0.0.0", "--port", "8973"] diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml new file mode 100644 index 0000000..736e68a --- /dev/null +++ b/docker-compose.webapp.yml @@ -0,0 +1,37 @@ +# 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 `serve` du docker-compose.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: + # Copie locale des tuiles traitées, synchronisée par LIDAR_SYNC_CMD + - ./output:/data/output + # Clé SSH du rsync vers la machine de traitement (LIDAR_SYNC_CMD) + # - ~/.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 + # Machine de traitement (service `serve` de son docker-compose.yml) + - 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 + # Ramène les tuiles traitées + sidecars DTM (les vignettes sont + # régénérées localement par /api/sync, ne pas les synchroniser) + # - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 2832084..fc7e985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,11 @@ # Lancement du pipeline LiDAR — TOUJOURS via docker compose : -# docker compose build # après chaque édition de code (code baké dans l'image) -# docker compose up -d serve # carte interactive + API sur http://localhost:8973 -# docker compose logs -f serve # journal du serveur -# docker compose down # arrêt -# Traitement ponctuel (sans serveur) : -# docker compose run --rm process [-r 0.5,0.2 | --force | --file ...] +# 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) +# docker compose logs -f serve # journal du serveur +# docker compose down # arrêt +# Traitement ponctuel (sans serveur) — --build pour la même raison : +# docker compose run --rm --build process [-r 0.5,0.2 | --force | --file ...] # Jupyter (opt-in) : # docker compose --profile interactive up -d jupyter @@ -30,6 +31,10 @@ services: # Les générations lancées depuis la carte utilisent le GPU - LIDAR_GPU=1 - LIDAR_WORKERS=2 + # Machine de traitement pour une webapp 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). + # - LIDAR_API_TOKEN=change-moi command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 restart: unless-stopped diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md new file mode 100644 index 0000000..8ea4071 --- /dev/null +++ b/docs/DEPLOY_WEBAPP.md @@ -0,0 +1,115 @@ +# Déploiement webapp légère + machine de traitement + +Architecture deux machines : la **webapp** (carte interactive, vignettes, +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. + +``` +Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp) + │ sert carte + vignettes (régénérées localement) + │ /api/generate, /api/preview, /api/status + │ └─ transmis à ──▶ machine de traitement + │ /api/sync : rsync output/ ──◀── machine puissante + ▼ + Machine puissante (image complète, docker-compose.yml + service `serve`) : téléchargement IGN + pipeline GPU +``` + +## Machine de traitement (puissante) + +Le service `serve` existant joue le rôle de worker : il accepte les demandes +de génération envoyées par la webapp du Pi. + +```bash +docker compose up -d --build serve # carte + API sur http://:8973 +``` + +Optionnel mais recommandé si le réseau n'est pas de confiance : protéger les +routes mutantes avec un jeton partagé — décommenter dans `docker-compose.yml` : + +```yaml +environment: + - LIDAR_API_TOKEN=un-secret-à-partager +``` + +## Raspberry Pi (webapp légère) + +### 1. Copier le code sur le Pi + +```bash +git clone lidar && cd lidar # ou rsync du poste de dev +``` + +### 2. Accès SSH pour le rsync (la Pi tire les tuiles traitées) + +```bash +ssh-keygen -t ed25519 # si pas encore de clé +ssh-copy-id lidar@ # compte lecture sur output/ +``` + +Sur la machine puissante, le dossier `output/` doit être lisible par ce +compte (ex. `/srv/lidar/output` si vous préférez un chemin dédié — adaptez +LIDAR_SYNC_CMD). + +### 3. Configurer et lancer + +É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) +- `LIDAR_SYNC_CMD` : la commande rsync qui copie `output/` distant vers + `/data/output/` local, ex : + +```yaml +- LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ +``` + +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/`. + +### 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). + +## 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` : rsync ramène les + images, le Pi régénère vignettes/sous-tuiles/index.html, puis recharge la + carte. Le bouton ↻ relance le même cycle à tout moment. +- Machine de traitement seule (sans Pi) : rien ne change, `--serve` / + `docker compose up serve` fonctionne comme avant en local. +- 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`, jeton `LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`. +- `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli + affine), recharge après sync. +- `Dockerfile.webapp`, `docker-compose.webapp.yml` : image légère ARM64 + (FastAPI + Pillow AVIF natif + pyproj, ~200 Mo). diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 39b0f96..5c91792 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -238,6 +238,12 @@ def main(): action="store_true", help="Ne pas générer la carte globale à 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)" + ) args = parser.parse_args() @@ -286,20 +292,11 @@ def main(): logger.warning("Aucune tuile traitée trouvée — carte globale non générée") return - # Téléchargement des dalles IGN manquantes avant le traitement - if args.fetch_tiles: - from .fetch_ign import fetch_tiles, parse_tile_specs - try: - specs = parse_tile_specs(args.fetch_tiles) - except ValueError as e: - logger.error(str(e)) - return - logger.info(f"Téléchargement de {len(specs)} dalle(s) LiDAR HD depuis l'IGN...") - fetched = fetch_tiles(args.input, specs, args.output) - if fetched: - logger.info(f"{len(fetched)} dalle(s) téléchargée(s) — traitement...") - else: - logger.warning("Aucune dalle téléchargée (déjà présentes ou introuvables)") + # Nouveau run : remise à zéro du journal d'événements (file de + # génération). Ici, avant le téléchargement — les événements download + # survivent au démarrage du traitement (process_all ne tronque plus). + from .progress import reset_events + reset_events(args.output) quality = 100 if args.lossless else args.quality # Parse --only and --skip: accept comma-separated values @@ -309,6 +306,27 @@ def main(): skip_viz = None if args.skip: skip_viz = [v.strip() for item in args.skip for v in item.split(',')] + resolutions = [float(r.strip()) for r in args.resolution.split(',') if r.strip()] + + # Téléchargement des dalles IGN manquantes avant le traitement. + # only_viz/resolutions : une tuile n'est ignorée que si elle possède + # déjà toutes les visualisations demandées — une tuile incomplète est + # (re)téléchargée pour générer ses visualisations manquantes. + if args.fetch_tiles: + from .fetch_ign import fetch_tiles, parse_tile_specs + try: + specs = parse_tile_specs(args.fetch_tiles) + except ValueError as e: + logger.error(str(e)) + return + logger.info(f"Téléchargement de {len(specs)} dalle(s) LiDAR HD depuis l'IGN...") + fetched = fetch_tiles(args.input, specs, args.output, + only_viz=only_viz, resolutions=resolutions, + force=args.force or args.force_classification) + if fetched: + logger.info(f"{len(fetched)} dalle(s) téléchargée(s) — traitement...") + else: + logger.warning("Aucune dalle téléchargée (déjà présentes ou introuvables)") pipeline = LidarArchaeoPipeline( input_dir=args.input, output_dir=args.output, @@ -326,6 +344,7 @@ def main(): output_format=args.format, gpu_ids=gpu_ids, no_index=args.no_index, + incremental_index=args.incremental_index, ) # If --file is specified, process only matching files diff --git a/lidar_pipeline/fetch_ign.py b/lidar_pipeline/fetch_ign.py index a6d3443..1c774df 100644 --- a/lidar_pipeline/fetch_ign.py +++ b/lidar_pipeline/fetch_ign.py @@ -54,6 +54,14 @@ def tile_filename(col, row): return f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69.copc.laz" +def _report_download(output_dir, tile, state, detail=None): + """Émet un événement de téléchargement pour la file de génération (best-effort).""" + if output_dir is None: + return + from .progress import report_event + report_event(output_dir, tile, "download", state, detail=detail) + + def _bbox_wgs84(col, row): """Bbox WGS84 de la dalle (col,row) pour la requête STAC (peut être élargie).""" try: @@ -117,46 +125,76 @@ def download_file(url, dest_path, timeout=120, chunk=1024 * 1024): return done -def fetch_tiles(input_dir, specs, output_dir=None): - """Télécharge les dalles IGN spécifiées, sauf celles déjà présentes/générées. +def fetch_tiles(input_dir, specs, output_dir=None, only_viz=None, resolutions=(0.5,), force=False): + """Télécharge les dalles IGN spécifiées, sauf celles inutiles au traitement. Args: input_dir: dossier des fichiers LAZ (écriture autorisée requise). specs: liste de tuples (col, row). output_dir: dossier de sortie (optionnel) — permet d'ignorer les - tuiles dont les visualisations existent déjà. + tuiles déjà complètes. + only_viz: noms d'étapes des visualisations demandées (ex: ['aspect']). + Si fourni, une tuile n'est ignorée que si elle possède déjà + toutes ces visualisations aux résolutions demandées — + sinon son LAZ est (re)téléchargé pour compléter les + visualisations manquantes. + resolutions: résolutions attendues (m/px) pour considérer une tuile + complète quand only_viz est fourni. + force: True pour télécharger même les tuiles complètes (régénération : + leur LAZ est requis pour retraiter avec --force). Returns: Liste des chemins téléchargés. """ input_dir = Path(input_dir) + complete = None + if output_dir is not None and only_viz and not force: + from .index import cells_with_all_viz, step_to_keyword + keys = [step_to_keyword(v) for v in only_viz] + complete = cells_with_all_viz(Path(output_dir) / "visualisations", + keys, resolutions) downloaded = [] for col, row in specs: name = tile_filename(col, row) + tile = name[:-len(".copc.laz")] if name.endswith(".copc.laz") else name dest = input_dir / name if dest.exists(): logger.info(f" {name} : déjà présent dans input/ — aucun téléchargement") + _report_download(output_dir, tile, "skip", "déjà dans input/") continue - if output_dir is not None: - vis_dir = Path(output_dir) / "visualisations" - if list(vis_dir.glob(f"LHD_FXX_{col:04d}_{row:04d}_PTS*")): - logger.info(f" {name} : visualisations déjà générées — ignorée") - continue + if output_dir is not None and not force: + if complete is not None: + if (col, row) in complete: + logger.info(f" {name} : visualisations déjà complètes — ignorée") + _report_download(output_dir, tile, "skip", "visualisations déjà complètes") + continue + else: + vis_dir = Path(output_dir) / "visualisations" + if list(vis_dir.glob(f"LHD_FXX_{col:04d}_{row:04d}_PTS*")): + logger.info(f" {name} : visualisations déjà générées — ignorée") + _report_download(output_dir, tile, "skip", "visualisations déjà générées") + continue logger.info(f" {name} : recherche dans le catalogue IGN...") + _report_download(output_dir, tile, "start", "catalogue IGN") try: url = find_tile_url(col, row) except Exception as e: logger.warning(f" ✗ {name} : erreur catalogue ({e})") + _report_download(output_dir, tile, "fail", f"erreur catalogue : {e}") continue if not url: logger.warning(f" ✗ {name} : introuvable dans le catalogue IGN (zone non publiée ?)") + _report_download(output_dir, tile, "fail", "introuvable dans le catalogue IGN") continue logger.info(f" {name} : téléchargement depuis la géoplateforme...") + _report_download(output_dir, tile, "start", "géoplateforme") try: download_file(url, dest) logger.info(f" ✓ {name} téléchargée") + _report_download(output_dir, tile, "ok") downloaded.append(dest) except Exception as e: dest.unlink(missing_ok=True) logger.warning(f" ✗ {name} : échec du téléchargement ({e})") + _report_download(output_dir, tile, "fail", f"échec du téléchargement : {e}") return downloaded diff --git a/lidar_pipeline/gpu.py b/lidar_pipeline/gpu.py index f3331fd..88c6b92 100644 --- a/lidar_pipeline/gpu.py +++ b/lidar_pipeline/gpu.py @@ -27,6 +27,12 @@ _gpu_reason = None # GPU restriction from -g flag (host-level indices) _restricted_gpu_ids: list[int] | None = None +# Vrai si CUDA_VISIBLE_DEVICES a été écrit par _init_gpu lui-même (choix du +# meilleur GPU). Cette valeur NE DOIT PAS être traitée comme une restriction +# externe : sinon available_gpu_ids() ne retourne plus que le GPU choisi et +# tous les workers reçoivent le même gpu_id (GPU 1 jamais utilisé). +_env_set_by_init = False + # Discovered GPU candidates (populated by _pick_gpu) _gpu_candidates: list = [] @@ -38,7 +44,7 @@ def _pick_gpu() -> list: result = subprocess.run( ['nvidia-smi', '--query-gpu=index,name,compute_cap,memory.total', '--format=csv,noheader,nounits'], - capture_output=True, text=True, timeout=5, + capture_output=True, text=True, timeout=15, ) if result.returncode != 0: return [] @@ -86,10 +92,12 @@ def _filter_candidates(gpus: list) -> list: nvidia-smi lists ALL GPUs even when CUDA_VISIBLE_DEVICES is set (driver 580.x behavior), so we must filter manually. + La variable écrite par _init_gpu (choix auto du meilleur GPU) est ignorée : + seules les restrictions externes (run.sh -g, compose) comptent. """ - # Filter by CUDA_VISIBLE_DEVICES if set + # Filter by CUDA_VISIBLE_DEVICES if set externally cuda_visible = os.environ.get('CUDA_VISIBLE_DEVICES') - if cuda_visible is not None: + if cuda_visible is not None and not _env_set_by_init: try: visible = {int(i.strip()) for i in cuda_visible.split(',')} gpus = [g for g in gpus if g[0] in visible] @@ -104,6 +112,40 @@ def _filter_candidates(gpus: list) -> list: return gpus +def _runtime_candidates(): + """Détection GPU de repli via le runtime CuPy (sans nvidia-smi). + + nvidia-smi peut dépasser son timeout quand le système est chargé : + _pick_gpu() retourne alors [] et les workers passent à tort en CPU. + Les indices CuPy sont renumérotés selon CUDA_VISIBLE_DEVICES — on les + remappe en indices hôtes pour rester compatible avec _filter_candidates. + """ + try: + import cupy as _cp_runtime + n = _cp_runtime.cuda.runtime.getDeviceCount() + cuda_visible = os.environ.get('CUDA_VISIBLE_DEVICES') + try: + host_ids = [int(v.strip()) for v in cuda_visible.split(',')] + except (ValueError, AttributeError): + host_ids = list(range(n)) + gpus = [] + for i in range(min(n, len(host_ids))): + props = _cp_runtime.cuda.runtime.getDeviceProperties(i) + name = props.get('name', b'?') + if isinstance(name, bytes): + name = name.decode() + major = int(props.get('major', 0)) + minor = int(props.get('minor', 0)) + mem_mi = int(props.get('totalGlobalMem', 0)) // (1024 * 1024) + cap = f"{major}.{minor}" + score = major * 1000 + minor * 100 + mem_mi + gpus.append((host_ids[i], name, cap, mem_mi, score, major)) + gpus.sort(key=lambda g: g[4], reverse=True) + return gpus + except Exception: + return [] + + def _init_gpu(): """Lazily initialize CuPy on first GPU use. @@ -118,6 +160,9 @@ def _init_gpu(): _gpu_initialized = True candidates = _filter_candidates(_gpu_candidates) + if not candidates: + # Repli runtime : nvidia-smi a échoué (timeout système chargé) + candidates = _filter_candidates(_runtime_candidates()) if not candidates: logger.info("Pas de GPU utilisable — mode CPU uniquement") _xp = np @@ -192,6 +237,8 @@ def _init_gpu(): return idx, name, mem_mi = _working_gpu + global _env_set_by_init + _env_set_by_init = True os.environ['CUDA_VISIBLE_DEVICES'] = str(idx) import cupy as _real_cupy @@ -238,9 +285,19 @@ def restrict_gpus(gpu_ids: list[int], set_env_var: bool = False): def set_active_gpu(gpu_id): - """Restrict to a single GPU by host-level index.""" + """Restrict to a single GPU by host-level index. + + Appelé par les workers spawnés (_process_file_standalone) : CuPy n'y est + pas encore initialisé (le worker n'exécute pas log_gpu_status), il faut + donc déclencher _init_gpu() ici, sinon le worker retombe silencieusement + en CPU. On réduit aussi CUDA_VISIBLE_DEVICES à ce seul GPU avant l'init + pour que le device 0 du worker soit le bon : sans cela, tous les workers + avec plusieurs GPU visibles partagent le premier d'entre eux. + """ global _restricted_gpu_ids _restricted_gpu_ids = [gpu_id] + os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id) + _init_gpu() def _gpu_available(): diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 30441a1..73f3f83 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -41,7 +41,6 @@ VIZ_LABELS = { 'positive_openness': 'Openness positive', 'negative_openness': 'Openness négative', 'svf': 'Sky-View Factor', - 'aniso_open': 'Openness anisotropique', 'roughness': 'Rugosité', 'wavelet': 'Ondelette', 'flow_acc': 'Accumulation d\'écoulement', @@ -54,26 +53,53 @@ VIZ_LABELS = { # Couche activée par défaut à l'ouverture de la carte. DEFAULT_VIZ = 'hillshade_multi' -# Couches proposées dans le panneau de couches et le sélecteur de génération -# (liste restreinte : base aspect + visualisations demandées). Les autres -# visualisations restent calculées et stockées mais ne sont pas proposées. -PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet') +# Visualisations proposées dans le panneau de couches et le sélecteur de +# génération/régénération : les mêmes, toujours — le sélecteur est piloté par +# les couches réellement proposées à l'affichage (voir _render_html). +PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet') -# Visualisations proposées dans la barre de génération de zones (noms -# d'étapes --only du pipeline) ; 'aspect' reste le choix par défaut (léger). -GEN_VIZ_CHOICES = [ - ('aspect', 'Aspect'), - ('slope', 'Pente'), - ('pos_open', 'Openness positive'), - ('aniso_open', 'Openness anisotropique'), - ('wavelet', 'Ondelette'), -] +# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline +# (les trois visualisations dont le nom de sortie diffère du nom d'étape, +# cf. _expected_output_path dans pipeline.py). +KEYWORD_TO_STEP = { + 'hillshade_multi': 'hillshade', + 'positive_openness': 'pos_open', + 'negative_openness': 'neg_open', +} + +# Correspondance inverse : nom d'étape --only → mot-clé de fichier de sortie. +STEP_TO_KEYWORD = {step: kw for kw, step in KEYWORD_TO_STEP.items()} + + +def step_to_keyword(step): + """Nom d'étape du pipeline (ex: 'pos_open') → mot-clé de fichier ('positive_openness').""" + return STEP_TO_KEYWORD.get(step, step) + + +def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)): + """Cellules (col, row) disposant de TOUTES les visualisations demandées. + + Une cellule est complète si, pour chaque résolution de `resolutions`, un + dossier de visualisations lui correspond et contient chaque mot-clé de + `viz_keys` (ex: 'aspect', 'hillshade_multi'). Sert à distinguer les tuiles + réellement terminées de celles à compléter : une tuile existante mais + incomplète (visualisation ou résolution manquante) reste à traiter. + + Returns: + Ensemble des (col, row) complets. + """ + by_cell = {} + for t in scan_tiles(vis_dir): + per_res = by_cell.setdefault((t['col'], t['row']), {}) + per_res.setdefault(t['resolution'], set()).update(t['viz'].keys()) + return {cell for cell, per_res in by_cell.items() + if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)} # Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la # carte — les autres viz restent disponibles en repli dalle entière. # Liste vide = découper toutes les visualisations disponibles. _CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness', - 'aniso_open', 'wavelet') + 'wavelet') # Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles : # q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur). @@ -85,7 +111,7 @@ _SUBTILE_AVIF_SPEED = 8 # 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', - 'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness', + 'negative_openness', 'aspect', 'sailore', 'roughness', 'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo', ] @@ -295,6 +321,18 @@ def _approx_l93_to_wgs84(x_m, y_m): return lon, lat +def _approx_wgs84_to_l93(lon, lat): + """Approximation affine WGS84 → Lambert 93 (inverse exacte du précédent). + + Précision de l'ordre du km — repli sans rasterio ni pyproj (webapp + légère, cf. bbox_to_cells dans webapp.py). + """ + import math + y = (lat - 46.5) * 111320.0 + 6600000.0 + x = (lon - 3.0) * (111320.0 * math.cos(math.radians(47.0))) + 700000.0 + return x, y + + def attach_gps_bounds(tiles): """Attache à chaque tuile ses coins GPS pour l'affichage Leaflet. @@ -302,25 +340,38 @@ def attach_gps_bounds(tiles): (col, row) → X ∈ [col, col+1] km, Y ∈ [row-1, row] km. (Vérifié sur les bounds des DTM : X_min = col×1000, Y_max = row×1000.) - Utilise rasterio.warp (conversion PROJ exacte) si disponible, - sinon l'approximation affine _approx_l93_to_wgs84. + Utilise rasterio.warp (conversion PROJ exacte) si disponible, sinon + pyproj, sinon l'approximation affine _approx_l93_to_wgs84 (webapp + légère sans GDAL — précision ~km). Ajoute à chaque tuile: corners: [[lat, lon] × 4] dans l'ordre SW, SE, NE, NW bounds : [[lat_sud, lon_ouest], [lat_nord, lon_est]] """ + # Coins SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000 + xs = [] + ys = [] + for t in tiles: + xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000, + (t['col'] + 1) * 1000, t['col'] * 1000]) + ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000, + t['row'] * 1000, t['row'] * 1000]) try: from rasterio.warp import transform as warp_transform - xs = [] - ys = [] - for t in tiles: - # SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000 - xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000, - (t['col'] + 1) * 1000, t['col'] * 1000]) - ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000, - t['row'] * 1000, t['row'] * 1000]) lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys) ok = True + except ImportError: + try: + from pyproj import Transformer + transformer = Transformer.from_crs('EPSG:2154', 'EPSG:4326', + always_xy=True) + lons, lats = transformer.transform(xs, ys) + ok = True + except ImportError: + logger.debug("Coins GPS approximatifs (rasterio et pyproj indisponibles)") + lons = None + lats = None + ok = False except Exception as e: logger.debug(f"Coins GPS approximatifs (rasterio indisponible: {e})") lons = None @@ -555,6 +606,19 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): return usable +def _viz_src_dir(tile, info): + """Dossier réel du fichier d'une visualisation. + + Les couches fusionnées d'une autre résolution (run interrompu entre les + deux passes) vivent dans leur dossier d'origine — info['dir_name'] — et + non dans dir_path de la dalle affichée. + """ + d = info.get('dir_name') or tile.get('dir_name') + if d and d != Path(tile['dir_path']).name: + return Path(tile['dir_path']).parent / d + return Path(tile['dir_path']) + + def _collect_tile_metadata(tile, dtm_dir): """Rassemble les métadonnées de génération d'une tuile. @@ -587,7 +651,8 @@ def _collect_tile_metadata(tile, dtm_dir): for viz_key, info in tile['viz'].items(): try: - st = (Path(tile['dir_path']) / info['filename']).stat() + viz_dir = _viz_src_dir(tile, info) + st = (viz_dir / info['filename']).stat() meta['viz'][viz_key] = { 'date': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'), 'size': st.st_size, @@ -643,12 +708,15 @@ def build_index(output_dir, output_format='avif'): # visualisations produites uniquement à l'autre résolution : sans cela, # une couche en cours de génération (passe 0,5 m faite, 0,2 m pas encore) # reste invisible sur la carte et absente du panneau de couches. + # dir_name mémorise le dossier d'origine : vignettes, URLs et métadonnées + # doivent lire le fichier là où il existe réellement. for key, best in best_by_pos.items(): for other in tiles_by_pos[key]: if other is best: continue for viz_key, viz_info in other['viz'].items(): - best['viz'].setdefault(viz_key, viz_info) + if viz_key not in best['viz']: + best['viz'][viz_key] = dict(viz_info, dir_name=other['dir_name']) tiles = sorted(best_by_pos.values(), key=lambda t: (t['resolution'], -t['row'], t['col'])) @@ -686,7 +754,9 @@ def build_index(output_dir, output_format='avif'): viz_thumbs = {} regen = 0 for viz_key, info in t['viz'].items(): - src = Path(t['dir_path']) / info['filename'] + # Les couches fusionnées d'une autre résolution vivent dans + # leur dossier d'origine (info['dir_name']), pas dir_path. + src = _viz_src_dir(t, info) / info['filename'] thumb_name = f"{t['dir_name']}_{viz_key}.jpg" thumb_path = thumb_dir / thumb_name # Régénère si manquante ou périmée (tuile recalculée depuis) @@ -699,9 +769,10 @@ def build_index(output_dir, output_format='avif'): continue else: thumbs_generated += 1 + viz_dir_name = _viz_src_dir(t, info).name viz_thumbs[viz_key] = { 'thumb': f"index_thumbs/{thumb_name}", - 'full': f"visualisations/{t['dir_name']}/{info['filename']}", + 'full': f"visualisations/{viz_dir_name}/{info['filename']}", } if regen: @@ -759,6 +830,16 @@ def build_index(output_dir, output_format='avif'): if n_sous: logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)") + # 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({ + 'tiles': display_tiles, + 'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz}, + 'stats': {'default_viz': default_viz, '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) html_path = output_dir / 'index.html' @@ -781,8 +862,12 @@ def _write_assets(output_dir): (assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8') -def _render_html(tiles, global_bbox, all_viz_keys, output_format): - """Construit la coquille HTML (données intégrées, styles/JS dans 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). + """ # Restriction du panneau de couches à la liste choisie (PANEL_VIZ) : # les autres visualisations existent sur disque mais ne sont pas proposées. panel_keys = set(all_viz_keys) @@ -793,24 +878,35 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format): 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, sinon aspect (base + # complète), sinon la 1re proposée. + default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz), + ordered_viz[0] if ordered_viz else DEFAULT_VIZ) + return ordered_viz, default_viz + + +def _render_html(tiles, global_bbox, all_viz_keys, output_format): + """Construit la coquille HTML (données intégrées, styles/JS dans assets/).""" + ordered_viz, default_viz = _panel_selection(all_viz_keys) tiles_json = json.dumps(tiles, ensure_ascii=False) - # Options du sélecteur de visualisations de la barre de génération. + # Options du sélecteur de génération/régénération : uniquement les couches + # réellement proposées dans le panneau de visualisation (mêmes couches, + # mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Repli sur + # aspect si aucune couche n'est encore disponible. + gen_keys = ordered_viz if ordered_viz else ['aspect'] gen_viz_options = "\n".join( - f' ' - for name, label in GEN_VIZ_CHOICES) + f' ' + for v in gen_keys) viz_meta_json = json.dumps({ k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz }, ensure_ascii=False) - # Couche activée par défaut : préférence globale, sinon aspect (base - # complète), sinon la 1re proposée. - default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz), - ordered_viz[0] if ordered_viz else DEFAULT_VIZ) stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)}, ensure_ascii=False) @@ -850,6 +946,7 @@ _HTML_TEMPLATE = """