"""Carte continue 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, affichées en carte continue sans bordure, avec zoom/pan fluide et sélecteur de visualisation. Au clic sur une tuile, un modal affiche l'image avec légende et permet de télécharger en PDF. 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', } # Informations de colormap pour génération de légende côté navigateur. # Utilisées par le JS pour dessiner la légende sur le canvas avant export PDF. VIZ_COLORMAPS = { 'hillshade_multi': {'cmap': 'gray', 'diverging': False}, 'slope': {'cmap': 'inferno', 'diverging': False}, 'aspect': {'cmap': 'twilight', 'diverging': False}, 'mslrm': {'cmap': 'seismic', 'diverging': True}, 'sailore': {'cmap': 'seismic', 'diverging': True}, 'positive_openness': {'cmap': 'YlOrBr', 'diverging': False}, 'negative_openness': {'cmap': 'PuBu', 'diverging': False}, 'svf': {'cmap': 'hot_r', 'diverging': False}, 'aniso_open': {'cmap': 'seismic', 'diverging': True}, 'roughness': {'cmap': 'plasma', 'diverging': False}, 'wavelet': {'cmap': 'cividis', 'diverging': False}, 'flow_acc': {'cmap': 'YlGn', 'diverging': False}, 'solar': {'cmap': 'gray', 'diverging': False}, 'anomaly': {'cmap': 'YlOrRd', 'diverging': False}, 'ortho': {'cmap': 'rgb', 'diverging': False}, 'topo': {'cmap': 'rgb', 'diverging': False}, } # 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 continue 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 (carte continue + modal PDF).""" # 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) viz_meta_json = json.dumps({ k: {'label': VIZ_LABELS.get(k, k), 'cmap': VIZ_COLORMAPS.get(k, {}).get('cmap', 'terrain'), 'diverging': VIZ_COLORMAPS.get(k, {}).get('diverging', False)} for k in ordered_viz }, ensure_ascii=False) options_html = '\n'.join( f' ' 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, viz_meta_json=viz_meta_json, options_html=options_html, n_tiles=n_tiles, grid_w=grid_w, grid_h=grid_h, output_format=output_format.upper(), ) _HTML_TEMPLATE = """