Files
lidar_rendu/lidar_pipeline/viewer.py
Jacquin Antoine f01683819c 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>
2026-05-10 17:15:37 +02:00

416 lines
13 KiB
Python

"""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