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:
@ -116,6 +116,11 @@ Exemples:
|
||||
action="store_true",
|
||||
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(
|
||||
"--ground-classification",
|
||||
choices=["auto", "smrf", "pmf", "csf"],
|
||||
@ -170,7 +175,8 @@ Exemples:
|
||||
force=args.force,
|
||||
ground_method=args.ground_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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -527,6 +527,146 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False):
|
||||
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):
|
||||
"""Generate A3 PDF report for a LiDAR file with all visualizations.
|
||||
|
||||
|
||||
117
lidar_pipeline/server.py
Normal file
117
lidar_pipeline/server.py
Normal 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
416
lidar_pipeline/viewer.py
Normal 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: '© 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: '© 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: '© 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
|
||||
Reference in New Issue
Block a user