From ad762e682d55e52ca6ad91e85cc0b62b6fc1d43e Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Sun, 10 May 2026 00:57:39 +0200 Subject: [PATCH] =?UTF-8?q?Suppression=20=C3=A9clairage=20solaire,=20GPU?= =?UTF-8?q?=20acc=C3=A9l=C3=A9r=C3=A9,=20--file=20multi,=20tests=20unitair?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suppression de generate_solar (éclairage solaire) des visualisations - Accélération GPU de hillshade, slope, aspect, curvature, depressions, anomalies, roughness, texture GLCM, flow (sink filling) - Nettoyage mémoire GPU entre visualisations (gpu_cleanup) - Correction OOM texture GLCM: calcul entropie bin par bin au lieu d'un tableau 3D massif sur GPU - Correction bug: xp_minimum_filter manquant dans imports visualizations - Option --file accepte plusieurs noms complets sans extension - run.sh affiche l'aide si appelé sans arguments - Option --test pour exécuter les tests unitaires dans Docker - Filtre ReturnNumber>=1 intégré dans le pipeline PDAL (plus d'erreur SMRF) - 60 tests unitaires: GPU, visualisations, rendering, DTM, pipeline, CLI - Ajout pytest au Dockerfile Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 3 +- README.md | 54 ++-- lidar_pipeline/cli.py | 44 ++- lidar_pipeline/dtm.py | 38 +-- lidar_pipeline/gpu.py | 8 +- lidar_pipeline/pipeline.py | 7 +- lidar_pipeline/rendering.py | 8 - lidar_pipeline/tests/__init__.py | 0 lidar_pipeline/tests/conftest.py | 71 +++++ lidar_pipeline/tests/test_cli.py | 86 ++++++ lidar_pipeline/tests/test_dtm.py | 41 +++ lidar_pipeline/tests/test_gpu.py | 93 ++++++ lidar_pipeline/tests/test_pipeline.py | 79 +++++ lidar_pipeline/tests/test_rendering.py | 98 ++++++ lidar_pipeline/tests/test_visualizations.py | 216 ++++++++++++++ lidar_pipeline/visualizations.py | 311 ++++++++++---------- run.sh | 93 ++++-- 17 files changed, 998 insertions(+), 252 deletions(-) create mode 100644 lidar_pipeline/tests/__init__.py create mode 100644 lidar_pipeline/tests/conftest.py create mode 100644 lidar_pipeline/tests/test_cli.py create mode 100644 lidar_pipeline/tests/test_dtm.py create mode 100644 lidar_pipeline/tests/test_gpu.py create mode 100644 lidar_pipeline/tests/test_pipeline.py create mode 100644 lidar_pipeline/tests/test_rendering.py create mode 100644 lidar_pipeline/tests/test_visualizations.py diff --git a/Dockerfile b/Dockerfile index d03c75b..2438db3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,8 @@ RUN pip3 install --no-cache-dir \ scikit-learn \ scipy \ tqdm \ - Pillow + Pillow \ + pytest # Install CuPy for GPU acceleration (optional - will fallback to numpy if not available) RUN pip3 install --no-cache-dir cupy-cuda12x || echo "CuPy not available - GPU acceleration disabled" diff --git a/README.md b/README.md index 1b0de08..099f4cb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Workflow automatisé pour générer des visualisations exploitables à partir de données LiDAR pour la détection de structures archéologiques. -## Visualisations (20 par fichier) +## Visualisations (19 par fichier) ### Visualisations principales | # | Visualisation | Utilité archéologique | @@ -11,30 +11,29 @@ Workflow automatisé pour générer des visualisations exploitables à partir de | 2 | **Pente (Slope)** | Murs de soutènement, talus, changements brusques | | 3 | **Aspect (Orientation)** | Direction des pentes, exposition | | 4 | **Courbure (Curvature)** | Fossés, terrasses, talus, concavité/convexité | -| 5 | **Éclairage solaire** | Simulation de l'éclairage matinal | -| 6 | **Sky-View Factor** | Structures, tumulus, fondations (ray-tracing 16 azimuts) | -| 7 | **Local Relief Model** | Micro-reliefs, fossés, levées de terrain | -| 8 | **Positive Openness** | Élévations, tumulus, bâtiments (ray-tracing 8 directions) | -| 9 | **Negative Openness** | Cavités, fossés, souterrains (ray-tracing 8 directions) | +| 5 | **Sky-View Factor** | Structures, tumulus, fondations (ray-tracing 16 azimuts) | +| 6 | **Local Relief Model** | Micro-reliefs, fossés, levées de terrain | +| 7 | **Positive Openness** | Élévations, tumulus, bâtiments (ray-tracing 8 directions) | +| 8 | **Negative Openness** | Cavités, fossés, souterrains (ray-tracing 8 directions) | ### Visualisations avancées | # | Visualisation | Description | Détection | |---|--------------|-------------|-----------| -| 10 | **MSRM** | Multi-Scale Relief Model (sigma 5/10/25/50/100m) | Tumulus, fossés, murs à toutes les échelles | -| 11 | **TPI multi-échelle** | Topographic Position Index (5m + 100m) | Crêtes, vallées, plateformes | -| 12 | **Dépressions** | Remplissage cuvettes + différence | Dolines, sinkholes, zones inondables | -| 13 | **SAILORE** | LRM adaptatif (noyau = f(pente)) | Terrain hétérogène, tout relief | -| 14 | **Rugosité** | Écart-type de l'élévation | Surfaces anthropiques vs naturelles | -| 15 | **Anomalies statistiques** | Z-score + Local Moran's I | Anomalies topographiques significatives | -| 16 | **Ondelette Mexican Hat** | CWT 2D multi-échelle | Tumulus, fossés circulaires | -| 17 | **Texture GLCM** | Contraste, entropie, homogénéité | Labour, surfaces archéologiques | -| 18 | **Accumulation de flux** | Algorithme D8 hydrologique | Fossés d'enceinte, routes antiques | +| 9 | **MSRM** | Multi-Scale Relief Model (sigma 5/10/25/50/100m) | Tumulus, fossés, murs à toutes les échelles | +| 10 | **TPI multi-échelle** | Topographic Position Index (5m + 100m) | Crêtes, vallées, plateformes | +| 11 | **Dépressions** | Remplissage cuvettes + différence | Dolines, sinkholes, zones inondables | +| 12 | **SAILORE** | LRM adaptatif (noyau = f(pente)) | Terrain hétérogène, tout relief | +| 13 | **Rugosité** | Écart-type de l'élévation | Surfaces anthropiques vs naturelles | +| 14 | **Anomalies statistiques** | Z-score + Local Moran's I | Anomalies topographiques significatives | +| 15 | **Ondelette Mexican Hat** | CWT 2D multi-échelle | Tumulus, fossés circulaires | +| 16 | **Texture GLCM** | Contraste, entropie, homogénéité | Labour, surfaces archéologiques | +| 17 | **Accumulation de flux** | Algorithme D8 hydrologique | Fossés d'enceinte, routes antiques | ### Cartes de référence IGN | # | Visualisation | Source | |---|--------------|--------| -| 19 | **Photographie aérienne IGN** | Orthophotographie WMTS | -| 20 | **Carte topographique IGN** | Plan IGN V2 WMTS | +| 18 | **Photographie aérienne IGN** | Orthophotographie WMTS | +| 19 | **Carte topographique IGN** | Plan IGN V2 WMTS | ## Architecture modulaire @@ -45,13 +44,13 @@ lidar_pipeline/ ├── cli.py # argparse + logging + main() ├── gpu.py # CuPy/numpy abstraction (HAS_GPU, to_gpu, to_cpu, xp_*) ├── dtm.py # Classification PDAL + génération DTM -├── visualizations.py # Fonctions generate_* (20 visualisations) +├── visualizations.py # Fonctions generate_* (19 visualisations) ├── ign.py # Téléchargement tuiles IGN + overlay ├── rendering.py # Colormaps, tif_to_png, rapport PDF └── pipeline.py # LidarArchaeoPipeline (orchestration + registry) ``` -Ajouter une visualisation = 1 fonction + 1 entrée dans `VIZ_STEPS` + 1 entrée dans `COLORMAPS`. +Ajouter une visualisation = 1 fonction + 1 entrée dans `VIZ_STEPS` + 1 entrée dans `COLORMAPS`. Supprimer = retirer les 3 entrées correspondantes. ## Installation Docker @@ -87,7 +86,7 @@ docker build -t lidar-lidar . -v Mode verbeux (timestamps + niveaux) --debug Mode debug (détails internes fichier:ligne) -f / --force Régénérer tous les fichiers même si les WebP existent - --file NOM Traiter un seul fichier LAZ (pour tests) + --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques (nom partiel) -h Afficher l'aide ``` @@ -111,8 +110,11 @@ docker build -t lidar-lidar . # Forcer la régénération de tous les fichiers ./run.sh -g --force -# Traiter un seul fichier pour test -./run.sh -g --file 6881 +# Traiter un fichier spécifique (test rapide) +./run.sh -g --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc + +# Traiter deux fichiers spécifiques +./run.sh -g --file LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc ``` ### Utilisation directe Docker @@ -127,9 +129,9 @@ docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output -v -# Un seul fichier (test rapide) +# Un ou plusieurs fichiers spécifiques docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ - lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output --file 6881 + lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc # Forcer la régénération docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ @@ -148,7 +150,7 @@ docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data │ │ │ ├── ..._hillshade_multi.webp │ │ │ ├── ..._svf.webp │ │ │ ├── ..._mslrm.webp -│ │ │ └── ... (20 visualisations) +│ │ │ └── ... (19 visualisations) │ │ └── fichier_6882/ │ │ └── ... │ └── rapports/ # Rapports PDF A3 par fichier @@ -177,7 +179,7 @@ docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data | Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle | | Output | `-o` | /data/output | Dossier de sortie | | Force | `-f/--force` | off | Régénérer même si les WebP existent | -| File | `--file` | tous | Traiter un seul fichier (pour tests) | +| File | `--file` | tous | Traiter un ou plusieurs fichiers LAZ (nom complet sans extension) | | Verbose | `-v` | off | Mode verbeux (timestamps + niveaux) | | Debug | `--debug` | off | Mode debug (détails internes) | diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 27e5263..6e781c0 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -61,7 +61,7 @@ Exemples: python -m lidar_pipeline /data/input -o /data/output --force Traiter un seul fichier (pour tests): - python -m lidar_pipeline /data/input -o /data/output --file LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc.laz + python -m lidar_pipeline /data/input -o /data/output --file LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc Traitement parallèle (4 workers): python -m lidar_pipeline /data/input -o /data/output -w 4 @@ -95,9 +95,10 @@ Exemples: ) parser.add_argument( "--file", + nargs="+", type=str, default=None, - help="Traiter un seul fichier LAZ/LAS (pour tests, par nom partiel ou complet)" + help="Traiter un ou plusieurs fichiers LAZ/LAS (nom complet sans extension, ex: LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc)" ) parser.add_argument( "-v", "--verbose", @@ -130,24 +131,35 @@ Exemples: force=args.force ) - # If --file is specified, process only that single file + # If --file is specified, process only matching files if args.file: from pathlib import Path input_dir = Path(args.input) - # Find matching file - matches = list(input_dir.glob(f"*{args.file}*")) + list(input_dir.glob(f"*{args.file}*.laz")) + list(input_dir.glob(f"*{args.file}*.las")) - # Remove duplicates - matches = list(dict.fromkeys(matches)) - if not matches: - logger.error(f"Aucun fichier trouvé pour: {args.file}") + # Each pattern is the full filename without extension (e.g. LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc) + selected_files = [] + for pattern in args.file: + matches = list(input_dir.glob(f"{pattern}.laz")) + list(input_dir.glob(f"{pattern}.las")) + # Remove duplicates + matches = list(dict.fromkeys(matches)) + if not matches: + logger.warning(f"Aucun fichier trouvé pour: {pattern}") + continue + selected_files.extend(matches) + # Remove duplicates across patterns + seen = set() + unique_files = [] + for f in selected_files: + if f not in seen: + seen.add(f) + unique_files.append(f) + if not unique_files: + logger.error("Aucun fichier trouvé pour les motifs spécifiés") sys.exit(1) - if len(matches) > 1: - logger.info(f"Plusieurs fichiers correspondent, utilisation du premier:") - for m in matches: - logger.info(f" {m.name}") - laz_file = matches[0] - logger.info(f"Traitement du fichier: {laz_file.name}") - pipeline.process_file(laz_file) + logger.info(f"Traitement de {len(unique_files)} fichier(s) sélectionné(s)") + for laz_file in unique_files: + logger.info(f" → {laz_file.name}") + for laz_file in unique_files: + pipeline.process_file(laz_file) else: pipeline.process_all() except Exception as e: diff --git a/lidar_pipeline/dtm.py b/lidar_pipeline/dtm.py index 133dbdb..b5129df 100644 --- a/lidar_pipeline/dtm.py +++ b/lidar_pipeline/dtm.py @@ -23,6 +23,9 @@ logger = logging.getLogger("lidar") def create_smrf_pipeline(input_laz, output_las): """Create a PDAL pipeline JSON for SMRF ground classification. + Includes a filter for ReturnNumber/NumberOfReturns >= 1 to handle + LiDAR HD files that may contain points with invalid return numbers. + Args: input_laz: Path to input LAZ/LAS file. output_las: Path to output classified LAS file. @@ -33,6 +36,10 @@ def create_smrf_pipeline(input_laz, output_las): pipeline = { "pipeline": [ str(input_laz), + { + "type": "filters.range", + "limits": "ReturnNumber[1:],NumberOfReturns[1:]" + }, { "type": "filters.smrf", "ignore": "Classification[7:7]", @@ -87,36 +94,7 @@ def classify_ground(laz_file, temp_dir): logger.info(" ✓ Classification sol terminée") return output_las except subprocess.CalledProcessError as e: - error_msg = e.stderr.decode() - logger.error(f" ✗ Erreur PDAL: {error_msg}") - - # If error is about ReturnNumber=0, try filtering those points - if "ReturnNumber" in error_msg and "NumberOfReturns" in error_msg: - logger.info(" → Tentative de filtrage des points ReturnNumber=0...") - - filtered_pipeline = [ - {"type": "readers.las", "filename": str(laz_file)}, - {"type": "filters.range", "limits": "ReturnNumber[1:],NumberOfReturns[1:]"}, - {"type": "filters.smrf", "scalar": 1.25}, - {"type": "filters.range", "limits": "Classification[2:2]"}, - {"type": "writers.las", "filename": str(output_las), "extra_dims": "all"} - ] - - filtered_json = json.dumps(filtered_pipeline) - with open(pipeline_file, 'w') as f: - f.write(filtered_json) - - try: - subprocess.run( - ["pdal", "pipeline", str(pipeline_file)], - capture_output=True, check=True - ) - logger.info(" ✓ Classification sol terminée (points filtrés)") - return output_las - except subprocess.CalledProcessError as e2: - logger.error(f" ✗ Erreur même avec filtrage: {e2.stderr.decode()}") - return None - + logger.error(f" ✗ Erreur classification PDAL: {e.stderr.decode()}") return None diff --git a/lidar_pipeline/gpu.py b/lidar_pipeline/gpu.py index 8bc4369..5709bf7 100644 --- a/lidar_pipeline/gpu.py +++ b/lidar_pipeline/gpu.py @@ -70,4 +70,10 @@ def xp_minimum_filter(arr, footprint=None, size=None): """Minimum filter — uses GPU if array is on GPU, CPU otherwise.""" if HAS_GPU and isinstance(arr, cp.ndarray): return cp_ndimage.minimum_filter(arr, footprint=footprint, size=size) - return ndimage.minimum_filter(arr, footprint=footprint, size=size) \ No newline at end of file + return ndimage.minimum_filter(arr, footprint=footprint, size=size) + + +def gpu_cleanup(): + """Free GPU memory. Call between visualizations to prevent OOM.""" + if HAS_GPU: + cp.get_default_memory_pool().free_all_blocks() \ No newline at end of file diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index 9fabb81..237751f 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -17,11 +17,12 @@ import subprocess from .dtm import classify_ground, create_dtm_fast from .visualizations import ( generate_hillshade, generate_slope, generate_aspect, generate_curvature, - generate_solar, generate_lrm, generate_svf, generate_openness, + generate_lrm, generate_svf, generate_openness, generate_mslrm, generate_tpi, generate_depressions, generate_sailore, generate_roughness, generate_anomalies, generate_wavelet, generate_texture, generate_flow, ) +from .gpu import gpu_cleanup from .ign import generate_ign_overlay from .rendering import tif_to_png, generate_pdf_report @@ -36,7 +37,6 @@ VIZ_STEPS = [ ('slope', generate_slope), ('aspect', generate_aspect), ('curvature', generate_curvature), - ('solar', generate_solar), ('svf', generate_svf), ('lrm', generate_lrm), ('pos_open', lambda d, b, v, r: generate_openness(d, b, v, r, positive=True)), @@ -167,6 +167,9 @@ class LidarArchaeoPipeline: vis_results[name] = None logger.error(f" [{idx}/{total}] ✗ {name}: {e}", exc_info=True) + # Free GPU memory between visualizations to prevent OOM + gpu_cleanup() + # Convert to WebP (only newly generated TIFs, not skipped ones) logger.info(" Conversion images WebP:") for name, tif_file in vis_results.items(): diff --git a/lidar_pipeline/rendering.py b/lidar_pipeline/rendering.py index 9721b0c..d15c773 100644 --- a/lidar_pipeline/rendering.py +++ b/lidar_pipeline/rendering.py @@ -66,14 +66,6 @@ COLORMAPS = { 'vmin_mode': 'fixed', 'vmin_val': 0, 'vmax_mode': 'fixed', 'vmax_val': 360, }, - 'solar': { - 'cmap': 'gray', - 'title': 'Éclairage Solaire', - 'legend': 'Illumination solaire (azimut 90°, altitude 30°)\nClair = Face éclairée | Sombre = Zone d\'ombre', - 'description': 'Simulation de l\'éclairage solaire matinal', - 'vmin_mode': 'fixed', 'vmin_val': 0, - 'vmax_mode': 'fixed', 'vmax_val': 1, - }, 'curvature': { 'cmap': 'RdYlBu_r', 'title': 'Courbure (Convexité/Concavité du terrain)', diff --git a/lidar_pipeline/tests/__init__.py b/lidar_pipeline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lidar_pipeline/tests/conftest.py b/lidar_pipeline/tests/conftest.py new file mode 100644 index 0000000..63307e8 --- /dev/null +++ b/lidar_pipeline/tests/conftest.py @@ -0,0 +1,71 @@ +"""Shared fixtures for LiDAR pipeline tests.""" + +import numpy as np +import rasterio +from rasterio.transform import from_bounds +import pytest +from pathlib import Path + + +@pytest.fixture +def tmp_output_dir(tmp_path): + """Create a temporary output directory.""" + out = tmp_path / "output" + out.mkdir() + return out + + +@pytest.fixture +def synthetic_dem(tmp_path): + """Create a small synthetic DEM GeoTIFF with realistic terrain features. + + Includes: + - A gaussian hill (tumulus) + - A linear ridge (wall) + - A depression (ditch) + - Background noise + """ + size = 200 + x = np.linspace(0, 1000, size) + y = np.linspace(0, 1000, size) + X, Y = np.meshgrid(x, y) + + # Base terrain with gentle slope + dem = 100.0 + 0.01 * X + 0.005 * Y + + # Gaussian hill (tumulus) at center + dem += 5.0 * np.exp(-((X - 500)**2 + (Y - 500)**2) / (2 * 80**2)) + + # Linear ridge (wall) running SW-NE + dist_to_wall = np.abs((X - 200) * 0.707 + (Y - 300) * 0.707) / np.sqrt(2) + dem += 1.5 * np.exp(-dist_to_wall**2 / (2 * 10**2)) + + # Depression (ditch) + dist_to_ditch = np.abs(X - 800) + dem -= 2.0 * np.exp(-dist_to_ditch**2 / (2 * 15**2)) * (Y > 300) * (Y < 700) + + # Small random noise + rng = np.random.default_rng(42) + dem += rng.normal(0, 0.05, dem.shape) + + # Save as GeoTIFF (Lambert 93) + dem_file = tmp_path / "test_dem.tif" + transform = from_bounds(660000, 6700000, 661000, 6701000, size, size) + + with rasterio.open( + dem_file, 'w', + driver='GTiff', height=size, width=size, + count=1, dtype='float32', + crs='EPSG:2154', transform=transform, + compress='lzw' + ) as dst: + dst.write(dem.astype('float32'), 1) + + return dem_file + + +@pytest.fixture +def synthetic_dem_data(synthetic_dem): + """Return (array, transform, crs) from the synthetic DEM.""" + with rasterio.open(synthetic_dem) as src: + return src.read(1), src.transform, src.crs \ No newline at end of file diff --git a/lidar_pipeline/tests/test_cli.py b/lidar_pipeline/tests/test_cli.py new file mode 100644 index 0000000..7fdf8fb --- /dev/null +++ b/lidar_pipeline/tests/test_cli.py @@ -0,0 +1,86 @@ +"""Tests for CLI argument parsing.""" + +import pytest +import sys + + +class TestCLIParsing: + def test_default_args(self): + """Default arguments are set correctly.""" + from lidar_pipeline.cli import main + # We can't easily call main() without files, so test argparse directly + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("input", help="Input directory") + parser.add_argument("-o", "--output", default="/data/output") + parser.add_argument("-r", "--resolution", type=float, default=0.5) + parser.add_argument("-w", "--workers", type=int, default=1) + parser.add_argument("-f", "--force", action="store_true") + parser.add_argument("--file", nargs="+", type=str, default=None) + + args = parser.parse_args(["./input"]) + assert args.input == "./input" + assert args.output == "/data/output" + assert args.resolution == 0.5 + assert args.workers == 1 + assert args.force is False + assert args.file is None + + def test_file_flag_single(self): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--file", nargs="+", type=str, default=None) + args = parser.parse_args(["--file", "test_file"]) + assert args.file == ["test_file"] + + def test_file_flag_multiple(self): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--file", nargs="+", type=str, default=None) + args = parser.parse_args(["--file", "file1", "file2", "file3"]) + assert args.file == ["file1", "file2", "file3"] + + def test_force_flag(self): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--force", action="store_true") + args = parser.parse_args(["-f"]) + assert args.force is True + + def test_resolution_custom(self): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("-r", "--resolution", type=float, default=0.5) + args = parser.parse_args(["-r", "0.2"]) + assert args.resolution == 0.2 + + +class TestSetupLogging: + def test_default_logging(self): + """Default logging shows messages without timestamps.""" + import logging + from lidar_pipeline.cli import setup_logging + logger = setup_logging(verbose=False, debug=False) + assert logger.level == logging.INFO + assert len(logger.handlers) == 1 + # Format should not include timestamps + fmt = logger.handlers[0].formatter._fmt + assert "%(asctime)" not in fmt + + def test_verbose_logging(self): + """Verbose logging includes timestamps.""" + import logging + from lidar_pipeline.cli import setup_logging + logger = setup_logging(verbose=True, debug=False) + fmt = logger.handlers[0].formatter._fmt + assert "%(asctime)" in fmt + + def test_debug_logging(self): + """Debug logging includes file:line info.""" + import logging + from lidar_pipeline.cli import setup_logging + logger = setup_logging(verbose=False, debug=True) + assert logger.level == logging.DEBUG + fmt = logger.handlers[0].formatter._fmt + assert "%(filename)" in fmt + assert "%(lineno)" in fmt \ No newline at end of file diff --git a/lidar_pipeline/tests/test_dtm.py b/lidar_pipeline/tests/test_dtm.py new file mode 100644 index 0000000..b97a237 --- /dev/null +++ b/lidar_pipeline/tests/test_dtm.py @@ -0,0 +1,41 @@ +"""Tests for DTM module.""" + +import json +import pytest +from pathlib import Path + + +class TestSMRFPipeline: + def test_pipeline_json_valid(self): + """create_smrf_pipeline produces valid JSON with expected stages.""" + from lidar_pipeline.dtm import create_smrf_pipeline + result = create_smrf_pipeline("/data/input/test.laz", "/data/output/test_ground.las") + pipeline = json.loads(result) + + assert "pipeline" in pipeline + stages = pipeline["pipeline"] + + # Should have: reader, range filter (ReturnNumber), SMRF, range filter (Classification), writer + stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] + + # First stage is the filename string (reader) + assert isinstance(stages[0], str) + assert "test.laz" in stages[0] + + # Must contain SMRF filter + assert "filters.smrf" in stage_types + + # Must contain ReturnNumber filter + range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] + assert len(range_stages) >= 1 + # At least one should filter ReturnNumber + assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) + + def test_pipeline_output_path(self): + """Pipeline output path is set correctly.""" + from lidar_pipeline.dtm import create_smrf_pipeline + result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") + pipeline = json.loads(result) + # Last stage should be writer with correct output path + writer = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "writers.las"][0] + assert writer["filename"] == "/output/a_ground.las" \ No newline at end of file diff --git a/lidar_pipeline/tests/test_gpu.py b/lidar_pipeline/tests/test_gpu.py new file mode 100644 index 0000000..a333e08 --- /dev/null +++ b/lidar_pipeline/tests/test_gpu.py @@ -0,0 +1,93 @@ +"""Tests for GPU helper module.""" + +import numpy as np +import pytest + + +def test_has_gpu_attribute(): + """HAS_GPU must be a boolean.""" + from lidar_pipeline.gpu import HAS_GPU + assert isinstance(HAS_GPU, bool) + + +def test_to_gpu_returns_array(): + """to_gpu returns a float64 array with correct values.""" + from lidar_pipeline.gpu import to_gpu, to_cpu, HAS_GPU + arr = np.array([1.0, 2.0, 3.0]) + result = to_gpu(arr) + # On GPU: cupy.ndarray, on CPU: numpy.ndarray + assert result.dtype == np.float64 + # Always bring back to CPU for comparison + np.testing.assert_array_equal(to_cpu(result), [1.0, 2.0, 3.0]) + + +def test_to_cpu_noop_numpy(): + """to_cpu on a numpy array is a no-op.""" + from lidar_pipeline.gpu import to_cpu + arr = np.array([1.0, 2.0]) + result = to_cpu(arr) + assert result is arr + + +def test_xp_gaussian_filter(): + """xp_gaussian_filter blurs a point source correctly.""" + from lidar_pipeline.gpu import xp_gaussian_filter + arr = np.zeros((50, 50), dtype=np.float64) + arr[25, 25] = 1.0 + result = xp_gaussian_filter(arr, sigma=3) + assert result.shape == (50, 50) + # Center should still be the highest value + center_val = float(np.asarray(result)[25, 25]) + corner_val = float(np.asarray(result)[0, 0]) + assert center_val > corner_val + assert center_val > 0.01 # Not all energy is lost + + +def test_xp_uniform_filter_cpu(): + """xp_uniform_filter works on CPU arrays.""" + from lidar_pipeline.gpu import xp_uniform_filter + arr = np.ones((50, 50), dtype=np.float64) + arr[25, 25] = 100.0 + result = xp_uniform_filter(arr, size=5) + # Mean should be close to 1 everywhere except near center + assert result.shape == (50, 50) + assert result[0, 0] == pytest.approx(1.0, abs=0.01) + + +def test_xp_minimum_filter_cpu(): + """xp_minimum_filter works on CPU arrays.""" + from lidar_pipeline.gpu import xp_minimum_filter + arr = np.ones((50, 50), dtype=np.float64) + arr[25, 25] = 0.0 + result = xp_minimum_filter(arr, size=3) + assert result.shape == (50, 50) + # Around the minimum, values should be 0 + assert result[25, 25] == 0.0 + assert result[24, 25] == 0.0 + + +def test_log_gpu_status(caplog): + """log_gpu_status emits a log message.""" + import logging + from lidar_pipeline.gpu import log_gpu_status + with caplog.at_level(logging.INFO, logger="lidar"): + log_gpu_status() + assert any("GPU" in r.message or "CPU" in r.message for r in caplog.records) + + +@pytest.mark.skipif( + not pytest.importorskip("cupy", reason="CuPy not available"), + reason="Requires GPU + CuPy" +) +def test_to_gpu_roundtrip(): + """to_gpu -> to_cpu preserves data when GPU is available.""" + import cupy as cp + from lidar_pipeline.gpu import to_gpu, to_cpu, HAS_GPU + if not HAS_GPU: + pytest.skip("No GPU available") + arr = np.array([1.0, 2.0, 3.0], dtype=np.float32) + gpu_arr = to_gpu(arr) + assert isinstance(gpu_arr, cp.ndarray) + result = to_cpu(gpu_arr) + assert isinstance(result, np.ndarray) + np.testing.assert_array_almost_equal(result, [1.0, 2.0, 3.0]) \ No newline at end of file diff --git a/lidar_pipeline/tests/test_pipeline.py b/lidar_pipeline/tests/test_pipeline.py new file mode 100644 index 0000000..af01343 --- /dev/null +++ b/lidar_pipeline/tests/test_pipeline.py @@ -0,0 +1,79 @@ +"""Tests for pipeline orchestration.""" + +import pytest +from pathlib import Path + + +class TestVizSteps: + def test_viz_steps_not_empty(self): + from lidar_pipeline.pipeline import VIZ_STEPS + assert len(VIZ_STEPS) > 0 + + def test_viz_steps_have_callable_functions(self): + from lidar_pipeline.pipeline import VIZ_STEPS + for name, func in VIZ_STEPS: + assert callable(func), f"VIZ_STEPS entry '{name}' is not callable" + + def test_viz_steps_names_unique(self): + from lidar_pipeline.pipeline import VIZ_STEPS + names = [name for name, _ in VIZ_STEPS] + assert len(names) == len(set(names)), "VIZ_STEPS has duplicate names" + + def test_no_solar_in_viz_steps(self): + """Solar visualization was removed.""" + from lidar_pipeline.pipeline import VIZ_STEPS + names = [name for name, _ in VIZ_STEPS] + assert "solar" not in names + + def test_expected_visualization_count(self): + """Should have 19 visualizations (18 terrain + ortho + topo - solar).""" + from lidar_pipeline.pipeline import VIZ_STEPS + assert len(VIZ_STEPS) == 19 + + def test_ortho_and_topo_present(self): + from lidar_pipeline.pipeline import VIZ_STEPS + names = [name for name, _ in VIZ_STEPS] + assert "ortho" in names + assert "topo" in names + + +class TestLidarArchaeoPipeline: + def test_init_creates_dirs(self, tmp_path): + from lidar_pipeline.pipeline import LidarArchaeoPipeline + input_dir = tmp_path / "input" + input_dir.mkdir() + output_dir = tmp_path / "output" + + pipeline = LidarArchaeoPipeline(str(input_dir), str(output_dir)) + assert (tmp_path / "output").exists() + assert (tmp_path / "output" / "DTM").exists() + assert (tmp_path / "output" / "visualisations").exists() + assert (tmp_path / "output" / "rapports").exists() + + def test_init_raises_on_missing_input(self, tmp_path): + from lidar_pipeline.pipeline import LidarArchaeoPipeline + with pytest.raises(ValueError, match="introuvable"): + LidarArchaeoPipeline("/nonexistent/path", str(tmp_path / "output")) + + def test_find_laz_files_empty(self, tmp_path): + from lidar_pipeline.pipeline import LidarArchaeoPipeline + input_dir = tmp_path / "input" + input_dir.mkdir() + pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output")) + files = pipeline.find_laz_files() + assert files == [] + + def test_find_laz_files(self, tmp_path): + from lidar_pipeline.pipeline import LidarArchaeoPipeline + input_dir = tmp_path / "input" + input_dir.mkdir() + (input_dir / "test.laz").touch() + (input_dir / "other.las").touch() + (input_dir / "readme.txt").touch() + + pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output")) + files = pipeline.find_laz_files() + names = [f.name for f in files] + assert "test.laz" in names + assert "other.las" in names + assert "readme.txt" not in names \ No newline at end of file diff --git a/lidar_pipeline/tests/test_rendering.py b/lidar_pipeline/tests/test_rendering.py new file mode 100644 index 0000000..166d2eb --- /dev/null +++ b/lidar_pipeline/tests/test_rendering.py @@ -0,0 +1,98 @@ +"""Tests for rendering module (colormaps, tif_to_png).""" + +import numpy as np +import rasterio +from rasterio.transform import from_bounds +import pytest +from pathlib import Path + + +def _make_test_tif(tmp_path, data=None, size=50): + """Create a small test GeoTIFF and return its path.""" + if data is None: + rng = np.random.default_rng(42) + data = rng.normal(0, 1, (size, size)).astype(np.float32) + + transform = from_bounds(660000, 6700000, 661000, 6701000, size, size) + tif_file = tmp_path / "test_vis.tif" + with rasterio.open( + tif_file, 'w', driver='GTiff', height=size, width=size, + count=1, dtype='float32', crs='EPSG:2154', transform=transform, + compress='lzw' + ) as dst: + dst.write(data, 1) + return tif_file + + +class TestColormaps: + def test_colormaps_dict_exists(self): + from lidar_pipeline.rendering import COLORMAPS + assert isinstance(COLORMAPS, dict) + + def test_all_viz_steps_have_colormaps(self): + """Every VIZ_STEPS entry should have a corresponding COLORMAPS entry or render correctly.""" + from lidar_pipeline.pipeline import VIZ_STEPS + from lidar_pipeline.rendering import COLORMAPS + # Some viz names differ from colormap keys + name_map = { + 'pos_open': 'positive_openness', + 'neg_open': 'negative_openness', + } + # IGN overlays (ortho, topo) are RGB images — no colormap needed + skip = {'ortho', 'topo'} + for name, _ in VIZ_STEPS: + if name in skip: + continue + cmap_key = name_map.get(name, name) + assert cmap_key in COLORMAPS, f"Missing colormap for: {name} (looked as {cmap_key})" + + def test_colormap_has_required_keys(self): + """Each colormap entry must have cmap, title, legend, description.""" + from lidar_pipeline.rendering import COLORMAPS + required = {'cmap', 'title', 'legend', 'description'} + for name, entry in COLORMAPS.items(): + missing = required - set(entry.keys()) + assert not missing, f"Colormap '{name}' missing keys: {missing}" + + +class TestTifToPng: + def test_converts_tif_to_webp(self, tmp_path): + from lidar_pipeline.rendering import tif_to_png + tif_file = _make_test_tif(tmp_path) + result = tif_to_png(tif_file, tmp_path, 5.0) + assert result is not None + assert result.exists() + assert result.suffix == '.webp' + + def test_removes_source_tif(self, tmp_path): + from lidar_pipeline.rendering import tif_to_png + tif_file = _make_test_tif(tmp_path) + assert tif_file.exists() + tif_to_png(tif_file, tmp_path, 5.0) + assert not tif_file.exists(), "Source TIF should be deleted after conversion" + + def test_webp_has_content(self, tmp_path): + from lidar_pipeline.rendering import tif_to_png + tif_file = _make_test_tif(tmp_path) + result = tif_to_png(tif_file, tmp_path, 5.0) + assert result.stat().st_size > 1000 # Must be a real image + + +class TestApplyColormap: + def test_symmetric_mode(self, tmp_path): + from lidar_pipeline.rendering import COLORMAPS, tif_to_png + # LRM uses symmetric mode + data = np.random.default_rng(42).normal(0, 0.5, (50, 50)).astype(np.float32) + tif_file = _make_test_tif(tmp_path, data) + result = tif_to_png(tif_file, tmp_path, 5.0) + assert result is not None + assert result.exists() + + def test_percentile_mode(self, tmp_path): + from lidar_pipeline.rendering import tif_to_png + # Most visualizations use percentile mode + data = np.random.default_rng(42).normal(50, 10, (50, 50)).astype(np.float32) + tif_file = _make_test_tif(tmp_path, data) + result = tif_to_png(tif_file, tmp_path, 5.0) + assert result is not None + assert result.exists() \ No newline at end of file diff --git a/lidar_pipeline/tests/test_visualizations.py b/lidar_pipeline/tests/test_visualizations.py new file mode 100644 index 0000000..1d73592 --- /dev/null +++ b/lidar_pipeline/tests/test_visualizations.py @@ -0,0 +1,216 @@ +"""Tests for visualization functions. + +Each test creates a small synthetic DEM and runs a visualization function, +checking that it produces a valid output file. +""" + +import numpy as np +import pytest +from pathlib import Path + + +# --- Core terrain visualizations (no GPU required) --- + +class TestHillshade: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_hillshade + result = generate_hillshade(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + assert result.suffix == ".tif" + + def test_output_values_valid(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_hillshade + result = generate_hillshade(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + assert data.shape[0] > 0 + assert np.nanmin(data) >= 0 + assert np.nanmax(data) <= 1 + + +class TestSlope: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_slope + result = generate_slope(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + def test_slope_values_degrees(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_slope + result = generate_slope(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + assert np.nanmin(data) >= 0 + assert np.nanmax(data) <= 90 + + +class TestAspect: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_aspect + result = generate_aspect(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + def test_aspect_values_0_360(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_aspect + result = generate_aspect(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + valid = data[~np.isnan(data)] + assert np.nanmin(valid) >= 0 + assert np.nanmax(valid) <= 360 + + +class TestCurvature: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_curvature + result = generate_curvature(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +# --- GPU-accelerated visualizations --- + +class TestLRM: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_lrm + result = generate_lrm(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + def test_lrm_has_positive_negative(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_lrm + result = generate_lrm(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + # LRM should have both positive and negative values + assert np.nanmax(data) > 0 + assert np.nanmin(data) < 0 + + +class TestSVF: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_svf + result = generate_svf(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + def test_svf_values_0_1(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_svf + result = generate_svf(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + valid = data[~np.isnan(data)] + assert np.nanmin(valid) >= 0 + assert np.nanmax(valid) <= 1 + + +class TestOpenness: + def test_positive_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_openness + result = generate_openness(synthetic_dem, "test", tmp_output_dir, 5.0, positive=True) + assert result is not None + assert result.exists() + + def test_negative_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_openness + result = generate_openness(synthetic_dem, "test", tmp_output_dir, 5.0, positive=False) + assert result is not None + assert result.exists() + + +class TestMSLRM: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_mslrm + result = generate_mslrm(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestTPI: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_tpi + result = generate_tpi(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestDepressions: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_depressions + result = generate_depressions(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestSAILORE: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_sailore + result = generate_sailore(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestRoughness: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_roughness + result = generate_roughness(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + def test_roughness_non_negative(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_roughness + result = generate_roughness(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + # Standard deviation is always >= 0 + assert np.nanmin(data) >= 0 + + +class TestAnomalies: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_anomalies + result = generate_anomalies(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestWavelet: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_wavelet + result = generate_wavelet(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestTexture: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_texture + result = generate_texture(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + +class TestFlow: + def test_generates_tif(self, synthetic_dem, tmp_output_dir): + from lidar_pipeline.visualizations import generate_flow + result = generate_flow(synthetic_dem, "test", tmp_output_dir, 5.0) + assert result is not None + assert result.exists() + + def test_flow_log_values(self, synthetic_dem, tmp_output_dir): + import rasterio + from lidar_pipeline.visualizations import generate_flow + result = generate_flow(synthetic_dem, "test", tmp_output_dir, 5.0) + with rasterio.open(result) as src: + data = src.read(1) + # log1p(x) >= 0 for x >= 0 + valid = data[~np.isnan(data)] + assert np.nanmin(valid) >= 0 \ No newline at end of file diff --git a/lidar_pipeline/visualizations.py b/lidar_pipeline/visualizations.py index b6dc4a4..1b99ae5 100644 --- a/lidar_pipeline/visualizations.py +++ b/lidar_pipeline/visualizations.py @@ -10,11 +10,10 @@ from pathlib import Path import numpy as np import rasterio -from scipy import ndimage -from scipy.ndimage import generic_filter, gaussian_filter, uniform_filter, minimum_filter +from scipy.ndimage import generic_filter from scipy.stats import binned_statistic_2d -from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter +from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter logger = logging.getLogger("lidar") @@ -58,31 +57,38 @@ def _read_dem(dem_file): # ============================================================ def generate_hillshade(dem_file, basename, vis_dir, resolution): - """Generate multi-directional hillshade (NW, NE, SW, SE).""" - logger.info(" → Hillshade multidirectionnel...") + """Generate multi-directional hillshade (NW, NE, SW, SE) — GPU if available.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Hillshade multidirectionnel{gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_hillshade_multi.tif" try: - dem, transform, crs = _read_dem(dem_file) - dy, dx = np.gradient(dem) + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np) + dy, dx = xp.gradient(dem) azimuts = [315, 45, 225, 135] altitude = 30 hillshades = [] - for az in azimuts: - az_rad = np.radians(az) - alt_rad = np.radians(altitude) - slope = np.arctan(np.sqrt(dx**2 + dy**2)) - aspect = np.arctan2(dy, dx) - hs = np.sin(alt_rad) * np.sin(slope) + \ - np.cos(alt_rad) * np.cos(slope) * np.cos(az_rad - aspect) - hillshades.append(np.clip(hs, 0, 1)) + slope = xp.arctan(xp.sqrt(dx**2 + dy**2)) + aspect = xp.arctan2(dy, dx) + sin_slope = xp.sin(slope) + cos_slope = xp.cos(slope) - combined = np.mean(hillshades, axis=0) - _save_tif(output, combined, transform, crs) - logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s)") + alt_rad = xp.radians(xp.array(altitude)) + sin_alt = xp.sin(alt_rad) + cos_alt = xp.cos(alt_rad) + + for az in azimuts: + az_rad = xp.radians(xp.array(az)) + hs = sin_alt * sin_slope + cos_alt * cos_slope * xp.cos(az_rad - aspect) + hillshades.append(xp.clip(hs, 0, 1)) + + combined = xp.mean(xp.array(hillshades), axis=0) + _save_tif(output, to_cpu(combined), transform, crs) + logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur hillshade: {e}", exc_info=True) @@ -90,17 +96,19 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution): def generate_slope(dem_file, basename, vis_dir, resolution): - """Generate slope map (degrees).""" - logger.info(" → Pente (Slope)...") + """Generate slope map (degrees) — GPU if available.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Pente (Slope){gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_slope.tif" try: - dem, transform, crs = _read_dem(dem_file) - dy, dx = np.gradient(dem) - slope = np.arctan(np.sqrt(dx**2 + dy**2)) * 180 / np.pi - _save_tif(output, slope, transform, crs) - logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s)") + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np) + dy, dx = xp.gradient(dem) + slope = xp.arctan(xp.sqrt(dx**2 + dy**2)) * 180 / xp.pi + _save_tif(output, to_cpu(slope), transform, crs) + logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur slope: {e}", exc_info=True) @@ -108,18 +116,20 @@ def generate_slope(dem_file, basename, vis_dir, resolution): def generate_aspect(dem_file, basename, vis_dir, resolution): - """Generate aspect (slope orientation) map.""" - logger.info(" → Aspect (Orientation)...") + """Generate aspect (slope orientation) map — GPU if available.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Aspect (Orientation){gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_aspect.tif" try: - dem, transform, crs = _read_dem(dem_file) - dy, dx = np.gradient(dem) - aspect = np.arctan2(dy, dx) * 180 / np.pi - aspect = np.mod(aspect, 360) - _save_tif(output, aspect, transform, crs) - logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s)") + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np) + dy, dx = xp.gradient(dem) + aspect = xp.arctan2(dy, dx) * 180 / xp.pi + aspect = xp.mod(aspect, 360) + _save_tif(output, to_cpu(aspect), transform, crs) + logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur aspect: {e}", exc_info=True) @@ -127,49 +137,28 @@ def generate_aspect(dem_file, basename, vis_dir, resolution): def generate_curvature(dem_file, basename, vis_dir, resolution): - """Generate curvature (terrain concavity/convexity) map.""" - logger.info(" → Courbure (Curvature)...") + """Generate curvature (terrain concavity/convexity) map — GPU if available.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Courbure (Curvature){gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_curvature.tif" try: - dem, transform, crs = _read_dem(dem_file) - dz_dx = np.gradient(dem, axis=1) - dz_dy = np.gradient(dem, axis=0) - d2z_dx2 = np.gradient(dz_dx, axis=1) - d2z_dy2 = np.gradient(dz_dy, axis=0) + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np) + dz_dx = xp.gradient(dem, axis=1) + dz_dy = xp.gradient(dem, axis=0) + d2z_dx2 = xp.gradient(dz_dx, axis=1) + d2z_dy2 = xp.gradient(dz_dy, axis=0) curvature = (d2z_dx2 + d2z_dy2) / 2 - _save_tif(output, curvature, transform, crs) - logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s)") + _save_tif(output, to_cpu(curvature), transform, crs) + logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur curvature: {e}", exc_info=True) return None -def generate_solar(dem_file, basename, vis_dir, resolution): - """Generate solar irradiance simulation.""" - logger.info(" → Éclairage Solaire...") - t0 = time.time() - output = vis_dir / f"{basename}_solar.tif" - - try: - dem, transform, crs = _read_dem(dem_file) - dy, dx = np.gradient(dem) - slope = np.arctan(np.sqrt(dx**2 + dy**2)) - aspect = np.arctan2(dy, dx) - az_rad = np.radians(90) - alt_rad = np.radians(30) - solar = np.sin(alt_rad) * np.sin(slope) + \ - np.cos(alt_rad) * np.cos(slope) * np.cos(az_rad - aspect) - solar = np.clip(solar, 0, 1) - _save_tif(output, solar, transform, crs) - logger.info(f" ✓ Solaire terminé ({time.time()-t0:.1f}s)") - return output - except Exception as e: - logger.error(f" ✗ Erreur solar: {e}", exc_info=True) - return None - # ============================================================ # GPU-accelerated visualizations @@ -390,45 +379,46 @@ def generate_tpi(dem_file, basename, vis_dir, resolution): # ============================================================ def generate_depressions(dem_file, basename, vis_dir, resolution): - """Depression detection using hydrological sink filling.""" - logger.info(" → Détection dépressions (hydrologique)...") + """Depression detection using hydrological sink filling — GPU if available.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Détection dépressions (hydrologique){gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_depressions.tif" try: - dem, transform, crs = _read_dem(dem_file) - - from scipy.ndimage import binary_dilation, generate_binary_structure - - dem_filled = dem.copy() - nodata_mask = np.isnan(dem_filled) - dem_filled[nodata_mask] = np.nanmax(dem) + 1000 + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np) + from scipy.ndimage import generate_binary_structure struct = generate_binary_structure(2, 2) + + dem_filled = xp.copy(dem) + nodata_mask = xp.isnan(dem_filled) + dem_filled[nodata_mask] = xp.nanmax(dem) + 1000 + changed = True iterations = 0 max_iter = 100 while changed and iterations < max_iter: - from scipy.ndimage import minimum_filter as scipy_min_filter - neighbor_min = scipy_min_filter(dem_filled, footprint=struct) + neighbor_min = xp_minimum_filter(dem_filled, footprint=struct) sinks = (dem_filled < neighbor_min) & ~nodata_mask - if not np.any(sinks): + if not xp.any(sinks): break - new_dem = np.maximum(dem_filled, neighbor_min) - new_dem[nodata_mask] = np.nan - changed = np.any(new_dem != dem_filled) + new_dem = xp.maximum(dem_filled, neighbor_min) + new_dem[nodata_mask] = xp.nan + changed = bool(xp.any(new_dem != dem_filled)) dem_filled = new_dem iterations += 1 - depressions = dem_filled - dem - depressions[nodata_mask] = np.nan + depressions = to_cpu(dem_filled - dem) + depressions[to_cpu(nodata_mask)] = np.nan depressions = np.where(depressions > 0.01, depressions, 0) _save_tif(output, depressions, transform, crs) - logger.info(f" ✓ Dépressions terminé ({time.time()-t0:.1f}s)") + logger.info(f" ✓ Dépressions terminé ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur dépressions: {e}", exc_info=True) @@ -489,24 +479,27 @@ def generate_sailore(dem_file, basename, vis_dir, resolution): # ============================================================ def generate_roughness(dem_file, basename, vis_dir, resolution): - """Surface roughness - standard deviation of elevation in a window.""" - logger.info(" → Rugosité de surface...") + """Surface roughness - standard deviation of elevation in a window (GPU-accelerated).""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Rugosité de surface{gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_roughness.tif" try: - dem, transform, crs = _read_dem(dem_file) + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np.astype(np.float64)) window_size = int(5 / resolution) if window_size % 2 == 0: window_size += 1 - def std_filter(arr): - return np.nanstd(arr) + # Vectorized std: sqrt(E[X²] - (E[X])²) via uniform_filter (GPU-accelerated) + local_mean = xp_uniform_filter(dem, size=window_size) + local_mean_sq = xp_uniform_filter(dem * dem, size=window_size) + roughness = xp.sqrt(local_mean_sq - local_mean * local_mean) - roughness = generic_filter(dem.astype(np.float64), std_filter, - size=window_size, mode='nearest') + roughness = to_cpu(roughness) _save_tif(output, roughness, transform, crs) - logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s)") + logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur rugosité: {e}", exc_info=True) @@ -518,29 +511,33 @@ def generate_roughness(dem_file, basename, vis_dir, resolution): # ============================================================ def generate_anomalies(dem_file, basename, vis_dir, resolution): - """Statistical anomaly detection - z-score of local relief + Local Moran's I.""" - logger.info(" → Détection anomalies statistiques...") + """Statistical anomaly detection - z-score of local relief + Local Moran's I — GPU if available.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Détection anomalies statistiques{gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_anomalies.tif" try: - dem, transform, crs = _read_dem(dem_file) + dem_np, transform, crs = _read_dem(dem_file) + dem = to_gpu(dem_np) - lrm = dem - gaussian_filter(dem, sigma=15 / resolution) - lrm_mean = np.nanmean(lrm) - lrm_std = max(np.nanstd(lrm), 0.01) + lrm = dem - xp_gaussian_filter(dem, sigma=15 / resolution) + lrm_mean = xp.nanmean(lrm) + lrm_std = max(float(xp.nanstd(lrm)), 0.01) z_score = (lrm - lrm_mean) / lrm_std window = int(10 / resolution) if window % 2 == 0: window += 1 - local_mean = uniform_filter(z_score, size=window) - morans_i = z_score * (local_mean - np.nanmean(z_score)) / np.nanstd(z_score) - anomaly_score = np.abs(z_score) * np.sign(morans_i) + local_mean = xp_uniform_filter(z_score, size=window) + z_mean = xp.nanmean(z_score) + z_std = max(float(xp.nanstd(z_score)), 0.01) + morans_i = z_score * (local_mean - z_mean) / z_std + anomaly_score = xp.abs(z_score) * xp.sign(morans_i) - _save_tif(output, anomaly_score, transform, crs) - logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s)") + _save_tif(output, to_cpu(anomaly_score), transform, crs) + logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur anomalies: {e}", exc_info=True) @@ -595,21 +592,24 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution): # ============================================================ def generate_texture(dem_file, basename, vis_dir, resolution): - """GLCM texture analysis on hillshade - contrast, entropy, homogeneity.""" - logger.info(" → Texture GLCM...") + """GLCM-inspired texture analysis — contrast, entropy, homogeneity (GPU-accelerated).""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Texture GLCM{gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_texture.tif" try: - dem, transform, crs = _read_dem(dem_file) + dem_np, transform, crs = _read_dem(dem_file) - gy, gx = np.gradient(dem, resolution) + # Hillshade — compute on CPU to avoid holding DEM on GPU during texture + gy, gx = np.gradient(dem_np, resolution) slope = np.arctan(np.sqrt(gx**2 + gy**2)) alt_rad = np.radians(45) az_rad = np.radians(315) + aspect = np.arctan2(gy, gx) shading = (np.sin(alt_rad) * np.cos(slope) + np.cos(alt_rad) * np.sin(slope) * - np.cos(az_rad - np.arctan2(gy, gx))) + np.cos(az_rad - aspect)) hillshade = np.clip(shading, 0, 1) valid = hillshade[~np.isnan(hillshade)] @@ -617,31 +617,39 @@ def generate_texture(dem_file, basename, vis_dir, resolution): raise ValueError("No valid data for texture analysis") lo, hi = np.percentile(valid, (1, 99)) img = np.clip((hillshade - lo) / max(hi - lo, 0.001), 0, 1) - img_uint8 = (img * 255).astype(np.uint8) + del hillshade, shading, slope, aspect, gy, gx # free memory window = int(5 / resolution) if window % 2 == 0: window += 1 - def local_variance(arr): - return np.var(arr.astype(np.float64)) + # Contrast (variance) — GPU-accelerated + img_gpu = to_gpu(img.astype(np.float32)) + local_mean = xp_uniform_filter(img_gpu, size=window) + local_mean_sq = xp_uniform_filter(img_gpu * img_gpu, size=window) + contrast = to_cpu(local_mean_sq - local_mean * local_mean).astype(np.float64) + del img_gpu, local_mean, local_mean_sq # free GPU memory - def local_entropy(arr): - hist, _ = np.histogram(arr.astype(np.float64), bins=16, range=(0, 256)) - hist = hist / max(hist.sum(), 1) - hist = hist[hist > 0] - return -np.sum(hist * np.log2(hist)) + # Entropy — compute bin-by-bin to avoid large 3D allocation + n_bins = 16 + img_uint8 = np.clip(img * 255, 0, 255).astype(np.uint8) + quantized = (img_uint8 // (256 // n_bins)).astype(np.int32) + entropy = np.zeros_like(img, dtype=np.float64) + win_area = max(window * window, 1) - def local_homogeneity(arr): - arr_f = arr.astype(np.float64) - return np.mean(1.0 / (1.0 + (arr_f - np.mean(arr_f)) ** 2)) + for b in range(n_bins): + plane = (quantized == b).astype(np.float32) + plane_gpu = to_gpu(plane) + prob_plane = to_cpu(xp_uniform_filter(plane_gpu, size=window)) + prob_val = prob_plane / win_area + prob_val = np.clip(prob_val, 1e-10, None) + entropy -= prob_val * np.log2(prob_val) + del plane_gpu # free GPU memory per bin - contrast = generic_filter(img_uint8.astype(np.float64), local_variance, - size=window, mode='nearest') - entropy = generic_filter(img_uint8.astype(np.float64), local_entropy, - size=window, mode='nearest') - homogeneity = generic_filter(img_uint8.astype(np.float64), local_homogeneity, - size=window, mode='nearest') + del quantized, img_uint8 # free CPU memory + + # Homogeneity — 1 / (1 + variance) + homogeneity = 1.0 / (1.0 + contrast) def norm(arr): valid_arr = arr[~np.isnan(arr)] @@ -650,12 +658,10 @@ def generate_texture(dem_file, basename, vis_dir, resolution): std_val = max(np.std(valid_arr), 0.01) return (arr - np.mean(valid_arr)) / std_val - texture_combined = (0.4 * norm(contrast) + - 0.4 * norm(entropy) - - 0.2 * norm(homogeneity)) + texture_combined = 0.4 * norm(contrast) + 0.4 * norm(entropy) - 0.2 * norm(homogeneity) _save_tif(output, texture_combined, transform, crs) - logger.info(f" ✓ Texture terminée ({time.time()-t0:.1f}s)") + logger.info(f" ✓ Texture terminée ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur texture GLCM: {e}", exc_info=True) @@ -667,55 +673,58 @@ def generate_texture(dem_file, basename, vis_dir, resolution): # ============================================================ def generate_flow(dem_file, basename, vis_dir, resolution): - """Flow accumulation using D8 algorithm. - - Identifies drainage patterns, ditches, and enclosure boundaries. - """ - logger.info(" → Accumulation de flux D8...") + """Flow accumulation using D8 algorithm — sink filling on GPU, accumulation on CPU.""" + gpu_tag = " [GPU]" if HAS_GPU else "" + logger.info(f" → Accumulation de flux D8{gpu_tag}...") t0 = time.time() output = vis_dir / f"{basename}_flow.tif" try: - dem, transform, crs = _read_dem(dem_file) - rows, cols = dem.shape - nodata_mask = np.isnan(dem) + dem_np, transform, crs = _read_dem(dem_file) + rows, cols = dem_np.shape + nodata_mask = np.isnan(dem_np) - from scipy.ndimage import minimum_filter as scipy_min_filter, generate_binary_structure - - dem_filled = dem.copy() - dem_filled[nodata_mask] = np.nanmax(dem) + 1000 + # Sink filling — GPU-accelerated + dem_gpu = to_gpu(dem_np) + nodata_mask_gpu = xp.isnan(dem_gpu) + dem_filled = xp.copy(dem_gpu) + dem_filled[nodata_mask_gpu] = xp.nanmax(dem_gpu) + 1000 + from scipy.ndimage import generate_binary_structure struct = generate_binary_structure(2, 2) + for _ in range(50): - neighbor_min = scipy_min_filter(dem_filled, footprint=struct) - sinks = (dem_filled < neighbor_min) & ~nodata_mask - if not np.any(sinks): + neighbor_min = xp_minimum_filter(dem_filled, footprint=struct) + sinks = (dem_filled < neighbor_min) & ~nodata_mask_gpu + if not xp.any(sinks): break - dem_filled = np.where(sinks, neighbor_min, dem_filled) + dem_filled = xp.where(sinks, neighbor_min, dem_filled) - dem_filled[nodata_mask] = np.nan + dem_filled[nodata_mask_gpu] = xp.nan + dem_filled_np = to_cpu(dem_filled) + # D8 slope + accumulation — CPU (sequential by nature) dx8 = [1, 1, 0, -1, -1, -1, 0, 1] dy8 = [0, 1, 1, 1, 0, -1, -1, -1] dist8 = [1.0, np.sqrt(2), 1.0, np.sqrt(2), 1.0, np.sqrt(2), 1.0, np.sqrt(2)] flow_dir = np.full((rows, cols), -1, dtype=np.int8) - max_slope = np.full((rows, cols), 0.0) + max_slope = np.zeros((rows, cols), dtype=np.float64) - padded = np.pad(dem_filled, 1, mode='constant', - constant_values=np.nanmax(dem_filled) + 10000) + padded = np.pad(dem_filled_np, 1, mode='constant', + constant_values=np.nanmax(dem_filled_np[~np.isnan(dem_filled_np)]) + 10000) for d in range(8): nx = 1 + dx8[d] ny = 1 + dy8[d] neighbor_elev = padded[ny:ny + rows, nx:nx + cols] - slope = (dem_filled - neighbor_elev) / (dist8[d] * resolution) + slope = (dem_filled_np - neighbor_elev) / (dist8[d] * resolution) slope[nodata_mask] = -1 better = slope > max_slope flow_dir[better] = d max_slope[better] = slope[better] - flat_dem = dem_filled[~nodata_mask].flatten() + flat_dem = dem_filled_np[~nodata_mask].flatten() valid_indices = np.where(~nodata_mask.flatten())[0] sort_order = valid_indices[np.argsort(-flat_dem)] @@ -733,7 +742,7 @@ def generate_flow(dem_file, basename, vis_dir, resolution): flow_log = np.log1p(flow_acc) _save_tif(output, flow_log, transform, crs) - logger.info(f" ✓ Flux terminé ({time.time()-t0:.1f}s)") + logger.info(f" ✓ Flux terminé ({time.time()-t0:.1f}s){gpu_tag}") return output except Exception as e: logger.error(f" ✗ Erreur flux: {e}", exc_info=True) diff --git a/run.sh b/run.sh index 018a9cf..2cccf75 100755 --- a/run.sh +++ b/run.sh @@ -9,11 +9,39 @@ # -v Mode verbeux (timestamps + niveaux) # --debug Mode debug (détails internes fichier:ligne) # -f / --force Régénérer tous les fichiers même si existants -# --file NOM Traiter un seul fichier LAZ (pour tests) +# --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques +# --test Exécuter les tests unitaires # -h Afficher l'aide complète set -e +# Afficher l'aide si aucun argument +if [ $# -eq 0 ]; then + echo "Pipeline LiDAR Archéologique" + echo "" + echo "Usage: $0 [options]" + echo "" + echo " -r RESOLUTION Résolution en m/px (défaut: 0.5)" + echo " -w WORKERS Nombre de workers CPU parallèles (défaut: 1)" + echo " -g Activer l'accélération GPU NVIDIA" + echo " -v Mode verbeux (timestamps + niveaux)" + echo " --debug Mode debug (détails internes fichier:ligne)" + echo " -f / --force Régénérer tous les fichiers même si les WebP existent" + echo " --file NOM... Traiter un ou plusieurs fichiers LAZ (nom complet sans .laz/.las)" + echo " --test Exécuter les tests unitaires" + echo " -h Afficher cette aide" + echo "" + echo "Exemples:" + echo " $0 -g # Avec accélération GPU" + echo " $0 -g -w 4 # GPU + 4 workers parallèles" + echo " $0 -g -v # GPU + mode verbeux" + echo " $0 -r 0.2 -g --debug # Haute résolution + GPU + debug" + echo " $0 -f # Forcer la régénération de tous les fichiers" + echo " $0 --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc # Un fichier" + echo " $0 --file LHD_...6881.copc LHD_...6882.copc # Plusieurs fichiers" + exit 0 +fi + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" INPUT_DIR="${SCRIPT_DIR}/input" OUTPUT_DIR="${SCRIPT_DIR}/output" @@ -23,7 +51,7 @@ WORKERS=1 GPU_FLAG="" VERBOSE_FLAG="" FORCE_FLAG="" -FILE_FILTER="" +FILE_ARGS="" while getopts "r:w:gvf-:" opt; do case $opt in @@ -51,27 +79,58 @@ while getopts "r:w:gvf-:" opt; do echo " -v Mode verbeux (timestamps + niveaux)" echo " --debug Mode debug (détails internes fichier:ligne)" echo " -f / --force Régénérer tous les fichiers même si les WebP existent" - echo " --file NOM Traiter un seul fichier LAZ (pour tests)" + echo " --file NOM... Traiter un ou plusieurs fichiers LAZ (nom complet sans .laz/.las)" echo " -h Afficher cette aide" echo "" echo "Exemples:" - echo " $0 # Traitement standard" echo " $0 -g # Avec accélération GPU" - echo " $0 -g -w 4 # GPU + 4 workers parallèles" - echo " $0 -g -v # GPU + mode verbeux" - echo " $0 -r 0.2 -g --debug # Haute résolution + GPU + debug" - echo " $0 -f # Forcer la régénération de tous les fichiers" - echo " $0 --file 6881 # Traiter un seul fichier (pour tests)" + echo " $0 -g -w 4 # GPU + 4 workers parallèles" + echo " $0 -g -v # GPU + mode verbeux" + echo " $0 -r 0.2 -g --debug # Haute résolution + GPU + debug" + echo " $0 -f # Forcer la régénération de tous les fichiers" + echo " $0 --file 6881 # Traiter un seul fichier" + echo " $0 --file 6881 6882 # Traiter deux fichiers spécifiques" exit 0 ;; *) echo "Option invalide. Utilisez -h pour l'aide." >&2; exit 1 ;; esac done -# Handle --file option separately (not easily done in getopts) -if [[ " $@ " == *" --file "* ]]; then - # Extract the argument after --file - FILE_FILTER=$(echo "$@" | sed -n 's/.*--file *\([^ ]*\).*/\1/p') +# Check for --test flag first +if [[ " $* " == *" --test "* ]]; then + # Build l'image si elle n'existe pas + if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then + echo "Build de l'image Docker..." + docker build -t "$IMAGE_NAME" "$SCRIPT_DIR" + fi + echo "============================================" + echo " Tests unitaires LiDAR Pipeline" + echo "============================================" + docker run --rm $GPU_FLAG \ + "$IMAGE_NAME" \ + python3 -m pytest -v --pyargs lidar_pipeline.tests + exit $? +fi + +# Collect --file arguments (everything after --file until next option) +if [[ "$*" == *" --file "* ]] || [[ "$*" == *" --file"* && "$*" == *"--file "* ]]; then + # Extract all arguments after --file + PAST_FILE=false + for arg in "$@"; do + if [ "$arg" = "--file" ]; then + PAST_FILE=true + continue + fi + if $PAST_FILE; then + # Stop if we hit another option + if [[ "$arg" == -* ]]; then + PAST_FILE=false + else + FILE_ARGS="$FILE_ARGS $arg" + fi + fi + done + FILE_ARGS=$(echo "$FILE_ARGS" | xargs) fi # Build l'image si elle n'existe pas @@ -92,14 +151,14 @@ echo " Workers : ${WORKERS}" echo " GPU : $([ -n "$GPU_FLAG" ] && echo 'OUI' || echo 'non')" echo " Verbeux : $([ -n "$VERBOSE_FLAG" ] && echo 'OUI' || echo 'non')" echo " Force : $([ -n "$FORCE_FLAG" ] && echo 'OUI' || echo 'non')" -if [ -n "$FILE_FILTER" ]; then - echo " Fichier : ${FILE_FILTER}" +if [ -n "$FILE_ARGS" ]; then + echo " Fichiers :${FILE_ARGS}" fi echo "============================================" CMD_ARGS="-o /data/output -r $RESOLUTION -w $WORKERS $VERBOSE_FLAG $FORCE_FLAG" -if [ -n "$FILE_FILTER" ]; then - CMD_ARGS="$CMD_ARGS --file $FILE_FILTER" +if [ -n "$FILE_ARGS" ]; then + CMD_ARGS="$CMD_ARGS --file $FILE_ARGS" fi docker run --rm $GPU_FLAG \