Traitement LiDAR Drone avec Python : Du Vol au Jumeau Numérique
1. Planification de Vol et Paramètres d'Acquisition
La qualité du traitement dépend directement de la qualité de l'acquisition. Avant même d'écrire une ligne de Python, il faut paramétrer correctement le vol. Les capteurs DJI Zenmuse L2 et Livox Mid-360 dominent le marché en 2026 avec des densités de points atteignant 200 pts/m2.
from dataclasses import dataclass
@dataclass
class FlightPlan:
"""Parametres de vol pour acquisition LiDAR drone."""
altitude_m: float = 80.0 # Altitude AGL
speed_ms: float = 5.0 # Vitesse de vol
overlap_pct: float = 70.0 # Recouvrement lateral
scan_rate_hz: int = 240000 # Frequence du LiDAR
fov_deg: float = 70.0 # Champ de vision
returns: int = 5 # Nombre de retours
@property
def swath_width(self):
import math
return 2 * self.altitude_m * math.tan(math.radians(self.fov_deg / 2))
@property
def point_density(self):
"""Densite theorique en pts/m2."""
return self.scan_rate_hz / (self.speed_ms * self.swath_width)
plan = FlightPlan(altitude_m=60, speed_ms=4)
print(f"Fauchee : {plan.swath_width:.1f} m")
print(f"Densite : {plan.point_density:.0f} pts/m2")
50 pts/m2. Pour le BIM et la détection de défauts, montez à 150 pts/m2 en abaissant l'altitude et la vitesse.
Le vol est terminé, les fichiers .LAZ sont sur la carte SD. Comment les ingérer efficacement ?
2. Ingestion et Contrôle Qualité des Fichiers LAZ
Les fichiers bruts du drone contiennent souvent plusieurs dalles .LAZ qu'il faut fusionner, vérifier et nettoyer. Le backend lazrs (Rust) décompresse en parallèle sur plusieurs coeurs, là où laszip reste mono-thread, et s'installe sans dépendance C++.
import laspy
import numpy as np
from pathlib import Path
def ingest_drone_laz(laz_dir, output_path):
"""Fusionne et valide les dalles LAZ d'un vol drone."""
laz_files = sorted(Path(laz_dir).glob("*.laz"))
print(f"Dalles detectees : {len(laz_files)}")
all_points = []
total_count = 0
for laz_file in laz_files:
las = laspy.read(str(laz_file))
# Controle qualite : verifier le CRS et les retours
if las.header.point_count == 0:
print(f"ATTENTION : {laz_file.name} est vide, ignore.")
continue
# Extraire les coordonnees avec precision float64
coords = np.vstack((las.x, las.y, las.z)).T
# Filtre de bruit : supprimer les points aberrants (z < 0 ou z > 500)
valid = (coords[:, 2] > 0) & (coords[:, 2] < 500)
coords = coords[valid]
all_points.append(coords)
total_count += len(coords)
print(f" {laz_file.name}: {len(coords):,} points valides")
merged = np.vstack(all_points)
print(f"Total fusionne : {total_count:,} points")
return merged
points = ingest_drone_laz("./vol_2026_02/", "merged.laz")
WGS84 (EPSG:4326) par défaut. Pensez à reprojeter en système métrique local (Lambert-93, UTM) avant tout calcul de distance ou de surface.
Les points sont propres et fusionnés. Comment classifier automatiquement le sol, la végétation et les bâtiments ?
3. Classification Automatique avec PDAL
PDAL (Point Data Abstraction Library) est le couteau suisse du traitement LiDAR en ligne de commande et en Python. Son filtre SMRF (Simple Morphological Filter) est la référence pour la classification sol/non-sol sur données drone.
import pdal
import json
def classify_ground(input_laz, output_laz):
"""Pipeline PDAL : classification sol + vegetation + batiments."""
pipeline_json = {
"pipeline": [
input_laz,
{
"type": "filters.assign",
"assignment": "Classification[:]=0"
},
{
# Filtre morphologique pour extraire le sol
"type": "filters.smrf",
"slope": 0.15,
"window": 18,
"threshold": 0.5,
"scalar": 1.2
},
{
# Classification hauteur : vegetation basse / haute
"type": "filters.hag_nn"
},
{
"type": "filters.assign",
"assignment": "Classification[0:3]=3",
"where": "HeightAboveGround > 0.3 AND HeightAboveGround < 2.0"
},
{
"type": "filters.assign",
"assignment": "Classification[0:5]=5",
"where": "HeightAboveGround >= 2.0 AND HeightAboveGround < 30.0"
},
{
"type": "writers.las",
"filename": output_laz,
"compression": "laszip"
}
]
}
pipeline = pdal.Pipeline(json.dumps(pipeline_json))
count = pipeline.execute()
print(f"Points traites : {count:,}")
return pipeline.arrays[0]
classified = classify_ground("merged.laz", "classified.laz")
| Code ASPRS | Classe | Description | Couleur |
|---|---|---|---|
| 2 | Sol | Terrain naturel | Marron |
| 3 | Végétation basse | 0.3m - 2.0m | Vert clair |
| 5 | Végétation haute | 2.0m - 30.0m | Vert foncé |
| 6 | Bâtiment | Structures | Rouge |
La classification est terminée. Comment générer les modèles numériques de terrain et de surface ?
4. Génération DTM/DSM avec Rasterio
Le DTM (Digital Terrain Model) représente le sol nu, tandis que le DSM (Digital Surface Model) inclut tous les objets. La différence des deux donne le CHM (Canopy Height Model), essentiel en foresterie.
import numpy as np
import rasterio
from rasterio.transform import from_bounds
from scipy.interpolate import griddata
def generate_raster(points, values, resolution, output_path, crs="EPSG:2154"):
"""Genere un raster GeoTIFF a partir de points 3D."""
x_min, y_min = points[:, :2].min(axis=0)
x_max, y_max = points[:, :2].max(axis=0)
# Grille reguliere
cols = int((x_max - x_min) / resolution)
rows = int((y_max - y_min) / resolution)
xi = np.linspace(x_min, x_max, cols)
yi = np.linspace(y_min, y_max, rows)
grid_x, grid_y = np.meshgrid(xi, yi)
# Interpolation IDW sur la grille
grid_z = griddata(
points[:, :2], values,
(grid_x, grid_y),
method="linear",
fill_value=np.nan
)
# Ecriture GeoTIFF
transform = from_bounds(x_min, y_min, x_max, y_max, cols, rows)
with rasterio.open(
output_path, "w",
driver="GTiff",
height=rows, width=cols,
count=1, dtype="float32",
crs=crs, transform=transform,
compress="lzw"
) as dst:
dst.write(grid_z.astype(np.float32), 1)
print(f"Raster genere : {output_path} ({cols}x{rows} px)")
# DTM : uniquement les points sol (classe 2)
ground_mask = classified["Classification"] == 2
ground_pts = np.vstack((classified["X"], classified["Y"], classified["Z"])).T
generate_raster(ground_pts[ground_mask], ground_pts[ground_mask, 2], 0.5, "dtm_50cm.tif")
# DSM : tous les points (premier retour)
generate_raster(ground_pts, ground_pts[:, 2], 0.5, "dsm_50cm.tif")
50 cm est idéale pour les relevés drone à 60m d'altitude. En dessous de 25 cm, vous risquez des artefacts d'interpolation dans les zones à faible densité de points sol.
Nos rasters sont prêts. Comment calculer les métriques dérivées utiles aux ingénieurs ?
5. Calcul de Métriques Dérivées : Pente, Aspect, Rugosité
Les modèles numériques bruts ne suffisent pas pour la prise de décision. Les métriques dérivées comme la pente, l'aspect (orientation) et la rugosite sont essentielles pour l'aménagement, l'hydrologie et la détection de risques.
import rasterio
import numpy as np
from scipy.ndimage import generic_filter
def compute_slope_aspect(dtm_path, output_slope, output_aspect):
"""Calcul de la pente et de l'aspect a partir du DTM."""
with rasterio.open(dtm_path) as src:
dem = src.read(1)
res = src.res[0] # Resolution en metres
profile = src.profile.copy()
# Gradient (derivees partielles)
dy, dx = np.gradient(dem, res)
# Pente en degres
slope = np.degrees(np.arctan(np.sqrt(dx**2 + dy**2)))
# Aspect (orientation) en degres
aspect = np.degrees(np.arctan2(-dy, dx))
aspect = (aspect + 360) % 360
# Ecriture des rasters derivees
for data, path in [(slope, output_slope), (aspect, output_aspect)]:
with rasterio.open(path, "w", **profile) as dst:
dst.write(data.astype(np.float32), 1)
print(f"Pente max : {slope.max():.1f} degres")
return slope, aspect
def compute_roughness(dtm_path, output_path, window=5):
"""Rugosite = ecart-type local de l'elevation."""
with rasterio.open(dtm_path) as src:
dem = src.read(1)
profile = src.profile.copy()
roughness = generic_filter(dem, np.std, size=window)
with rasterio.open(output_path, "w", **profile) as dst:
dst.write(roughness.astype(np.float32), 1)
return roughness
slope, aspect = compute_slope_aspect("dtm_50cm.tif", "slope.tif", "aspect.tif")
roughness = compute_roughness("dtm_50cm.tif", "roughness.tif")
NoData en bordure du raster. Les calculs de gradient sur des valeurs NaN propageront des artefacts. Appliquez un masque ou utilisez np.nan_to_num avant le calcul.
Les métriques terrain sont calculées. Comment intégrer tout cela dans un workflow BIM ?
6. Intégration BIM : Du Nuage au Modèle IFC
L'étape finale du pipeline consiste à injecter les données traitées dans un environnement BIM. Nous utilisons IfcOpenShell pour générer un fichier IFC contenant le terrain et les éléments détectés.
import ifcopenshell
import ifcopenshell.api as api
import numpy as np
def create_terrain_ifc(dtm_points, output_path):
"""Cree un fichier IFC avec le terrain en IfcSite."""
ifc = ifcopenshell.file(schema="IFC4")
# Entites de base
project = api.run("root.create_entity", ifc, ifc_class="IfcProject", name="Drone Survey")
site = api.run("root.create_entity", ifc, ifc_class="IfcSite", name="Terrain")
# Sous-echantillonner pour le maillage IFC
step = max(1, len(dtm_points) // 50000)
sampled = dtm_points[::step]
# Creer la representation geometrique du terrain
# (Triangulation de Delaunay simplifiee)
from scipy.spatial import Delaunay
tri = Delaunay(sampled[:, :2])
print(f"Terrain IFC : {len(sampled)} vertices, {len(tri.simplices)} triangles")
ifc.write(output_path)
print(f"Fichier IFC exporte : {output_path}")
create_terrain_ifc(ground_pts[ground_mask], "terrain_drone.ifc")
IFC4x3 supporte nativement les alignements et les infrastructures linéaires.
Comment valider la précision de notre pipeline complet ?
7. Validation et Contrôle Qualité du Pipeline
Un pipeline de production exige des métriques de validation. Nous comparons nos DTM/DSM avec des points de contrôle au sol (GCP) mesurés au GNSS RTK.
import numpy as np
import rasterio
def validate_dtm(dtm_path, gcp_file):
"""Compare le DTM avec les points de controle GNSS."""
gcps = np.loadtxt(gcp_file, delimiter=",", skiprows=1)
# Colonnes : X, Y, Z_gnss
with rasterio.open(dtm_path) as src:
errors = []
for x, y, z_gnss in gcps:
row, col = src.index(x, y)
z_dtm = src.read(1)[row, col]
if not np.isnan(z_dtm):
errors.append(z_dtm - z_gnss)
errors = np.array(errors)
rmse = np.sqrt(np.mean(errors**2))
mae = np.mean(np.abs(errors))
bias = np.mean(errors)
print(f"GCPs valides : {len(errors)}")
print(f"RMSE : {rmse:.3f} m")
print(f"MAE : {mae:.3f} m")
print(f"Biais : {bias:.3f} m")
return {"rmse": rmse, "mae": mae, "bias": bias}
metrics = validate_dtm("dtm_50cm.tif", "gcps_rtk.csv")
| Métrique | Seuil Acceptable | Seuil Excellent | Unité |
|---|---|---|---|
| RMSE vertical | < 0.10 | < 0.05 | mètres |
| MAE | < 0.08 | < 0.03 | mètres |
| Biais | < 0.05 | < 0.02 | mètres |
Conclusion : Un Pipeline Industriel Complet
Nous avons construit un pipeline de bout en bout, de l'acquisition drone à l'intégration BIM :
- Acquisition : Planification paramétrée pour une densité optimale.
- Ingestion : Fusion et nettoyage des dalles LAZ avec Laspy.
- Classification : SMRF via PDAL pour la séparation sol/végétation/bâtiment.
- Rasterisation : Génération DTM/DSM avec Rasterio et interpolation scipy.
- BIM : Export IFC pour intégration dans les workflows construction.
Ce pipeline est directement déployable en production et constitue la base de tout projet de jumeau numérique géospatial.
Sur ce sujet : Ce que la formation couvre en acquisition et reconstruction.
Du Drone au Digital Twin
Ce tutoriel couvre les bases. Notre formation Elite vous apprend à industrialiser ces pipelines et à les connecter à des systèmes d'IA spatiale.
Recevoir le guide gratuit Passer le test (10 min)