Skip ground classification when DTM already exists

If the DTM .tif exists and --force is not set, skip both ground
classification and DTM generation entirely. Previously, the pipeline
would spend 3+ minutes reclassifying ground even when the DTM was
already present and would be reused anyway.

Also includes: SharedDEM cache, enhanced WebP cartouche (compass rose,
adaptive scale bar, enriched info bar), removed COG/viewer, UTF-8
fix for parallel workers, skip logic for DTM and PDF.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-13 23:41:21 +02:00
parent f01683819c
commit 5b74322077
9 changed files with 564 additions and 942 deletions

View File

@ -2,6 +2,10 @@
Each function takes (dem_file, basename, vis_dir, resolution) as explicit
parameters and returns the path to the output GeoTIFF file, or None on error.
When a SharedDEM object is provided via the `shared` parameter, pre-computed
data (gradient, NaN mask, LRM) is reused across visualizations to avoid
redundant I/O and computation.
"""
import logging
@ -26,6 +30,76 @@ else:
xp = np
class SharedDEM:
"""Pre-computed DEM data shared across all visualizations.
Reads the DEM once and pre-computes:
- NaN mask and filled DEM (avoids 20+ calls to _fill_nans)
- Gradient components (shared by hillshade, slope, aspect, curvature)
- LRM at 15m kernel (shared by lrm + anomalies)
"""
def __init__(self, dem_file, resolution):
dem_np, transform, crs = _read_dem(dem_file)
self.dem_file = dem_file
self.resolution = resolution
self.transform = transform
self.crs = crs
self.nan_mask = np.isnan(dem_np)
self.dem_np = dem_np.astype(np.float32)
# Pre-fill NaNs once (saves ~20 calls to NearestNDInterpolator)
self.filled, _ = _fill_nans(self.dem_np)
# Initialize GPU lazy caches before any filter calls
self._filled_gpu = None
self._dem_gpu = None
# Pre-compute gradient (shared by hillshade, slope, aspect, curvature)
self.dy = np.gradient(self.filled, resolution, axis=0)
self.dx = np.gradient(self.filled, resolution, axis=1)
self.slope_rad = np.arctan(np.sqrt(self.dx**2 + self.dy**2))
self.slope_deg = np.degrees(self.slope_rad)
self.aspect = np.mod(np.degrees(np.arctan2(self.dy, self.dx)), 360)
# Pre-compute LRM at 15m (shared by lrm + anomalies)
sigma_15 = 15.0 / resolution
local_mean_15 = _filter_nanaware_from_filled(self, xp_gaussian_filter, sigma=sigma_15)
self.lrm_15 = self.dem_np - local_mean_15
self.lrm_15[self.nan_mask] = np.nan
@property
def filled_gpu(self):
"""Lazy GPU copy of the filled DEM."""
if self._filled_gpu is None and HAS_GPU:
self._filled_gpu = to_gpu(self.filled)
return self._filled_gpu
@property
def dem_gpu(self):
"""Lazy GPU copy of the DEM."""
if self._dem_gpu is None and HAS_GPU:
self._dem_gpu = to_gpu(self.dem_np)
return self._dem_gpu
def _filter_nanaware_from_filled(shared, filter_func, *args, **kwargs):
"""Apply filter on pre-filled DEM data (skips expensive _fill_nans).
Uses the SharedDEM.filled array directly, then restores NaN mask.
If GPU is available, uses the lazy GPU copy to avoid CPU↔GPU transfers.
"""
if HAS_GPU and shared.filled_gpu is not None:
filled_gpu = to_gpu(shared.filled)
result_gpu = filter_func(filled_gpu, *args, **kwargs)
result = to_cpu(result_gpu)
gpu_cleanup()
else:
result = filter_func(shared.filled, *args, **kwargs)
result[shared.nan_mask] = np.nan
return result
def _save_tif(output_path, data, transform, crs, dtype='float32', count=1, nodata=None):
"""Helper to save a 2D or 3D array as GeoTIFF."""
# Auto-detect nodata for float types with NaN
@ -38,7 +112,7 @@ def _save_tif(output_path, data, transform, crs, dtype='float32', count=1, nodat
output_path, 'w', driver='GTiff',
height=height, width=width, count=count,
dtype=dtype, crs=crs, transform=transform,
compress='lzw', nodata=nodata
compress='deflate', nodata=nodata
) as dst:
dst.write(data.astype(dtype), 1)
elif data.ndim == 3:
@ -47,7 +121,7 @@ def _save_tif(output_path, data, transform, crs, dtype='float32', count=1, nodat
output_path, 'w', driver='GTiff',
height=height, width=width, count=bands,
dtype=dtype, crs=crs, transform=transform,
compress='lzw', nodata=nodata
compress='deflate', nodata=nodata
) as dst:
for i in range(bands):
dst.write(data[i].astype(dtype), i + 1)
@ -116,7 +190,7 @@ def _filter_nanaware(arr, filter_func, *args, use_gpu=True, **kwargs):
# Core terrain visualizations
# ============================================================
def generate_hillshade(dem_file, basename, vis_dir, resolution):
def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate multi-directional hillshade with slope shading — GPU if available.
Combines 4-direction hillshade (NW, NE, SW, SE) with slope shading
@ -129,19 +203,29 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_hillshade_multi.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
if shared:
transform = shared.transform
crs = shared.crs
dem = to_gpu(shared.dem_np)
dy = to_gpu(shared.dy) if HAS_GPU else shared.dy
dx = to_gpu(shared.dx) if HAS_GPU else shared.dx
slope = to_gpu(shared.slope_rad) if HAS_GPU else shared.slope_rad
aspect = xp.arctan2(dy, dx)
sin_slope = xp.sin(slope)
cos_slope = xp.cos(slope)
else:
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))
aspect = xp.arctan2(dy, dx)
sin_slope = xp.sin(slope)
cos_slope = xp.cos(slope)
azimuts = [315, 45, 225, 135]
altitude = 30
hillshades = []
slope = xp.arctan(xp.sqrt(dx**2 + dy**2))
aspect = xp.arctan2(dy, dx)
sin_slope = xp.sin(slope)
cos_slope = xp.cos(slope)
alt_rad = xp.radians(xp.array(altitude))
sin_alt = xp.sin(alt_rad)
cos_alt = xp.cos(alt_rad)
@ -152,8 +236,7 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution):
hillshades.append(xp.clip(hs, 0, 1))
combined_hillshade = xp.mean(xp.array(hillshades), axis=0)
# Blend with slope shading for better micro-relief on flat terrain
slope_shaded = cos_slope # bright on flat, dark on steep
slope_shaded = cos_slope
combined = 0.7 * combined_hillshade + 0.3 * slope_shaded
_save_tif(output, to_cpu(combined), transform, crs)
logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s){gpu_tag}")
@ -163,7 +246,7 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution):
return None
def generate_slope(dem_file, basename, vis_dir, resolution):
def generate_slope(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate slope map (degrees) — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Pente (Slope){gpu_tag}...")
@ -171,11 +254,18 @@ def generate_slope(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_slope.tif"
try:
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)
if shared:
transform = shared.transform
crs = shared.crs
slope = shared.slope_deg
if HAS_GPU:
slope = to_gpu(slope)
else:
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) if HAS_GPU else slope, transform, crs)
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
@ -183,7 +273,7 @@ def generate_slope(dem_file, basename, vis_dir, resolution):
return None
def generate_aspect(dem_file, basename, vis_dir, resolution):
def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate aspect (slope orientation) map — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Aspect (Orientation){gpu_tag}...")
@ -191,12 +281,19 @@ def generate_aspect(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_aspect.tif"
try:
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)
if shared:
transform = shared.transform
crs = shared.crs
aspect = shared.aspect
if HAS_GPU:
aspect = to_gpu(aspect)
else:
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) if HAS_GPU else aspect, transform, crs)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
@ -204,7 +301,7 @@ def generate_aspect(dem_file, basename, vis_dir, resolution):
return None
def generate_curvature(dem_file, basename, vis_dir, resolution):
def generate_curvature(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate curvature (terrain concavity/convexity) map — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Courbure (Curvature){gpu_tag}...")
@ -212,12 +309,20 @@ def generate_curvature(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_curvature.tif"
try:
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)
if shared:
transform = shared.transform
crs = shared.crs
dx = shared.dx
dy = shared.dy
if HAS_GPU:
dx = to_gpu(dx)
dy = to_gpu(dy)
else:
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
d2z_dx2 = xp.gradient(dx, axis=1)
d2z_dy2 = xp.gradient(dy, axis=0)
curvature = (d2z_dx2 + d2z_dy2) / 2
_save_tif(output, to_cpu(curvature), transform, crs)
logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s){gpu_tag}")
@ -232,7 +337,7 @@ def generate_curvature(dem_file, basename, vis_dir, resolution):
# GPU-accelerated visualizations
# ============================================================
def generate_lrm(dem_file, basename, vis_dir, resolution):
def generate_lrm(dem_file, basename, vis_dir, resolution, shared=None):
"""Local Relief Model - deviation from local mean (GPU if available)."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Local Relief Model{gpu_tag}...")
@ -240,11 +345,16 @@ def generate_lrm(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_lrm.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
local_mean = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=15/resolution)
lrm = dem_np - local_mean
lrm[nan_mask] = np.nan
if shared:
transform = shared.transform
crs = shared.crs
lrm = shared.lrm_15.copy()
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
local_mean = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=15/resolution)
lrm = dem_np - local_mean
lrm[nan_mask] = np.nan
_save_tif(output, lrm.astype(np.float32), transform, crs)
logger.info(f" ✓ LRM terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
@ -253,7 +363,7 @@ def generate_lrm(dem_file, basename, vis_dir, resolution):
return None
def generate_svf(dem_file, basename, vis_dir, resolution):
def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
"""Sky-View Factor - ray-tracing on 16 azimuths (GPU if available).
For each pixel, trace rays in N directions, find the max horizon
@ -266,23 +376,33 @@ def generate_svf(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_svf.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
rows, cols = dem_np.shape
res = resolution
dem = to_gpu(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
rows, cols = dem_np.shape
res = resolution
dem = to_gpu(shared.filled) if HAS_GPU else shared.filled
nan_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
rows, cols = dem_np.shape
res = resolution
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if HAS_GPU else filled
n_dirs = 16
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
dx = np.cos(angles)
dy = np.sin(angles)
dx_dir = np.cos(angles)
dy_dir = np.sin(angles)
max_dist = int(50 / res)
padded = xp.pad(dem, max_dist, mode='constant', constant_values=xp.nan)
svf = xp.zeros_like(dem)
for d_idx in range(n_dirs):
ddx, ddy = dx[d_idx], dy[d_idx]
ddx, ddy = dx_dir[d_idx], dy_dir[d_idx]
horizon = xp.zeros_like(dem)
# Pre-compute all valid steps for this direction
@ -307,6 +427,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution):
svf /= n_dirs
svf_np = to_cpu(svf).astype(np.float32)
svf_np[nan_mask] = np.nan
_save_tif(output, svf_np, transform, crs)
logger.info(f" ✓ SVF terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
@ -315,7 +436,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution):
return None
def generate_openness(dem_file, basename, vis_dir, resolution, positive=True):
def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, shared=None):
"""Positive/Negative Openness - true zenith/nadir angle computation (GPU if available).
For each pixel, in 8 directions (N, NE, E, SE, S, SW, W, NW):
@ -330,23 +451,33 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True):
output = vis_dir / f"{basename}_{name}.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
rows, cols = dem_np.shape
res = resolution
dem = to_gpu(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
rows, cols = dem_np.shape
res = resolution
dem = to_gpu(shared.filled) if HAS_GPU else shared.filled
nan_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
rows, cols = dem_np.shape
res = resolution
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if HAS_GPU else filled
n_dirs = 8
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
dx = np.cos(angles)
dy = np.sin(angles)
dx_dir = np.cos(angles)
dy_dir = np.sin(angles)
max_dist = int(50 / res)
padded = xp.pad(dem, max_dist, mode='constant', constant_values=xp.nan)
openness_sum = xp.zeros_like(dem)
for d_idx in range(n_dirs):
ddx, ddy = dx[d_idx], dy[d_idx]
ddx, ddy = dx_dir[d_idx], dy_dir[d_idx]
max_angle = xp.zeros_like(dem)
for step in range(1, max_dist + 1):
@ -370,6 +501,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True):
openness_sum += max_angle
openness_result = to_cpu(xp.degrees(openness_sum / n_dirs)).astype(np.float32)
openness_result[nan_mask] = np.nan
_save_tif(output, openness_result, transform, crs)
logger.info(f"{name} terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
@ -378,7 +510,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True):
return None
def generate_mslrm(dem_file, basename, vis_dir, resolution):
def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=None):
"""Multi-Scale Relief Model (MSRM) - LRM at 5 scales combined (GPU if available)."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Multi-Scale Relief Model (MSRM){gpu_tag}...")
@ -386,15 +518,24 @@ def generate_mslrm(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_mslrm.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
sigmas = [5, 10, 25, 50, 100]
lrm_stack = []
for sigma in sigmas:
sigma_px = sigma / resolution
local_mean = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_px)
if shared:
local_mean = _filter_nanaware_from_filled(shared, xp_gaussian_filter, sigma=sigma_px)
else:
local_mean = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_px)
lrm = dem_np - local_mean
lrm[nan_mask] = np.nan
valid_lrm = lrm[~nan_mask]
@ -416,7 +557,7 @@ def generate_mslrm(dem_file, basename, vis_dir, resolution):
return None
def generate_tpi(dem_file, basename, vis_dir, resolution):
def generate_tpi(dem_file, basename, vis_dir, resolution, shared=None):
"""Multi-Scale Topographic Position Index (GPU if available).
TPI = elevation - mean(neighborhood).
@ -428,20 +569,32 @@ def generate_tpi(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_tpi.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
fine_size = int(5 / resolution)
if fine_size % 2 == 0:
fine_size += 1
fine_mean = _filter_nanaware(dem_np, xp_uniform_filter, size=fine_size)
if shared:
fine_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=fine_size)
else:
fine_mean = _filter_nanaware(dem_np, xp_uniform_filter, size=fine_size)
tpi_fine = dem_np - fine_mean
tpi_fine[nan_mask] = np.nan
broad_size = int(100 / resolution)
if broad_size % 2 == 0:
broad_size += 1
broad_mean = _filter_nanaware(dem_np, xp_uniform_filter, size=broad_size)
if shared:
broad_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=broad_size)
else:
broad_mean = _filter_nanaware(dem_np, xp_uniform_filter, size=broad_size)
tpi_broad = dem_np - broad_mean
tpi_broad[nan_mask] = np.nan
@ -464,7 +617,7 @@ def generate_tpi(dem_file, basename, vis_dir, resolution):
# SAILORE
# ============================================================
def generate_sailore(dem_file, basename, vis_dir, resolution):
def generate_sailore(dem_file, basename, vis_dir, resolution, shared=None):
"""SAILORE - Self-Adaptive Improved Local Relief Model (GPU if available).
Kernel size adapts to local slope: flat areas get larger kernels,
@ -476,23 +629,40 @@ def generate_sailore(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_sailore.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
gy, gx = np.gradient(dem_np, resolution)
slope = np.arctan(np.sqrt(gx**2 + gy**2))
slope_deg = np.degrees(slope)
slope_deg[nan_mask] = np.nan
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
slope_deg = shared.slope_deg
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
gy, gx = np.gradient(dem_np, resolution)
slope = np.arctan(np.sqrt(gx**2 + gy**2))
slope_deg = np.degrees(slope)
slope_deg[nan_mask] = np.nan
sigma_min = 2.0 / resolution
sigma_max = 25.0 / resolution
slope_norm = np.clip(slope_deg / 30.0, 0, 1)
lrm_fine = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_min)
if shared:
lrm_fine = dem_np - _filter_nanaware_from_filled(shared, xp_gaussian_filter, sigma=sigma_min)
else:
lrm_fine = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_min)
lrm_fine[nan_mask] = np.nan
lrm_medium = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=(sigma_min + sigma_max) / 2)
if shared:
lrm_medium = dem_np - _filter_nanaware_from_filled(shared, xp_gaussian_filter, sigma=(sigma_min + sigma_max) / 2)
else:
lrm_medium = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=(sigma_min + sigma_max) / 2)
lrm_medium[nan_mask] = np.nan
lrm_coarse = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_max)
if shared:
lrm_coarse = dem_np - _filter_nanaware_from_filled(shared, xp_gaussian_filter, sigma=sigma_max)
else:
lrm_coarse = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_max)
lrm_coarse[nan_mask] = np.nan
w_fine = slope_norm
@ -516,7 +686,7 @@ def generate_sailore(dem_file, basename, vis_dir, resolution):
# Roughness
# ============================================================
def generate_roughness(dem_file, basename, vis_dir, resolution):
def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
"""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}...")
@ -524,15 +694,28 @@ def generate_roughness(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_roughness.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
window_size = int(5 / resolution)
if window_size % 2 == 0:
window_size += 1
# Vectorized std: sqrt(E[X²] - (E[X])²) via uniform_filter (NaN-aware)
local_mean = _filter_nanaware(dem_np.astype(np.float64), xp_uniform_filter, size=window_size)
local_mean_sq = _filter_nanaware(dem_np.astype(np.float64)**2, xp_uniform_filter, size=window_size)
if shared:
local_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=window_size)
# For local_mean_sq, we need to filter filled², not filled
local_mean_sq = _filter_nanaware(shared.filled.astype(np.float64)**2, xp_uniform_filter, size=window_size)
local_mean_sq[shared.nan_mask] = np.nan
else:
local_mean = _filter_nanaware(dem_np.astype(np.float64), xp_uniform_filter, size=window_size)
local_mean_sq = _filter_nanaware(dem_np.astype(np.float64)**2, xp_uniform_filter, size=window_size)
roughness = np.sqrt(np.maximum(local_mean_sq - local_mean * local_mean, 0))
roughness[nan_mask] = np.nan
@ -549,7 +732,7 @@ def generate_roughness(dem_file, basename, vis_dir, resolution):
# Anomalies
# ============================================================
def generate_anomalies(dem_file, basename, vis_dir, resolution):
def generate_anomalies(dem_file, basename, vis_dir, resolution, shared=None):
"""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}...")
@ -557,12 +740,19 @@ def generate_anomalies(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_anomalies.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
lrm = shared.lrm_15.copy()
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
lrm_mean_val = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=15 / resolution)
lrm = dem_np - lrm_mean_val
lrm[nan_mask] = np.nan
lrm_mean_val = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=15 / resolution)
lrm = dem_np - lrm_mean_val
lrm[nan_mask] = np.nan
valid_lrm = lrm[~nan_mask]
lrm_mean = np.nanmean(valid_lrm) if len(valid_lrm) > 0 else 0
lrm_std = max(np.nanstd(valid_lrm), 0.01) if len(valid_lrm) > 0 else 0.01
@ -572,7 +762,10 @@ def generate_anomalies(dem_file, basename, vis_dir, resolution):
if window % 2 == 0:
window += 1
local_mean = _filter_nanaware(z_score, xp_uniform_filter, size=window)
if shared:
local_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=window)
else:
local_mean = _filter_nanaware(z_score, xp_uniform_filter, size=window)
z_mean = np.nanmean(valid_lrm) if len(valid_lrm) > 0 else 0
z_std = max(np.nanstd(z_score[~nan_mask]), 0.01) if np.any(~nan_mask) else 0.01
morans_i = z_score * (local_mean - z_mean) / z_std
@ -591,7 +784,7 @@ def generate_anomalies(dem_file, basename, vis_dir, resolution):
# Wavelet
# ============================================================
def generate_wavelet(dem_file, basename, vis_dir, resolution):
def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
"""Mexican Hat wavelet multi-scale analysis (GPU if available).
CWT 2D at multiple scales to detect circular features.
@ -602,15 +795,22 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution):
output = vis_dir / f"{basename}_wavelet.tif"
try:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
filled = shared.filled.astype(np.float64)
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np.astype(np.float64))
scales = [2, 5, 10, 20, 50]
wavelet_stack = []
for scale_m in scales:
sigma_px = scale_m / resolution
filled, _ = _fill_nans(dem_np.astype(np.float64))
if HAS_GPU:
from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace
response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px)
@ -697,36 +897,68 @@ def _d8_accumulate_numba(flow_dir, nodata_mask, rows, cols):
return None
def generate_flow(dem_file, basename, vis_dir, resolution):
"""Flow accumulation using D8 algorithm sink filling on GPU, accumulation via numba."""
def _priority_flood(dem, nodata_mask):
"""Priority-flood algorithm for sink filling (Wang & Liu 2006).
O(n log n) compared to 50 iterations of minimum_filter.
Fills pits so water can flow downhill.
"""
import heapq
rows, cols = dem.shape
filled = dem.copy()
closed = nodata_mask.copy()
open_queue = []
# Initialize border cells
for r in range(rows):
for c in [0, cols - 1]:
if not closed[r, c]:
heapq.heappush(open_queue, (filled[r, c], r, c))
closed[r, c] = True
for c in range(1, cols - 1):
for r in [0, rows - 1]:
if not closed[r, c]:
heapq.heappush(open_queue, (filled[r, c], r, c))
closed[r, c] = True
dx8 = [1, 1, 0, -1, -1, -1, 0, 1]
dy8 = [0, 1, 1, 1, 0, -1, -1, -1]
while open_queue:
elev, r, c = heapq.heappop(open_queue)
for d in range(8):
nr, nc = r + dy8[d], c + dx8[d]
if 0 <= nr < rows and 0 <= nc < cols and not closed[nr, nc]:
if filled[nr, nc] < elev:
filled[nr, nc] = elev # Fill the pit
closed[nr, nc] = True
heapq.heappush(open_queue, (filled[nr, nc], nr, nc))
return filled
def generate_flow(dem_file, basename, vis_dir, resolution, shared=None):
"""Flow accumulation using D8 algorithm — priority-flood sink filling, accumulation via numba."""
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_np, transform, crs = _read_dem(dem_file)
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nodata_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
nodata_mask = np.isnan(dem_np)
rows, cols = dem_np.shape
nodata_mask = np.isnan(dem_np)
# 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 = xp_minimum_filter(dem_filled, footprint=struct)
sinks = (dem_filled < neighbor_min) & ~nodata_mask_gpu
if not xp.any(sinks):
break
dem_filled = xp.where(sinks, neighbor_min, dem_filled)
dem_filled[nodata_mask_gpu] = xp.nan
dem_filled_np = to_cpu(dem_filled)
# Sink filling — priority-flood (O(n log n), faster than 50× minimum_filter)
dem_filled_np = _priority_flood(dem_np, nodata_mask)
# D8 slope — vectorized
dx8 = np.array([1, 1, 0, -1, -1, -1, 0, 1], dtype=np.int32)