Files
lidar_rendu/lidar_pipeline/index.py

633 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Carte globale interactive des tuiles LiDAR traitées.
Génère une page HTML unique (output/index.html) présentant toutes les tuiles
1×1 km traitées, positionnées sur une grille Lambert 93, avec zoom/pan natifs
et sélecteur de visualisation. Permet de choisir rapidement la tuile à examiner.
Sortie:
- output/index.html : page interactive auto-suffisante
- output/index_thumbs/*.jpg : vignettes JPEG (~256px) par tuile/visualisation
Intégration:
- Appelé automatiquement à la fin de process_all() dans pipeline.py
- Régénération solo via --rebuild-index dans cli.py
"""
import json
import logging
import re
from pathlib import Path
logger = logging.getLogger("lidar")
# Noms d'affichage (français) pour le sélecteur de visualisation.
# Clé = mot-clé dans le nom de fichier de sortie (post-basename).
VIZ_LABELS = {
'hillshade_multi': 'Hillshade multidirectionnel',
'slope': 'Pente',
'aspect': 'Aspect',
'mslrm': 'MSRM (relief multi-échelle)',
'sailore': 'SAILORE (LRM adaptatif)',
'positive_openness': 'Openness positive',
'negative_openness': 'Openness négative',
'svf': 'Sky-View Factor',
'aniso_open': 'Openness anisotropique',
'roughness': 'Rugosité',
'wavelet': 'Ondelette',
'flow_acc': 'Accumulation d\'écoulement',
'solar': 'Éclairage solaire',
'anomaly': 'Carte d\'anomalies',
'ortho': 'Orthophoto IGN',
'topo': 'Carte topographique IGN',
}
# Visualisation par défaut pour la vignette (si disponible).
DEFAULT_VIZ = 'hillshade_multi'
# Ordre préféré pour le choix de la vignette de repli.
_VIZ_FALLBACK_ORDER = [
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness',
'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo',
]
# Regex pour parser les coordonnées tuile dans le basename LHD.
# LHD_FXX_{COL}_{ROW}_PTS_LAMB93_IGN69 (COL/ROW en km, Lambert 93)
_RE_LHD_COORDS = re.compile(r'^LHD_FXX_(\d+)_(\d+)_PTS_LAMB93')
def parse_basename_coords(name):
"""Extrait les coordonnées tuile (col, row en km) depuis un basename.
Args:
name: basename potentiel (ex: 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69')
ou nom de dossier avec suffixe résolution ('..._r0p2').
Returns:
(col_km, row_km) ou None si le nom ne correspond pas au pattern LHD.
"""
m = _RE_LHD_COORDS.match(name)
if not m:
return None
return int(m.group(1)), int(m.group(2))
def _strip_res_suffix(dirname):
"""Sépare le basename de base et la résolution d'un nom de dossier de viz.
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69' → (basename, 0.5)
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2' → (basename, 0.2)
Returns:
(basename_without_suffix, resolution_float) ou (dirname, 0.5) si pas de suffixe.
"""
m = re.match(r'^(.+?)_r(\d+p\d+)$', dirname)
if m:
res_str = m.group(2).replace('p', '.')
try:
return m.group(1), float(res_str)
except ValueError:
pass
return dirname, 0.5
def scan_tiles(vis_dir):
"""Scanne le dossier des visualisations pour inventorier les tuiles traitées.
Args:
vis_dir: Path vers output/visualisations/
Returns:
Liste de dictionnaires:
{basename, col, row, resolution, dir_path,
viz: {viz_key: {filename, ext}}, dir_name}
Triée par (resolution, row, col).
"""
vis_dir = Path(vis_dir)
if not vis_dir.is_dir():
return []
tiles = []
for entry in sorted(vis_dir.iterdir()):
if not entry.is_dir():
continue
coords = parse_basename_coords(entry.name)
if coords is None:
continue
col, row = coords
basename, resolution = _strip_res_suffix(entry.name)
# Liste les fichiers image de visualisation dans le dossier.
viz = {}
for f in sorted(entry.iterdir()):
if not f.is_file():
continue
# Détection extension AVIF/WebP
ext = None
low = f.name.lower()
for e in ('.avif', '.webp'):
if low.endswith(e):
ext = e.lstrip('.')
break
if ext is None:
continue
# viz_key = nom sans le préfixe basename_ ni l'extension
stem = f.name[:-len('.' + ext)]
prefix = basename + '_'
if not stem.startswith(prefix):
continue
viz_key = stem[len(prefix):]
viz[viz_key] = {'filename': f.name, 'ext': ext}
if not viz:
# Dossier vide ou sans image valide → ignoré
continue
tiles.append({
'basename': basename,
'col': col,
'row': row,
'resolution': resolution,
'dir_name': entry.name,
'dir_path': str(entry),
'viz': viz,
})
tiles.sort(key=lambda t: (t['resolution'], -t['row'], t['col']))
return tiles
def compute_bbox(tiles):
"""Calcule la bounding box (en km) couverte par les tuiles.
Returns:
Dict {min_col, max_col, min_row, max_row} ou None si aucune tuile.
"""
if not tiles:
return None
cols = [t['col'] for t in tiles]
rows = [t['row'] for t in tiles]
return {
'min_col': min(cols),
'max_col': max(cols),
'min_row': min(rows),
'max_row': max(rows),
}
def generate_thumbnail(src_path, thumb_path, max_size=256):
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
Args:
src_path: chemin de l'image source (AVIF/WebP).
thumb_path: chemin de sortie JPEG.
max_size: taille maximale (côté le plus grand) en pixels.
Returns:
True si OK, False si échec.
"""
try:
from PIL import Image as PILImage
except ImportError:
logger.warning("PIL indisponible — impossible de générer les vignettes")
return False
try:
img = PILImage.open(str(src_path))
img = img.convert('RGB')
w, h = img.size
scale = min(1.0, max_size / max(w, h))
if scale < 1.0:
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
try:
resample = PILImage.Resampling.LANCZOS
except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
img = img.resize(new_size, resample)
Path(thumb_path).parent.mkdir(parents=True, exist_ok=True)
img.save(str(thumb_path), format='JPEG', quality=80)
return True
except Exception as e:
logger.debug(f"Vignette ignorée {src_path}: {e}")
return False
def _pick_display_viz(viz_keys):
"""Choisit la visualisation par défaut pour une tuile.
Privilégie hillshade_multi, sinon la première disponible selon l'ordre de repli.
"""
for v in _VIZ_FALLBACK_ORDER:
if v in viz_keys:
return v
return sorted(viz_keys)[0]
def build_index(output_dir, output_format='avif'):
"""Génère la carte interactive HTML des tuiles traitées.
Scanne output_dir/visualisations/, génère les vignettes JPEG, puis écrit
output_dir/index.html (auto-suffisant) + output_dir/index_thumbs/.
Args:
output_dir: dossier de sortie racine (contient visualisations/).
output_format: format des images ('avif' ou 'webp') — pour info.
Returns:
Path vers index.html si succès, None si échec ou aucune tuile.
"""
output_dir = Path(output_dir)
vis_dir = output_dir / 'visualisations'
tiles = scan_tiles(vis_dir)
if not tiles:
logger.info("Aucune tuile traitée trouvée — index global non généré")
return None
bbox = compute_bbox(tiles)
assert bbox is not None # garanti par le test tiles non vide ci-dessus
thumb_dir = output_dir / 'index_thumbs'
thumb_dir.mkdir(parents=True, exist_ok=True)
# Collecte toutes les visualisations disponibles (pour le sélecteur).
all_viz_keys = set()
for t in tiles:
all_viz_keys.update(t['viz'].keys())
# Génère les vignettes et construit les données pour le HTML.
tile_records = []
thumbs_generated = 0
thumbs_failed = 0
for t in tiles:
viz_thumbs = {}
for viz_key, info in t['viz'].items():
src = Path(t['dir_path']) / info['filename']
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
thumb_path = thumb_dir / thumb_name
# Régénère seulement si manquante
if not thumb_path.exists():
if generate_thumbnail(src, thumb_path):
thumbs_generated += 1
else:
thumbs_failed += 1
continue
else:
thumbs_generated += 1
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{t['dir_name']}/{info['filename']}",
}
if not viz_thumbs:
continue
display_viz = _pick_display_viz(viz_thumbs.keys())
tile_records.append({
'col': t['col'],
'row': t['row'],
'name': t['basename'],
'dir_name': t['dir_name'],
'resolution': t['resolution'],
'display_viz': display_viz,
'viz': viz_thumbs,
})
if not tile_records:
logger.warning("Aucune vignette générée — index global abandonné")
return None
# HTML avec données intégrées.
html = _render_html(tile_records, bbox, all_viz_keys, output_format)
html_path = output_dir / 'index.html'
html_path.write_text(html, encoding='utf-8')
logger.info(f"Index global généré : {html_path}")
logger.info(f" {len(tile_records)} tuile(s) • {thumbs_generated} vignette(s) générée(s)"
+ (f" • {thumbs_failed} échec(s)" if thumbs_failed else ""))
logger.info(f" Grille : {bbox['min_col']}-{bbox['max_col']} km E × "
f"{bbox['min_row']}-{bbox['max_row']} km N")
return html_path
def _render_html(tile_records, bbox, all_viz_keys, output_format):
"""Construit le HTML complet avec CSS et JS natifs (zoom/pan)."""
# Ordre des viz dans le sélecteur (selon ordre préféré puis alpha).
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in all_viz_keys]
for v in sorted(all_viz_keys):
if v not in ordered_viz:
ordered_viz.append(v)
data_json = json.dumps({
'tiles': tile_records,
'bbox': bbox,
'vizList': ordered_viz,
}, ensure_ascii=False)
options_html = '\n'.join(
f' <option value="{v}"{" selected" if v == DEFAULT_VIZ else ""}>'
f'{VIZ_LABELS.get(v, v)}</option>'
for v in ordered_viz
)
n_tiles = len(tile_records)
grid_w = bbox['max_col'] - bbox['min_col'] + 1
grid_h = bbox['max_row'] - bbox['min_row'] + 1
return _HTML_TEMPLATE.format(
data_json=data_json,
options_html=options_html,
n_tiles=n_tiles,
grid_w=grid_w,
grid_h=grid_h,
output_format=output_format.upper(),
)
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte globale des tuiles LiDAR</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
html, body {{ height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; overflow: hidden; }}
#topbar {{
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding: 10px 16px; background: #16213e; border-bottom: 1px solid #0f3460;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}}
#topbar h1 {{ font-size: 16px; font-weight: 600; color: #e94560; white-space: nowrap; }}
#topbar .stat {{ font-size: 13px; color: #a0a0c0; white-space: nowrap; }}
#topbar label {{ font-size: 13px; color: #a0a0c0; }}
#topbar select {{
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
padding: 5px 8px; border-radius: 4px; font-size: 13px; cursor: pointer;
}}
#topbar select:hover {{ border-color: #e94560; }}
.spacer {{ flex: 1; }}
.controls {{ display: flex; gap: 6px; }}
.controls button {{
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
width: 34px; height: 34px; border-radius: 4px; font-size: 18px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}}
.controls button:hover {{ background: #1a4a7a; border-color: #e94560; }}
.controls button:active {{ background: #e94560; }}
#viewport {{
position: absolute; top: 56px; left: 0; right: 0; bottom: 0;
overflow: hidden; cursor: grab; background: #0d0d1a;
}}
#viewport.dragging {{ cursor: grabbing; }}
#grid {{
position: absolute; transform-origin: 0 0;
/* Dimensions fixées en JS selon le nombre de cellules */
}}
.cell {{
position: absolute; border: 1px solid #2a2a4a; background: #111122;
overflow: hidden; display: block; text-decoration: none;
}}
.cell.has-tile {{ border-color: #0f3460; }}
.cell.has-tile:hover {{ border-color: #e94560; box-shadow: 0 0 12px rgba(233,69,96,0.5); z-index: 10; }}
.cell img {{ width: 100%; height: 100%; object-fit: cover; display: block; }}
.cell .label {{
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,0.65); color: #e0e0e0; font-size: 9px;
padding: 2px 4px; text-align: center; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; opacity: 0; transition: opacity 0.15s;
}}
.cell:hover .label {{ opacity: 1; }}
.cell.empty {{ display: flex; align-items: center; justify-content: center; opacity: 0.3; }}
.cell.empty .coords {{ font-size: 10px; color: #444466; }}
#zoomIndicator {{
position: fixed; bottom: 12px; left: 12px; z-index: 100;
background: rgba(22,33,62,0.9); padding: 6px 12px; border-radius: 4px;
font-size: 12px; color: #a0a0c0; border: 1px solid #0f3460;
}}
#hint {{
position: fixed; bottom: 12px; right: 12px; z-index: 100;
background: rgba(22,33,62,0.9); padding: 8px 12px; border-radius: 4px;
font-size: 11px; color: #808090; border: 1px solid #0f3460; max-width: 280px;
}}
</style>
</head>
<body>
<div id="topbar">
<h1>Carte globale LiDAR</h1>
<span class="stat" id="statTiles">{n_tiles} tuile(s) • {grid_w}×{grid_h} km</span>
<label>Visualisation :</label>
<select id="vizSelect">
{options_html}
</select>
<div class="spacer"></div>
<div class="controls">
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Réinitialiser la vue" style="font-size:14px">⤢</button>
<button id="zoomIn" title="Zoomer">+</button>
</div>
</div>
<div id="viewport">
<div id="grid"></div>
</div>
<div id="zoomIndicator">Zoom : 100%</div>
<div id="hint">
Molette : zoom • Clic-glisser : déplacer<br>
Double-clic : zoom rapide • Clic tuile : ouvrir l'image
</div>
<script>
const DATA = {data_json};
const CELL_PX = 200; // taille d'une cellule 1×1 km (en px, à zoom 1)
const GAP_PX = 2;
const bbox = DATA.bbox;
const gridCols = bbox.max_col - bbox.min_col + 1;
const gridRows = bbox.max_row - bbox.min_row + 1;
const grid = document.getElementById('grid');
const viewport = document.getElementById('viewport');
const zoomInd = document.getElementById('zoomIndicator');
const vizSelect = document.getElementById('vizSelect');
grid.style.width = (gridCols * CELL_PX) + 'px';
grid.style.height = (gridRows * CELL_PX) + 'px';
// Index des tuiles par (col,row)
const tileMap = {{}};
for (const t of DATA.tiles) {{
tileMap[t.col + ',' + t.row] = t;
}}
// --- Construction de la grille (toutes les cellules, y compris vides) ---
function colToX(col) {{ return (col - bbox.min_col) * CELL_PX; }}
function rowToY(row) {{ return (bbox.max_row - row) * CELL_PX; }} // Y inversé (Nord en haut)
const fragment = document.createDocumentFragment();
for (let r = bbox.max_row; r >= bbox.min_row; r--) {{
for (let c = bbox.min_col; c <= bbox.max_col; c++) {{
const key = c + ',' + r;
const tile = tileMap[key];
const cell = document.createElement('a');
cell.className = 'cell' + (tile ? ' has-tile' : ' empty');
cell.style.left = colToX(c) + 'px';
cell.style.top = rowToY(r) + 'px';
cell.style.width = (CELL_PX - GAP_PX) + 'px';
cell.style.height = (CELL_PX - GAP_PX) + 'px';
if (tile) {{
cell.target = '_blank';
cell.title = tile.name;
const img = document.createElement('img');
img.loading = 'lazy';
cell.appendChild(img);
const label = document.createElement('div');
label.className = 'label';
label.textContent = tile.name;
cell.appendChild(label);
}} else {{
const coords = document.createElement('div');
coords.className = 'coords';
coords.textContent = c + '\\n' + r;
coords.style.whiteSpace = 'pre';
cell.appendChild(coords);
}}
fragment.appendChild(cell);
}}
}}
grid.appendChild(fragment);
// --- Gestion de la visualisation affichée ---
function applyViz(vizKey) {{
const cells = grid.querySelectorAll('.cell.has-tile');
cells.forEach(cell => {{
// Retrouve la tuile via la position
const left = parseFloat(cell.style.left);
const top = parseFloat(cell.style.top);
const col = bbox.min_col + Math.round(left / CELL_PX);
const row = bbox.max_row - Math.round(top / CELL_PX);
const tile = tileMap[col + ',' + row];
if (!tile) return;
const img = cell.querySelector('img');
// Viz demandée, sinon display_viz, sinon première dispo
let chosen = vizKey;
if (!tile.viz[chosen]) chosen = tile.display_viz;
if (!tile.viz[chosen]) chosen = Object.keys(tile.viz)[0];
const v = tile.viz[chosen];
if (img) {{
img.src = v.thumb;
}}
cell.href = v.full;
}});
}}
applyViz(vizSelect.value);
vizSelect.addEventListener('change', () => applyViz(vizSelect.value));
// --- Zoom / Pan ---
let zoom = 1, panX = 0, panY = 0;
let isDragging = false, dragStartX = 0, dragStartY = 0, startPanX = 0, startPanY = 0;
function applyTransform() {{
grid.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`;
zoomInd.textContent = 'Zoom : ' + Math.round(zoom * 100) + '%';
}}
function clampPan() {{
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const gw = gridCols * CELL_PX * zoom;
const gh = gridRows * CELL_PX * zoom;
// Permet un léger débordement pour ne pas bloquer le déplacement
panX = Math.min(vpW * 0.5, Math.max(vpW - gw - vpW * 0.5, panX));
panY = Math.min(vpH * 0.5, Math.max(vpH - gh - vpH * 0.5, panY));
// Si la grille est plus petite que le viewport, centre
if (gw < vpW) panX = (vpW - gw) / 2;
if (gh < vpH) panY = (vpH - gh) / 2;
}}
function resetView() {{
zoom = 1;
panX = (viewport.clientWidth - gridCols * CELL_PX) / 2;
panY = (viewport.clientHeight - gridRows * CELL_PX) / 2;
applyTransform();
}}
function zoomAt(factor, cx, cy) {{
const newZoom = Math.max(0.2, Math.min(20, zoom * factor));
const realFactor = newZoom / zoom;
// Garde le point (cx,cy) fixe dans la grille
panX = cx - (cx - panX) * realFactor;
panY = cy - (cy - panY) * realFactor;
zoom = newZoom;
clampPan();
applyTransform();
}}
viewport.addEventListener('wheel', (e) => {{
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top);
}}, {{ passive: false }});
viewport.addEventListener('mousedown', (e) => {{
if (e.target.closest('a.has-tile')) return; // ne déplace pas si clic sur tuile
isDragging = true;
viewport.classList.add('dragging');
dragStartX = e.clientX;
dragStartY = e.clientY;
startPanX = panX;
startPanY = panY;
}});
window.addEventListener('mousemove', (e) => {{
if (!isDragging) return;
panX = startPanX + (e.clientX - dragStartX);
panY = startPanY + (e.clientY - dragStartY);
clampPan();
applyTransform();
}});
window.addEventListener('mouseup', () => {{
isDragging = false;
viewport.classList.remove('dragging');
}});
viewport.addEventListener('dblclick', (e) => {{
if (e.target.closest('a.has-tile')) return;
const rect = viewport.getBoundingClientRect();
zoomAt(2, e.clientX - rect.left, e.clientY - rect.top);
}});
document.getElementById('zoomIn').addEventListener('click', () => {{
zoomAt(1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
}});
document.getElementById('zoomOut').addEventListener('click', () => {{
zoomAt(1 / 1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
}});
document.getElementById('zoomReset').addEventListener('click', resetView);
// Recalcule au redimensionnement de la fenêtre
window.addEventListener('resize', () => {{ clampPan(); applyTransform(); }});
// Vue initiale centrée
resetView();
</script>
</body>
</html>
"""