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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user