Split webapp for Raspberry Pi deployment, remote generation API and sync
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.
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@ -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/
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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.<module>`
|
||||
- test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>`
|
||||
- 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`
|
||||
|
||||
|
||||
49
Dockerfile.webapp
Normal file
49
Dockerfile.webapp
Normal file
@ -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"]
|
||||
37
docker-compose.webapp.yml
Normal file
37
docker-compose.webapp.yml
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
115
docs/DEPLOY_WEBAPP.md
Normal file
115
docs/DEPLOY_WEBAPP.md
Normal file
@ -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://<ip-machine>: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 <ce-dépôt> 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@<ip-machine> # 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://<ip-machine>: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://<ip-pi>:8973/`.
|
||||
|
||||
### 4. Premier chargement
|
||||
|
||||
Si aucune tuile n'a encore été synchronisée, déclencher une première fois :
|
||||
|
||||
```bash
|
||||
curl -X POST http://<ip-pi>:8973/api/sync
|
||||
```
|
||||
|
||||
(puis attendre la fin : `curl http://<ip-pi>:8973/api/sync` → `"running":
|
||||
false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes).
|
||||
|
||||
## 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).
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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' <option value="{name}"{" selected" if name == "aspect" else ""}>'
|
||||
f'{label}</option>'
|
||||
for name, label in GEN_VIZ_CHOICES)
|
||||
f' <option value="{KEYWORD_TO_STEP.get(v, v)}"'
|
||||
f'{" selected" if v == "aspect" else ""}>'
|
||||
f'{VIZ_LABELS.get(v, v)}</option>'
|
||||
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 = """<!DOCTYPE html>
|
||||
|
||||
<nav id="mapTools" class="panel">
|
||||
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
|
||||
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
|
||||
<button id="zoomIn" title="Zoomer">+</button>
|
||||
<button id="zoomOut" title="Dézoomer">−</button>
|
||||
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
|
||||
@ -955,6 +1052,8 @@ _APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */
|
||||
--accent: #5aa9e6;
|
||||
--accent-dim: #2e7cc4;
|
||||
--danger: #e94560;
|
||||
--success: #4cc98a;
|
||||
--success-dim: #2f8f63;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
@ -1020,6 +1119,13 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
|
||||
#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; }
|
||||
#mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); }
|
||||
#mapTools #btnGenZone:hover { background: var(--accent); }
|
||||
#mapTools #btnComplete {
|
||||
width: auto; padding: 0 14px; font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em;
|
||||
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
|
||||
box-shadow: 0 2px 10px rgba(76, 201, 138, 0.25);
|
||||
}
|
||||
#mapTools #btnComplete:hover { background: var(--success); border-color: var(--success); box-shadow: 0 2px 14px rgba(76, 201, 138, 0.45); }
|
||||
#mapTools #btnComplete .ok-ic { margin-right: 6px; font-weight: 700; }
|
||||
|
||||
/* --- Panneau de couches (gauche) --- */
|
||||
#layerPanel { top: 74px; left: 12px; width: 300px; }
|
||||
@ -1150,10 +1256,15 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* File de génération : panneau latéral, le journal replié par défaut */
|
||||
#genQueue { bottom: 48px; right: 12px; width: 780px; max-width: 94vw; }
|
||||
/* File de génération : panneau latéral — peut occuper toute la hauteur
|
||||
de l'écran si besoin (plafonné au viewport, scroll interne au-delà) */
|
||||
#genQueue {
|
||||
bottom: 48px; right: 12px; width: 780px; max-width: 94vw;
|
||||
max-height: calc(100vh - 60px);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#genQueueBody {
|
||||
padding: 8px 10px; max-height: 42vh; overflow-y: auto;
|
||||
padding: 8px 10px; flex: 1; min-height: 0; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.gen-queue-actions { display: flex; align-items: center; gap: 8px; }
|
||||
@ -1189,6 +1300,40 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: #9fb3d9; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Tuiles une par une : nom court + puces d'étapes (types de visualisation).
|
||||
Sans plafond : c'est le corps du panneau qui scrolle si trop long. */
|
||||
.gen-task-tiles {
|
||||
margin: 0 8px 8px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.gen-tile {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--panel-border); border-radius: 6px;
|
||||
padding: 4px 8px; font-size: 11.5px;
|
||||
}
|
||||
.gen-tile-name {
|
||||
min-width: 78px; flex: none; font-variant-numeric: tabular-nums;
|
||||
color: var(--text); cursor: default;
|
||||
}
|
||||
.gen-tile-steps { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
|
||||
.gen-step {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 1px 7px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05); color: var(--muted);
|
||||
font-size: 10.5px; white-space: nowrap;
|
||||
}
|
||||
.gen-step::before { content: '·'; font-weight: bold; }
|
||||
.gen-step.running { color: var(--accent); background: rgba(90, 169, 230, 0.12); }
|
||||
.gen-step.running::before { content: '⟳'; animation: gen-pulse 1.1s ease-in-out infinite; }
|
||||
.gen-step.ok { color: #39d98a; background: rgba(57, 217, 138, 0.10); }
|
||||
.gen-step.ok::before { content: '✓'; }
|
||||
.gen-step.fail { color: var(--danger); background: rgba(233, 69, 96, 0.12); }
|
||||
.gen-step.fail::before { content: '✗'; }
|
||||
.gen-step.skip::before { content: '–'; }
|
||||
.gen-tile-state { flex: none; font-size: 11px; }
|
||||
.gen-tile.done .gen-tile-state { color: #39d98a; }
|
||||
.gen-tile.failed .gen-tile-state { color: var(--danger); }
|
||||
|
||||
body.drawing, body.drawing #viewport { cursor: crosshair !important; }
|
||||
|
||||
/* --- Responsive --- */
|
||||
@ -1289,8 +1434,10 @@ let gridBounds = null;
|
||||
const markers = new Map();
|
||||
const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0);
|
||||
|
||||
for (const t of TILES) {
|
||||
if (!t.corners) continue;
|
||||
// Crée le marqueur Leaflet d'une tuile (utilisé à l'init et en direct,
|
||||
// quand une tuile terminée apparaît en cours de génération).
|
||||
function registerTile(t) {
|
||||
if (!t.corners) return;
|
||||
const center = [(t.corners[0][0] + t.corners[2][0]) / 2,
|
||||
(t.corners[0][1] + t.corners[2][1]) / 2];
|
||||
const icon = L.divIcon({
|
||||
@ -1305,10 +1452,20 @@ for (const t of TILES) {
|
||||
}).addTo(map);
|
||||
mk.on('click', () => { if (!genMode) showTileInfo(t); });
|
||||
markers.set(tileKey(t), mk);
|
||||
const b = L.latLngBounds(t.corners);
|
||||
gridBounds = gridBounds ? gridBounds.extend(b) : b;
|
||||
}
|
||||
|
||||
function recomputeGridBounds() {
|
||||
gridBounds = null;
|
||||
for (const t of TILES) {
|
||||
if (!t.corners) continue;
|
||||
const b = L.latLngBounds(t.corners);
|
||||
gridBounds = gridBounds ? gridBounds.extend(b) : b;
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of TILES) registerTile(t);
|
||||
recomputeGridBounds();
|
||||
|
||||
function tileDiv(t) {
|
||||
const mk = markers.get(tileKey(t));
|
||||
const el = mk && mk.getElement();
|
||||
@ -1666,6 +1823,7 @@ function regenerateCurrentTile() {
|
||||
hideTileInfo();
|
||||
pendingTiles = [[t.col, t.row]];
|
||||
pendingRegen = true;
|
||||
pendingCompleteAll = false;
|
||||
document.getElementById('genRegen').checked = true;
|
||||
genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez';
|
||||
genGo.style.display = '';
|
||||
@ -1677,6 +1835,7 @@ function regenerateCurrentTile() {
|
||||
// ============================================================
|
||||
let apiAvailable = false;
|
||||
const genBtn = document.getElementById('btnGenZone');
|
||||
const btnComplete = document.getElementById('btnComplete');
|
||||
const genBar = document.getElementById('genBar');
|
||||
const genMsg = document.getElementById('genMsg');
|
||||
const genGo = document.getElementById('genGo');
|
||||
@ -1691,16 +1850,24 @@ const genQueueBody = document.getElementById('genQueueBody');
|
||||
const genQueueClear = document.getElementById('genQueueClear');
|
||||
const genQueueCount = document.getElementById('genQueueCount');
|
||||
let genMode = false, drawStart = null, drawRect = null;
|
||||
let pendingTiles = [], pendingRegen = false;
|
||||
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
|
||||
const ghostCells = [];
|
||||
|
||||
fetch('/api/status').then(r => {
|
||||
apiAvailable = r.ok;
|
||||
if (r.ok) genBtn.hidden = false;
|
||||
if (r.ok) { genBtn.hidden = false; btnComplete.hidden = false; }
|
||||
}).catch(() => {});
|
||||
|
||||
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.
|
||||
function selectedGenViz() {
|
||||
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
||||
return sel.length ? sel : ['aspect'];
|
||||
}
|
||||
|
||||
function exitGenMode() {
|
||||
genMode = false; drawStart = null;
|
||||
document.body.classList.remove('drawing');
|
||||
@ -1743,50 +1910,87 @@ map.on('mouseup', e => {
|
||||
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 })
|
||||
regenerate: genRegen.checked,
|
||||
viz: selectedGenViz() })
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (drawRect) { drawRect.remove(); drawRect = null; }
|
||||
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
|
||||
pendingCompleteAll = false; // zone dessinée : le scope global est levé
|
||||
(d.cells || []).forEach(c => ghostCells.push(
|
||||
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
|
||||
));
|
||||
if (!pendingTiles.length) {
|
||||
genMsg.textContent = 'Aucune tuile manquante dans cette zone' +
|
||||
(genRegen.checked ? '' : ' (cochez « Régénérer » pour traiter les tuiles existantes)');
|
||||
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 = pendingTiles.length + ' tuile(s) à ' +
|
||||
(genRegen.checked ? 'régénérer' : 'télécharger et générer') +
|
||||
(genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') +
|
||||
(d.capped ? ' (zone tronquée à 400)' : '');
|
||||
genGo.style.display = '';
|
||||
}).catch(() => { genMsg.textContent = 'API indisponible'; });
|
||||
});
|
||||
|
||||
// « 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();
|
||||
genBar.classList.add('show');
|
||||
genMsg.textContent = 'Analyse des tuiles présentes dans input/...';
|
||||
genGo.style.display = 'none';
|
||||
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;
|
||||
(d.cells || []).forEach(c => ghostCells.push(
|
||||
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).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(() => { genMsg.textContent = 'API indisponible'; });
|
||||
});
|
||||
|
||||
genGo.addEventListener('click', () => {
|
||||
if (!pendingTiles.length) return;
|
||||
genBar.classList.remove('show');
|
||||
clearGhosts();
|
||||
const nTiles = pendingTiles.length;
|
||||
const isRegen = genRegen.checked || pendingRegen;
|
||||
const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
||||
fetch('/api/generate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tiles: pendingTiles,
|
||||
regenerate: isRegen,
|
||||
viz: vizSel.length ? vizSel : ['aspect'],
|
||||
viz: selectedGenViz(),
|
||||
all_missing: pendingCompleteAll,
|
||||
ground_class: genClass ? genClass.value : 'ign',
|
||||
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
|
||||
bare_earth: genBareEarth ? genBareEarth.checked : false })
|
||||
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
|
||||
.then(() => startGenTask(nTiles + ' tuile(s) — ' + (isRegen ? 'régénération' : 'génération')))
|
||||
.then(() => {
|
||||
const verb = isRegen ? 'régénération'
|
||||
: pendingCompleteAll ? 'complétion' : 'génération';
|
||||
pendingCompleteAll = false;
|
||||
return startGenTask(nTiles + ' tuile(s) — ' + verb);
|
||||
})
|
||||
.catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); });
|
||||
});
|
||||
|
||||
genCancel.addEventListener('click', () => {
|
||||
genBar.classList.remove('show');
|
||||
clearGhosts();
|
||||
pendingTiles = []; pendingRegen = false;
|
||||
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
|
||||
if (genMode) exitGenMode();
|
||||
});
|
||||
|
||||
@ -1794,16 +1998,45 @@ genCancel.addEventListener('click', () => {
|
||||
// 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é */ }
|
||||
}
|
||||
|
||||
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;
|
||||
@ -1814,17 +2047,35 @@ function genTaskEl(t) {
|
||||
label.className = 'gen-task-label';
|
||||
label.title = t.label; label.textContent = t.label;
|
||||
const state = document.createElement('span'); state.className = 'gen-task-state';
|
||||
state.textContent = t.state === 'running' ? 'En cours…'
|
||||
: t.state === 'done' ? 'Terminé'
|
||||
: 'Échec (code ' + t.returncode + ')';
|
||||
const tiles = t.tiles || [];
|
||||
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 {
|
||||
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';
|
||||
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;
|
||||
@ -1853,7 +2104,7 @@ function startGenTask(labelText) {
|
||||
genTasks.push({
|
||||
id: Date.now(), label: labelText,
|
||||
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
state: 'running', returncode: null, log: [], logOpen: false,
|
||||
state: 'running', returncode: null, log: [], logOpen: false, tiles: [],
|
||||
});
|
||||
saveGenTasks(); renderGenQueue(); pollGenStatus();
|
||||
}
|
||||
@ -1870,17 +2121,98 @@ function finishRunningTasks(d) {
|
||||
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.
|
||||
let liveTilesStamp = null;
|
||||
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);
|
||||
}
|
||||
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 mk = markers.get(key);
|
||||
if (mk) { mk.remove(); markers.delete(key); }
|
||||
TILES.splice(i, 1);
|
||||
changed = true;
|
||||
}
|
||||
byKey.forEach((t, key) => {
|
||||
const cur = TILES.find(x => tileKey(x) === 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 (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;
|
||||
}
|
||||
}
|
||||
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) { syncTileLayers(); layoutTiles(); }
|
||||
}
|
||||
function pollLiveTiles() {
|
||||
const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : '';
|
||||
fetch('/api/tiles' + q).then(r => r.ok ? r.json() : null).then(d => {
|
||||
if (!d) return;
|
||||
liveTilesStamp = d.stamp;
|
||||
if (d.tiles) mergeTilesData(d);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function pollGenStatus() {
|
||||
if (genPollTimer) return; // un seul sondage global pour toute la file
|
||||
genPollTimer = setInterval(() => {
|
||||
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;
|
||||
const running = genTasks.filter(t => t.state === 'running');
|
||||
if (!running.length) {
|
||||
clearInterval(genPollTimer); genPollTimer = null;
|
||||
return;
|
||||
}
|
||||
const lines = d.log || [];
|
||||
for (const t of running) t.log = lines.slice(-60);
|
||||
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) {
|
||||
clearInterval(genPollTimer); genPollTimer = null;
|
||||
finishRunningTasks(d);
|
||||
@ -1890,14 +2222,33 @@ function pollGenStatus() {
|
||||
const c = map.getCenter();
|
||||
sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
|
||||
} catch (e) { /* sessionStorage indisponible : pas grave */ }
|
||||
setTimeout(() => location.reload(), 2500);
|
||||
syncThenReload();
|
||||
} else {
|
||||
saveGenTasks(); renderGenQueue();
|
||||
pollLiveTiles();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Après un run : ramener les tuiles produites par la machine de traitement
|
||||
// (rsync via /api/sync — mode webapp sur Raspberry Pi), reconstruire vignettes
|
||||
// et carte, puis recharger. Sans backend distant, /api/sync se réduit à un
|
||||
// rebuild local : même flux partout. Rechargement de secours après 30 min.
|
||||
function syncThenReload() {
|
||||
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
|
||||
const t0 = Date.now();
|
||||
const check = setInterval(() => {
|
||||
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
|
||||
if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) {
|
||||
clearInterval(check);
|
||||
location.reload();
|
||||
}
|
||||
}).catch(() => { clearInterval(check); location.reload(); });
|
||||
}, 2000);
|
||||
}).catch(() => location.reload());
|
||||
}
|
||||
|
||||
genQueueClear.addEventListener('click', () => {
|
||||
genTasks = genTasks.filter(t => t.state === 'running');
|
||||
saveGenTasks(); renderGenQueue();
|
||||
@ -1905,17 +2256,30 @@ genQueueClear.addEventListener('click', () => {
|
||||
|
||||
renderGenQueue();
|
||||
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
|
||||
// ou adopte une génération lancée hors interface (API directe) pour
|
||||
// l'afficher dans la file avec son journal.
|
||||
// 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')) pollGenStatus();
|
||||
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
|
||||
if (!s || !s.running) return;
|
||||
genTasks.push({
|
||||
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
|
||||
time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
state: 'running', returncode: null, log: [], logOpen: false,
|
||||
});
|
||||
saveGenTasks(); renderGenQueue(); pollGenStatus();
|
||||
if (!s || genTasks.some(t => t.started === s.started)) 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();
|
||||
} 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' : 'failed', returncode: s.returncode,
|
||||
log: [], logOpen: false, tiles: s.tiles,
|
||||
});
|
||||
saveGenTasks(); renderGenQueue();
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// ============================================================
|
||||
@ -1932,8 +2296,8 @@ zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
||||
// 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 ↻ reconstruit
|
||||
// la carte (vignettes + sous-tuiles) puis recharge la page.
|
||||
// /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 => {
|
||||
@ -1951,11 +2315,11 @@ setInterval(checkNewLayers, 60000);
|
||||
if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
|
||||
layerRefreshBtn.textContent = '…';
|
||||
layerRefreshBtn.disabled = true;
|
||||
fetch('/api/rebuild', { method: 'POST' })
|
||||
fetch('/api/sync', { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : Promise.reject('rejeté'))
|
||||
.then(() => new Promise(resolve => {
|
||||
const poll = setInterval(() => {
|
||||
fetch('/api/rebuild').then(r => r.json()).then(s => {
|
||||
fetch('/api/sync').then(r => r.json()).then(s => {
|
||||
if (!s.running) { clearInterval(poll); resolve(s.error); }
|
||||
}).catch(() => { clearInterval(poll); resolve(null); });
|
||||
}, 3000);
|
||||
@ -1964,7 +2328,7 @@ if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
|
||||
if (err) {
|
||||
layerRefreshBtn.textContent = '↻';
|
||||
layerRefreshBtn.disabled = false;
|
||||
layerRefreshBtn.title = 'Erreur rebuild : ' + err;
|
||||
layerRefreshBtn.title = 'Erreur synchronisation/rebuild : ' + err;
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@ class FilePrefixFilter(logging.Filter):
|
||||
# Module-level filter instance so process_file can set it
|
||||
_file_filter = FilePrefixFilter()
|
||||
|
||||
from .progress import report_event
|
||||
from .dtm import classify_ground, create_dtm_fast
|
||||
from .visualizations import (
|
||||
SharedDEM,
|
||||
@ -62,7 +63,7 @@ from .visualizations import (
|
||||
generate_mslrm, generate_sailore,
|
||||
generate_roughness, generate_wavelet,
|
||||
generate_solar,
|
||||
generate_svf, generate_aniso_open,
|
||||
generate_svf,
|
||||
generate_flow_accumulation,
|
||||
generate_anomaly_mask,
|
||||
)
|
||||
@ -83,7 +84,6 @@ VIZ_STEPS = [
|
||||
('pos_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=True, shared=shared)),
|
||||
('neg_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=False, shared=shared)),
|
||||
('svf', generate_svf),
|
||||
('aniso_open', generate_aniso_open),
|
||||
('roughness', generate_roughness),
|
||||
('wavelet', generate_wavelet),
|
||||
('flow_acc', generate_flow_accumulation),
|
||||
@ -109,7 +109,7 @@ VIZ_STEPS = [
|
||||
class LidarArchaeoPipeline:
|
||||
"""Orchestrates the LiDAR archaeological analysis pipeline."""
|
||||
|
||||
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False):
|
||||
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False):
|
||||
self.input_dir = Path(input_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
# Accept single float or comma-separated string for multi-resolution
|
||||
@ -133,6 +133,8 @@ class LidarArchaeoPipeline:
|
||||
self.output_format = output_format
|
||||
self.gpu_ids = gpu_ids
|
||||
self.no_index = no_index
|
||||
self.incremental_index = incremental_index
|
||||
self._last_index_rebuild = 0.0
|
||||
self.temp_dir = self.output_dir / "temp"
|
||||
|
||||
if not self.input_dir.exists():
|
||||
@ -265,6 +267,7 @@ class LidarArchaeoPipeline:
|
||||
for idx, (name, func) in enumerate(self.viz_steps, 1):
|
||||
if not needs_generation[name]:
|
||||
logger.info(f" [{idx}/{total}] {name}: déjà existant, ignoré")
|
||||
self._report(basename, "viz", "skip", name, res=resolution)
|
||||
vis_results[name] = self._expected_output_path(name, basename, file_vis_dir, self.output_format)
|
||||
continue
|
||||
|
||||
@ -283,6 +286,7 @@ class LidarArchaeoPipeline:
|
||||
tif.unlink(missing_ok=True)
|
||||
|
||||
logger.info(f" [{idx}/{total}] {name}...")
|
||||
self._report(basename, "viz", "start", name, res=resolution)
|
||||
t0 = time.time()
|
||||
try:
|
||||
# IGN overlays don't use SharedDEM (they download external data)
|
||||
@ -295,11 +299,14 @@ class LidarArchaeoPipeline:
|
||||
elapsed = time.time() - t0
|
||||
if result:
|
||||
logger.info(f" [{idx}/{total}] ✓ {name} ({elapsed:.1f}s)")
|
||||
self._report(basename, "viz", "ok", name, res=resolution)
|
||||
else:
|
||||
logger.warning(f" [{idx}/{total}] ✗ {name} — no output ({elapsed:.1f}s)")
|
||||
self._report(basename, "viz", "fail", name, res=resolution)
|
||||
except Exception as e:
|
||||
vis_results[name] = None
|
||||
logger.error(f" [{idx}/{total}] ✗ {name}: {e}", exc_info=True)
|
||||
self._report(basename, "viz", "fail", name, res=resolution)
|
||||
|
||||
# Free GPU memory between visualizations to prevent OOM
|
||||
gpu_cleanup()
|
||||
@ -370,6 +377,10 @@ class LidarArchaeoPipeline:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _report(self, basename, phase, state, detail=None, res=None):
|
||||
"""Émet un événement de progression (file de génération, best-effort)."""
|
||||
report_event(self.output_dir, basename, phase, state, detail=detail, res=res)
|
||||
|
||||
def process_file(self, laz_file):
|
||||
"""Process a single LAZ file through the full pipeline.
|
||||
|
||||
@ -389,6 +400,7 @@ class LidarArchaeoPipeline:
|
||||
# Validate file integrity before any processing
|
||||
from .dtm import validate_laz
|
||||
if not validate_laz(laz_file):
|
||||
self._report(basename, "tile", "fail", "fichier LAZ invalide")
|
||||
return False
|
||||
|
||||
# Step 1: Ground classification (shared across all resolutions)
|
||||
@ -417,8 +429,10 @@ class LidarArchaeoPipeline:
|
||||
if i == 0:
|
||||
logger.info(f"[1/5] Classification du sol — sautée (DTM existant)")
|
||||
logger.info(f"[2/5] Génération DTM {res}m/px — sautée (DTM existant)")
|
||||
self._report(basename, "classif", "skip", "DTM existant")
|
||||
else:
|
||||
logger.info(f" DTM {res}m/px déjà existant — ignoré")
|
||||
self._report(basename, "dtm", "skip", res=res)
|
||||
continue
|
||||
except Exception:
|
||||
logger.warning(f"Impossible de lire le DTM existant — régénération")
|
||||
@ -431,16 +445,21 @@ class LidarArchaeoPipeline:
|
||||
if las_file is None:
|
||||
# First time: do ground classification
|
||||
logger.info("[1/5] Classification du sol...")
|
||||
self._report(basename, "classif", "start")
|
||||
t1 = time.time()
|
||||
las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify, ign_classes=self.ign_classes)
|
||||
t_classif = time.time() - t1
|
||||
if not las_file:
|
||||
logger.error(f" ✗ Échec classification ({t_classif:.1f}s)")
|
||||
self._report(basename, "classif", "fail")
|
||||
self._report(basename, "tile", "fail", "échec classification")
|
||||
return False
|
||||
logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)")
|
||||
self._report(basename, "classif", "ok")
|
||||
|
||||
# Generate DTM at this resolution
|
||||
logger.info(f"{'[2/5]' if i == 0 else ' '} Génération DTM {res}m/px...")
|
||||
self._report(basename, "dtm", "start", res=res)
|
||||
t2 = time.time()
|
||||
# Classification IGN → mode pur : DTM = rasterisation brute des
|
||||
# classes choisies, sans plancher ni comblement (sauf --bare-earth)
|
||||
@ -454,10 +473,13 @@ class LidarArchaeoPipeline:
|
||||
t_dtm = time.time() - t2
|
||||
if not dtm_file:
|
||||
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
|
||||
self._report(basename, "dtm", "fail", res=res)
|
||||
if i == 0:
|
||||
self._report(basename, "tile", "fail", "échec DTM")
|
||||
return False # Primary resolution failure is fatal
|
||||
continue # Additional resolution failure is non-fatal
|
||||
logger.info(f" ✓ DTM {res}m/px terminé ({t_dtm:.1f}s)")
|
||||
self._report(basename, "dtm", "ok", res=res)
|
||||
dtm_rebuilt = True
|
||||
self._write_dtm_method(basename, res_suffix)
|
||||
|
||||
@ -492,9 +514,36 @@ class LidarArchaeoPipeline:
|
||||
|
||||
t_total = time.time() - t_start
|
||||
logger.info(f"✓ {basename} terminé en {t_total:.1f}s")
|
||||
self._report(basename, "tile", "ok", f"{t_total:.0f}s")
|
||||
_file_filter.basename = None
|
||||
return True
|
||||
|
||||
def _rebuild_index_incremental(self):
|
||||
"""Régénère la carte juste après une tuile terminée (mode incrémental).
|
||||
|
||||
Réécrit index.html + index_tiles.json pour que la webapp affiche la
|
||||
tuile sans attendre la fin du run. Anti-rebond : 3 s minimum entre
|
||||
deux passes — les tuiles terminées pendant l'intervalle sont couvertes
|
||||
par la passe suivante ou par la passe finale. Les logs de build_index
|
||||
sont masqués pour ne pas noyer le journal du run.
|
||||
"""
|
||||
if self.no_index:
|
||||
return
|
||||
now = time.time()
|
||||
if now - self._last_index_rebuild < 3.0:
|
||||
return
|
||||
self._last_index_rebuild = now
|
||||
try:
|
||||
from .index import build_index
|
||||
saved_level = logger.getEffectiveLevel()
|
||||
logger.setLevel(logging.WARNING)
|
||||
try:
|
||||
build_index(self.output_dir, self.output_format)
|
||||
finally:
|
||||
logger.setLevel(saved_level)
|
||||
except Exception as e:
|
||||
logger.debug(f"Rebuild incrémental de l'index ignoré : {e}")
|
||||
|
||||
def process_all(self, files=None):
|
||||
"""Process all LAZ files in input directory (or an explicit list)."""
|
||||
files = files if files is not None else self.find_laz_files()
|
||||
@ -547,9 +596,13 @@ class LidarArchaeoPipeline:
|
||||
results[laz_file.name] = success
|
||||
status = "✓" if success else "✗"
|
||||
logger.info(f" [{done}/{len(files)}] {status} {laz_file.name}")
|
||||
if success and self.incremental_index:
|
||||
self._rebuild_index_incremental()
|
||||
except Exception as e:
|
||||
logger.error(f" [{done}/{len(files)}] ✗ {laz_file.name}: {e}")
|
||||
logger.debug(f" Traceback:", exc_info=True)
|
||||
report_event(self.output_dir, _file_basename(laz_file),
|
||||
"tile", "fail", detail=str(e))
|
||||
results[laz_file.name] = False
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interruption — annulation des travaux en cours...")
|
||||
@ -564,12 +617,16 @@ class LidarArchaeoPipeline:
|
||||
logger.info(f"--- Fichier {idx}/{total} ---")
|
||||
try:
|
||||
results[laz_file.name] = self.process_file(laz_file)
|
||||
if results[laz_file.name] and self.incremental_index:
|
||||
self._rebuild_index_incremental()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interruption — arrêt immédiat.")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Erreur traitement {laz_file.name}: {e}")
|
||||
logger.debug("Traceback:", exc_info=True)
|
||||
report_event(self.output_dir, _file_basename(laz_file),
|
||||
"tile", "fail", detail=str(e))
|
||||
results[laz_file.name] = False
|
||||
|
||||
# Summary
|
||||
|
||||
154
lidar_pipeline/progress.py
Normal file
154
lidar_pipeline/progress.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Progression structurée d'un run de génération (contrat pipeline → interface).
|
||||
|
||||
Le pipeline et le téléchargement IGN écrivent des événements JSON ligne à
|
||||
ligne dans <sortie>/.generation.events.jsonl ; la webapp les agrège via
|
||||
/api/status pour afficher les tuiles une par une (nom, type, état) dans la
|
||||
file de génération, à la place du journal brut.
|
||||
|
||||
Module volontairement découplé de la webapp : le rendu des tuiles pourra être
|
||||
déporté hors du serveur web — le producteur d'événements ne dépend que du
|
||||
dossier de sortie. Les workers parallèles écrivent chacun leur ligne en mode
|
||||
ajout (une ligne < 4 Ko avec O_APPEND est atomique sur POSIX).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
EVENTS_FILENAME = ".generation.events.jsonl"
|
||||
|
||||
# Phases d'une tuile, dans l'ordre d'exécution.
|
||||
PHASES = ("download", "classif", "dtm", "viz", "tile")
|
||||
|
||||
_TILE_RE = re.compile(r"LHD_FXX_(\d+)_(\d+)_")
|
||||
|
||||
|
||||
def events_path(output_dir):
|
||||
"""Chemin du journal d'événements d'un run."""
|
||||
return Path(output_dir) / EVENTS_FILENAME
|
||||
|
||||
|
||||
def tile_short_name(name):
|
||||
"""Nom court d'une tuile : LHD_FXX_1054_6882_PTS_LAMB93_IGN69 → 1054-6882."""
|
||||
m = _TILE_RE.search(name or "")
|
||||
if not m:
|
||||
return name
|
||||
return f"{int(m.group(1))}-{int(m.group(2))}"
|
||||
|
||||
|
||||
def reset_events(output_dir):
|
||||
"""Prépare un journal vide pour un nouveau run (tronque l'ancien)."""
|
||||
try:
|
||||
path = events_path(output_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("", encoding="utf-8")
|
||||
except OSError:
|
||||
pass # progression best-effort : ne jamais bloquer le traitement
|
||||
|
||||
|
||||
def report_event(output_dir, tile, phase, state, detail=None, res=None):
|
||||
"""Ajoute un événement (une ligne JSON). Ne lève jamais.
|
||||
|
||||
Ouverture en mode ajout à chaque appel : les workers parallélisés
|
||||
(ProcessPoolExecutor, 'spawn') ne partagent aucun descripteur de fichier.
|
||||
"""
|
||||
event = {"ts": round(time.time(), 3), "tile": tile, "phase": phase, "state": state}
|
||||
if detail is not None:
|
||||
event["detail"] = detail
|
||||
if res is not None:
|
||||
event["res"] = res
|
||||
try:
|
||||
with open(events_path(output_dir), "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_events(output_dir, limit=5000):
|
||||
"""Lit les événements du journal (dernières `limit` lignes, invalides ignorées)."""
|
||||
try:
|
||||
lines = events_path(output_dir).read_text(
|
||||
encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
events = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue # ligne partielle en cours d'écriture par le run
|
||||
return events
|
||||
|
||||
|
||||
def _step_label(phase, detail, res, viz_labels):
|
||||
"""Libellé français d'une étape, affiché tel quel dans la file."""
|
||||
if phase == "download":
|
||||
return "Téléchargement"
|
||||
if phase == "classif":
|
||||
return "Classification"
|
||||
if phase == "dtm":
|
||||
return f"DTM {str(res).replace('.', ',')} m"
|
||||
if phase == "viz":
|
||||
label = (viz_labels or {}).get(detail, detail)
|
||||
if res is not None and float(res) != 0.5:
|
||||
label += f" {str(res).replace('.', ',')} m"
|
||||
return label
|
||||
return detail or phase
|
||||
|
||||
|
||||
def aggregate_tiles(events, viz_labels=None):
|
||||
"""Regroupe les événements en tuiles : une entrée par tuile, étapes ordonnées.
|
||||
|
||||
Retourne [{short, name, state, steps: [{key, label, state, detail}]}] dans
|
||||
l'ordre de première apparition. État d'une étape : running | ok | fail |
|
||||
skip. État d'une tuile : pending | running | done | failed.
|
||||
"""
|
||||
tiles = {}
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict) or ev.get("phase") not in PHASES:
|
||||
continue
|
||||
name = ev.get("tile") or ""
|
||||
short = tile_short_name(name)
|
||||
tile = tiles.setdefault(
|
||||
short, {"short": short, "name": name, "state": "pending", "steps": {}})
|
||||
if name:
|
||||
tile["name"] = name
|
||||
state = ev.get("state")
|
||||
if state == "start":
|
||||
state = "running" # état d'affichage normalisé
|
||||
detail = ev.get("detail")
|
||||
res = ev.get("res")
|
||||
if ev.get("phase") == "tile":
|
||||
if state == "ok":
|
||||
tile["state"] = "done"
|
||||
elif state == "fail":
|
||||
tile["state"] = "failed"
|
||||
continue
|
||||
# Clé d'étape : le détail n'identifie une étape que pour les viz
|
||||
# (leur nom) — les sous-étapes du téléchargement fusionnent en une.
|
||||
phase = ev.get("phase")
|
||||
key = f"viz:{detail}:{res}" if phase == "viz" else f"{phase}:{res}"
|
||||
step = tile["steps"].setdefault(
|
||||
key, {"key": key, "detail": detail, "res": res, "state": state,
|
||||
"label": _step_label(phase, detail, res, viz_labels)})
|
||||
# ok/fail sont terminaux — on ne régresse pas vers running/skip
|
||||
if step["state"] not in ("ok", "fail") or state == "fail":
|
||||
step["state"] = state
|
||||
|
||||
result = []
|
||||
for tile in tiles.values():
|
||||
steps = list(tile["steps"].values())
|
||||
if tile["state"] == "pending":
|
||||
if any(s["state"] == "fail" for s in steps):
|
||||
tile["state"] = "failed"
|
||||
elif steps:
|
||||
tile["state"] = "running" # pas encore d'événement tuile final
|
||||
result.append({"short": tile["short"], "name": tile["name"],
|
||||
"state": tile["state"], "steps": steps})
|
||||
return result
|
||||
|
||||
|
||||
def progress_snapshot(output_dir, viz_labels=None):
|
||||
"""État consolidé des tuiles du dernier run (prêt pour /api/status)."""
|
||||
return aggregate_tiles(read_events(output_dir), viz_labels=viz_labels)
|
||||
@ -97,14 +97,6 @@ COLORMAPS = {
|
||||
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||
},
|
||||
'aniso_open': {
|
||||
'cmap': 'seismic',
|
||||
'title': 'Openness Anisotropique (pondération directionnelle)',
|
||||
'legend': 'Openness positive − négative pondérée (z-score local)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nPondère les directions NW-SE et NE-SW davantage',
|
||||
'description': 'Openness avec pondération anisotropique — détecte mieux les structures alignées NW-SE et NE-SW',
|
||||
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||
},
|
||||
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
|
||||
'positive_openness': {
|
||||
'cmap': 'YlOrBr',
|
||||
@ -164,12 +156,26 @@ COLORMAPS = {
|
||||
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
||||
},
|
||||
'wavelet': {
|
||||
'cmap': 'cividis',
|
||||
'cmap': 'inferno',
|
||||
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
|
||||
'legend': 'Réponse RMS multi-échelles (σ locales)\nÉchelles adaptées à la résolution\n\nClair = Structure détectée à cette échelle\nSombre = Pas de structure\nÉchelle fixe 0–3σ — couleurs homogènes entre tuiles\n\nOptimisé pour formes circulaires:\ntumulus, enclos, fossés annulaires',
|
||||
'description': 'Transformée en ondelette 2D — excellente pour détecter structures circulaires',
|
||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
|
||||
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
|
||||
# Nœuds (percentile → valeur) mesurés sur 20 tuiles réelles par
|
||||
# résolution avec l'algorithme actuel (détendage 35 m, échelles
|
||||
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
|
||||
# aux tuiles très structurées. Distribution plus large qu'avant
|
||||
# détendage : le fond macro-relief supprimé, les petites structures
|
||||
# percent bien plus au-dessus du bruit (p98 ≈ 9 vs 1,65 avant).
|
||||
'knots': {
|
||||
0.5: ([0.089, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.274,
|
||||
1.661, 2.32, 3.842, 5.661, 8.936, 11.93, 15.06],
|
||||
[0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60,
|
||||
0.70, 0.80, 0.90, 0.95, 0.98, 0.99, 0.995]),
|
||||
0.2: ([0.088, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.287,
|
||||
1.694, 2.358, 3.846, 5.687, 8.971, 11.928, 14.971],
|
||||
[0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60,
|
||||
0.70, 0.80, 0.90, 0.95, 0.98, 0.99, 0.995]),
|
||||
},
|
||||
},
|
||||
'flow_acc': {
|
||||
'cmap': 'YlGn',
|
||||
@ -212,7 +218,7 @@ RGB_LEGENDS = {
|
||||
}
|
||||
|
||||
|
||||
def _apply_colormap(data, tif_file):
|
||||
def _apply_colormap(data, tif_file, resolution=None):
|
||||
"""Apply the registered colormap normalization to data based on filename.
|
||||
|
||||
Returns (data, cmap, title, legend_label, description, is_rgb).
|
||||
@ -238,31 +244,45 @@ def _apply_colormap(data, tif_file):
|
||||
|
||||
vmin = vmax = None
|
||||
|
||||
# Compute vmin/vmax based on mode
|
||||
if info['vmin_mode'] == 'fixed':
|
||||
vmin = info['vmin_val']
|
||||
elif info['vmin_mode'] == 'percentile':
|
||||
vmin = np.percentile(valid_data, info['vmin_pct'])
|
||||
elif info['vmin_mode'] == 'symmetric':
|
||||
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
|
||||
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
|
||||
vmin = -vmax_abs
|
||||
vmax = vmax_abs # symmetric mode sets both vmin and vmax
|
||||
|
||||
if vmax is None:
|
||||
# Only compute vmax if not already set by symmetric mode
|
||||
if info.get('vmax_mode') == 'fixed':
|
||||
vmax = info['vmax_val']
|
||||
elif info.get('vmax_mode') == 'percentile':
|
||||
vmax = np.percentile(valid_data, info['vmax_pct'])
|
||||
elif info.get('vmax_mode') == 'symmetric':
|
||||
knots = info.get('knots')
|
||||
if knots is not None:
|
||||
# Étalonnage quantile figé (appariement d'histogramme global,
|
||||
# cf. normalisation radiométrique des mosaïques) : fonction de
|
||||
# transfert en nœuds mesurés une fois sur un échantillon de
|
||||
# tuiles — même valeur → même couleur sur toutes les tuiles,
|
||||
# toute la palette utilisée, insensible aux queues locales.
|
||||
if isinstance(knots, dict):
|
||||
key = min(knots, key=lambda r: abs(float(r) - float(resolution or 0.5)))
|
||||
knots = knots[key]
|
||||
kv, kt = knots
|
||||
data = np.interp(np.asarray(data, dtype=float), kv, kt, left=0.0, right=1.0)
|
||||
vmin, vmax = kv[0], kv[-1]
|
||||
else:
|
||||
# Compute vmin/vmax based on mode
|
||||
if info['vmin_mode'] == 'fixed':
|
||||
vmin = info['vmin_val']
|
||||
elif info['vmin_mode'] == 'percentile':
|
||||
vmin = np.percentile(valid_data, info['vmin_pct'])
|
||||
elif info['vmin_mode'] == 'symmetric':
|
||||
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
|
||||
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
|
||||
vmax = vmax_abs
|
||||
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
|
||||
vmin = -vmax_abs
|
||||
vmax = vmax_abs # symmetric mode sets both vmin and vmax
|
||||
|
||||
# Apply normalization
|
||||
if vmin is not None and vmax is not None:
|
||||
data = np.clip((data - vmin) / max(vmax - vmin, 0.001), 0, 1)
|
||||
if vmax is None:
|
||||
# Only compute vmax if not already set by symmetric mode
|
||||
if info.get('vmax_mode') == 'fixed':
|
||||
vmax = info['vmax_val']
|
||||
elif info.get('vmax_mode') == 'percentile':
|
||||
vmax = np.percentile(valid_data, info['vmax_pct'])
|
||||
elif info.get('vmax_mode') == 'symmetric':
|
||||
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
|
||||
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
|
||||
vmax = vmax_abs
|
||||
|
||||
# Apply normalization
|
||||
if vmin is not None and vmax is not None:
|
||||
data = np.clip((data - vmin) / max(vmax - vmin, 0.001), 0, 1)
|
||||
|
||||
legend = info['legend'].format(vmin=vmin or 0, vmax=vmax or 0)
|
||||
return data, info['cmap'], info['title'], legend, info['description'], False
|
||||
@ -463,7 +483,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
|
||||
nan_mask = nan_mask # keep for later
|
||||
|
||||
# Apply colormap
|
||||
data, cmap, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
|
||||
data, cmap, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file, resolution=resolution)
|
||||
|
||||
# Apply NaN mask: make zones without data transparent
|
||||
has_nan_mask = nan_mask is not None and not is_rgb_result
|
||||
@ -818,7 +838,7 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=98, outpu
|
||||
data = src.read(1)
|
||||
|
||||
# Apply colormap normalization
|
||||
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
|
||||
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file, resolution=resolution)
|
||||
|
||||
# Convert to RGB using colormap
|
||||
if is_rgb_result:
|
||||
@ -907,7 +927,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
|
||||
|
||||
# Sort analysis files by archaeological priority
|
||||
order = ['mslrm', 'svf', 'negative_openness',
|
||||
'positive_openness', 'aniso_open', 'sailore', 'hillshade_multi',
|
||||
'positive_openness', 'sailore', 'hillshade_multi',
|
||||
'flow_acc', 'solar', 'slope', 'roughness', 'wavelet']
|
||||
|
||||
def sort_key(f):
|
||||
|
||||
@ -96,3 +96,82 @@ def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
|
||||
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
|
||||
output_dir=output_dir)
|
||||
assert result == []
|
||||
|
||||
|
||||
def _make_viz_dirs(output_dir, 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 = output_dir / "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_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch):
|
||||
"""Une tuile incomplète (visualisation manquante) est téléchargée, une complète non."""
|
||||
from pathlib import Path
|
||||
from lidar_pipeline import fetch_ign
|
||||
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
output_dir = tmp_path / "output"
|
||||
_make_viz_dirs(output_dir, 1054, 6882, ["aspect"]) # slope manquant
|
||||
_make_viz_dirs(output_dir, 1055, 6882, ["aspect", "slope"]) # complète
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_find_tile_url(col, row, timeout=20):
|
||||
calls.append((col, row))
|
||||
return "https://example.org/dalle.copc.laz"
|
||||
|
||||
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
|
||||
Path(dest).write_bytes(b"laz")
|
||||
return 3
|
||||
|
||||
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
|
||||
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
|
||||
|
||||
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
|
||||
output_dir=output_dir,
|
||||
only_viz=["aspect", "slope"],
|
||||
resolutions=(0.5, 0.2))
|
||||
# Seule la tuile incomplète est retéléchargée
|
||||
assert calls == [(1054, 6882)]
|
||||
assert [p.name for p in result] == [fetch_ign.tile_filename(1054, 6882)]
|
||||
|
||||
|
||||
def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch):
|
||||
"""force=True télécharge même une tuile complète (régénération avec --force)."""
|
||||
from pathlib import Path
|
||||
from lidar_pipeline import fetch_ign
|
||||
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
output_dir = tmp_path / "output"
|
||||
_make_viz_dirs(output_dir, 1054, 6882, ["aspect", "slope"])
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_find_tile_url(col, row, timeout=20):
|
||||
calls.append((col, row))
|
||||
return "https://example.org/dalle.copc.laz"
|
||||
|
||||
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
|
||||
Path(dest).write_bytes(b"laz")
|
||||
return 3
|
||||
|
||||
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
|
||||
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
|
||||
|
||||
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882)],
|
||||
output_dir=output_dir,
|
||||
only_viz=["aspect", "slope"],
|
||||
resolutions=(0.5, 0.2), force=True)
|
||||
assert calls == [(1054, 6882)]
|
||||
assert len(result) == 1
|
||||
|
||||
@ -214,7 +214,9 @@ def test_build_index_generates_html(tmp_path):
|
||||
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', 'svf'))
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881,
|
||||
('aspect', 'slope', 'positive_openness',
|
||||
'wavelet', 'hillshade_multi', 'svf'))
|
||||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
|
||||
|
||||
result = build_index(output_dir)
|
||||
@ -235,19 +237,46 @@ def test_build_index_generates_html(tmp_path):
|
||||
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
||||
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
||||
assert "Couches" in app_js or "layers" in app_js
|
||||
# Barre de génération : sélecteur multi-visualisations (wavelet, aniso_open,
|
||||
# openness positive, pente...) et envoi du champ viz à /api/generate
|
||||
# Barre de génération : sélecteur limité aux couches proposées dans le
|
||||
# panneau de visualisation (aspect + slope + openness + ondelette,
|
||||
# valeurs = noms d'étapes --only). La sélection est envoyée à /api/preview
|
||||
# (détection des tuiles incomplètes) comme à /api/generate.
|
||||
# Les couches hors panneau (svf, hillshade) n'y figurent pas.
|
||||
assert 'id="genViz"' in content
|
||||
assert 'value="wavelet"' in content
|
||||
assert 'value="aniso_open"' in content
|
||||
assert 'value="aniso_open"' not in content
|
||||
assert 'value="pos_open"' in content
|
||||
assert 'value="slope"' in content
|
||||
assert "genViz" in app_js and "viz: vizSel.length" in app_js
|
||||
assert 'value="svf"' not in content
|
||||
assert 'value="hillshade"' not in content
|
||||
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
|
||||
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
|
||||
# Adoption du dernier run au chargement (redémarrage du serveur) :
|
||||
# un run terminé réapparaît dans la file avec ses tuiles, sans doublon.
|
||||
assert "Dernière génération" in app_js
|
||||
assert "t.started === s.started" in app_js
|
||||
# Bouton « Compléter » : visualisations manquantes des dalles présentes
|
||||
# dans input/ (scope all_missing envoyé au preview et au generate).
|
||||
assert 'id="btnComplete"' in content
|
||||
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
|
||||
assert 'assets/app.css' in content
|
||||
# Bouton d'actualisation des couches (détection des nouveautés sur disque)
|
||||
# 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/rebuild" in app_js
|
||||
assert "/api/layers" in app_js and "/api/sync" in app_js
|
||||
assert 'assets/app.js' in content
|
||||
# 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
|
||||
# Export des données de carte (servi par /api/tiles)
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
assert data['tiles'] and data['tiles'][0]['corners']
|
||||
assert 'aspect' in data['viz_meta']
|
||||
assert data['stats']['n_tiles'] == len(data['tiles'])
|
||||
# Vérifie les vignettes générées
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
assert thumb_dir.is_dir()
|
||||
@ -259,16 +288,53 @@ def test_subtiled_viz_covers_generation_choices():
|
||||
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
|
||||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
|
||||
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
|
||||
'aniso_open', 'wavelet'):
|
||||
'wavelet'):
|
||||
assert key in _CARTO_SUBTILED_VIZ
|
||||
|
||||
|
||||
def test_panel_restricted_to_requested_layers():
|
||||
"""Le panneau et le sélecteur ne proposent que la base aspect + les 4 couches demandées."""
|
||||
from lidar_pipeline.index import PANEL_VIZ, GEN_VIZ_CHOICES
|
||||
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
|
||||
assert {name for name, _ in GEN_VIZ_CHOICES} == {
|
||||
'aspect', 'slope', 'pos_open', 'aniso_open', 'wavelet'}
|
||||
"""Le panneau et le sélecteur ne proposent que la base aspect + les 3 couches demandées."""
|
||||
from lidar_pipeline.index import PANEL_VIZ, KEYWORD_TO_STEP
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'wavelet')
|
||||
# Chaque couche du panneau correspond à une étape --only valide du pipeline
|
||||
steps = {name for name, _ in VIZ_STEPS}
|
||||
for key in PANEL_VIZ:
|
||||
assert KEYWORD_TO_STEP.get(key, key) in steps
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Le dernier run peut être interrompu entre les deux passes de résolution :
|
||||
la fusion inter-résolutions doit pointer vignettes et URLs vers le dossier
|
||||
d'origine de chaque fichier (dir_name), pas vers dir_path de la dalle
|
||||
affichée — sinon les vignettes échouent et la couche disparaît.
|
||||
"""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
base = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
# Passe 0,5 m : aspect + slope ; passe 0,2 m : aspect seulement
|
||||
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect', 'slope'))
|
||||
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect',), res_suffix='_r0p2')
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
# Une seule position affichée (résolution la plus fine), deux couches
|
||||
tiles = [t for t in data['tiles'] if (t['col'], t['row']) == (1000, 6881)]
|
||||
keys = set()
|
||||
for t in tiles:
|
||||
keys.update(t['viz'].keys())
|
||||
assert {'aspect', 'slope'} <= keys
|
||||
# Vignette slope générée + chaque thumb/full existe réellement sur disque
|
||||
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
|
||||
for t in tiles:
|
||||
for v in t['viz'].values():
|
||||
assert (output_dir / v['thumb']).exists(), v['thumb']
|
||||
assert (output_dir / v['full']).exists(), v['full']
|
||||
|
||||
|
||||
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||
@ -449,3 +515,12 @@ def test_subtile_corners_grid():
|
||||
se_quad = _subtile_corners(corners, 1, 0, k)
|
||||
assert sw_quad[1] == se_quad[0]
|
||||
assert sw_quad[2] == se_quad[3]
|
||||
|
||||
|
||||
def test_approx_wgs84_to_l93_roundtrip():
|
||||
"""L'approximation affine WGS84→L93 est l'inverse exacte de L93→WGS84."""
|
||||
from lidar_pipeline.index import _approx_l93_to_wgs84, _approx_wgs84_to_l93
|
||||
for x, y in ((1054000.0, 6882000.0), (700000.0, 6600000.0), (950123.0, 6410456.0)):
|
||||
lon, lat = _approx_l93_to_wgs84(x, y)
|
||||
x2, y2 = _approx_wgs84_to_l93(lon, lat)
|
||||
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6
|
||||
|
||||
@ -20,9 +20,9 @@ class TestVizSteps:
|
||||
assert len(names) == len(set(names)), "VIZ_STEPS has duplicate names"
|
||||
|
||||
def test_expected_visualization_count(self):
|
||||
"""Should have 16 visualizations (14 terrain + ortho + topo)."""
|
||||
"""Should have 15 visualizations (13 terrain + ortho + topo)."""
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
assert len(VIZ_STEPS) == 16
|
||||
assert len(VIZ_STEPS) == 15
|
||||
|
||||
def test_ortho_and_topo_present(self):
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
@ -49,6 +49,29 @@ class TestLidarArchaeoPipeline:
|
||||
with pytest.raises(ValueError, match="introuvable"):
|
||||
LidarArchaeoPipeline("/nonexistent/path", str(tmp_path / "output"))
|
||||
|
||||
def test_incremental_index_rebuild(self, tmp_path, monkeypatch):
|
||||
"""Mode incrémental : l'index est régénéré après une tuile, avec anti-rebond."""
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
import lidar_pipeline.index as index_mod
|
||||
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
calls = []
|
||||
monkeypatch.setattr(index_mod, "build_index",
|
||||
lambda *a, **k: calls.append(a) or None)
|
||||
|
||||
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"),
|
||||
incremental_index=True)
|
||||
pipeline._rebuild_index_incremental()
|
||||
pipeline._rebuild_index_incremental() # < 3 s : anti-rebond, ignoré
|
||||
assert len(calls) == 1
|
||||
|
||||
# --no-index : jamais de rebuild incrémental
|
||||
pipeline._last_index_rebuild = 0.0
|
||||
pipeline.no_index = True
|
||||
pipeline._rebuild_index_incremental()
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_find_laz_files_empty(self, tmp_path):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
input_dir = tmp_path / "input"
|
||||
|
||||
127
lidar_pipeline/tests/test_progress.py
Normal file
127
lidar_pipeline/tests/test_progress.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Tests du journal d'événements de progression (file de génération)."""
|
||||
|
||||
|
||||
def test_report_and_read_events(tmp_path):
|
||||
"""Les événements s'écrivent en JSONL et se relisent dans l'ordre."""
|
||||
from lidar_pipeline.progress import read_events, report_event, reset_events
|
||||
reset_events(tmp_path)
|
||||
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "download", "start")
|
||||
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "download", "ok")
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == 2
|
||||
assert events[0]["phase"] == "download" and events[0]["state"] == "start"
|
||||
assert events[1]["state"] == "ok"
|
||||
assert events[1]["tile"] == "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
|
||||
|
||||
def test_reset_clears_previous_run(tmp_path):
|
||||
"""reset_events tronque le journal : un nouveau run repart de zéro."""
|
||||
from lidar_pipeline.progress import read_events, report_event, reset_events
|
||||
reset_events(tmp_path)
|
||||
report_event(tmp_path, "X", "tile", "ok")
|
||||
reset_events(tmp_path)
|
||||
assert read_events(tmp_path) == []
|
||||
|
||||
|
||||
def test_read_events_missing_or_partial(tmp_path):
|
||||
"""Dossier sans journal → liste vide ; ligne partielle → ignorée."""
|
||||
from lidar_pipeline.progress import events_path, read_events
|
||||
assert read_events(tmp_path) == []
|
||||
events_path(tmp_path).write_text(
|
||||
'{"tile": "X", "phase": "tile", "state": "ok"}\n'
|
||||
'{"tile": "X", "phase": "vi', encoding="utf-8")
|
||||
events = read_events(tmp_path)
|
||||
assert len(events) == 1 and events[0]["state"] == "ok"
|
||||
|
||||
|
||||
def test_short_name():
|
||||
"""Le nom court extrait col-row du nom de dalle IGN."""
|
||||
from lidar_pipeline.progress import tile_short_name
|
||||
assert tile_short_name("LHD_FXX_1054_6882_PTS_LAMB93_IGN69") == "1054-6882"
|
||||
assert tile_short_name("LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz") == "1054-6882"
|
||||
assert tile_short_name("autre.tif") == "autre.tif"
|
||||
assert tile_short_name("") == ""
|
||||
|
||||
|
||||
def test_aggregate_full_run(tmp_path):
|
||||
"""Run complet : étapes ordonnées avec libellés français, tuile terminée."""
|
||||
from lidar_pipeline.progress import aggregate_tiles, read_events, report_event, reset_events
|
||||
reset_events(tmp_path)
|
||||
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
report_event(tmp_path, b, "download", "skip", "déjà dans input/")
|
||||
report_event(tmp_path, b, "classif", "start")
|
||||
report_event(tmp_path, b, "classif", "ok")
|
||||
report_event(tmp_path, b, "dtm", "ok", res=0.5)
|
||||
report_event(tmp_path, b, "viz", "start", "aspect", res=0.5)
|
||||
report_event(tmp_path, b, "viz", "ok", "aspect", res=0.5)
|
||||
report_event(tmp_path, b, "tile", "ok", "123s")
|
||||
tiles = aggregate_tiles(read_events(tmp_path), viz_labels={"aspect": "Exposition"})
|
||||
assert len(tiles) == 1
|
||||
t = tiles[0]
|
||||
assert t["short"] == "1054-6882" and t["state"] == "done"
|
||||
assert [s["label"] for s in t["steps"]] == [
|
||||
"Téléchargement", "Classification", "DTM 0,5 m", "Exposition"]
|
||||
assert [s["state"] for s in t["steps"]] == ["skip", "ok", "ok", "ok"]
|
||||
|
||||
|
||||
def test_aggregate_running_step(tmp_path):
|
||||
"""Une étape démarrée non terminée laisse la tuile « running »."""
|
||||
from lidar_pipeline.progress import aggregate_tiles, read_events, report_event, reset_events
|
||||
reset_events(tmp_path)
|
||||
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
report_event(tmp_path, b, "download", "ok")
|
||||
report_event(tmp_path, b, "classif", "ok")
|
||||
report_event(tmp_path, b, "dtm", "ok", res=0.5)
|
||||
report_event(tmp_path, b, "viz", "start", "aspect", res=0.5)
|
||||
tiles = aggregate_tiles(read_events(tmp_path))
|
||||
assert tiles[0]["state"] == "running"
|
||||
assert tiles[0]["steps"][-1]["state"] == "running"
|
||||
|
||||
|
||||
def test_aggregate_failure_marks_tile_failed():
|
||||
"""Une viz en échec marque la tuile failed même sans événement tuile."""
|
||||
from lidar_pipeline.progress import aggregate_tiles
|
||||
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
tiles = aggregate_tiles([
|
||||
{"tile": b, "phase": "classif", "state": "ok"},
|
||||
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.5},
|
||||
{"tile": b, "phase": "viz", "state": "start", "detail": "aspect", "res": 0.5},
|
||||
{"tile": b, "phase": "viz", "state": "fail", "detail": "aspect", "res": 0.5},
|
||||
])
|
||||
assert tiles[0]["state"] == "failed"
|
||||
aspect = [s for s in tiles[0]["steps"] if s["detail"] == "aspect"][0]
|
||||
assert aspect["state"] == "fail"
|
||||
|
||||
|
||||
def test_aggregate_multi_resolution_and_bad_input():
|
||||
"""Deux résolutions → deux étapes DTM/viz distinctes ; entrées invalides ignorées."""
|
||||
from lidar_pipeline.progress import aggregate_tiles
|
||||
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
tiles = aggregate_tiles([
|
||||
"pas du tout du json",
|
||||
None,
|
||||
{"tile": b, "phase": "inconnu", "state": "ok"},
|
||||
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.5},
|
||||
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.2},
|
||||
{"tile": b, "phase": "viz", "state": "ok", "detail": "aspect", "res": 0.2},
|
||||
{"tile": b, "phase": "tile", "state": "ok"},
|
||||
], viz_labels={"aspect": "Exposition"})
|
||||
assert len(tiles) == 1
|
||||
labels = [s["label"] for s in tiles[0]["steps"]]
|
||||
assert labels == ["DTM 0,5 m", "DTM 0,2 m", "Exposition 0,2 m"]
|
||||
# La résolution secondaire (0,2 m) est suffixée, la primaire (0,5 m) non
|
||||
|
||||
|
||||
def test_aggregate_multiple_tiles_ordered():
|
||||
"""Une entrée par tuile, dans l'ordre de première apparition."""
|
||||
from lidar_pipeline.progress import aggregate_tiles
|
||||
a = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
b = "LHD_FXX_1055_6882_PTS_LAMB93_IGN69"
|
||||
tiles = aggregate_tiles([
|
||||
{"tile": a, "phase": "download", "state": "ok"},
|
||||
{"tile": b, "phase": "download", "state": "start", "detail": "catalogue IGN"},
|
||||
{"tile": a, "phase": "tile", "state": "ok"},
|
||||
])
|
||||
assert [t["short"] for t in tiles] == ["1054-6882", "1055-6882"]
|
||||
assert tiles[0]["state"] == "done"
|
||||
assert tiles[1]["state"] == "running"
|
||||
@ -97,6 +97,30 @@ class TestApplyColormap:
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
|
||||
def test_knots_mode_fixed_transfer(self):
|
||||
"""L'étalonnage quantile figé (ondelette) : même valeur → même couleur.
|
||||
|
||||
Les nœuds sont constants (pas de percentile local) : clamp aux
|
||||
extrêmes, médiane (1.0) → 0.5, transfert monotone.
|
||||
"""
|
||||
from lidar_pipeline.rendering import COLORMAPS, _apply_colormap
|
||||
info = COLORMAPS['wavelet']
|
||||
kv, kt = info['knots'][0.5]
|
||||
assert kv[6] == 1.0 and kt[6] == 0.5 # nœud médian
|
||||
vals = np.array([0.05, kv[0], 1.0, kv[-1], 50.0], dtype=float)
|
||||
out, cmap, *_ = _apply_colormap(vals, 'x_wavelet.tif', resolution=0.5)
|
||||
assert cmap == 'inferno'
|
||||
assert out[0] == 0.0 and out[1] == 0.01 # clamp bas
|
||||
assert abs(out[2] - 0.5) < 1e-9 # médiane → 0.5
|
||||
assert out[3] == 0.995 and out[4] == 1.0 # nœud haut, puis clamp au-delà
|
||||
# Monotonie (pas d'inversion de teinte)
|
||||
s = np.sort(np.random.default_rng(1).uniform(0.2, 3.0, 500))
|
||||
o, *_ = _apply_colormap(s, 'x_wavelet.tif', resolution=0.5)
|
||||
assert np.all(np.diff(o) >= -1e-12)
|
||||
# Les nœuds 0.2 m diffèrent de ceux 0.5 m (calibrations distinctes)
|
||||
kv02, _ = info['knots'][0.2]
|
||||
assert kv02 != kv
|
||||
|
||||
|
||||
class TestTifToCrop:
|
||||
"""Conversion TIF → dalle cartographique (tif_to_crop)."""
|
||||
|
||||
@ -121,6 +121,77 @@ class TestWavelet:
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
|
||||
def test_output_median_centered(self, synthetic_dem, tmp_output_dir):
|
||||
"""Recentrage robuste : médiane ≈ 1 quel que soit le terrain.
|
||||
|
||||
C'est la condition pour qu'un étirement couleur global fixe donne
|
||||
des couleurs homogènes entre tuiles (cf. knots dans rendering.py).
|
||||
"""
|
||||
import rasterio
|
||||
from lidar_pipeline.visualizations import generate_wavelet
|
||||
result = generate_wavelet(synthetic_dem, "test", tmp_output_dir, 5.0)
|
||||
with rasterio.open(result) as src:
|
||||
data = src.read(1)
|
||||
valid = data[np.isfinite(data)]
|
||||
assert abs(np.median(valid) - 1.0) < 0.05
|
||||
|
||||
def test_ditch_on_hilltop_not_amplified(self, tmp_path, tmp_output_dir):
|
||||
"""Un fossé en sommet de colline ne ressort pas plus qu'à plat.
|
||||
|
||||
Le détendage (moyenne locale gaussienne ~35 m) doit neutraliser la
|
||||
position topographique :
|
||||
- le pic d'indice sur le fossé en sommet reste comparable au même
|
||||
fossé sur terrain plat ;
|
||||
- le fond du sommet (sans structure) reste comparable au fond plat.
|
||||
|
||||
MNT synthétique bruité (σ=8 cm) : sans détendage, le fond sommet
|
||||
ressort ~1,7× le fond plat (sommets jaunes sur la carte).
|
||||
"""
|
||||
import rasterio
|
||||
from rasterio.transform import from_bounds
|
||||
from lidar_pipeline.visualizations import generate_wavelet
|
||||
|
||||
size = 600
|
||||
res = 1.0
|
||||
x = np.arange(size) * res
|
||||
y = np.arange(size) * res
|
||||
X, Y = np.meshgrid(x, y)
|
||||
dem = 100.0 + 0.01 * X
|
||||
|
||||
# Colline réaliste (sigma 80 m, 25 m de haut) à gauche + bruit capteur
|
||||
dem += 25.0 * np.exp(-((X - 150)**2 + (Y - 300)**2) / (2 * 80**2))
|
||||
rng = np.random.default_rng(42)
|
||||
dem += rng.normal(0, 0.08, dem.shape)
|
||||
|
||||
# Deux fossés identiques : l'un au sommet de la colline, l'autre à plat
|
||||
for xc in (150, 480):
|
||||
dem -= 1.5 * np.exp(-((X - xc)**2) / (2 * 1.2**2))
|
||||
|
||||
dem_file = tmp_path / "ditch_dem.tif"
|
||||
transform = from_bounds(660000, 6700000, 660600, 6700600, size, size)
|
||||
with rasterio.open(
|
||||
dem_file, 'w', driver='GTiff', height=size, width=size,
|
||||
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
|
||||
) as dst:
|
||||
dst.write(dem.astype('float32'), 1)
|
||||
|
||||
result = generate_wavelet(dem_file, "ditch", tmp_output_dir, res)
|
||||
assert result is not None and result.exists()
|
||||
with rasterio.open(result) as src:
|
||||
data = src.read(1)
|
||||
|
||||
# Pics sur une bande verticale autour de chaque fossé
|
||||
peak_hilltop = np.nanmax(data[:, 145:156])
|
||||
peak_flat = np.nanmax(data[:, 475:486])
|
||||
assert peak_flat > 5.0 # le fossé ressort nettement au-dessus du fond
|
||||
# Pas d'amplification du fossé par la position topographique
|
||||
assert peak_hilltop < 1.5 * peak_flat
|
||||
|
||||
# Fond du sommet (35 m à l'est du fossé sommital) vs fond plat
|
||||
bg_hilltop = np.nanmedian(data[250:350, 185:226])
|
||||
bg_flat = np.nanmedian(data[250:350, 500:561])
|
||||
assert bg_hilltop < 1.4 * bg_flat # sans détendage : ~1,7×
|
||||
|
||||
|
||||
class TestFlowAccumulation:
|
||||
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
||||
|
||||
@ -26,7 +26,7 @@ def test_processed_cells(tmp_path):
|
||||
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_r0p2_aspect.avif").write_bytes(b"x")
|
||||
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x")
|
||||
assert processed_cells(tmp_path) == {(1054, 6882)}
|
||||
|
||||
|
||||
@ -53,6 +53,174 @@ def test_missing_cells_include_done(tmp_path):
|
||||
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_both_resolutions(tmp_path):
|
||||
"""Une tuile complète à 0.5 mais sans 0.2 reste à traiter."""
|
||||
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_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_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_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=True ajoute --force --force-classification à la commande."""
|
||||
from lidar_pipeline.webapp import _build_command
|
||||
@ -92,9 +260,37 @@ def test_build_command_default_viz_aspect():
|
||||
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", "aniso_open"])
|
||||
cmd = _build_command([(1054, 6882)], viz=["aspect", "wavelet", "slope"])
|
||||
i = cmd.index("--only")
|
||||
assert cmd[i + 1:i + 4] == ["aspect", "wavelet", "aniso_open"]
|
||||
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
|
||||
assert webapp.tiles_data() == {"stamp": None, "tiles": None}
|
||||
|
||||
# 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_viz_step_names_match_pipeline():
|
||||
@ -117,9 +313,8 @@ def test_generate_rejects_unknown_viz():
|
||||
assert "inconnu" in e.detail
|
||||
|
||||
|
||||
def test_generate_accepts_wavelet_and_aniso():
|
||||
"""L'API accepte wavelet + aniso_open et les transmet à --only."""
|
||||
import subprocess
|
||||
def test_generate_accepts_wavelet_and_slope(monkeypatch):
|
||||
"""L'API accepte wavelet + slope et les transmet à --only."""
|
||||
import lidar_pipeline.webapp as webapp
|
||||
captured = {}
|
||||
|
||||
@ -134,16 +329,22 @@ def test_generate_accepts_wavelet_and_aniso():
|
||||
return 0
|
||||
return _P()
|
||||
|
||||
webapp.subprocess.Popen = fake_popen
|
||||
try:
|
||||
req = webapp.GenerateRequest(tiles=[[1054, 6882]],
|
||||
viz=["wavelet", "aniso_open"])
|
||||
result = webapp.generate(req)
|
||||
assert result["demarré"] is True
|
||||
i = captured["cmd"].index("--only")
|
||||
assert captured["cmd"][i + 1:i + 3] == ["wavelet", "aniso_open"]
|
||||
finally:
|
||||
webapp.subprocess.Popen = subprocess.Popen
|
||||
# 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):
|
||||
@ -173,3 +374,212 @@ def test_rebuild_index_background(tmp_path, monkeypatch):
|
||||
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)."""
|
||||
import lidar_pipeline.webapp as webapp
|
||||
from lidar_pipeline.progress import report_event, reset_events
|
||||
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||
reset_events(tmp_path)
|
||||
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "viz",
|
||||
"start", "aspect", res=0.5)
|
||||
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"])
|
||||
|
||||
|
||||
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,
|
||||
"ground_class": "ign", "ign_classes": "sol",
|
||||
"bare_earth": 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_runs_command_then_rebuild(tmp_path, monkeypatch):
|
||||
"""POST /api/sync exécute LIDAR_SYNC_CMD puis build_index en arrière-plan."""
|
||||
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: calls.append(out))
|
||||
monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0")
|
||||
webapp._rebuild["done"] = None # ignore l'état d'un test précédent
|
||||
assert webapp.sync_and_rebuild()["sync"] 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 après la synchronisation
|
||||
assert webapp.rebuild_status()["error"] is None
|
||||
assert webapp.rebuild_status()["done"] is not None
|
||||
|
||||
|
||||
def test_sync_failure_reported_but_index_rebuilt(tmp_path, monkeypatch):
|
||||
"""Un rsync en échec est remonté dans l'état sans bloquer le rebuild."""
|
||||
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: calls.append(out))
|
||||
monkeypatch.setattr(webapp, "SYNC_CMD", "exit 3")
|
||||
webapp._rebuild["done"] = None
|
||||
webapp.sync_and_rebuild()
|
||||
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 # rebuild quand même (données locales éventuelles)
|
||||
assert "rc=3" in webapp.rebuild_status()["error"]
|
||||
|
||||
|
||||
def test_sync_without_cmd_is_rebuild_only(monkeypatch):
|
||||
"""Sans LIDAR_SYNC_CMD (machine locale), /api/sync = simple rebuild."""
|
||||
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: calls.append(out))
|
||||
monkeypatch.setattr(webapp, "SYNC_CMD", None)
|
||||
webapp._rebuild["done"] = None
|
||||
assert webapp.sync_and_rebuild()["sync"] is False
|
||||
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 and webapp.rebuild_status()["error"] is 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
|
||||
|
||||
@ -960,12 +960,21 @@ def generate_solar(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Mexican Hat wavelet multi-scale analysis (GPU if available).
|
||||
|
||||
CWT 2D at multiple scales adapted to resolution.
|
||||
- At 0.5m/px: [1, 2, 5, 10, 20, 50, 100]m
|
||||
- At 0.2m/px: [0.5, 1, 2, 5, 10, 20, 50, 100]m
|
||||
Focused on small archaeological structures (paths, ditches, ramparts).
|
||||
CWT 2D at scales [1, 2, 5, 10, 20, 50]m (0.5m added below 0.25m/px).
|
||||
The 100m scale was dropped: it mostly responds to landforms (hills,
|
||||
valleys), not to structures.
|
||||
|
||||
Uses std normalization per scale and weighted RMS combination
|
||||
with emphasis on archaeologically relevant scales (2-50m).
|
||||
Large landforms are removed first by subtracting a Gaussian local-mean
|
||||
trend (~35m). The residual is analyzed relative to its ~35m neighborhood,
|
||||
so a ditch on a hilltop or slope does not stand out more than the same
|
||||
ditch on flat ground (topographic-position independence). A Gaussian
|
||||
smoothing preserves locally planar slopes, so slopes are removed too.
|
||||
|
||||
Uses robust per-scale normalization (MAD) and median-centered weighted RMS
|
||||
combination with emphasis on small scales (1-10m).
|
||||
The output is an index relative to the tile's own median level (≈ 1):
|
||||
comparable from tile to tile, so a single fixed color stretch works.
|
||||
"""
|
||||
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
|
||||
logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...")
|
||||
@ -985,18 +994,44 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
filled, _ = _fill_nans(dem_np.astype(np.float64))
|
||||
|
||||
min_scale = max(resolution * 2, 1.0)
|
||||
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50, 100]
|
||||
# 100m retirée : elle répond surtout aux grandes formes du terrain,
|
||||
# pas aux structures. Accent sur 1-10m (petites structures).
|
||||
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50]
|
||||
scales = [s for s in candidate_scales if s >= min_scale]
|
||||
|
||||
scale_weights = {
|
||||
0.5: 0.6, 1.0: 0.8, 2.0: 1.5, 5.0: 2.0,
|
||||
10.0: 1.8, 20.0: 1.5, 50.0: 1.0, 100.0: 0.6,
|
||||
0.5: 0.7, 1.0: 1.2, 2.0: 1.8, 5.0: 2.2,
|
||||
10.0: 2.0, 20.0: 1.5, 50.0: 0.8,
|
||||
}
|
||||
weights = np.array([scale_weights.get(s, 1.0) for s in scales])
|
||||
|
||||
logger.info(f" Échelles CWT: {scales}m (résolution {resolution}m/px)")
|
||||
|
||||
from scipy.ndimage import gaussian_laplace
|
||||
from scipy.ndimage import gaussian_laplace, gaussian_filter
|
||||
|
||||
# Retrait des grands volumes (collines, vallées) : on soustrait une
|
||||
# moyenne locale gaussienne avant la CWT. Un lissage gaussien préserve
|
||||
# les pentes planes, donc le résidu est analysé par rapport à son
|
||||
# voisinage ~35m : un fossé en sommet ou en flanc de colline ne
|
||||
# ressort pas plus que le même fossé à plat.
|
||||
# Fraction conservée pour une structure gaussienne de largeur σ_f :
|
||||
# σ_t²/(σ_f²+σ_t²) → 10m : 92%, 20m : 75%, colline 150m : 5%.
|
||||
# Mesuré sur MNT synthétique bruité : le contraste des petites
|
||||
# structures est insensible à σ_t ; seul le fond sommet/plat varie
|
||||
# (1.71 sans détendage → 1.21 à 35m).
|
||||
detrend_sigma_m = 35.0
|
||||
detrend_sigma_px = detrend_sigma_m / resolution
|
||||
if _gpu_mod.HAS_GPU:
|
||||
try:
|
||||
from cupyx.scipy.ndimage import gaussian_filter as gpu_gaussian_filter
|
||||
trend = to_cpu(gpu_gaussian_filter(to_gpu(filled), sigma=detrend_sigma_px))
|
||||
except Exception:
|
||||
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
|
||||
else:
|
||||
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
|
||||
residual = filled - trend
|
||||
del trend
|
||||
logger.info(f" Retrait des grands volumes (tendance gaussienne {detrend_sigma_m:.0f}m)")
|
||||
|
||||
wavelet_stack = []
|
||||
|
||||
@ -1005,16 +1040,20 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
if _gpu_mod.HAS_GPU:
|
||||
try:
|
||||
from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace
|
||||
response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px)
|
||||
response = -gpu_gaussian_laplace(to_gpu(residual), sigma=sigma_px)
|
||||
response = to_cpu(response)
|
||||
except Exception:
|
||||
response = -gaussian_laplace(filled, sigma=sigma_px)
|
||||
response = -gaussian_laplace(residual, sigma=sigma_px)
|
||||
else:
|
||||
response = -gaussian_laplace(filled, sigma=sigma_px)
|
||||
response = -gaussian_laplace(residual, sigma=sigma_px)
|
||||
response[nan_mask] = np.nan
|
||||
valid = response[~nan_mask]
|
||||
std_val = max(np.nanstd(valid), 0.01) if len(valid) > 0 else 0.01
|
||||
response = response / std_val
|
||||
# σ robuste (MAD) : le std classique est gonflé par les queues
|
||||
# (structures marquées, bords de tuile) et varie fortement d'une
|
||||
# tuile à l'autre — cause première des dominantes de couleur
|
||||
# par tuile sur la carte.
|
||||
mad = np.nanmedian(np.abs(valid - np.nanmedian(valid))) if len(valid) > 0 else 0.0
|
||||
response = response / max(1.4826 * mad, 0.01)
|
||||
wavelet_stack.append(response)
|
||||
|
||||
stack = np.array(wavelet_stack)
|
||||
@ -1025,6 +1064,15 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
combined = np.sqrt(np.nansum((stack ** 2) * weights_3d, axis=0) / np.sum(weights))
|
||||
combined[nan_mask] = np.nan
|
||||
|
||||
# Recentrage par la médiane de la tuile : la RMS devient un indice
|
||||
# relatif au niveau moyen de la tuile (médiane = 1). La distribution
|
||||
# est alors comparable d'une tuile à l'autre — condition pour un
|
||||
# étirement couleur global fixe et homogène entre tuiles.
|
||||
finite = combined[np.isfinite(combined)]
|
||||
if finite.size:
|
||||
combined = combined / max(float(np.median(finite)), 0.01)
|
||||
combined[nan_mask] = np.nan
|
||||
|
||||
_save_tif(output, combined.astype(np.float32), transform, crs)
|
||||
logger.info(f" ✓ Ondelette terminée ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
|
||||
return output
|
||||
@ -1227,69 +1275,6 @@ def generate_flow_accumulation(dem_file, basename, vis_dir, resolution, shared=N
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Anisotropic Openness
|
||||
# ============================================================
|
||||
|
||||
def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Anisotropic Openness - weighted directional openness, multi-radius (GPU if available).
|
||||
|
||||
Computes positive and negative openness with anisotropic weighting:
|
||||
NW/SE directions weighted more heavily to enhance detection of structures
|
||||
aligned NE-SW (common in French archaeological sites: villas, enclosures).
|
||||
|
||||
Multi-radius (25, 50, 100m) with equal weight, results std-normalized
|
||||
for cross-tile comparability.
|
||||
"""
|
||||
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
|
||||
logger.info(f" → Openness Anisotropique (multi-rayon){gpu_tag}...")
|
||||
t0 = time.time()
|
||||
output = vis_dir / f"{basename}_aniso_open.tif"
|
||||
|
||||
try:
|
||||
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
|
||||
_prepare_dem_for_raycast(dem_file, shared, resolution)
|
||||
|
||||
n_dirs = 8
|
||||
# Anisotropic weights: emphasize NW-SE and NE-SW directions
|
||||
weights = np.array([1.0, 1.5, 1.0, 1.5, 1.0, 1.5, 1.0, 1.5])
|
||||
|
||||
radii_m = [25, 50, 100]
|
||||
max_dist = min(int(100 / res), 300)
|
||||
|
||||
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
|
||||
|
||||
# Weighted combination across directions and radii
|
||||
weight_total = np.sum(weights)
|
||||
n_radii = len(radii_m)
|
||||
|
||||
pos_combined = np.zeros((rows, cols), dtype=np.float64)
|
||||
neg_combined = np.zeros((rows, cols), dtype=np.float64)
|
||||
|
||||
for r_idx in range(n_radii):
|
||||
for d_idx in range(n_dirs):
|
||||
w = weights[d_idx]
|
||||
pos_combined += pos_angles[d_idx, r_idx] * w / (n_radii * weight_total)
|
||||
neg_combined += neg_angles[d_idx, r_idx] * w / (n_radii * weight_total)
|
||||
|
||||
aniso_result = np.degrees(pos_combined - neg_combined).astype(np.float32)
|
||||
aniso_result[nan_mask] = np.nan
|
||||
|
||||
# Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
|
||||
# plage de rendu fixe → mosaïque de couleur homogène
|
||||
valid = aniso_result[~nan_mask]
|
||||
if len(valid) > 0:
|
||||
std_val = max(np.nanstd(valid), 0.01)
|
||||
aniso_result = (aniso_result - np.nanmean(valid)) / std_val
|
||||
|
||||
_save_tif(output, aniso_result, transform, crs)
|
||||
logger.info(f" ✓ Openness anisotropique terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
|
||||
return output
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Erreur openness anisotropique: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Anomaly Mask — automatic threshold detection
|
||||
# ============================================================
|
||||
@ -1342,7 +1327,6 @@ def generate_anomaly_mask(dem_file, basename, vis_dir, resolution, shared=None,
|
||||
("wavelet", 1.5), # Circular + linear structures
|
||||
("svf", 1.3), # Sky-view depressions
|
||||
("flow_acc", 1.2), # Drainage channels / ditches
|
||||
("aniso_open", 1.0), # Anisotropic structures
|
||||
("positive_openness", 0.8), # Surélevations
|
||||
]
|
||||
|
||||
|
||||
@ -5,18 +5,42 @@ 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)
|
||||
POST /api/preview → cellules 1 km intersectant une bbox WGS84 (option
|
||||
regenerate=true pour inclure celles déjà générées)
|
||||
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 → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync
|
||||
depuis la machine de traitement) puis régénérer la
|
||||
carte en arrière-plan
|
||||
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 --force-classification ;
|
||||
option ground_class choisit la méthode de classification du sol)
|
||||
option ground_class choisit la méthode de classification du sol ;
|
||||
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)
|
||||
|
||||
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 résultats reviennent par la commande de
|
||||
synchronisation LIDAR_SYNC_CMD (rsync), 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 math
|
||||
import os
|
||||
import subprocess
|
||||
@ -25,7 +49,9 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@ -33,7 +59,25 @@ 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"
|
||||
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande
|
||||
JOB_FILE = OUTPUT_DIR / ".generation.job.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)
|
||||
|
||||
# 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
|
||||
# Commande shell qui ramène les tuiles traitées dans output/ local (rsync
|
||||
# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild.
|
||||
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
|
||||
|
||||
# Résolutions générées par /api/generate (cf. _build_command). La détection des
|
||||
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
|
||||
# que si elle possède les visualisations demandées aux deux résolutions.
|
||||
GENERATE_RESOLUTIONS = (0.5, 0.2)
|
||||
|
||||
app = FastAPI(title="Carte LiDAR — génération de zones")
|
||||
|
||||
@ -62,15 +106,90 @@ def assets(file_path: str):
|
||||
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 and x_lidar_token != API_TOKEN:
|
||||
raise HTTPException(401, "token d'API manquant ou invalide")
|
||||
|
||||
|
||||
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)."""
|
||||
from .pipeline import VIZ_STEPS
|
||||
return [name for name, _ in VIZ_STEPS]
|
||||
"""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 _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: list = Field(..., description="[ouest, sud, est, nord] en WGS84")
|
||||
bbox: 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: list = Field(None,
|
||||
description="Visualisations demandées, noms d'étapes du "
|
||||
"pipeline (ex: aspect, wavelet) ; défaut : aspect. "
|
||||
"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):
|
||||
@ -91,25 +210,74 @@ class GenerateRequest(BaseModel):
|
||||
"bas en terrain)")
|
||||
viz: list = Field(None,
|
||||
description="Visualisations à générer, noms d'étapes du "
|
||||
"pipeline (ex: aspect, wavelet, aniso_open) ; "
|
||||
"pipeline (ex: aspect, wavelet, slope) ; "
|
||||
"défaut : aspect")
|
||||
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)")
|
||||
|
||||
|
||||
# --- État du job de génération -------------------------------------------
|
||||
_job = {"proc": None, "started": None, "returncode": None, "cmd": None}
|
||||
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": 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")}
|
||||
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")
|
||||
|
||||
|
||||
_load_job_state()
|
||||
|
||||
|
||||
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 rasterio.warp import transform as warp_transform
|
||||
lons, lats = warp_transform("EPSG:4326", "EPSG:2154", [w, e, w, e], [s, s, n, n])
|
||||
# warp_transform renvoie (xs, ys) dans la CRS cible
|
||||
min_x, max_x = min(lons) + 0.5, max(lons) - 0.5 # rétrécit d'1 m : bords exclus
|
||||
min_y, max_y = min(lats) + 0.5, max(lats) - 0.5
|
||||
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)
|
||||
@ -124,13 +292,54 @@ def processed_cells(output_dir):
|
||||
return {(t["col"], t["row"]) for t in tiles}
|
||||
|
||||
|
||||
def missing_cells_with_corners(cells, output_dir, include_done=False):
|
||||
"""Filtre les cellules déjà traitées et calcule leurs coins WGS84.
|
||||
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, aux deux résolutions.
|
||||
|
||||
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
|
||||
done = processed_cells(output_dir)
|
||||
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:
|
||||
@ -157,6 +366,12 @@ def root():
|
||||
|
||||
@app.get("/api/status")
|
||||
def status():
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : l'état vient de la machine de traitement
|
||||
data = _proxy_api("GET", "/api/status", timeout=10)
|
||||
data["distant"] = True
|
||||
return data
|
||||
from .progress import progress_snapshot
|
||||
with _job_lock:
|
||||
proc = _job["proc"]
|
||||
running = proc is not None and proc.poll() is None
|
||||
@ -166,6 +381,9 @@ def status():
|
||||
"returncode": _job["returncode"],
|
||||
"cmd": _job["cmd"],
|
||||
"log": _tail_log(40),
|
||||
# Tuiles une par une (nom court, type, état) — agrégées depuis les
|
||||
# événements écrits par le pipeline (.generation.events.jsonl)
|
||||
"tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()),
|
||||
}
|
||||
|
||||
|
||||
@ -189,36 +407,99 @@ def available_layers():
|
||||
return found
|
||||
|
||||
|
||||
# --- Rebuild de l'index en arrière-plan -------------------------------------
|
||||
_rebuild = {"running": False, "error": None, "done": None}
|
||||
@app.get("/api/tiles")
|
||||
def tiles_data(stamp: Optional[float] = None):
|
||||
"""Données de carte (index_tiles.json) — tuiles visibles sans recharger.
|
||||
|
||||
Le pipeline lancé par /api/generate tourne avec --incremental-index : il
|
||||
réécrit ce fichier après chaque tuile terminée. La carte sonde cet
|
||||
endpoint pendant un run et fusionne les nouveautés. Avec ?stamp=X : la
|
||||
réponse est allégée ({tiles: null}) tant que le fichier n'a pas changé.
|
||||
"""
|
||||
path = OUTPUT_DIR / "index_tiles.json"
|
||||
try:
|
||||
current = path.stat().st_mtime
|
||||
if stamp is not None and abs(current - stamp) < 1e-4:
|
||||
return {"stamp": current, "tiles": None}
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"stamp": None, "tiles": None}
|
||||
data["stamp"] = current
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/api/rebuild")
|
||||
def rebuild_index():
|
||||
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
|
||||
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
|
||||
_rebuild = {"running": False, "error": None, "done": None, "phase": None}
|
||||
|
||||
|
||||
def _start_rebuild(with_sync):
|
||||
"""Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index.
|
||||
|
||||
Sur la webapp légère, with_sync=True ramène d'abord les tuiles traitées
|
||||
par la machine distante (rsync) ; l'échec éventuel de la synchronisation
|
||||
est remonté dans l'état sans empêcher le rebuild des données locales.
|
||||
"""
|
||||
if _rebuild["running"]:
|
||||
raise HTTPException(409, "un rebuild est déjà en cours")
|
||||
raise HTTPException(409, "une synchronisation/rebuild est déjà en cours")
|
||||
|
||||
def _run():
|
||||
_rebuild["running"] = True
|
||||
_rebuild["error"] = None
|
||||
_rebuild["done"] = None
|
||||
sync_error = None
|
||||
if with_sync and SYNC_CMD:
|
||||
_rebuild["phase"] = "sync"
|
||||
try:
|
||||
r = subprocess.run(SYNC_CMD, shell=True, capture_output=True,
|
||||
text=True, timeout=4 * 3600)
|
||||
if r.returncode != 0:
|
||||
tail = (r.stderr or r.stdout or "").strip()[-500:]
|
||||
sync_error = (f"synchronisation échouée (rc={r.returncode})"
|
||||
+ (f" : {tail}" if tail else ""))
|
||||
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
|
||||
sync_error = f"synchronisation échouée : {e}"
|
||||
_rebuild["phase"] = "index"
|
||||
try:
|
||||
from .index import build_index
|
||||
build_index(OUTPUT_DIR)
|
||||
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'API
|
||||
_rebuild["error"] = str(e)
|
||||
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
|
||||
_rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e)
|
||||
else:
|
||||
_rebuild["error"] = sync_error
|
||||
finally:
|
||||
_rebuild["running"] = False
|
||||
_rebuild["phase"] = None
|
||||
_rebuild["done"] = time.time()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"demarré": True}
|
||||
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)}
|
||||
|
||||
|
||||
@app.post("/api/sync", dependencies=[Depends(_require_token)])
|
||||
def sync_and_rebuild():
|
||||
"""Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan.
|
||||
|
||||
Sans LIDAR_SYNC_CMD (machine de traitement locale), se réduit à un simple
|
||||
rebuild de l'index — même comportement que /api/rebuild.
|
||||
"""
|
||||
return _start_rebuild(with_sync=True)
|
||||
|
||||
|
||||
@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(with_sync=False)
|
||||
|
||||
|
||||
@app.get("/api/rebuild")
|
||||
def rebuild_status():
|
||||
return {"running": _rebuild["running"], "error": _rebuild["error"],
|
||||
"done": _rebuild["done"]}
|
||||
"done": _rebuild["done"], "phase": _rebuild["phase"]}
|
||||
|
||||
|
||||
def _tail_log(n_lines):
|
||||
@ -229,15 +510,32 @@ def _tail_log(n_lines):
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
@app.post("/api/preview", dependencies=[Depends(_require_token)])
|
||||
def preview(req: PreviewRequest):
|
||||
if 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)
|
||||
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 ["aspect"]
|
||||
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}
|
||||
|
||||
|
||||
@ -252,14 +550,15 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
régénération des visualisations des tuiles déjà présentes. Avec
|
||||
bare_earth=True, le DTM est ramené au retour le plus bas de chaque
|
||||
cellule (sol nu). Avec viz, on choisit les visualisations générées
|
||||
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "aniso_open"] ;
|
||||
(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", "0.5,0.2",
|
||||
"-o", str(OUTPUT_DIR),
|
||||
"-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
|
||||
"--only", *(viz or ["aspect"]),
|
||||
"--ground-classification", ground_class,
|
||||
"--ign-classes", ign_classes]
|
||||
@ -269,6 +568,9 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
cmd += ["--force", "--force-classification"]
|
||||
if os.environ.get("LIDAR_GPU", "") == "1":
|
||||
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "2")]
|
||||
# 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"]
|
||||
@ -276,21 +578,14 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
return cmd
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
@app.post("/api/generate", dependencies=[Depends(_require_token)])
|
||||
def generate(req: GenerateRequest):
|
||||
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)})")
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : téléchargement + traitement sur la machine distante,
|
||||
# qui valide les paramètres et renvoie son propre état de job.
|
||||
data = _proxy_api("POST", "/api/generate", json.loads(req.json()))
|
||||
data["distant"] = True
|
||||
return data
|
||||
names = _viz_step_names()
|
||||
viz = [v for v in (req.viz or []) if v] or ["aspect"]
|
||||
invalid = [v for v in viz if v not in names]
|
||||
@ -298,6 +593,28 @@ def generate(req: GenerateRequest):
|
||||
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/")
|
||||
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)})")
|
||||
|
||||
with _job_lock:
|
||||
proc = _job["proc"]
|
||||
@ -306,7 +623,12 @@ def generate(req: GenerateRequest):
|
||||
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz)
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(LOG_FILE, "w", encoding="utf-8")
|
||||
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd})
|
||||
# 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)
|
||||
from .progress import reset_events
|
||||
reset_events(OUTPUT_DIR)
|
||||
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None})
|
||||
_save_job_state()
|
||||
p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
|
||||
cwd="/app" if Path("/app").exists() else None)
|
||||
|
||||
@ -315,6 +637,8 @@ def generate(req: GenerateRequest):
|
||||
log_fh.close()
|
||||
with _job_lock:
|
||||
_job["returncode"] = rc
|
||||
_job["finished"] = time.time()
|
||||
_save_job_state()
|
||||
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
_job["proc"] = p
|
||||
|
||||
25
run.sh
25
run.sh
@ -211,13 +211,22 @@ if [ -n "$FETCH_TILES_ARGS" ]; then
|
||||
INPUT_RO=""
|
||||
fi
|
||||
|
||||
# Build systématique de l'image avant tout docker run. Le cache Docker rend
|
||||
# l'opération quasi instantanée si le code n'a pas changé ; en cas d'échec,
|
||||
# le journal complet est affiché. Ça évite de lancer tests/serveur/pipeline
|
||||
# sur une image périmée (code baké à la dernière édition).
|
||||
build_image() {
|
||||
echo "Mise à jour de l'image Docker (cache)..."
|
||||
if ! docker build -t "$IMAGE_NAME" "$SCRIPT_DIR" >/tmp/lidar_build.log 2>&1; then
|
||||
cat /tmp/lidar_build.log >&2
|
||||
echo "Échec du build de l'image (journal : /tmp/lidar_build.log)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check for --test flag first
|
||||
if [ "$TEST_FLAG" -eq 1 ]; then
|
||||
# Build l'image si elle n'existe pas
|
||||
if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
echo "Build de l'image Docker..."
|
||||
docker build -t "$IMAGE_NAME" "$SCRIPT_DIR"
|
||||
fi
|
||||
build_image
|
||||
echo "============================================"
|
||||
echo " Tests unitaires LiDAR Pipeline"
|
||||
echo "============================================"
|
||||
@ -227,11 +236,7 @@ if [ "$TEST_FLAG" -eq 1 ]; then
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Build l'image si elle n'existe pas
|
||||
if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
echo "Build de l'image Docker..."
|
||||
docker build -t "$IMAGE_NAME" "$SCRIPT_DIR"
|
||||
fi
|
||||
build_image
|
||||
|
||||
# Créer les répertoires s'ils n'existent pas
|
||||
mkdir -p "$INPUT_DIR" "$OUTPUT_DIR"
|
||||
|
||||
Reference in New Issue
Block a user