Interface web cartographique: COG + TiTiler + viewer MapLibre

- Ajout de convert_to_cog() et generate_cog_metadata() dans rendering.py
- Nouveau module viewer.py: génération HTML MapLibre GL JS avec couches et opacité
- Nouveau module server.py: serveur FastAPI avec TiTiler pour tuiles COG
- Pipeline: étapes 5 (COGs) et 6 (viewer web) après le rapport PDF
- CLI: flag --no-viewer pour désactiver la génération du viewer
- run.sh: commande 'serve' pour démarrer le serveur sur port 8000
- Dockerfile: ajout de rio-cogeo, titiler.core, fastapi, uvicorn, piexif
- setup.py: point d'entrée lidar-server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-10 17:15:37 +02:00
parent 2986400a0a
commit f01683819c
9 changed files with 779 additions and 21 deletions

View File

@ -63,7 +63,8 @@ from .visualizations import (
)
from .gpu import gpu_cleanup
from .ign import generate_ign_overlay
from .rendering import tif_to_png, generate_pdf_report
from .rendering import tif_to_png, generate_pdf_report, convert_to_cog, generate_cog_metadata
from .viewer import generate_viewer
# Ordered list of visualization steps.
@ -105,7 +106,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', force_classify=False, keep_tif=False):
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False, keep_tif=False, no_viewer=False):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
self.resolution = resolution
@ -114,6 +115,7 @@ class LidarArchaeoPipeline:
self.ground_method = ground_method
self.force_classify = force_classify
self.keep_tif = keep_tif
self.no_viewer = no_viewer
self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists():
@ -138,6 +140,7 @@ class LidarArchaeoPipeline:
logger.info(f" Classification sol : {self.ground_method}")
logger.info(f" Force classif.: {'OUI' if self.force_classify else 'non'}")
logger.info(f" Keep TIFF : {'OUI' if self.keep_tif else 'non'}")
logger.info(f" Viewer web : {'non' if self.no_viewer else 'OUI'}")
def find_laz_files(self):
"""Find all LAZ/LAS files in input directory."""
@ -234,10 +237,17 @@ class LidarArchaeoPipeline:
gpu_cleanup()
# Convert to WebP (only newly generated TIFs, not skipped ones)
# Also generate COGs for web viewer if enabled
logger.info(" Conversion images WebP:")
cog_dir = file_vis_dir / "cog"
if not self.no_viewer:
cog_dir.mkdir(exist_ok=True)
for name, tif_file in vis_results.items():
if tif_file and isinstance(tif_file, Path) and tif_file.suffix == '.tif' and tif_file.exists():
webp_file = tif_to_png(tif_file, file_vis_dir, self.resolution, keep_tif=self.keep_tif)
# Generate COG before WebP conversion (which may delete the TIF)
if not self.no_viewer:
convert_to_cog(tif_file, cog_dir)
webp_file = tif_to_png(tif_file, file_vis_dir, self.resolution, keep_tif=self.keep_tif or not self.no_viewer)
if webp_file:
logger.info(f"{webp_file.name}")
@ -254,7 +264,7 @@ class LidarArchaeoPipeline:
logger.info("=" * 60)
# Step 1: Ground classification
logger.info("[1/4] Classification du sol...")
logger.info("[1/6] Classification du sol...")
t1 = time.time()
las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify)
t_classif = time.time() - t1
@ -264,7 +274,7 @@ class LidarArchaeoPipeline:
logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)")
# Step 2: Generate DTM
logger.info("[2/4] Génération DTM...")
logger.info("[2/6] Génération DTM...")
t2 = time.time()
dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, self.resolution)
t_dtm = time.time() - t2
@ -274,17 +284,40 @@ class LidarArchaeoPipeline:
logger.info(f" ✓ DTM terminé ({t_dtm:.1f}s)")
# Step 3: Visualizations
logger.info("[3/4] Visualisations archéologiques...")
logger.info("[3/6] Visualisations archéologiques...")
self.generate_all_visualizations(dtm_file, basename)
# Step 4: PDF report
file_vis_dir = self.vis_dir / basename
logger.info("[4/4] Rapport PDF A3...")
logger.info("[4/6] Rapport PDF A3...")
t4 = time.time()
generate_pdf_report(basename, file_vis_dir, self.pdf_dir, self.resolution)
t_pdf = time.time() - t4
logger.info(f" ✓ Rapport PDF terminé ({t_pdf:.1f}s)")
# Step 5: COGs for web viewer
logger.info("[5/6] Génération métadonnées viewer web...")
t5 = time.time()
if not self.no_viewer:
# Convert DTM to COG as well
dtm_cog_dir = self.dtm_dir / "cog"
dtm_cog_dir.mkdir(exist_ok=True)
for dtm_file in sorted(self.dtm_dir.glob(f"{basename}_dtm.tif")):
convert_to_cog(dtm_file, dtm_cog_dir)
generate_cog_metadata(self.vis_dir, basename)
t_cog = time.time() - t5
logger.info(f" ✓ Métadonnées viewer web terminées ({t_cog:.1f}s)")
# Step 6: Web viewer
if not self.no_viewer:
logger.info("[6/6] Génération viewer web...")
t6 = time.time()
generate_viewer(basename, file_vis_dir, self.vis_dir)
t_viewer = time.time() - t6
logger.info(f" ✓ Viewer web terminé ({t_viewer:.1f}s)")
else:
logger.info("[6/6] Viewer web: ignoré (--no-viewer)")
t_total = time.time() - t_start
logger.info(f"{basename} terminé en {t_total:.1f}s")
logger.debug(f" Détails: classification={t_classif:.1f}s, DTM={t_dtm:.1f}s, PDF={t_pdf:.1f}s")
@ -316,7 +349,7 @@ class LidarArchaeoPipeline:
logger.info(f"Fichiers: {len(files)}")
with ProcessPoolExecutor(max_workers=self.workers) as executor:
future_to_file = {
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), self.resolution, self.force, self.ground_method, self.force_classify, self.keep_tif): laz_file
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), self.resolution, self.force, self.ground_method, self.force_classify, self.keep_tif, self.no_viewer): laz_file
for laz_file in files
}
done = 0
@ -378,7 +411,7 @@ class LidarArchaeoPipeline:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', force_classify=False, keep_tif=False):
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', force_classify=False, keep_tif=False, no_viewer=False):
"""Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts.
@ -394,7 +427,7 @@ def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, fo
worker_logger.addHandler(handler)
worker_logger.addFilter(_file_filter)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, force_classify=force_classify, keep_tif=keep_tif)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, force_classify=force_classify, keep_tif=keep_tif, no_viewer=no_viewer)
basename = _file_basename(laz_file_str)
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
pipeline.temp_dir.mkdir(exist_ok=True)