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

@ -18,6 +18,8 @@ All commands run inside Docker. Use `./run.sh` as the primary interface.
./run.sh -g --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc # Single file ./run.sh -g --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc # Single file
./run.sh --ground-classification pmf # Force PMF ground classification ./run.sh --ground-classification pmf # Force PMF ground classification
./run.sh -g --keep-tif # Keep intermediate TIFF files ./run.sh -g --keep-tif # Keep intermediate TIFF files
./run.sh -g --no-viewer # Skip web viewer generation
./run.sh serve # Start web map server
./run.sh # Print help (no args) ./run.sh # Print help (no args)
``` ```
@ -37,7 +39,9 @@ docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data
- **`visualizations.py`** — 15 `generate_*` functions + 2 IGN overlay lambdas. All take `(dem_file, basename, vis_dir, resolution)` and return a TIF path or None. - **`visualizations.py`** — 15 `generate_*` functions + 2 IGN overlay lambdas. All take `(dem_file, basename, vis_dir, resolution)` and return a TIF path or None.
- **`gpu.py`** — CuPy/numpy abstraction: `HAS_GPU`, `to_gpu()`, `to_cpu()`, `xp_gaussian_filter()`, `xp_uniform_filter()`, `xp_minimum_filter()`, `gpu_cleanup()`. Falls back to CPU gracefully. - **`gpu.py`** — CuPy/numpy abstraction: `HAS_GPU`, `to_gpu()`, `to_cpu()`, `xp_gaussian_filter()`, `xp_uniform_filter()`, `xp_minimum_filter()`, `gpu_cleanup()`. Falls back to CPU gracefully.
- **`ign.py`** — IGN WMTS tile download + overlay generation for orthophoto and topographic maps. - **`ign.py`** — IGN WMTS tile download + overlay generation for orthophoto and topographic maps.
- **`rendering.py`** — `COLORMAPS` dict maps filename keywords to (cmap, title, legend, description). `tif_to_png()` converts TIF→WebP with legend/scale/north arrow. `generate_pdf_report()` creates A3 PDF. - **`rendering.py`** — `COLORMAPS` dict maps filename keywords to (cmap, title, legend, description). `tif_to_png()` converts TIF→WebP with legend/scale/north arrow. `convert_to_cog()` converts TIF→Cloud Optimized GeoTIFF. `generate_cog_metadata()` creates metadata JSON for web viewer. `generate_pdf_report()` creates A3 PDF.
- **`viewer.py`** — Generates MapLibre GL JS HTML viewer with layer controls, opacity sliders, and IGN/OSM basemaps.
- **`server.py`** — TiTiler-based Starlette server for serving COG tiles and viewer HTML. Entry point via `python -m lidar_pipeline.server`.
### Adding a visualization ### Adding a visualization
@ -68,6 +72,7 @@ Uses `ProcessPoolExecutor` with `'spawn'` start method (required for CUDA). Each
- **Language**: UI messages and comments in French. Code identifiers in English. - **Language**: UI messages and comments in French. Code identifiers in English.
- **Logging**: Use `logger = logging.getLogger("lidar")`. Prefix per-file logs via `_file_filter.basename`. - **Logging**: Use `logger = logging.getLogger("lidar")`. Prefix per-file logs via `_file_filter.basename`.
- **GPU pattern**: `arr_gpu = to_gpu(arr)` compute `result = to_cpu(arr_gpu)` `gpu_cleanup()` between visualizations. - **GPU pattern**: `arr_gpu = to_gpu(arr)` compute `result = to_cpu(arr_gpu)` `gpu_cleanup()` between visualizations.
- **Output format**: Visualizations saved as WebP (not PNG). TIFF intermediates deleted unless `--keep-tif`. PDF reports use `PILImage.open().convert('RGB')`. - **Output format**: Visualizations saved as WebP (not PNG). TIFF intermediates deleted unless `--keep-tif` or viewer enabled. COGs generated for web viewer by default. PDF reports use `PILImage.open().convert('RGB')`.
- **Web viewer**: MapLibre GL JS + TiTiler. COGs served as raster tiles. `./run.sh serve` starts server on port 8000.
- **Flow accumulation**: Uses numba JIT for D8 accumulation loop. Falls back to pure Python if numba unavailable. - **Flow accumulation**: Uses numba JIT for D8 accumulation loop. Falls back to pure Python if numba unavailable.
- **Tests**: Run only inside Docker via `./run.sh --test`. Synthetic DEM fixture in `tests/conftest.py`. - **Tests**: Run only inside Docker via `./run.sh --test`. Synthetic DEM fixture in `tests/conftest.py`.

View File

@ -32,7 +32,12 @@ RUN pip3 install --no-cache-dir \
tqdm \ tqdm \
Pillow \ Pillow \
pytest \ pytest \
numba numba \
rio-cogeo \
titiler.core \
fastapi \
uvicorn \
piexif
# Install CuPy for GPU acceleration (optional - will fallback to numpy if not available) # Install CuPy for GPU acceleration (optional - will fallback to numpy if not available)
RUN pip3 install --no-cache-dir cupy-cuda12x || echo "CuPy not available - GPU acceleration disabled" RUN pip3 install --no-cache-dir cupy-cuda12x || echo "CuPy not available - GPU acceleration disabled"

View File

@ -116,6 +116,11 @@ Exemples:
action="store_true", action="store_true",
help="Conserver les fichiers TIFF intermédiaires (sinon supprimés après conversion WebP)" help="Conserver les fichiers TIFF intermédiaires (sinon supprimés après conversion WebP)"
) )
parser.add_argument(
"--no-viewer",
action="store_true",
help="Ne pas générer le viewer web (COGs + HTML MapLibre)"
)
parser.add_argument( parser.add_argument(
"--ground-classification", "--ground-classification",
choices=["auto", "smrf", "pmf", "csf"], choices=["auto", "smrf", "pmf", "csf"],
@ -170,7 +175,8 @@ Exemples:
force=args.force, force=args.force,
ground_method=args.ground_classification, ground_method=args.ground_classification,
force_classify=args.force_classification, force_classify=args.force_classification,
keep_tif=args.keep_tif keep_tif=args.keep_tif,
no_viewer=args.no_viewer
) )
# If --file is specified, process only matching files # If --file is specified, process only matching files

View File

@ -63,7 +63,8 @@ from .visualizations import (
) )
from .gpu import gpu_cleanup from .gpu import gpu_cleanup
from .ign import generate_ign_overlay 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. # Ordered list of visualization steps.
@ -105,7 +106,7 @@ VIZ_STEPS = [
class LidarArchaeoPipeline: class LidarArchaeoPipeline:
"""Orchestrates the LiDAR archaeological analysis pipeline.""" """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.input_dir = Path(input_dir)
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.resolution = resolution self.resolution = resolution
@ -114,6 +115,7 @@ class LidarArchaeoPipeline:
self.ground_method = ground_method self.ground_method = ground_method
self.force_classify = force_classify self.force_classify = force_classify
self.keep_tif = keep_tif self.keep_tif = keep_tif
self.no_viewer = no_viewer
self.temp_dir = self.output_dir / "temp" self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists(): if not self.input_dir.exists():
@ -138,6 +140,7 @@ class LidarArchaeoPipeline:
logger.info(f" Classification sol : {self.ground_method}") logger.info(f" Classification sol : {self.ground_method}")
logger.info(f" Force classif.: {'OUI' if self.force_classify else 'non'}") 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" 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): def find_laz_files(self):
"""Find all LAZ/LAS files in input directory.""" """Find all LAZ/LAS files in input directory."""
@ -234,10 +237,17 @@ class LidarArchaeoPipeline:
gpu_cleanup() gpu_cleanup()
# Convert to WebP (only newly generated TIFs, not skipped ones) # Convert to WebP (only newly generated TIFs, not skipped ones)
# Also generate COGs for web viewer if enabled
logger.info(" Conversion images WebP:") 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(): 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(): 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: if webp_file:
logger.info(f"{webp_file.name}") logger.info(f"{webp_file.name}")
@ -254,7 +264,7 @@ class LidarArchaeoPipeline:
logger.info("=" * 60) logger.info("=" * 60)
# Step 1: Ground classification # Step 1: Ground classification
logger.info("[1/4] Classification du sol...") logger.info("[1/6] Classification du sol...")
t1 = time.time() t1 = time.time()
las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify) las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify)
t_classif = time.time() - t1 t_classif = time.time() - t1
@ -264,7 +274,7 @@ class LidarArchaeoPipeline:
logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)") logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)")
# Step 2: Generate DTM # Step 2: Generate DTM
logger.info("[2/4] Génération DTM...") logger.info("[2/6] Génération DTM...")
t2 = time.time() t2 = time.time()
dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, self.resolution) dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, self.resolution)
t_dtm = time.time() - t2 t_dtm = time.time() - t2
@ -274,17 +284,40 @@ class LidarArchaeoPipeline:
logger.info(f" ✓ DTM terminé ({t_dtm:.1f}s)") logger.info(f" ✓ DTM terminé ({t_dtm:.1f}s)")
# Step 3: Visualizations # Step 3: Visualizations
logger.info("[3/4] Visualisations archéologiques...") logger.info("[3/6] Visualisations archéologiques...")
self.generate_all_visualizations(dtm_file, basename) self.generate_all_visualizations(dtm_file, basename)
# Step 4: PDF report # Step 4: PDF report
file_vis_dir = self.vis_dir / basename file_vis_dir = self.vis_dir / basename
logger.info("[4/4] Rapport PDF A3...") logger.info("[4/6] Rapport PDF A3...")
t4 = time.time() t4 = time.time()
generate_pdf_report(basename, file_vis_dir, self.pdf_dir, self.resolution) generate_pdf_report(basename, file_vis_dir, self.pdf_dir, self.resolution)
t_pdf = time.time() - t4 t_pdf = time.time() - t4
logger.info(f" ✓ Rapport PDF terminé ({t_pdf:.1f}s)") 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 t_total = time.time() - t_start
logger.info(f"{basename} terminé en {t_total:.1f}s") 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") 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)}") logger.info(f"Fichiers: {len(files)}")
with ProcessPoolExecutor(max_workers=self.workers) as executor: with ProcessPoolExecutor(max_workers=self.workers) as executor:
future_to_file = { 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 for laz_file in files
} }
done = 0 done = 0
@ -378,7 +411,7 @@ class LidarArchaeoPipeline:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}") 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. """Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts. 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.addHandler(handler)
worker_logger.addFilter(_file_filter) 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) basename = _file_basename(laz_file_str)
pipeline.temp_dir = pipeline.output_dir / "temp" / basename pipeline.temp_dir = pipeline.output_dir / "temp" / basename
pipeline.temp_dir.mkdir(exist_ok=True) pipeline.temp_dir.mkdir(exist_ok=True)

View File

@ -527,6 +527,146 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False):
return None return None
def convert_to_cog(tif_file, cog_dir):
"""Convert a GeoTIFF to Cloud Optimized GeoTIFF for web map serving.
Args:
tif_file: Path to input GeoTIFF.
cog_dir: Directory for output COG file.
Returns:
Path to output COG file, or None on failure.
"""
cog_file = cog_dir / tif_file.name
if cog_file.exists():
logger.debug(f" COG déjà existant: {cog_file.name}")
return cog_file
try:
from rio_cogeo.cogeo import cog_translate
from rio_cogeo.profiles import cog_profiles
with rasterio.open(tif_file) as src:
is_rgb = src.count >= 3
# Use deflate compression profile
profile = dict(cog_profiles["deflate"]) # Make a mutable copy
if not is_rgb:
# Single-band terrain data: keep float32 for continuous values
profile.update(dtype="float32")
cog_translate(str(tif_file), str(cog_file), profile, quiet=True)
logger.debug(f" COG créé: {cog_file.name}")
return cog_file
except ImportError:
logger.warning(" rio-cogeo non disponible — COG non généré")
return None
except Exception as e:
logger.warning(f" Erreur conversion COG: {e}")
return None
def generate_cog_metadata(vis_dir, basename):
"""Generate metadata JSON for all visualization layers.
Scans the COG directory for files and reads their geographic bounds.
Creates a metadata.json with WGS84 bounds and layer info for the web viewer.
Args:
vis_dir: Directory containing COG files (vis_dir/basename/cog/).
basename: Base name for the LiDAR tile.
Returns:
Path to metadata.json, or None on failure.
"""
import json
cog_dir = vis_dir / basename / "cog"
if not cog_dir.exists():
return None
# Find the DTM to get geographic bounds
# The COG files inherit bounds from the DTM, so we can read any COG
cog_files = sorted(cog_dir.glob("*.tif"))
if not cog_files:
return None
# Read bounds from first COG file
try:
ref_cog = cog_files[0]
with rasterio.open(ref_cog) as src:
bounds = src.bounds
crs = src.crs
if crs and HAS_WARP:
l93_xs = [bounds.left, bounds.right]
l93_ys = [bounds.top, bounds.bottom]
lons, lats = warp_transform(crs, 'EPSG:4326', l93_xs, l93_ys)
bounds_wgs84 = {
'west': float(min(lons)),
'south': float(min(lats)),
'east': float(max(lons)),
'north': float(max(lats)),
}
else:
# Fallback: use Lambert 93 bounds directly
bounds_wgs84 = {
'west': float(bounds.left),
'south': float(bounds.bottom),
'east': float(bounds.right),
'north': float(bounds.top),
}
bounds_l93 = {
'min_x': float(bounds.left),
'min_y': float(bounds.bottom),
'max_x': float(bounds.right),
'max_y': float(bounds.top),
}
resolution = float(abs(src.transform.a))
except Exception as e:
logger.warning(f" Erreur lecture bounds COG: {e}")
return None
# Build layer list with titles
layers = []
for cog_file in cog_files:
name = cog_file.stem.replace(basename + "_", "")
# Find matching title from COLORMAPS or RGB_LEGENDS
title = name.replace("_", " ").title()
for key in sorted(COLORMAPS.keys(), key=len, reverse=True):
if key in name:
title = COLORMAPS[key]['title']
break
for key in RGB_LEGENDS:
if key in name:
title = RGB_LEGENDS[key]['title']
break
layers.append({
'name': name,
'title': title,
'file': cog_file.name,
})
metadata = {
'basename': basename,
'bounds_l93': bounds_l93,
'bounds_wgs84': bounds_wgs84,
'resolution': resolution,
'layers': layers,
}
metadata_file = vis_dir / basename / "metadata.json"
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
logger.info(f" Métadonnées: {len(layers)} couches, bounds={bounds_wgs84}")
return metadata_file
def generate_pdf_report(basename, vis_dir, pdf_dir, resolution): def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
"""Generate A3 PDF report for a LiDAR file with all visualizations. """Generate A3 PDF report for a LiDAR file with all visualizations.

117
lidar_pipeline/server.py Normal file
View File

@ -0,0 +1,117 @@
"""TiTiler-based web server for serving LiDAR COG visualizations.
Provides:
- COG tile serving via TiTiler API
- Static file serving for viewer HTML
- CORS headers for local development
Usage:
python -m lidar_pipeline.server /path/to/output [--port 8000]
"""
import logging
import sys
from pathlib import Path
logger = logging.getLogger("lidar")
def create_app(output_dir):
"""Create the FastAPI application with TiTiler and static file serving.
Args:
output_dir: Path to the output directory containing visualisations/ and viewer/.
Returns:
FastAPI application instance.
"""
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from titiler.core.factory import TilerFactory
output_dir = Path(output_dir).resolve()
# TiTiler COG endpoint
cog = TilerFactory(router_prefix="/cog")
app = FastAPI(title="LiDAR Archéologique", docs_url="/docs")
# CORS for local development
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# Mount TiTiler routes
app.include_router(cog.router, prefix="/cog")
@app.get("/")
async def index():
"""Landing page with links."""
return HTMLResponse(
'<html><head><title>LiDAR Server</title>'
'<style>body{font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px}'
'h1{color:#1a1a2e}a{color:#4fc3f7}</style></head>'
'<body>'
'<h1>Serveur LiDAR Archéologique</h1>'
'<p><a href="/viewer">Visualisations</a></p>'
'<p>TiTiler API: <a href="/cog/">/cog/</a></p>'
'</body></html>'
)
@app.get("/viewer/{basename}")
@app.get("/viewer")
async def serve_viewer(basename: str = ""):
"""Serve viewer HTML files."""
if not basename:
# List available viewers
viewer_dir = output_dir / 'visualisations' / 'viewer'
if viewer_dir.exists():
viewers = sorted(viewer_dir.glob('*.html'))
if viewers:
links = ''.join(
f'<li><a href="/viewer/{f.stem}">{f.stem}</a></li>'
for f in viewers
)
return HTMLResponse(
f'<html><head><title>LiDAR Viewer</title>'
f'<style>body{{font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px}}'
f'h1{{color:#1a1a2e}}li{{margin:8px 0}}a{{color:#4fc3f7}}</style></head>'
f'<body><h1>Visualisations LiDAR</h1><ul>{links}</ul></body></html>'
)
return HTMLResponse('<html><body><p>Aucun viewer disponible</p></body></html>')
viewer_file = output_dir / 'visualisations' / 'viewer' / f"{basename}.html"
if viewer_file.exists():
return FileResponse(str(viewer_file), media_type='text/html')
return JSONResponse({'error': f'Viewer not found: {basename}'}, status_code=404)
return app
def main():
"""Entry point for the LiDAR web server."""
import argparse
import uvicorn
parser = argparse.ArgumentParser(description='Serveur cartographique LiDAR')
parser.add_argument('output_dir', help='Répertoire de sortie contenant les visualisations')
parser.add_argument('--host', default='0.0.0.0', help='Hôte (défaut: 0.0.0.0)')
parser.add_argument('--port', type=int, default=8000, help='Port (défaut: 8000)')
args = parser.parse_args()
output_dir = Path(args.output_dir)
if not output_dir.exists():
logger.error(f"Répertoire introuvable: {output_dir}")
sys.exit(1)
app = create_app(output_dir)
print(f"Serveur LiDAR Archéologique")
print(f" Viewer: http://{args.host}:{args.port}/viewer")
print(f" TiTiler: http://{args.host}:{args.port}/cog/")
print(f" Répertoire: {output_dir}")
uvicorn.run(app, host=args.host, port=args.port, log_level='info')
if __name__ == '__main__':
main()

416
lidar_pipeline/viewer.py Normal file
View File

@ -0,0 +1,416 @@
"""Web map viewer generator for LiDAR visualizations.
Generates a self-contained HTML file with MapLibre GL JS that displays
all visualization layers with opacity controls and IGN/OSM basemaps.
Served by TiTiler for COG tile access.
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger("lidar")
# Layer ordering for the viewer panel (archaeological priority)
LAYER_ORDER = [
'hillshade_multi',
'slope',
'aspect',
'curvature',
'svf',
'lrm',
'positive_openness',
'negative_openness',
'mslrm',
'tpi',
'sailore',
'roughness',
'anomalies',
'wavelet',
'flow',
'ortho',
'topo',
]
# Colormaps for TiTiler rendering of single-band COGs
LAYER_COLORMAPS = {
'hillshade_multi': 'gray',
'slope': 'inferno',
'aspect': 'turbo',
'curvature': 'RdYlBu_r',
'svf': 'viridis',
'lrm': 'RdBu_r',
'positive_openness': 'YlOrBr',
'negative_openness': 'GnBu_r',
'mslrm': 'RdBu_r',
'tpi': 'BrBG',
'sailore': 'seismic',
'roughness': 'magma',
'anomalies': 'coolwarm',
'wavelet': 'cividis',
'flow': 'Blues',
}
def generate_viewer(basename, vis_dir, output_vis_dir, titiler_url='http://localhost:8000'):
"""Generate a MapLibre GL JS viewer HTML file for the LiDAR tile.
Args:
basename: Base name for the LiDAR tile.
vis_dir: Per-file visualization directory (vis_dir/basename/).
output_vis_dir: Parent visualization directory for viewer output.
titiler_url: Base URL of the TiTiler server.
Returns:
Path to the generated HTML file, or None on failure.
"""
# Read metadata.json
metadata_file = vis_dir / "metadata.json"
if not metadata_file.exists():
logger.error(f" Métadonnées manquantes: {metadata_file}")
return None
with open(metadata_file) as f:
metadata = json.load(f)
bounds_wgs84 = metadata['bounds_wgs84']
resolution = metadata.get('resolution', 0.5)
# Determine which files are RGB (ortho/topo)
layers = []
for layer in metadata['layers']:
is_rgb = 'ortho' in layer['name'] or 'topo' in layer['name']
layers.append({
'name': layer['name'],
'title': layer['title'],
'file': layer['file'],
'is_rgb': is_rgb,
})
# Sort layers by archaeological priority
def layer_sort_key(l):
name = l['name']
for i, key in enumerate(LAYER_ORDER):
if key in name:
return i
return len(LAYER_ORDER)
layers.sort(key=layer_sort_key)
# COG path prefix for TiTiler (absolute path inside Docker container)
cog_path_prefix = f'/data/output/visualisations/{basename}/cog'
# Build layer controls HTML
layer_controls = []
for layer in layers:
checked = 'checked' if layer['name'] == 'hillshade_multi' else ''
initial_opacity = 100 if layer['name'] == 'hillshade_multi' else 0
layer_type = 'RGB' if layer['is_rgb'] else 'DEM'
layer_controls.append(
f' <div class="layer-control">\n'
f' <label>\n'
f' <input type="checkbox" data-layer="{layer["name"]}" {checked}>\n'
f' <span class="layer-title">{layer["title"]}</span>\n'
f' <span class="layer-type">{layer_type}</span>\n'
f' </label>\n'
f' <input type="range" class="opacity-slider" data-layer="{layer["name"]}" '
f'min="0" max="100" value="{initial_opacity}">\n'
f' </div>'
)
layer_controls_html = '\n'.join(layer_controls)
# Serialize data for JS
bounds_json = json.dumps(bounds_wgs84)
layers_json = json.dumps(layers, ensure_ascii=False)
colormaps_json = json.dumps(LAYER_COLORMAPS)
center_lng = (bounds_wgs84['west'] + bounds_wgs84['east']) / 2
center_lat = (bounds_wgs84['south'] + bounds_wgs84['north']) / 2
# Build the full HTML using string concatenation to avoid f-string issues with {z}/{x}/{y}
html_parts = []
html_parts.append(f'''<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LiDAR Archéologique — {basename}</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
#map {{ position: absolute; top: 0; left: 280px; right: 0; bottom: 0; }}
#panel {{
position: absolute; top: 0; left: 0; width: 280px; bottom: 0;
background: #1a1a2e; color: #e0e0e0; overflow-y: auto;
z-index: 10; box-shadow: 2px 0 8px rgba(0,0,0,0.3);
}}
#panel h1 {{
font-size: 14px; padding: 12px 14px; background: #16213e;
border-bottom: 1px solid #0f3460; letter-spacing: 0.5px;
}}
#panel h2 {{
font-size: 11px; padding: 8px 14px; color: #a0a0a0;
text-transform: uppercase; letter-spacing: 1px;
border-bottom: 1px solid #2a2a4a;
}}
.layer-group {{ padding: 4px 0; border-bottom: 1px solid #2a2a4a; }}
.layer-control {{
padding: 6px 14px;
transition: background 0.15s;
}}
.layer-control:hover {{ background: #252550; }}
.layer-control label {{
display: flex; align-items: center; gap: 8px;
font-size: 13px; cursor: pointer; margin-bottom: 4px;
}}
.layer-control input[type="checkbox"] {{
width: 16px; height: 16px; accent-color: #4fc3f7;
}}
.layer-control .layer-title {{ flex: 1; }}
.layer-control .layer-type {{
font-size: 10px; color: #888; text-transform: uppercase;
}}
.opacity-slider {{
width: 100%; height: 4px; margin: 2px 0 0 24px;
-webkit-appearance: none; appearance: none;
background: #333; border-radius: 2px; outline: none;
}}
.opacity-slider::-webkit-slider-thumb {{
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: #4fc3f7; cursor: pointer;
}}
.basemap-section {{
padding: 10px 14px; border-bottom: 1px solid #2a2a4a;
}}
.basemap-section h3 {{
font-size: 11px; color: #a0a0a0; text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 8px;
}}
.basemap-btn {{
display: inline-block; padding: 5px 10px; margin: 2px;
background: #2a2a4a; color: #ccc; border: 1px solid #444;
border-radius: 4px; cursor: pointer; font-size: 12px;
transition: all 0.15s;
}}
.basemap-btn:hover {{ background: #3a3a6a; }}
.basemap-btn.active {{ background: #4fc3f7; color: #000; border-color: #4fc3f7; }}
#coords {{
position: absolute; bottom: 8px; left: 288px;
background: rgba(26,26,46,0.9); color: #4fc3f7;
padding: 4px 10px; border-radius: 4px; font-size: 12px;
font-family: monospace; z-index: 5;
}}
</style>
</head>
<body>
<div id="panel">
<h1>LiDAR Archéologique</h1>
<div style="padding: 6px 14px; font-size: 12px; color: #888;">
{basename}<br>
<span style="font-size: 10px;">Res: {resolution}m/px | EPSG:2154</span>
</div>
<div class="basemap-section">
<h3>Fond de carte</h3>
<button class="basemap-btn active" onclick="setBasemap('osm')">OSM</button>
<button class="basemap-btn" onclick="setBasemap('ign-photo')">Photo IGN</button>
<button class="basemap-btn" onclick="setBasemap('ign-map')">Carte IGN</button>
</div>
<h2>Visualisations</h2>
<div class="layer-group" id="layers">
{layer_controls_html}
</div>
</div>
<div id="map"></div>
<div id="coords"></div>
<script>
const TITILER_URL = '{titiler_url}';
const BASENAME = '{basename}';
const BOUNDS = {bounds_json};
const LAYERS = {layers_json};
const COG_PATH_PREFIX = '{cog_path_prefix}';
const LAYER_COLORMAPS = {colormaps_json};''')
# JavaScript code — use regular string to avoid f-string issues with {z}/{x}/{y}
html_parts.append(r'''
// Map initialization
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {},
layers: []
},
center: [' + f'{center_lng}, {center_lat}' + r'],
zoom: 13,
maxZoom: 19,
minZoom: 8,
});
// Basemap layers
const basemapStyles = {
'osm': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; OpenStreetMap',
maxzoom: 19
},
'ign-photo': {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILECOL={x}&TILEROW={y}&FORMAT=image/jpeg'],
tileSize: 256,
attribution: '&copy; IGN',
maxzoom: 20
},
'ign-map': {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILECOL={x}&TILEROW={y}&FORMAT=image/png'],
tileSize: 256,
attribution: '&copy; IGN',
maxzoom: 19
}
};
let currentBasemap = 'osm';
function setBasemap(name) {
if (map.getLayer('basemap')) map.removeLayer('basemap');
if (map.getSource('basemap')) map.removeSource('basemap');
currentBasemap = name;
const style = basemapStyles[name];
map.addSource('basemap', style);
map.addLayer({ id: 'basemap', type: 'raster', source: 'basemap' });
document.querySelectorAll('.basemap-btn').forEach(btn => {
const names = {'osm': 'OSM', 'ign-photo': 'Photo IGN', 'ign-map': 'Carte IGN'};
btn.classList.toggle('active', btn.textContent.trim() === names[name]);
});
// Re-add data layers on top of basemap
LAYERS.forEach(layer => {
const layerId = 'layer-' + layer.name;
if (map.getLayer(layerId)) {
map.moveLayer(layerId, 'basemap');
}
});
}
// Add visualization layers
function addVisualizationLayer(layerInfo) {
const name = layerInfo.name;
const isRgb = layerInfo.is_rgb;
const cogUrl = COG_PATH_PREFIX + '/' + layerInfo.file;
let tileUrl;
if (isRgb) {
tileUrl = TITILER_URL + '/cog/tiles/{z}/{x}/{y}.png?url=' +
encodeURIComponent(cogUrl) + '&bidx=1&bidx=2&bidx=3';
} else {
const cmap = LAYER_COLORMAPS[name] || 'viridis';
tileUrl = TITILER_URL + '/cog/tiles/{z}/{x}/{y}.png?url=' +
encodeURIComponent(cogUrl) + '&colormap_name=' + cmap + '&rescale=-1,1';
}
const sourceId = 'source-' + name;
const layerId = 'layer-' + name;
map.addSource(sourceId, {
type: 'raster',
tiles: [tileUrl],
tileSize: 256,
minzoom: 8,
maxzoom: 19,
});
map.addLayer({
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': name === 'hillshade_multi' ? 1.0 : 0.0,
'raster-resampling': 'bilinear',
}
});
}
// Layer control event handlers
document.querySelectorAll('.layer-control input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
const layerId = 'layer-' + cb.dataset.layer;
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility',
cb.checked ? 'visible' : 'none');
}
// When checking a layer, set its opacity to 70% if it was 0
const slider = document.querySelector('.opacity-slider[data-layer="' + cb.dataset.layer + '"]');
if (cb.checked && slider && parseInt(slider.value) === 0) {
slider.value = 70;
const layerId = 'layer-' + cb.dataset.layer;
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'raster-opacity', 0.7);
}}
});
});
document.querySelectorAll('.opacity-slider').forEach(slider => {
slider.addEventListener('input', () => {
const layerId = 'layer-' + slider.dataset.layer;
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'raster-opacity', slider.value / 100);
}
});
});
// Coordinate display
map.on('mousemove', (e) => {
const { lng, lat } = e.lngLat;
document.getElementById('coords').textContent =
lat.toFixed(6) + 'N ' + lng.toFixed(6) + 'E';
});
// Fit map to bounds on load
map.on('load', () => {
setBasemap('osm');
// Add all visualization layers
LAYERS.forEach(layer => {
addVisualizationLayer(layer);
});
// Initially hide all except hillshade
LAYERS.forEach(layer => {
if (layer.name !== 'hillshade_multi') {
const layerId = 'layer-' + layer.name;
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', 'none');
}
}
});
// Fit bounds
map.fitBounds([[BOUNDS.west, BOUNDS.south], [BOUNDS.east, BOUNDS.north]], { padding: 20 });
});
</script>
</body>
</html>''')
html = ''.join(html_parts)
# Write viewer HTML
viewer_dir = output_vis_dir / 'viewer'
viewer_dir.mkdir(exist_ok=True)
viewer_file = viewer_dir / f"{basename}.html"
with open(viewer_file, 'w', encoding='utf-8') as f:
f.write(html)
logger.info(f" Viewer: {viewer_file}")
return viewer_file

49
run.sh
View File

@ -10,22 +10,52 @@
# --debug Mode debug (détails internes fichier:ligne) # --debug Mode debug (détails internes fichier:ligne)
# -f / --force Régénérer tous les fichiers même si existants # -f / --force Régénérer tous les fichiers même si existants
# --keep-tif Conserver les fichiers TIFF intermédiaires # --keep-tif Conserver les fichiers TIFF intermédiaires
# --no-viewer Ne pas générer le viewer web (COGs + HTML)
# --force-classification # --force-classification
# Reclassifier le sol même si le fichier .las existe déjà # Reclassifier le sol même si le fichier .las existe déjà
# --ground-classification {auto,smrf,pmf,csf} # --ground-classification {auto,smrf,pmf,csf}
# Méthode de classification du sol (défaut: auto) # Méthode de classification du sol (défaut: auto)
# --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques # --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques
# --test Exécuter les tests unitaires # --test Exécuter les tests unitaires
# serve Démarrer le serveur cartographique
# -h Afficher l'aide complète # -h Afficher l'aide complète
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT_DIR="${SCRIPT_DIR}/input"
OUTPUT_DIR="${SCRIPT_DIR}/output"
IMAGE_NAME="lidar-lidar"
# Serve command — start the web map server
if [ "$1" = "serve" ]; 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
mkdir -p "$OUTPUT_DIR"
echo "============================================"
echo " Serveur cartographique LiDAR"
echo "============================================"
echo " Viewer: http://localhost:8000/viewer"
echo " TiTiler: http://localhost:8000/cog/"
echo "============================================"
docker run --rm -p 8000:8000 \
-v "${OUTPUT_DIR}:/data/output" \
"$IMAGE_NAME" \
python3 -m lidar_pipeline.server /data/output
exit 0
fi
# Afficher l'aide si aucun argument # Afficher l'aide si aucun argument
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
echo "Pipeline LiDAR Archéologique" echo "Pipeline LiDAR Archéologique"
echo "" echo ""
echo "Usage: $0 [options]" echo "Usage: $0 [options]"
echo " $0 serve # Démarrer le serveur cartographique"
echo "" echo ""
echo "Options:"
echo " -r RESOLUTION Résolution en m/px (défaut: 0.5)" echo " -r RESOLUTION Résolution en m/px (défaut: 0.5)"
echo " -w WORKERS Nombre de workers CPU parallèles (défaut: 1)" echo " -w WORKERS Nombre de workers CPU parallèles (défaut: 1)"
echo " -g Activer l'accélération GPU NVIDIA" echo " -g Activer l'accélération GPU NVIDIA"
@ -35,10 +65,12 @@ if [ $# -eq 0 ]; then
echo " --force-classification" echo " --force-classification"
echo " Reclassifier le sol même si le fichier .las existe" echo " Reclassifier le sol même si le fichier .las existe"
echo " --keep-tif Conserver les fichiers TIFF intermédiaires" echo " --keep-tif Conserver les fichiers TIFF intermédiaires"
echo " --no-viewer Ne pas générer le viewer web"
echo " --ground-classification {auto,smrf,pmf,csf}" echo " --ground-classification {auto,smrf,pmf,csf}"
echo " Méthode de classification du sol (défaut: auto)" echo " Méthode de classification du sol (défaut: auto)"
echo " --file NOM... Traiter un ou plusieurs fichiers LAZ (nom complet sans .laz/.las)" echo " --file NOM... Traiter un ou plusieurs fichiers LAZ (nom complet sans .laz/.las)"
echo " --test Exécuter les tests unitaires" echo " --test Exécuter les tests unitaires"
echo " serve Démarrer le serveur cartographique"
echo " -h Afficher cette aide" echo " -h Afficher cette aide"
echo "" echo ""
echo "Exemples:" echo "Exemples:"
@ -50,14 +82,10 @@ if [ $# -eq 0 ]; then
echo " $0 -g --force-classification # Reclassifier le sol seulement" echo " $0 -g --force-classification # Reclassifier le sol seulement"
echo " $0 -g --ground-classification pmf # Forcer PMF" echo " $0 -g --ground-classification pmf # Forcer PMF"
echo " $0 -g --file LHD_...IGN69.copc # Un fichier" echo " $0 -g --file LHD_...IGN69.copc # Un fichier"
echo " $0 -g --file LHD_...6881.copc LHD_...6882.copc" echo " $0 serve # Démarrer le serveur web"
exit 0 exit 0
fi fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT_DIR="${SCRIPT_DIR}/input"
OUTPUT_DIR="${SCRIPT_DIR}/output"
IMAGE_NAME="lidar-lidar"
RESOLUTION=0.5 RESOLUTION=0.5
WORKERS=1 WORKERS=1
GPU_FLAG="" GPU_FLAG=""
@ -67,6 +95,7 @@ FILE_ARGS=""
GROUND_METHOD="" GROUND_METHOD=""
FORCE_CLASSIFY_FLAG="" FORCE_CLASSIFY_FLAG=""
KEEP_TIF_FLAG="" KEEP_TIF_FLAG=""
NO_VIEWER_FLAG=""
# Parse arguments manually (more robust than getopts for mixed short/long options) # Parse arguments manually (more robust than getopts for mixed short/long options)
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@ -80,6 +109,7 @@ while [ $# -gt 0 ]; do
--force) FORCE_FLAG="--force"; shift ;; --force) FORCE_FLAG="--force"; shift ;;
--force-classification) FORCE_CLASSIFY_FLAG="--force-classification"; shift ;; --force-classification) FORCE_CLASSIFY_FLAG="--force-classification"; shift ;;
--keep-tif) KEEP_TIF_FLAG="--keep-tif"; shift ;; --keep-tif) KEEP_TIF_FLAG="--keep-tif"; shift ;;
--no-viewer) NO_VIEWER_FLAG="--no-viewer"; shift ;;
--ground-classification) GROUND_METHOD="$2"; shift 2 ;; --ground-classification) GROUND_METHOD="$2"; shift 2 ;;
--ground-classification=*) GROUND_METHOD="${1#--ground-classification=}"; shift ;; --ground-classification=*) GROUND_METHOD="${1#--ground-classification=}"; shift ;;
--file) shift; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do FILE_ARGS="$FILE_ARGS $1"; shift; done ;; --file) shift; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do FILE_ARGS="$FILE_ARGS $1"; shift; done ;;
@ -88,7 +118,9 @@ while [ $# -gt 0 ]; do
echo "Pipeline LiDAR Archéologique" echo "Pipeline LiDAR Archéologique"
echo "" echo ""
echo "Usage: $0 [options]" echo "Usage: $0 [options]"
echo " $0 serve # Démarrer le serveur cartographique"
echo "" echo ""
echo "Options:"
echo " -r RESOLUTION Résolution en m/px (défaut: 0.5)" echo " -r RESOLUTION Résolution en m/px (défaut: 0.5)"
echo " -w WORKERS Nombre de workers CPU parallèles (défaut: 1)" echo " -w WORKERS Nombre de workers CPU parallèles (défaut: 1)"
echo " -g Activer l'accélération GPU NVIDIA" echo " -g Activer l'accélération GPU NVIDIA"
@ -98,10 +130,12 @@ while [ $# -gt 0 ]; do
echo " --force-classification" echo " --force-classification"
echo " Reclassifier le sol même si le fichier .las existe" echo " Reclassifier le sol même si le fichier .las existe"
echo " --keep-tif Conserver les fichiers TIFF intermédiaires" echo " --keep-tif Conserver les fichiers TIFF intermédiaires"
echo " --no-viewer Ne pas générer le viewer web"
echo " --ground-classification {auto,smrf,pmf,csf}" echo " --ground-classification {auto,smrf,pmf,csf}"
echo " Méthode de classification du sol (défaut: auto)" echo " Méthode de classification du sol (défaut: auto)"
echo " --file NOM... Traiter un ou plusieurs fichiers LAZ (nom complet sans .laz/.las)" echo " --file NOM... Traiter un ou plusieurs fichiers LAZ (nom complet sans .laz/.las)"
echo " --test Exécuter les tests unitaires" echo " --test Exécuter les tests unitaires"
echo " serve Démarrer le serveur cartographique"
echo " -h Afficher cette aide" echo " -h Afficher cette aide"
echo "" echo ""
echo "Exemples:" echo "Exemples:"
@ -113,7 +147,7 @@ while [ $# -gt 0 ]; do
echo " $0 -g --force-classification # Reclassifier le sol seulement" echo " $0 -g --force-classification # Reclassifier le sol seulement"
echo " $0 -g --ground-classification pmf # Forcer PMF" echo " $0 -g --ground-classification pmf # Forcer PMF"
echo " $0 -g --file LHD_...IGN69.copc # Un fichier" echo " $0 -g --file LHD_...IGN69.copc # Un fichier"
echo " $0 -g --file LHD_...6881.copc LHD_...6882.copc" echo " $0 serve # Démarrer le serveur web"
exit 0 exit 0
;; ;;
*) echo "Option invalide: $1" >&2; exit 1 ;; *) echo "Option invalide: $1" >&2; exit 1 ;;
@ -159,13 +193,14 @@ echo " Verbeux : $([ -n "$VERBOSE_FLAG" ] && echo 'OUI' || echo 'non')"
echo " Force : $([ -n "$FORCE_FLAG" ] && echo 'OUI' || echo 'non')" echo " Force : $([ -n "$FORCE_FLAG" ] && echo 'OUI' || echo 'non')"
echo " Force classif.: $([ -n "$FORCE_CLASSIFY_FLAG" ] && echo 'OUI' || echo 'non')" echo " Force classif.: $([ -n "$FORCE_CLASSIFY_FLAG" ] && echo 'OUI' || echo 'non')"
echo " Keep TIFF : $([ -n "$KEEP_TIF_FLAG" ] && echo 'OUI' || echo 'non')" echo " Keep TIFF : $([ -n "$KEEP_TIF_FLAG" ] && echo 'OUI' || echo 'non')"
echo " Viewer web : $([ -n "$NO_VIEWER_FLAG" ] && echo 'non' || echo 'OUI')"
echo " Classification sol : $([ -n "$GROUND_METHOD" ] && echo "$GROUND_METHOD" || echo 'auto')" echo " Classification sol : $([ -n "$GROUND_METHOD" ] && echo "$GROUND_METHOD" || echo 'auto')"
if [ -n "$FILE_ARGS" ]; then if [ -n "$FILE_ARGS" ]; then
echo " Fichiers :${FILE_ARGS}" echo " Fichiers :${FILE_ARGS}"
fi fi
echo "============================================" echo "============================================"
CMD_ARGS="-o /data/output -r $RESOLUTION -w $WORKERS $VERBOSE_FLAG $FORCE_FLAG $FORCE_CLASSIFY_FLAG $KEEP_TIF_FLAG" CMD_ARGS="-o /data/output -r $RESOLUTION -w $WORKERS $VERBOSE_FLAG $FORCE_FLAG $FORCE_CLASSIFY_FLAG $KEEP_TIF_FLAG $NO_VIEWER_FLAG"
if [ -n "$GROUND_METHOD" ]; then if [ -n "$GROUND_METHOD" ]; then
CMD_ARGS="$CMD_ARGS --ground-classification $GROUND_METHOD" CMD_ARGS="$CMD_ARGS --ground-classification $GROUND_METHOD"
fi fi

View File

@ -10,6 +10,7 @@ setup(
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'lidar-pipeline=lidar_pipeline.cli:main', 'lidar-pipeline=lidar_pipeline.cli:main',
'lidar-server=lidar_pipeline.server:main',
], ],
}, },
) )