Suppression éclairage solaire, GPU accéléré, --file multi, tests unitaires

- 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 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-10 00:57:39 +02:00
parent f07e915f6d
commit ad762e682d
17 changed files with 998 additions and 252 deletions

View File

@ -28,7 +28,8 @@ RUN pip3 install --no-cache-dir \
scikit-learn \ scikit-learn \
scipy \ scipy \
tqdm \ tqdm \
Pillow Pillow \
pytest
# Install CuPy for GPU acceleration (optional - will fallback to numpy if not available) # 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" RUN pip3 install --no-cache-dir cupy-cuda12x || echo "CuPy not available - GPU acceleration disabled"

View File

@ -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. 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 ### Visualisations principales
| # | Visualisation | Utilité archéologique | | # | 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 | | 2 | **Pente (Slope)** | Murs de soutènement, talus, changements brusques |
| 3 | **Aspect (Orientation)** | Direction des pentes, exposition | | 3 | **Aspect (Orientation)** | Direction des pentes, exposition |
| 4 | **Courbure (Curvature)** | Fossés, terrasses, talus, concavité/convexité | | 4 | **Courbure (Curvature)** | Fossés, terrasses, talus, concavité/convexité |
| 5 | **Éclairage solaire** | Simulation de l'éclairage matinal | | 5 | **Sky-View Factor** | Structures, tumulus, fondations (ray-tracing 16 azimuts) |
| 6 | **Sky-View Factor** | Structures, tumulus, fondations (ray-tracing 16 azimuts) | | 6 | **Local Relief Model** | Micro-reliefs, fossés, levées de terrain |
| 7 | **Local Relief Model** | Micro-reliefs, fossés, levées de terrain | | 7 | **Positive Openness** | Élévations, tumulus, bâtiments (ray-tracing 8 directions) |
| 8 | **Positive Openness** | Élévations, tumulus, bâtiments (ray-tracing 8 directions) | | 8 | **Negative Openness** | Cavités, fossés, souterrains (ray-tracing 8 directions) |
| 9 | **Negative Openness** | Cavités, fossés, souterrains (ray-tracing 8 directions) |
### Visualisations avancées ### Visualisations avancées
| # | Visualisation | Description | Détection | | # | Visualisation | Description | Détection |
|---|--------------|-------------|-----------| |---|--------------|-------------|-----------|
| 10 | **MSRM** | Multi-Scale Relief Model (sigma 5/10/25/50/100m) | Tumulus, fossés, murs à toutes les échelles | | 9 | **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 | | 10 | **TPI multi-échelle** | Topographic Position Index (5m + 100m) | Crêtes, vallées, plateformes |
| 12 | **Dépressions** | Remplissage cuvettes + différence | Dolines, sinkholes, zones inondables | | 11 | **Dépressions** | Remplissage cuvettes + différence | Dolines, sinkholes, zones inondables |
| 13 | **SAILORE** | LRM adaptatif (noyau = f(pente)) | Terrain hétérogène, tout relief | | 12 | **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 | | 13 | **Rugosité** | Écart-type de l'élévation | Surfaces anthropiques vs naturelles |
| 15 | **Anomalies statistiques** | Z-score + Local Moran's I | Anomalies topographiques significatives | | 14 | **Anomalies statistiques** | Z-score + Local Moran's I | Anomalies topographiques significatives |
| 16 | **Ondelette Mexican Hat** | CWT 2D multi-échelle | Tumulus, fossés circulaires | | 15 | **Ondelette Mexican Hat** | CWT 2D multi-échelle | Tumulus, fossés circulaires |
| 17 | **Texture GLCM** | Contraste, entropie, homogénéité | Labour, surfaces archéologiques | | 16 | **Texture GLCM** | Contraste, entropie, homogénéité | Labour, surfaces archéologiques |
| 18 | **Accumulation de flux** | Algorithme D8 hydrologique | Fossés d'enceinte, routes antiques | | 17 | **Accumulation de flux** | Algorithme D8 hydrologique | Fossés d'enceinte, routes antiques |
### Cartes de référence IGN ### Cartes de référence IGN
| # | Visualisation | Source | | # | Visualisation | Source |
|---|--------------|--------| |---|--------------|--------|
| 19 | **Photographie aérienne IGN** | Orthophotographie WMTS | | 18 | **Photographie aérienne IGN** | Orthophotographie WMTS |
| 20 | **Carte topographique IGN** | Plan IGN V2 WMTS | | 19 | **Carte topographique IGN** | Plan IGN V2 WMTS |
## Architecture modulaire ## Architecture modulaire
@ -45,13 +44,13 @@ lidar_pipeline/
├── cli.py # argparse + logging + main() ├── cli.py # argparse + logging + main()
├── gpu.py # CuPy/numpy abstraction (HAS_GPU, to_gpu, to_cpu, xp_*) ├── gpu.py # CuPy/numpy abstraction (HAS_GPU, to_gpu, to_cpu, xp_*)
├── dtm.py # Classification PDAL + génération DTM ├── 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 ├── ign.py # Téléchargement tuiles IGN + overlay
├── rendering.py # Colormaps, tif_to_png, rapport PDF ├── rendering.py # Colormaps, tif_to_png, rapport PDF
└── pipeline.py # LidarArchaeoPipeline (orchestration + registry) └── 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 ## Installation Docker
@ -87,7 +86,7 @@ docker build -t lidar-lidar .
-v Mode verbeux (timestamps + niveaux) -v Mode verbeux (timestamps + niveaux)
--debug Mode debug (détails internes fichier:ligne) --debug Mode debug (détails internes fichier:ligne)
-f / --force Régénérer tous les fichiers même si les WebP existent -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 -h Afficher l'aide
``` ```
@ -111,8 +110,11 @@ docker build -t lidar-lidar .
# Forcer la régénération de tous les fichiers # Forcer la régénération de tous les fichiers
./run.sh -g --force ./run.sh -g --force
# Traiter un seul fichier pour test # Traiter un fichier spécifique (test rapide)
./run.sh -g --file 6881 ./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 ### 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 \ 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 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 \ 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 # Forcer la régénération
docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ 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 │ │ │ ├── ..._hillshade_multi.webp
│ │ │ ├── ..._svf.webp │ │ │ ├── ..._svf.webp
│ │ │ ├── ..._mslrm.webp │ │ │ ├── ..._mslrm.webp
│ │ │ └── ... (20 visualisations) │ │ │ └── ... (19 visualisations)
│ │ └── fichier_6882/ │ │ └── fichier_6882/
│ │ └── ... │ │ └── ...
│ └── rapports/ # Rapports PDF A3 par fichier │ └── 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 | | Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle |
| Output | `-o` | /data/output | Dossier de sortie | | Output | `-o` | /data/output | Dossier de sortie |
| Force | `-f/--force` | off | Régénérer même si les WebP existent | | 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) | | Verbose | `-v` | off | Mode verbeux (timestamps + niveaux) |
| Debug | `--debug` | off | Mode debug (détails internes) | | Debug | `--debug` | off | Mode debug (détails internes) |

View File

@ -61,7 +61,7 @@ Exemples:
python -m lidar_pipeline /data/input -o /data/output --force python -m lidar_pipeline /data/input -o /data/output --force
Traiter un seul fichier (pour tests): 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): Traitement parallèle (4 workers):
python -m lidar_pipeline /data/input -o /data/output -w 4 python -m lidar_pipeline /data/input -o /data/output -w 4
@ -95,9 +95,10 @@ Exemples:
) )
parser.add_argument( parser.add_argument(
"--file", "--file",
nargs="+",
type=str, type=str,
default=None, 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( parser.add_argument(
"-v", "--verbose", "-v", "--verbose",
@ -130,24 +131,35 @@ Exemples:
force=args.force force=args.force
) )
# If --file is specified, process only that single file # If --file is specified, process only matching files
if args.file: if args.file:
from pathlib import Path from pathlib import Path
input_dir = Path(args.input) input_dir = Path(args.input)
# Find matching file # Each pattern is the full filename without extension (e.g. LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc)
matches = list(input_dir.glob(f"*{args.file}*")) + list(input_dir.glob(f"*{args.file}*.laz")) + list(input_dir.glob(f"*{args.file}*.las")) selected_files = []
# Remove duplicates for pattern in args.file:
matches = list(dict.fromkeys(matches)) matches = list(input_dir.glob(f"{pattern}.laz")) + list(input_dir.glob(f"{pattern}.las"))
if not matches: # Remove duplicates
logger.error(f"Aucun fichier trouvé pour: {args.file}") 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) sys.exit(1)
if len(matches) > 1: logger.info(f"Traitement de {len(unique_files)} fichier(s) sélectionné(s)")
logger.info(f"Plusieurs fichiers correspondent, utilisation du premier:") for laz_file in unique_files:
for m in matches: logger.info(f"{laz_file.name}")
logger.info(f" {m.name}") for laz_file in unique_files:
laz_file = matches[0] pipeline.process_file(laz_file)
logger.info(f"Traitement du fichier: {laz_file.name}")
pipeline.process_file(laz_file)
else: else:
pipeline.process_all() pipeline.process_all()
except Exception as e: except Exception as e:

View File

@ -23,6 +23,9 @@ logger = logging.getLogger("lidar")
def create_smrf_pipeline(input_laz, output_las): def create_smrf_pipeline(input_laz, output_las):
"""Create a PDAL pipeline JSON for SMRF ground classification. """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: Args:
input_laz: Path to input LAZ/LAS file. input_laz: Path to input LAZ/LAS file.
output_las: Path to output classified LAS file. output_las: Path to output classified LAS file.
@ -33,6 +36,10 @@ def create_smrf_pipeline(input_laz, output_las):
pipeline = { pipeline = {
"pipeline": [ "pipeline": [
str(input_laz), str(input_laz),
{
"type": "filters.range",
"limits": "ReturnNumber[1:],NumberOfReturns[1:]"
},
{ {
"type": "filters.smrf", "type": "filters.smrf",
"ignore": "Classification[7:7]", "ignore": "Classification[7:7]",
@ -87,36 +94,7 @@ def classify_ground(laz_file, temp_dir):
logger.info(" ✓ Classification sol terminée") logger.info(" ✓ Classification sol terminée")
return output_las return output_las
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() logger.error(f" ✗ Erreur classification PDAL: {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
return None return None

View File

@ -71,3 +71,9 @@ def xp_minimum_filter(arr, footprint=None, size=None):
if HAS_GPU and isinstance(arr, cp.ndarray): if HAS_GPU and isinstance(arr, cp.ndarray):
return cp_ndimage.minimum_filter(arr, footprint=footprint, size=size) return cp_ndimage.minimum_filter(arr, footprint=footprint, size=size)
return ndimage.minimum_filter(arr, footprint=footprint, size=size) 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()

View File

@ -17,11 +17,12 @@ import subprocess
from .dtm import classify_ground, create_dtm_fast from .dtm import classify_ground, create_dtm_fast
from .visualizations import ( from .visualizations import (
generate_hillshade, generate_slope, generate_aspect, generate_curvature, 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_mslrm, generate_tpi, generate_depressions, generate_sailore,
generate_roughness, generate_anomalies, generate_wavelet, generate_texture, generate_roughness, generate_anomalies, generate_wavelet, generate_texture,
generate_flow, generate_flow,
) )
from .gpu import gpu_cleanup
from .ign import generate_ign_overlay from .ign import generate_ign_overlay
from .rendering import tif_to_png, generate_pdf_report from .rendering import tif_to_png, generate_pdf_report
@ -36,7 +37,6 @@ VIZ_STEPS = [
('slope', generate_slope), ('slope', generate_slope),
('aspect', generate_aspect), ('aspect', generate_aspect),
('curvature', generate_curvature), ('curvature', generate_curvature),
('solar', generate_solar),
('svf', generate_svf), ('svf', generate_svf),
('lrm', generate_lrm), ('lrm', generate_lrm),
('pos_open', lambda d, b, v, r: generate_openness(d, b, v, r, positive=True)), ('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 vis_results[name] = None
logger.error(f" [{idx}/{total}] ✗ {name}: {e}", exc_info=True) 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) # Convert to WebP (only newly generated TIFs, not skipped ones)
logger.info(" Conversion images WebP:") logger.info(" Conversion images WebP:")
for name, tif_file in vis_results.items(): for name, tif_file in vis_results.items():

View File

@ -66,14 +66,6 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': 0, 'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 360, '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': { 'curvature': {
'cmap': 'RdYlBu_r', 'cmap': 'RdYlBu_r',
'title': 'Courbure (Convexité/Concavité du terrain)', 'title': 'Courbure (Convexité/Concavité du terrain)',

View File

View File

@ -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

View File

@ -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

View File

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

View File

@ -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])

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -10,11 +10,10 @@ from pathlib import Path
import numpy as np import numpy as np
import rasterio import rasterio
from scipy import ndimage from scipy.ndimage import generic_filter
from scipy.ndimage import generic_filter, gaussian_filter, uniform_filter, minimum_filter
from scipy.stats import binned_statistic_2d 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") logger = logging.getLogger("lidar")
@ -58,31 +57,38 @@ def _read_dem(dem_file):
# ============================================================ # ============================================================
def generate_hillshade(dem_file, basename, vis_dir, resolution): def generate_hillshade(dem_file, basename, vis_dir, resolution):
"""Generate multi-directional hillshade (NW, NE, SW, SE).""" """Generate multi-directional hillshade (NW, NE, SW, SE) — GPU if available."""
logger.info(" → Hillshade multidirectionnel...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Hillshade multidirectionnel{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_hillshade_multi.tif" output = vis_dir / f"{basename}_hillshade_multi.tif"
try: try:
dem, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem) dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
azimuts = [315, 45, 225, 135] azimuts = [315, 45, 225, 135]
altitude = 30 altitude = 30
hillshades = [] hillshades = []
for az in azimuts: slope = xp.arctan(xp.sqrt(dx**2 + dy**2))
az_rad = np.radians(az) aspect = xp.arctan2(dy, dx)
alt_rad = np.radians(altitude) sin_slope = xp.sin(slope)
slope = np.arctan(np.sqrt(dx**2 + dy**2)) cos_slope = xp.cos(slope)
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))
combined = np.mean(hillshades, axis=0) alt_rad = xp.radians(xp.array(altitude))
_save_tif(output, combined, transform, crs) sin_alt = xp.sin(alt_rad)
logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s)") 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 return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur hillshade: {e}", exc_info=True) 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): def generate_slope(dem_file, basename, vis_dir, resolution):
"""Generate slope map (degrees).""" """Generate slope map (degrees) — GPU if available."""
logger.info(" → Pente (Slope)...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Pente (Slope){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_slope.tif" output = vis_dir / f"{basename}_slope.tif"
try: try:
dem, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem) dem = to_gpu(dem_np)
slope = np.arctan(np.sqrt(dx**2 + dy**2)) * 180 / np.pi dy, dx = xp.gradient(dem)
_save_tif(output, slope, transform, crs) slope = xp.arctan(xp.sqrt(dx**2 + dy**2)) * 180 / xp.pi
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s)") _save_tif(output, to_cpu(slope), transform, crs)
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur slope: {e}", exc_info=True) 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): def generate_aspect(dem_file, basename, vis_dir, resolution):
"""Generate aspect (slope orientation) map.""" """Generate aspect (slope orientation) map — GPU if available."""
logger.info(" → Aspect (Orientation)...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Aspect (Orientation){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_aspect.tif" output = vis_dir / f"{basename}_aspect.tif"
try: try:
dem, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem) dem = to_gpu(dem_np)
aspect = np.arctan2(dy, dx) * 180 / np.pi dy, dx = xp.gradient(dem)
aspect = np.mod(aspect, 360) aspect = xp.arctan2(dy, dx) * 180 / xp.pi
_save_tif(output, aspect, transform, crs) aspect = xp.mod(aspect, 360)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s)") _save_tif(output, to_cpu(aspect), transform, crs)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur aspect: {e}", exc_info=True) 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): def generate_curvature(dem_file, basename, vis_dir, resolution):
"""Generate curvature (terrain concavity/convexity) map.""" """Generate curvature (terrain concavity/convexity) map — GPU if available."""
logger.info(" → Courbure (Curvature)...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Courbure (Curvature){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_curvature.tif" output = vis_dir / f"{basename}_curvature.tif"
try: try:
dem, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
dz_dx = np.gradient(dem, axis=1) dem = to_gpu(dem_np)
dz_dy = np.gradient(dem, axis=0) dz_dx = xp.gradient(dem, axis=1)
d2z_dx2 = np.gradient(dz_dx, axis=1) dz_dy = xp.gradient(dem, axis=0)
d2z_dy2 = np.gradient(dz_dy, axis=0) d2z_dx2 = xp.gradient(dz_dx, axis=1)
d2z_dy2 = xp.gradient(dz_dy, axis=0)
curvature = (d2z_dx2 + d2z_dy2) / 2 curvature = (d2z_dx2 + d2z_dy2) / 2
_save_tif(output, curvature, transform, crs) _save_tif(output, to_cpu(curvature), transform, crs)
logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s)") logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur curvature: {e}", exc_info=True) logger.error(f" ✗ Erreur curvature: {e}", exc_info=True)
return None 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 # 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): def generate_depressions(dem_file, basename, vis_dir, resolution):
"""Depression detection using hydrological sink filling.""" """Depression detection using hydrological sink filling — GPU if available."""
logger.info(" → Détection dépressions (hydrologique)...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Détection dépressions (hydrologique){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_depressions.tif" output = vis_dir / f"{basename}_depressions.tif"
try: try:
dem, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
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
from scipy.ndimage import generate_binary_structure
struct = generate_binary_structure(2, 2) 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 changed = True
iterations = 0 iterations = 0
max_iter = 100 max_iter = 100
while changed and iterations < max_iter: while changed and iterations < max_iter:
from scipy.ndimage import minimum_filter as scipy_min_filter neighbor_min = xp_minimum_filter(dem_filled, footprint=struct)
neighbor_min = scipy_min_filter(dem_filled, footprint=struct)
sinks = (dem_filled < neighbor_min) & ~nodata_mask sinks = (dem_filled < neighbor_min) & ~nodata_mask
if not np.any(sinks): if not xp.any(sinks):
break break
new_dem = np.maximum(dem_filled, neighbor_min) new_dem = xp.maximum(dem_filled, neighbor_min)
new_dem[nodata_mask] = np.nan new_dem[nodata_mask] = xp.nan
changed = np.any(new_dem != dem_filled) changed = bool(xp.any(new_dem != dem_filled))
dem_filled = new_dem dem_filled = new_dem
iterations += 1 iterations += 1
depressions = dem_filled - dem depressions = to_cpu(dem_filled - dem)
depressions[nodata_mask] = np.nan depressions[to_cpu(nodata_mask)] = np.nan
depressions = np.where(depressions > 0.01, depressions, 0) depressions = np.where(depressions > 0.01, depressions, 0)
_save_tif(output, depressions, transform, crs) _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 return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur dépressions: {e}", exc_info=True) 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): def generate_roughness(dem_file, basename, vis_dir, resolution):
"""Surface roughness - standard deviation of elevation in a window.""" """Surface roughness - standard deviation of elevation in a window (GPU-accelerated)."""
logger.info(" → Rugosité de surface...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Rugosité de surface{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_roughness.tif" output = vis_dir / f"{basename}_roughness.tif"
try: 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) window_size = int(5 / resolution)
if window_size % 2 == 0: if window_size % 2 == 0:
window_size += 1 window_size += 1
def std_filter(arr): # Vectorized std: sqrt(E[X²] - (E[X])²) via uniform_filter (GPU-accelerated)
return np.nanstd(arr) 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, roughness = to_cpu(roughness)
size=window_size, mode='nearest')
_save_tif(output, roughness, transform, crs) _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 return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur rugosité: {e}", exc_info=True) 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): def generate_anomalies(dem_file, basename, vis_dir, resolution):
"""Statistical anomaly detection - z-score of local relief + Local Moran's I.""" """Statistical anomaly detection - z-score of local relief + Local Moran's I — GPU if available."""
logger.info(" → Détection anomalies statistiques...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Détection anomalies statistiques{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_anomalies.tif" output = vis_dir / f"{basename}_anomalies.tif"
try: 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 = dem - xp_gaussian_filter(dem, sigma=15 / resolution)
lrm_mean = np.nanmean(lrm) lrm_mean = xp.nanmean(lrm)
lrm_std = max(np.nanstd(lrm), 0.01) lrm_std = max(float(xp.nanstd(lrm)), 0.01)
z_score = (lrm - lrm_mean) / lrm_std z_score = (lrm - lrm_mean) / lrm_std
window = int(10 / resolution) window = int(10 / resolution)
if window % 2 == 0: if window % 2 == 0:
window += 1 window += 1
local_mean = uniform_filter(z_score, size=window) local_mean = xp_uniform_filter(z_score, size=window)
morans_i = z_score * (local_mean - np.nanmean(z_score)) / np.nanstd(z_score) z_mean = xp.nanmean(z_score)
anomaly_score = np.abs(z_score) * np.sign(morans_i) 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) _save_tif(output, to_cpu(anomaly_score), transform, crs)
logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s)") logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur anomalies: {e}", exc_info=True) 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): def generate_texture(dem_file, basename, vis_dir, resolution):
"""GLCM texture analysis on hillshade - contrast, entropy, homogeneity.""" """GLCM-inspired texture analysis contrast, entropy, homogeneity (GPU-accelerated)."""
logger.info(" → Texture GLCM...") gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Texture GLCM{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_texture.tif" output = vis_dir / f"{basename}_texture.tif"
try: 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)) slope = np.arctan(np.sqrt(gx**2 + gy**2))
alt_rad = np.radians(45) alt_rad = np.radians(45)
az_rad = np.radians(315) az_rad = np.radians(315)
aspect = np.arctan2(gy, gx)
shading = (np.sin(alt_rad) * np.cos(slope) + shading = (np.sin(alt_rad) * np.cos(slope) +
np.cos(alt_rad) * np.sin(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) hillshade = np.clip(shading, 0, 1)
valid = hillshade[~np.isnan(hillshade)] 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") raise ValueError("No valid data for texture analysis")
lo, hi = np.percentile(valid, (1, 99)) lo, hi = np.percentile(valid, (1, 99))
img = np.clip((hillshade - lo) / max(hi - lo, 0.001), 0, 1) 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) window = int(5 / resolution)
if window % 2 == 0: if window % 2 == 0:
window += 1 window += 1
def local_variance(arr): # Contrast (variance) — GPU-accelerated
return np.var(arr.astype(np.float64)) 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): # Entropy — compute bin-by-bin to avoid large 3D allocation
hist, _ = np.histogram(arr.astype(np.float64), bins=16, range=(0, 256)) n_bins = 16
hist = hist / max(hist.sum(), 1) img_uint8 = np.clip(img * 255, 0, 255).astype(np.uint8)
hist = hist[hist > 0] quantized = (img_uint8 // (256 // n_bins)).astype(np.int32)
return -np.sum(hist * np.log2(hist)) entropy = np.zeros_like(img, dtype=np.float64)
win_area = max(window * window, 1)
def local_homogeneity(arr): for b in range(n_bins):
arr_f = arr.astype(np.float64) plane = (quantized == b).astype(np.float32)
return np.mean(1.0 / (1.0 + (arr_f - np.mean(arr_f)) ** 2)) 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, del quantized, img_uint8 # free CPU memory
size=window, mode='nearest')
entropy = generic_filter(img_uint8.astype(np.float64), local_entropy, # Homogeneity — 1 / (1 + variance)
size=window, mode='nearest') homogeneity = 1.0 / (1.0 + contrast)
homogeneity = generic_filter(img_uint8.astype(np.float64), local_homogeneity,
size=window, mode='nearest')
def norm(arr): def norm(arr):
valid_arr = arr[~np.isnan(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) std_val = max(np.std(valid_arr), 0.01)
return (arr - np.mean(valid_arr)) / std_val return (arr - np.mean(valid_arr)) / std_val
texture_combined = (0.4 * norm(contrast) + texture_combined = 0.4 * norm(contrast) + 0.4 * norm(entropy) - 0.2 * norm(homogeneity)
0.4 * norm(entropy) -
0.2 * norm(homogeneity))
_save_tif(output, texture_combined, transform, crs) _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 return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur texture GLCM: {e}", exc_info=True) 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): def generate_flow(dem_file, basename, vis_dir, resolution):
"""Flow accumulation using D8 algorithm. """Flow accumulation using D8 algorithm — sink filling on GPU, accumulation on CPU."""
gpu_tag = " [GPU]" if HAS_GPU else ""
Identifies drainage patterns, ditches, and enclosure boundaries. logger.info(f" → Accumulation de flux D8{gpu_tag}...")
"""
logger.info(" → Accumulation de flux D8...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_flow.tif" output = vis_dir / f"{basename}_flow.tif"
try: try:
dem, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
rows, cols = dem.shape rows, cols = dem_np.shape
nodata_mask = np.isnan(dem) nodata_mask = np.isnan(dem_np)
from scipy.ndimage import minimum_filter as scipy_min_filter, generate_binary_structure # Sink filling — GPU-accelerated
dem_gpu = to_gpu(dem_np)
dem_filled = dem.copy() nodata_mask_gpu = xp.isnan(dem_gpu)
dem_filled[nodata_mask] = np.nanmax(dem) + 1000 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) struct = generate_binary_structure(2, 2)
for _ in range(50): for _ in range(50):
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 sinks = (dem_filled < neighbor_min) & ~nodata_mask_gpu
if not np.any(sinks): if not xp.any(sinks):
break 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] dx8 = [1, 1, 0, -1, -1, -1, 0, 1]
dy8 = [0, 1, 1, 1, 0, -1, -1, -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)] 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) 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', padded = np.pad(dem_filled_np, 1, mode='constant',
constant_values=np.nanmax(dem_filled) + 10000) constant_values=np.nanmax(dem_filled_np[~np.isnan(dem_filled_np)]) + 10000)
for d in range(8): for d in range(8):
nx = 1 + dx8[d] nx = 1 + dx8[d]
ny = 1 + dy8[d] ny = 1 + dy8[d]
neighbor_elev = padded[ny:ny + rows, nx:nx + cols] 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 slope[nodata_mask] = -1
better = slope > max_slope better = slope > max_slope
flow_dir[better] = d flow_dir[better] = d
max_slope[better] = slope[better] 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] valid_indices = np.where(~nodata_mask.flatten())[0]
sort_order = valid_indices[np.argsort(-flat_dem)] 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) flow_log = np.log1p(flow_acc)
_save_tif(output, flow_log, transform, crs) _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 return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur flux: {e}", exc_info=True) logger.error(f" ✗ Erreur flux: {e}", exc_info=True)

93
run.sh
View File

@ -9,11 +9,39 @@
# -v Mode verbeux (timestamps + niveaux) # -v Mode verbeux (timestamps + niveaux)
# --debug Mode debug (détails internes fichier:ligne) # --debug Mode debug (détails internes fichier:ligne)
# -f / --force Régénérer tous les fichiers même si existants # -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 # -h Afficher l'aide complète
set -e 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)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT_DIR="${SCRIPT_DIR}/input" INPUT_DIR="${SCRIPT_DIR}/input"
OUTPUT_DIR="${SCRIPT_DIR}/output" OUTPUT_DIR="${SCRIPT_DIR}/output"
@ -23,7 +51,7 @@ WORKERS=1
GPU_FLAG="" GPU_FLAG=""
VERBOSE_FLAG="" VERBOSE_FLAG=""
FORCE_FLAG="" FORCE_FLAG=""
FILE_FILTER="" FILE_ARGS=""
while getopts "r:w:gvf-:" opt; do while getopts "r:w:gvf-:" opt; do
case $opt in case $opt in
@ -51,27 +79,58 @@ while getopts "r:w:gvf-:" opt; do
echo " -v Mode verbeux (timestamps + niveaux)" echo " -v Mode verbeux (timestamps + niveaux)"
echo " --debug Mode debug (détails internes fichier:ligne)" 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 " -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 " -h Afficher cette aide"
echo "" echo ""
echo "Exemples:" echo "Exemples:"
echo " $0 # Traitement standard"
echo " $0 -g # Avec accélération GPU" echo " $0 -g # Avec accélération GPU"
echo " $0 -g -w 4 # GPU + 4 workers parallèles" echo " $0 -g -w 4 # GPU + 4 workers parallèles"
echo " $0 -g -v # GPU + mode verbeux" echo " $0 -g -v # GPU + mode verbeux"
echo " $0 -r 0.2 -g --debug # Haute résolution + GPU + debug" 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 -f # Forcer la régénération de tous les fichiers"
echo " $0 --file 6881 # Traiter un seul fichier (pour tests)" echo " $0 --file 6881 # Traiter un seul fichier"
echo " $0 --file 6881 6882 # Traiter deux fichiers spécifiques"
exit 0 exit 0
;; ;;
*) echo "Option invalide. Utilisez -h pour l'aide." >&2; exit 1 ;; *) echo "Option invalide. Utilisez -h pour l'aide." >&2; exit 1 ;;
esac esac
done done
# Handle --file option separately (not easily done in getopts) # Check for --test flag first
if [[ " $@ " == *" --file "* ]]; then if [[ " $* " == *" --test "* ]]; then
# Extract the argument after --file # Build l'image si elle n'existe pas
FILE_FILTER=$(echo "$@" | sed -n 's/.*--file *\([^ ]*\).*/\1/p') 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 fi
# Build l'image si elle n'existe pas # 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 " GPU : $([ -n "$GPU_FLAG" ] && echo 'OUI' || echo 'non')"
echo " Verbeux : $([ -n "$VERBOSE_FLAG" ] && echo 'OUI' || echo 'non')" echo " Verbeux : $([ -n "$VERBOSE_FLAG" ] && echo 'OUI' || echo 'non')"
echo " Force : $([ -n "$FORCE_FLAG" ] && echo 'OUI' || echo 'non')" echo " Force : $([ -n "$FORCE_FLAG" ] && echo 'OUI' || echo 'non')"
if [ -n "$FILE_FILTER" ]; then if [ -n "$FILE_ARGS" ]; then
echo " Fichier : ${FILE_FILTER}" echo " Fichiers :${FILE_ARGS}"
fi fi
echo "============================================" echo "============================================"
CMD_ARGS="-o /data/output -r $RESOLUTION -w $WORKERS $VERBOSE_FLAG $FORCE_FLAG" CMD_ARGS="-o /data/output -r $RESOLUTION -w $WORKERS $VERBOSE_FLAG $FORCE_FLAG"
if [ -n "$FILE_FILTER" ]; then if [ -n "$FILE_ARGS" ]; then
CMD_ARGS="$CMD_ARGS --file $FILE_FILTER" CMD_ARGS="$CMD_ARGS --file $FILE_ARGS"
fi fi
docker run --rm $GPU_FLAG \ docker run --rm $GPU_FLAG \