Rasterisation GPU du DTM (bin_mean_2d) + GPU fallback renforcé et workers mono-thread

Rastérisation des points sol via gpu.bin_mean_2d (bincount 2D CuPy, sémantique
identique au repli scipy binned_statistic_2d) : ~x7 plus rapide sur cette
étape, logs de durée par phase dans create_dtm_fast. safe_gpu_call retente en
CPU sur toute erreur GPU (pas seulement les messages CUDA) : un transfert
échoué en cours de run mélangeait types numpy/cupy et faisait échouer la
visualisation entière. Workers en mono-thread BLAS/OpenMP sur la machine de
traitement (plus de saturation des cœurs pendant les runs).
This commit is contained in:
Antoine Jacquin
2026-09-21 23:42:55 +02:00
parent 2d5a9b2a46
commit 7c10ae3e18
4 changed files with 219 additions and 15 deletions

View File

@ -2,7 +2,8 @@
Handles ground classification via PDAL (IGN supplier pre-classification,
SMRF or CSF) and DTM rasterisation
using scipy binned_statistic_2d. Gaps without LiDAR data (common in
using scipy binned_statistic_2d (GPU-first via gpu.bin_mean_2d, scipy as
fallback). Gaps without LiDAR data (common in
complex/rocky terrain) are filled with a terrain-aware interpolation so the
DTM stays continuous.
"""
@ -10,6 +11,7 @@ DTM stays continuous.
import json
import logging
import subprocess
import time
from pathlib import Path
import numpy as np
@ -17,6 +19,8 @@ import rasterio
from rasterio.transform import from_bounds
from scipy.stats import binned_statistic_2d
from .gpu import bin_mean_2d
logger = logging.getLogger("lidar")
# Classes LAS exploitables de la pré-classification LiDAR HD (noms → codes)
@ -1221,7 +1225,10 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
logger.info(" → Génération DTM...")
try:
t_read = time.perf_counter()
las = laspy.read(str(las_file))
logger.info(f" Lecture {len(las.points):,} points "
f"({time.perf_counter() - t_read:.1f}s)")
except Exception as e:
# laspy can't read COPC v1.1 — try PDAL conversion
logger.warning(f" laspy: {e}")
@ -1240,6 +1247,7 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
strip_offsets = {}
strip_jitter = {}
gps_time = None
t_align = time.perf_counter()
if strip_align:
try:
strip_offsets = _strip_offsets_for_file(las_file, las)
@ -1270,6 +1278,7 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
f"{len(jt)} fenêtres de {STRIP_JITTER_BIN:g} s)")
else:
logger.debug(" Gigue intra-faisceau : rien à corriger")
logger.info(f" Calage faisceaux : {time.perf_counter() - t_align:.1f}s")
try:
@ -1324,23 +1333,36 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
# non calés par faisceau : bande de contexte, l'image finale est
# recadrée sur la dalle avant livraison).
if used_edge_buffer > 0 and source_laz is not None:
t_neigh = time.perf_counter()
nx, ny, nz = _neighbor_ground_points(
source_laz, (min_x, min_y, max_x, max_y),
neighbor_classes if neighbor_classes is not None else [2])
logger.info(f" Voisines (raccord) : {len(nx):,} points "
f"({time.perf_counter() - t_neigh:.1f}s)")
if len(nx):
xs = np.concatenate([xs, nx])
ys = np.concatenate([ys, ny])
zs = np.concatenate([zs, nz])
stat = binned_statistic_2d(
xs, ys, zs,
statistic='mean',
bins=[width, height],
range=[[min_x, max_x], [min_y, max_y]]
)
dtm = stat.statistic.T
dtm = dtm[::-1, :] # Flip Y so north is at top
t_raster = time.perf_counter()
dtm = bin_mean_2d(xs, ys, zs, width, height,
(min_x, max_x), (min_y, max_y))
if dtm is not None:
# Sortie déjà en (height, width) : seul le flip Y reste à faire
dtm = dtm[::-1, :] # nord en haut
logger.info(f" ✓ Rasterisation {width}x{height} GPU "
f"({time.perf_counter() - t_raster:.1f}s)")
else:
stat = binned_statistic_2d(
xs, ys, zs,
statistic='mean',
bins=[width, height],
range=[[min_x, max_x], [min_y, max_y]]
)
dtm = stat.statistic.T
dtm = dtm[::-1, :] # Flip Y so north is at top
logger.info(f" ✓ Rasterisation {width}x{height} CPU "
f"({time.perf_counter() - t_raster:.1f}s)")
# Comblement « historique » (fonctionnement d'origine, rétabli) :
# seuls les petits trous proches des données sont remplis ; les grands
@ -1357,6 +1379,7 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
logger.info(f" {nan_count:,} pixels sans données ({nan_pct:.1f}%)")
max_gap_pixels = max(1, int(1.0 / resolution))
t_fill = time.perf_counter()
from rasterio.fill import fillnodata
valid_mask = ~np.isnan(dtm)
dtm_filled = fillnodata(dtm, mask=valid_mask, max_search_distance=max_gap_pixels)
@ -1364,12 +1387,14 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
filled_count = np.count_nonzero(small_gap_mask)
if filled_count > 0:
dtm = np.where(small_gap_mask, dtm_filled, dtm)
logger.info(f" {filled_count:,} petits trous comblés (< {max_gap_pixels}px)")
logger.info(f" {filled_count:,} petits trous comblés "
f"(< {max_gap_pixels}px, {time.perf_counter() - t_fill:.1f}s)")
# Save as GeoTIFF
output_tif = dtm_dir / f"{basename}_dtm{output_suffix}.tif"
transform = from_bounds(min_x, min_y, max_x, max_y, width, height)
t_write = time.perf_counter()
with rasterio.open(
output_tif, 'w',
driver='GTiff', height=height, width=width,
@ -1383,6 +1408,7 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
# Tampon de raccord inscrit dans le fichier : changement de
# --edge-buffer ⇒ invalidation automatique du cache DTM.
dst.update_tags(**{EDGE_BUFFER_TAG: f"{used_edge_buffer:g}"})
logger.info(f" Écriture GeoTIFF : {time.perf_counter() - t_write:.1f}s")
if strip_align:
_write_strip_align_sidecar(dtm_dir, basename, output_suffix,