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