From 54dbec145e07341daf3f25c3a97c638bf9f374eb Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Tue, 28 Jul 2026 23:20:20 +0200 Subject: [PATCH] prepare: add index module, update pipeline, tests, Dockerfile, and run.sh --- Dockerfile | 11 +- lidar_pipeline/cli.py | 31 ++ lidar_pipeline/index.py | 632 +++++++++++++++++++++++++ lidar_pipeline/pipeline.py | 17 +- lidar_pipeline/tests/test_dtm.py | 30 +- lidar_pipeline/tests/test_index.py | 205 ++++++++ lidar_pipeline/tests/test_rendering.py | 2 +- lidar_pipeline/visualizations.py | 21 +- run.sh | 10 + 9 files changed, 924 insertions(+), 35 deletions(-) create mode 100644 lidar_pipeline/index.py create mode 100644 lidar_pipeline/tests/test_index.py diff --git a/Dockerfile b/Dockerfile index ac839c7..ca40e83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nvidia/cuda:11.8.0-devel-ubuntu22.04 +FROM nvidia/cuda:12.9.2-devel-ubuntu22.04 ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Europe/Paris @@ -45,14 +45,13 @@ RUN pip3 install --no-cache-dir \ pillow-avif-plugin \ cmcrameri -# CuPy 13.4 on CUDA 11.8 with JIT compilation (CUPY_CUDA_COMPILE_WITH_CACHE=1). +# CuPy on CUDA 12.9 with JIT compilation (CUPY_CUDA_COMPILE_WITH_CACHE=1). # JIT allows CuPy to compile kernels at runtime for GPU architectures not in -# the pre-built wheel (sm_89 = RTX 4060 Ti). nvcc must be in PATH at runtime. -# NOTE: RTX 5060 (sm_120) is NOT yet supported by any CuPy version. -# The pipeline auto-detects the best usable GPU (falls back to 4060 Ti). +# the pre-built wheel (sm_89 = RTX 4060 Ti, sm_120 = RTX 5060 Ti). +# nvcc must be in PATH at runtime for JIT compilation. ENV CUPY_CUDA_COMPILE_WITH_CACHE=1 ENV PATH=/usr/local/cuda/bin:${PATH} -RUN pip3 install --no-cache-dir cupy-cuda11x==13.4.0 +RUN pip3 install --no-cache-dir cupy-cuda12x # Copy and install the pipeline package COPY setup.py . diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 0e1112f..e79206f 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -195,6 +195,16 @@ def main(): action="store_true", help="Mode debug : affiche les détails internes (fichier:ligne)" ) + parser.add_argument( + "--rebuild-index", + action="store_true", + help="Régénérer uniquement la carte globale HTML des tuiles déjà traitées (sans retraiter)" + ) + parser.add_argument( + "--no-index", + action="store_true", + help="Ne pas générer la carte globale à la fin du traitement" + ) args = parser.parse_args() @@ -233,6 +243,16 @@ def main(): log_gpu_status() try: + # Mode --rebuild-index : régénère uniquement la carte globale, sans retraiter + if args.rebuild_index: + from .index import build_index + index_path = build_index(args.output, args.format) + if index_path: + logger.info(f"Carte globale générée : {index_path}") + else: + logger.warning("Aucune tuile traitée trouvée — carte globale non générée") + return + quality = 100 if args.lossless else args.quality # Parse --only and --skip: accept comma-separated values only_viz = None @@ -255,6 +275,7 @@ def main(): skip_viz=skip_viz, output_format=args.format, gpu_ids=gpu_ids, + no_index=args.no_index, ) # If --file is specified, process only matching files @@ -308,6 +329,16 @@ def main(): logger.info(" ✓ Fichiers temporaires supprimés") except Exception as e: logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}") + + # Génère la carte globale après traitement --file + if not args.no_index: + try: + from .index import build_index + index_path = build_index(pipeline.output_dir, pipeline.output_format) + if index_path: + logger.info(f"Carte globale générée : {index_path}") + except Exception as e: + logger.warning(f"Index global non généré: {e}") else: pipeline.process_all() except Exception as e: diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py new file mode 100644 index 0000000..990141a --- /dev/null +++ b/lidar_pipeline/index.py @@ -0,0 +1,632 @@ +"""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' ' + 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 = """ + + + + +Carte globale des tuiles LiDAR + + + + +
+

Carte globale LiDAR

+ {n_tiles} tuile(s) • {grid_w}×{grid_h} km + + +
+
+ + + +
+
+ +
+
+
+ +
Zoom : 100%
+
+ Molette : zoom • Clic-glisser : déplacer
+ Double-clic : zoom rapide • Clic tuile : ouvrir l'image +
+ + + + + +""" diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index fe343f8..0bb0e72 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -66,7 +66,7 @@ from .visualizations import ( generate_flow_accumulation, generate_anomaly_mask, ) -from .gpu import gpu_cleanup, num_gpus, restrict_gpus, safe_gpu_call +from .gpu import gpu_cleanup, num_gpus, available_gpu_ids, restrict_gpus, safe_gpu_call from .ign import generate_ign_overlay from .rendering import tif_to_png @@ -109,7 +109,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, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None): + def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False, keep_tif=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False): self.input_dir = Path(input_dir) self.output_dir = Path(output_dir) # Accept single float or comma-separated string for multi-resolution @@ -130,6 +130,7 @@ class LidarArchaeoPipeline: self.skip_viz = skip_viz self.output_format = output_format self.gpu_ids = gpu_ids + self.no_index = no_index self.temp_dir = self.output_dir / "temp" if not self.input_dir.exists(): @@ -467,7 +468,7 @@ class LidarArchaeoPipeline: with ProcessPoolExecutor(max_workers=self.workers) as executor: # Round-robin assign each file to a real GPU host index - active_ids = self.gpu_ids if self.gpu_ids else _gpu_mod.available_gpu_ids() + active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids() resolutions_str = ','.join(str(r) for r in self.resolutions) future_to_file = { executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.force_classify, self.keep_tif, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None): laz_file @@ -529,6 +530,16 @@ class LidarArchaeoPipeline: logger.info(f" • DTM : {self.dtm_dir}") logger.info(f" • Visualisations: {self.vis_dir}") + # Génère la carte globale interactive des tuiles traitées + if not self.no_index: + try: + from .index import build_index + index_path = build_index(self.output_dir, self.output_format) + if index_path: + logger.info(f" • Carte globale : {index_path}") + except Exception as e: + logger.warning(f"Index global non généré: {e}") + # Clean up temporary files logger.info("Nettoyage des fichiers temporaires...") try: diff --git a/lidar_pipeline/tests/test_dtm.py b/lidar_pipeline/tests/test_dtm.py index 0393b8d..78c5587 100644 --- a/lidar_pipeline/tests/test_dtm.py +++ b/lidar_pipeline/tests/test_dtm.py @@ -110,8 +110,9 @@ class TestDetectGroundMethod: mock_las.points.__len__ = lambda self: len(num_returns) return mock_las - @patch('lidar_pipeline.dtm.laspy') - def test_urban_terrain_returns_csf(self, mock_laspy): + @patch('lidar_pipeline.dtm._read_with_pdal') + @patch('laspy.read') + def test_urban_terrain_returns_csf(self, mock_read, mock_pdal): """High single-return ratio (>0.6) should select CSF.""" from lidar_pipeline.dtm import detect_ground_method @@ -121,13 +122,14 @@ class TestDetectGroundMethod: num_returns[:int(n * 0.3)] = 2 # 30% multi-return z_values = np.random.normal(100, 5, n) # Low variance = flat terrain - mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values) + mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'csf' - @patch('lidar_pipeline.dtm.laspy') - def test_natural_terrain_returns_smrf(self, mock_laspy): + @patch('lidar_pipeline.dtm._read_with_pdal') + @patch('laspy.read') + def test_natural_terrain_returns_smrf(self, mock_read, mock_pdal): """Low single-return ratio and moderate variance should select SMRF.""" from lidar_pipeline.dtm import detect_ground_method @@ -137,13 +139,14 @@ class TestDetectGroundMethod: num_returns[:int(n * 0.6)] = 2 # 60% multi-return (forest) z_values = np.random.normal(100, 15, n) # Moderate variance - mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values) + mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'smrf' - @patch('lidar_pipeline.dtm.laspy') - def test_mountainous_terrain_returns_csf(self, mock_laspy): + @patch('lidar_pipeline.dtm._read_with_pdal') + @patch('laspy.read') + def test_mountainous_terrain_returns_csf(self, mock_read, mock_pdal): """High variance terrain (>30m std) selects CSF for complex terrain.""" from lidar_pipeline.dtm import detect_ground_method @@ -153,7 +156,7 @@ class TestDetectGroundMethod: num_returns[:int(n * 0.5)] = 2 z_values = np.random.normal(100, 50, n) # Very high variance = mountainous - mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values) + mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'csf' @@ -161,8 +164,7 @@ class TestDetectGroundMethod: class TestClassifyGroundMethod: @patch('lidar_pipeline.dtm.subprocess') - @patch('lidar_pipeline.dtm.laspy') - def test_classify_ground_auto_calls_detect(self, mock_laspy, mock_subprocess): + def test_classify_ground_auto_calls_detect(self, mock_subprocess): """classify_ground with method='auto' should call detect_ground_method.""" from lidar_pipeline.dtm import classify_ground @@ -174,8 +176,7 @@ class TestClassifyGroundMethod: mock_detect.assert_called_once() @patch('lidar_pipeline.dtm.subprocess') - @patch('lidar_pipeline.dtm.laspy') - def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_laspy, mock_subprocess): + def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_subprocess): """classify_ground with method='smrf' should create SMRF pipeline.""" from lidar_pipeline.dtm import classify_ground, _create_ground_pipeline @@ -195,8 +196,7 @@ class TestClassifyGroundMethod: assert "filters.smrf" in stage_types @patch('lidar_pipeline.dtm.subprocess') - @patch('lidar_pipeline.dtm.laspy') - def test_classify_ground_csf_uses_csf_pipeline(self, mock_laspy, mock_subprocess): + def test_classify_ground_csf_uses_csf_pipeline(self, mock_subprocess): """classify_ground with method='csf' should create CSF pipeline.""" from lidar_pipeline.dtm import classify_ground diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py new file mode 100644 index 0000000..3bbe312 --- /dev/null +++ b/lidar_pipeline/tests/test_index.py @@ -0,0 +1,205 @@ +"""Tests pour la carte globale interactive (index.py).""" + +import json +from pathlib import Path + + +def test_parse_basename_coords_valid(): + """Parse les coordonnées d'un basename LHD valide.""" + from lidar_pipeline.index import parse_basename_coords + assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69") == (1000, 6881) + assert parse_basename_coords("LHD_FXX_1049_6895_PTS_LAMB93_IGN69") == (1049, 6895) + + +def test_parse_basename_coords_with_res_suffix(): + """Les noms de dossier avec suffixe résolution sont aussi parsables.""" + from lidar_pipeline.index import parse_basename_coords + assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2") == (1000, 6881) + + +def test_parse_basename_coords_invalid(): + """Les noms non-LHD retournent None.""" + from lidar_pipeline.index import parse_basename_coords + assert parse_basename_coords("random_dir") is None + assert parse_basename_coords("DTM") is None + assert parse_basename_coords("") is None + + +def test_strip_res_suffix_primary(): + """Dossier sans suffixe = résolution primaire (0.5).""" + from lidar_pipeline.index import _strip_res_suffix + base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69") + assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" + assert res == 0.5 + + +def test_strip_res_suffix_multi(): + """Dossier avec suffixe _r0p2 = résolution 0.2.""" + from lidar_pipeline.index import _strip_res_suffix + base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2") + assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" + assert res == 0.2 + + +def test_compute_bbox(): + """Calcule la bounding box d'un ensemble de tuiles.""" + from lidar_pipeline.index import compute_bbox + tiles = [ + {'col': 1000, 'row': 6881}, + {'col': 1001, 'row': 6882}, + {'col': 1002, 'row': 6880}, + ] + bbox = compute_bbox(tiles) + assert bbox == {'min_col': 1000, 'max_col': 1002, 'min_row': 6880, 'max_row': 6882} + + +def test_compute_bbox_empty(): + """Aucune tuile → None.""" + from lidar_pipeline.index import compute_bbox + assert compute_bbox([]) is None + + +def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi', 'svf'), ext='webp', res_suffix=''): + """Crée un faux dossier de visualisations avec de petites images.""" + from PIL import Image as PILImage + import numpy as np + + dir_name = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}" + tile_dir = Path(vis_dir) / dir_name + tile_dir.mkdir(parents=True, exist_ok=True) + for v in viz_keys: + arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8) + img = PILImage.fromarray(arr) + fname = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}_{v}.{ext}" + img.save(str(tile_dir / fname), format='WEBP', quality=80) + return tile_dir + + +def test_scan_tiles(tmp_path): + """scan_tiles détecte les dossiers de tuiles et leurs visualisations.""" + from lidar_pipeline.index import scan_tiles + + vis_dir = tmp_path / "visualisations" + vis_dir.mkdir() + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf')) + _make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',)) + + tiles = scan_tiles(vis_dir) + assert len(tiles) == 2 + names = sorted(t['dir_name'] for t in tiles) + assert "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" in names + assert "LHD_FXX_1001_6881_PTS_LAMB93_IGN69" in names + # Vérifie que les viz sont détectées + t0 = next(t for t in tiles if t['col'] == 1000) + assert 'hillshade_multi' in t0['viz'] + assert 'svf' in t0['viz'] + + +def test_scan_tiles_ignores_non_lhd(tmp_path): + """Les dossiers non-LHD (ex: temp, DTM) sont ignorés.""" + from lidar_pipeline.index import scan_tiles + + vis_dir = tmp_path / "visualisations" + vis_dir.mkdir() + (vis_dir / "random_folder").mkdir() + _make_fake_viz_dir(vis_dir, "a", 1000, 6881) + + tiles = scan_tiles(vis_dir) + assert len(tiles) == 1 + assert tiles[0]['col'] == 1000 + + +def test_scan_tiles_multi_resolution(tmp_path): + """Les dossiers avec suffixe résolution sont correctement décodés.""" + from lidar_pipeline.index import scan_tiles + + vis_dir = tmp_path / "visualisations" + vis_dir.mkdir() + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='') + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='_r0p2') + + tiles = scan_tiles(vis_dir) + assert len(tiles) == 2 + resolutions = sorted(t['resolution'] for t in tiles) + assert resolutions == [0.2, 0.5] + + +def test_build_index_generates_html(tmp_path): + """build_index génère index.html et les vignettes.""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf')) + _make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',)) + + result = build_index(output_dir) + assert result is not None + html_path = Path(result) + assert html_path.exists() + assert html_path.name == "index.html" + + content = html_path.read_text(encoding='utf-8') + # Vérifie la présence des éléments clés + assert "Carte globale LiDAR" in content + assert "LHD_FXX_1000_6881" in content + assert "LHD_FXX_1001_6881" in content + # Vérifie que le JSON intégré est valide + assert "const DATA" in content + # Vérifie les vignettes générées + thumb_dir = output_dir / "index_thumbs" + assert thumb_dir.is_dir() + thumbs = list(thumb_dir.glob("*.jpg")) + assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile + + +def test_build_index_empty_returns_none(tmp_path): + """Aucune tuile → build_index retourne None sans crash.""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + (output_dir / "visualisations").mkdir(parents=True) + result = build_index(output_dir) + assert result is None + + +def test_build_index_embeds_valid_json(tmp_path): + """Le JSON embarqué dans le HTML est valide et contient les tuiles.""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',)) + + build_index(output_dir) + content = (output_dir / "index.html").read_text(encoding='utf-8') + # Extrait le JSON entre "const DATA = " et ";" + start = content.index("const DATA = ") + len("const DATA = ") + # Trouve le ; de fin de déclaration + depth = 0 + end = start + for i, ch in enumerate(content[start:], start): + if ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + end = i + 1 + break + data = json.loads(content[start:end]) + assert 'tiles' in data + assert 'bbox' in data + assert 'vizList' in data + assert len(data['tiles']) == 1 + assert data['tiles'][0]['col'] == 1000 + assert data['tiles'][0]['row'] == 6881 + + +def test_pick_display_viz_prefers_hillshade(): + """Le choix de viz par défaut privilégie hillshade_multi.""" + from lidar_pipeline.index import _pick_display_viz + assert _pick_display_viz(['svf', 'hillshade_multi', 'slope']) == 'hillshade_multi' + assert _pick_display_viz(['svf', 'slope']) == 'svf' + assert _pick_display_viz(['topo']) == 'topo' diff --git a/lidar_pipeline/tests/test_rendering.py b/lidar_pipeline/tests/test_rendering.py index 166d2eb..69f1fba 100644 --- a/lidar_pipeline/tests/test_rendering.py +++ b/lidar_pipeline/tests/test_rendering.py @@ -62,7 +62,7 @@ class TestTifToPng: result = tif_to_png(tif_file, tmp_path, 5.0) assert result is not None assert result.exists() - assert result.suffix == '.webp' + assert result.suffix == '.avif' def test_removes_source_tif(self, tmp_path): from lidar_pipeline.rendering import tif_to_png diff --git a/lidar_pipeline/visualizations.py b/lidar_pipeline/visualizations.py index 857899d..028d744 100644 --- a/lidar_pipeline/visualizations.py +++ b/lidar_pipeline/visualizations.py @@ -16,7 +16,7 @@ from pathlib import Path import numpy as np import rasterio -from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter, xp_maximum_filter, gpu_cleanup +from .gpu import to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, gpu_cleanup from . import gpu as _gpu_mod logger = logging.getLogger("lidar") @@ -37,7 +37,6 @@ class _XPProxy: def __getattr__(self, name): global _cp - from . import gpu as _gpu_mod if _gpu_mod.HAS_GPU: if _cp is None: try: @@ -272,20 +271,21 @@ def _filter_nanaware(arr, filter_func, *args, use_gpu=True, **kwargs): def _prepare_dem_for_raycast(dem_file, shared, resolution): """Load DEM and prepare padded array for ray-tracing. - Returns (dem_gpu_or_cpu, dem_np, rows, cols, res, nan_mask, - transform, crs, padded) ready for ray-tracing. + Returns (dem_filled, dem_np, rows, cols, res, nan_mask, + transform, crs) ready for ray-tracing. + dem_filled is a CPU numpy array (filled, no NaN). """ if shared: dem_np = shared.dem_np nan_mask = shared.nan_mask transform = shared.transform crs = shared.crs - dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled + dem = shared.filled else: dem_np, transform, crs = _read_dem(dem_file) nan_mask = np.isnan(dem_np) filled, _ = _fill_nans(dem_np) - dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled + dem = filled res = resolution rows, cols = dem_np.shape return dem, dem_np, rows, cols, res, nan_mask, transform, crs @@ -304,7 +304,7 @@ def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None): processed and results are streamed back to CPU. Args: - dem: GPU or CPU filled DEM array (rows, cols). + dem: CPU numpy array — filled DEM (no NaN), shape (rows, cols). rows, cols: dimensions. res: resolution in m/px. n_dirs: number of directions. @@ -329,13 +329,14 @@ def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None): # Pad on CPU (numpy) — avoids GPU memory pressure and # pre-compiled kernel issues (NO_BINARY_FOR_GPU on sm_89). - dem_np = to_cpu(dem) - padded_np = np.pad(dem_np, max_dist, mode='constant', constant_values=np.nan) + padded_np = np.pad(dem, max_dist, mode='constant', constant_values=np.nan) # Transfer padded DEM to GPU for computation padded = to_gpu(padded_np) + # GPU view of central region — reference elevation for ray-tracing + dem = padded[max_dist:max_dist+rows, max_dist:max_dist+cols] # Free the CPU copy — we don't need it anymore - del dem_np, padded_np + del padded_np # Process one direction at a time to limit GPU memory. # Store results as flat CPU arrays — transfer back to GPU at the end. diff --git a/run.sh b/run.sh index 7836595..641395e 100755 --- a/run.sh +++ b/run.sh @@ -90,6 +90,8 @@ FORMAT_FLAG="" ONLY_FLAG="" SKIP_FLAG="" TEST_FLAG=0 +REBUILD_INDEX_FLAG="" +NO_INDEX_FLAG="" # Parse arguments manually (more robust than getopts for mixed short/long options) while [ $# -gt 0 ]; do @@ -121,6 +123,8 @@ while [ $# -gt 0 ]; do --only) shift; ONLY_FLAG="--only"; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do ONLY_FLAG="$ONLY_FLAG $1"; shift; done ;; --skip) shift; SKIP_FLAG="--skip"; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do SKIP_FLAG="$SKIP_FLAG $1"; shift; done ;; --file) shift; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do FILE_ARGS="$FILE_ARGS $1"; shift; done ;; + --rebuild-index) REBUILD_INDEX_FLAG="--rebuild-index"; shift ;; + --no-index) NO_INDEX_FLAG="--no-index"; shift ;; --test) TEST_FLAG=1 ;; -h|--help|-help) echo "Pipeline LiDAR Archéologique" @@ -246,6 +250,12 @@ fi if [ -n "$FILE_ARGS" ]; then CMD_ARGS="$CMD_ARGS --file $FILE_ARGS" fi +if [ -n "$REBUILD_INDEX_FLAG" ]; then + CMD_ARGS="$CMD_ARGS $REBUILD_INDEX_FLAG" +fi +if [ -n "$NO_INDEX_FLAG" ]; then + CMD_ARGS="$CMD_ARGS $NO_INDEX_FLAG" +fi # Build CUDA_VISIBLE_DEVICES env var from GPU_ARG CUDA_ENV_FLAG=""