Files
lidar_rendu/lidar_pipeline/visualizations.py
Antoine Jacquin 8478106e51 Fix GPU bugs, restore aspect viz, fix anomaly mask, revert flow_acc to vectorized D8
GPU: num_gpus() returns real count via _gpu_candidates (was always 0/1),
available_gpu_ids() added. Pipeline: round-robin on real GPU host indices
instead of file enumerate index. _process_file_standalone signature simplified.

Restore generate_aspect using SharedDEM gradient (dy, dx). Colormap twilight
0-360 fixed range. VIZ_STEPS back to 16.

Flow accumulation: revert to vectorized numpy D8 direction + module-level
numba accumulator (cached, top-down sort) with Python fallback. Priority-flood
NaN-aware. Log1p transform.

Anomaly mask: replace RMS+fixed 2sigma threshold (was blank) with weighted
sum of |z-score| + adaptive percentile threshold. Absolute z-score captures
both positive and negative deviations. 6% signal detected vs 0% before.
2026-06-01 23:03:19 +02:00

1405 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Terrain visualization functions for LiDAR archaeological analysis.
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
import math
import time
import warnings
from pathlib import Path
import numpy as np
import rasterio
from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter, xp_maximum_filter, gpu_cleanup
from . import gpu as _gpu_mod
logger = logging.getLogger("lidar")
# CuPy module reference — lazily imported on first GPU use.
# If disable_gpu() is called at runtime, HAS_GPU becomes False
# and xp delegates to numpy instead.
_cp = None
class _XPProxy:
"""Proxy that delegates array operations to cupy or numpy.
Checks HAS_GPU on every attribute access so that disable_gpu()
(called on CUDA errors) takes effect immediately, without needing
to change every call site in visualizations.py.
"""
def __getattr__(self, name):
global _cp
from . import gpu as _gpu_mod
if _gpu_mod.HAS_GPU:
if _cp is None:
try:
import cupy
_cp = cupy
except ImportError:
pass
if _cp is not None:
return getattr(_cp, name)
return getattr(np, name)
xp = _XPProxy()
class SharedDEM:
"""Pre-computed DEM data shared across all visualizations.
Reads the DEM once and lazily computes on first access:
- NaN mask and filled DEM (avoids 20+ calls to _fill_nans)
- Gradient components (shared by hillshade, slope)
- LRM at 15m kernel (shared by mslrm + sailore)
Attributes are computed lazily on first access to avoid computing
data that is never used (e.g. LRM when only hillshade needs generation).
"""
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)
# Lazy caches — computed on first access
self._filled = None
self._gradient = None # (dy, dx, slope_rad, slope_deg)
self._lrm_15 = None
# GPU lazy caches
self._filled_gpu = None
self._dem_gpu = None
@property
def filled(self):
"""Filled DEM (NaN interpolated) — computed lazily."""
if self._filled is None:
logger.debug(" → Calcul filled DEM (interpolation NaN)...")
self._filled, _ = _fill_nans(self.dem_np)
return self._filled
@property
def dy(self):
self._ensure_gradient()
return self._gradient[0]
@property
def dx(self):
self._ensure_gradient()
return self._gradient[1]
@property
def slope_rad(self):
self._ensure_gradient()
return self._gradient[2]
@property
def slope_deg(self):
self._ensure_gradient()
return self._gradient[3]
@property
def lrm_15(self):
"""LRM at 15m kernel — computed lazily."""
if self._lrm_15 is None:
logger.debug(" → Calcul LRM 15m...")
sigma_15 = 15.0 / self.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
return self._lrm_15
def _ensure_gradient(self):
"""Compute gradient components lazily on first access."""
if self._gradient is None:
logger.debug(" → Calcul gradient...")
dy = np.gradient(self.filled, self.resolution, axis=0)
dx = np.gradient(self.filled, self.resolution, axis=1)
slope_rad = np.arctan(np.sqrt(dx**2 + dy**2))
slope_deg = np.degrees(slope_rad)
self._gradient = (dy, dx, slope_rad, slope_deg)
@property
def filled_gpu(self):
"""Lazy GPU copy of the filled DEM."""
if self._filled_gpu is None and _gpu_mod.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 _gpu_mod.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, reuses the lazy GPU copy to avoid redundant transfers.
"""
if _gpu_mod.HAS_GPU:
filled_gpu = shared.filled_gpu
else:
filled_gpu = None
if filled_gpu is not None:
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, nan_mask=None):
"""Helper to save a 2D or 3D array as GeoTIFF.
Args:
nan_mask: Optional boolean mask (True=NaN) to apply before saving.
Restores NaN zones in gradient-derived products that were
computed on the filled DEM.
"""
if nan_mask is not None:
data = np.array(data, dtype=dtype, copy=True)
data[nan_mask] = np.nan
# Auto-detect nodata for float types with NaN
if nodata is None and dtype.startswith('float') and np.any(np.isnan(data)):
nodata = float('nan')
if data.ndim == 2:
height, width = data.shape
with rasterio.open(
output_path, 'w', driver='GTiff',
height=height, width=width, count=count,
dtype=dtype, crs=crs, transform=transform,
compress='deflate', nodata=nodata
) as dst:
dst.write(data.astype(dtype), 1)
elif data.ndim == 3:
bands, height, width = data.shape
with rasterio.open(
output_path, 'w', driver='GTiff',
height=height, width=width, count=bands,
dtype=dtype, crs=crs, transform=transform,
compress='deflate', nodata=nodata
) as dst:
for i in range(bands):
dst.write(data[i].astype(dtype), i + 1)
def _read_dem(dem_file):
"""Read DEM file and return (data, transform, crs)."""
with rasterio.open(dem_file) as src:
return src.read(1), src.transform, src.crs
def _fill_nans(arr):
"""Fill NaN values using nearest-neighbor interpolation.
Returns (filled_array, nan_mask) so the caller can restore NaN after filtering.
"""
from scipy.interpolate import NearestNDInterpolator
nan_mask = np.isnan(arr)
if not np.any(nan_mask):
return arr, nan_mask
valid = ~nan_mask
y_coords, x_coords = np.where(valid)
if len(y_coords) == 0:
return np.zeros_like(arr), nan_mask
z_values = arr[valid]
interp = NearestNDInterpolator(
np.column_stack((y_coords, x_coords)), z_values
)
y_missing, x_missing = np.where(nan_mask)
filled = arr.copy()
filled[y_missing, x_missing] = interp(y_missing, x_missing)
return filled, nan_mask
def _filter_nanaware(arr, filter_func, *args, use_gpu=True, **kwargs):
"""Apply a filter to an array while preserving NaN zones.
1. Fill NaN with nearest-neighbor interpolation
2. Apply the filter
3. Restore original NaN mask on the result
Args:
arr: Input array (numpy or cupy).
filter_func: Function that takes (array, *args, **kwargs) and returns filtered array.
use_gpu: If True, apply filter on GPU (send filled array to GPU first).
Returns:
Filtered array with original NaN positions preserved.
"""
is_gpu_arr = _gpu_mod.HAS_GPU and _cp is not None and isinstance(arr, _cp.ndarray)
arr_np = to_cpu(arr) if is_gpu_arr else arr
filled, nan_mask = _fill_nans(arr_np)
if use_gpu and _gpu_mod.HAS_GPU:
filled_gpu = to_gpu(filled)
result_gpu = filter_func(filled_gpu, *args, **kwargs)
result = to_cpu(result_gpu)
gpu_cleanup()
else:
result = filter_func(filled, *args, **kwargs)
result[nan_mask] = np.nan
return result
# ============================================================
# Shared ray-tracing core
# ============================================================
def _prepare_dem_for_raycast(dem_file, shared, resolution):
"""Load DEM and prepare padded array for ray-tracing.
Returns (dem_gpu_or_cpu, dem_np, rows, cols, res, nan_mask,
transform, crs, padded) ready for ray-tracing.
"""
if shared:
dem_np = shared.dem_np
nan_mask = shared.nan_mask
transform = shared.transform
crs = shared.crs
dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled
res = resolution
rows, cols = dem_np.shape
return dem, dem_np, rows, cols, res, nan_mask, transform, crs
def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
"""Core ray-tracing: compute max zenith/nadir angles per direction and radius.
For each pixel, in each direction, traces rays outward up to max_dist steps,
recording the max upward angle (positive openness) and max downward angle
(negative openness) reached at each radius checkpoint.
Padding is done on CPU (numpy) to avoid GPU memory pressure and
pre-compiled kernel mismatches (CUDA_ERROR_NO_BINARY_FOR_GPU on sm_89).
The padded array is transferred to GPU once, then each direction is
processed and results are streamed back to CPU.
Args:
dem: GPU or CPU filled DEM array (rows, cols).
rows, cols: dimensions.
res: resolution in m/px.
n_dirs: number of directions.
max_dist: max ray steps.
radii_m: list of radii in meters to record checkpoints.
If None, records only at max_dist.
Returns:
pos_angles: array of shape (n_dirs, n_radii, rows, cols) — max zenith angles
neg_angles: array of shape (n_dirs, n_radii, rows, cols) — max nadir angles
"""
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
dx_dir = np.cos(angles)
dy_dir = np.sin(angles)
if radii_m is not None:
radii_steps = [min(int(r / res), max_dist) for r in radii_m]
n_radii = len(radii_m)
else:
radii_steps = [max_dist]
n_radii = 1
# Pad on CPU (numpy) — avoids GPU memory pressure and
# pre-compiled kernel issues (NO_BINARY_FOR_GPU on sm_89).
dem_np = to_cpu(dem)
padded_np = np.pad(dem_np, max_dist, mode='constant', constant_values=np.nan)
# Transfer padded DEM to GPU for computation
padded = to_gpu(padded_np)
# Free the CPU copy — we don't need it anymore
del dem_np, padded_np
# Process one direction at a time to limit GPU memory.
# Store results as flat CPU arrays — transfer back to GPU at the end.
pos_results = [None] * n_dirs
neg_results = [None] * n_dirs
for d_idx in range(n_dirs):
ddx, ddy = dx_dir[d_idx], dy_dir[d_idx]
# Pre-compute valid steps for this direction
valid_steps = []
for step in range(1, max_dist + 1):
px = int(round(ddx * step))
py = int(round(ddy * step))
dist_m = math.sqrt((ddx * step * res) ** 2 + (ddy * step * res) ** 2)
if dist_m < res * 0.5:
continue
valid_steps.append((step, px, py, dist_m))
# Running max angles per radius
running_pos = xp.zeros((n_radii, rows, cols))
running_neg = xp.zeros((n_radii, rows, cols))
# Track which radius checkpoints have been passed
radii_remaining = set(range(n_radii))
for step, px, py, dist_m in valid_steps:
# Slice from padded array, subtract original dem
view = padded[max_dist + py:max_dist + py + rows,
max_dist + px:max_dist + px + cols]
elev_diff = view - dem
del view # free slice reference
# Positive: angle to terrain above viewer
pos_angle = xp.arctan2(xp.maximum(elev_diff, 0), dist_m)
# Negative: angle to terrain below viewer
neg_angle = xp.arctan2(xp.maximum(-elev_diff, 0), dist_m)
del elev_diff # free intermediate
# Update running max for all radius checkpoints still active
for r_idx in radii_remaining:
pos_angle_safe = xp.nan_to_num(pos_angle, nan=0)
neg_angle_safe = xp.nan_to_num(neg_angle, nan=0)
running_pos[r_idx] = xp.where(xp.isnan(pos_angle), running_pos[r_idx],
xp.maximum(running_pos[r_idx], pos_angle_safe))
running_neg[r_idx] = xp.where(xp.isnan(neg_angle), running_neg[r_idx],
xp.maximum(running_neg[r_idx], neg_angle_safe))
# Check which radii have been passed
new_remaining = set()
for r_idx in radii_remaining:
if step < radii_steps[r_idx]:
new_remaining.add(r_idx)
radii_remaining = new_remaining
if not radii_remaining:
break
# Store results on CPU, free GPU memory before next direction
pos_results[d_idx] = to_cpu(running_pos)
neg_results[d_idx] = to_cpu(running_neg)
del running_pos, running_neg
gpu_cleanup()
# Free the large padded array
del padded
gpu_cleanup()
# Reassemble into final arrays (on CPU to avoid GPU memory pressure)
pos_angles = np.array(pos_results)
neg_angles = np.array(neg_results)
return pos_angles, neg_angles
# ============================================================
# Core terrain visualizations
# ============================================================
def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate multi-directional hillshade with contrast enhancement — GPU if available.
Combines 8-direction hillshade with slope shading for balanced illumination.
Applies percentile normalization and gamma correction to restore
contrast lost by averaging multiple azimuths.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Hillshade multidirectionnel{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_hillshade_multi.tif"
used_gpu = _gpu_mod.HAS_GPU
try:
if shared:
transform = shared.transform
crs = shared.crs
dem = to_gpu(shared.dem_np)
dy = to_gpu(shared.dy) if _gpu_mod.HAS_GPU else shared.dy
dx = to_gpu(shared.dx) if _gpu_mod.HAS_GPU else shared.dx
slope = to_gpu(shared.slope_rad) if _gpu_mod.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)
# 8 azimuths for balanced illumination (eliminates directional bias)
azimuts = [0, 45, 90, 135, 180, 225, 270, 315]
altitude = 35 # Higher altitude for better micro-relief detection
hillshades = []
alt_rad = xp.radians(xp.array(altitude))
sin_alt = xp.sin(alt_rad)
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_hillshade = xp.mean(xp.array(hillshades), axis=0)
slope_shaded = cos_slope
combined = 0.7 * combined_hillshade + 0.3 * slope_shaded
# Contrast enhancement: percentile stretch + gamma
combined_np = to_cpu(combined)
nan_mask = shared.nan_mask if shared else np.isnan(dem_np)
valid = combined_np[~nan_mask]
if len(valid) > 0:
p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98)
if p98 - p2 > 0.01:
combined_np = np.clip((combined_np - p2) / (p98 - p2), 0, 1)
# Gamma correction to enhance shadows
gamma = 0.8
combined_np = np.power(combined_np, gamma)
_save_tif(output, combined_np.astype(np.float32), transform, crs, nan_mask=nan_mask)
logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur hillshade: {e}", exc_info=True)
return None
def generate_slope(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate slope map (degrees) — GPU if available."""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Pente (Slope){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_slope.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
slope = shared.slope_deg
nan_mask = shared.nan_mask
if _gpu_mod.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
nan_mask = np.isnan(dem_np)
_save_tif(output, to_cpu(slope) if _gpu_mod.HAS_GPU else slope, transform, crs, nan_mask=nan_mask)
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur slope: {e}", exc_info=True)
return None
def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate aspect (slope orientation) map — GPU if available.
0° = North, 90° = East, 180° = South, 270° = West.
Direction toward which the terrain descends.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Aspect (Orientation des pentes){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_aspect.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
dy = shared.dy
dx = shared.dx
nan_mask = shared.nan_mask
if _gpu_mod.HAS_GPU:
dy = to_gpu(dy)
dx = to_gpu(dx)
else:
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
nan_mask = np.isnan(dem_np)
aspect = xp.arctan2(dy, dx) * 180 / xp.pi
aspect = xp.mod(aspect, 360)
_save_tif(output, to_cpu(aspect) if _gpu_mod.HAS_GPU else aspect, transform, crs, nan_mask=nan_mask)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur aspect: {e}", exc_info=True)
return None
# ============================================================
# GPU-accelerated visualizations
# ============================================================
def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
"""Sky-View Factor - ray-tracing on 16 azimuths, multi-radius (GPU if available).
Traces rays in 16 directions at 3 radii (25, 50, 100m) and combines
with weights favoring medium range for archaeological feature detection.
SVF = (1/N) * sum(cos²(horizon_angle)). Valleys/crevices have low SVF,
ridges/peaks have high SVF.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Sky-View Factor (ray-tracing multi-rayon){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_svf.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
radii_m = [25, 50, 100]
radius_weights = [0.3, 0.4, 0.3] # Medium range weighted more
max_dist = min(int(100 / res), 300)
n_dirs = 16
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
# pos/neg are now numpy arrays (CPU) — combine on CPU
svf_combined = np.zeros((rows, cols), dtype=np.float32)
for r_idx in range(len(radii_m)):
horizon = np.maximum(pos_angles[:, r_idx], neg_angles[:, r_idx])
svf_r = np.mean(np.cos(horizon) ** 2, axis=0)
svf_combined += svf_r * radius_weights[r_idx]
svf_np = svf_combined
svf_np[nan_mask] = np.nan
_save_tif(output, svf_np, transform, crs)
logger.info(f" ✓ SVF terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur SVF: {e}", exc_info=True)
return None
def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, shared=None):
"""Positive/Negative Openness - multi-radius ray-tracing with std normalization.
Traces rays in 8 directions at 3 radii (25, 50, 100m).
Results are combined with equal weight across radii, then normalized
by standard deviation for cross-tile comparability.
"""
name = "positive_openness" if positive else "negative_openness"
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → {name.replace('_', ' ').title()} (ray-tracing multi-rayon){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_{name}.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
radii_m = [25, 50, 100]
max_dist = min(int(100 / res), 300)
n_dirs = 8
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
# Select positive or negative
if positive:
angles = pos_angles
else:
angles = neg_angles
# Mean across directions and radii (equal weight) — on CPU now
openness = np.mean(angles, axis=(0, 1))
openness_result = np.degrees(openness).astype(np.float32)
openness_result[nan_mask] = np.nan
# Std normalization for cross-tile comparability
valid = openness_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
openness_result = openness_result / std_val
_save_tif(output, openness_result, transform, crs)
logger.info(f" ✓ {name} terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur openness: {e}", exc_info=True)
return None
def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=None):
"""Multi-Scale Relief Model (MSRM) - LRM at adaptive scales combined (GPU if available).
Scales adapt to resolution. Std normalization per scale.
Weighted combination favoring archaeologically relevant scales (5-25m).
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Multi-Scale Relief Model (MSRM){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_mslrm.tif"
try:
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)
# Adaptive scales: finer at higher resolution
min_scale = max(2.0, resolution * 4)
candidate_scales = [2, 5, 10, 20, 50, 100, 200]
sigmas = [s for s in candidate_scales if s >= min_scale]
# Archaeological weights: favor 5-25m range (ditches, enclosures, tumulus)
scale_weights = {
2: 0.8, 5: 2.0, 10: 1.8, 20: 1.5, 50: 1.0, 100: 0.6, 200: 0.4,
}
weights = np.array([scale_weights.get(s, 1.0) for s in sigmas])
logger.info(f" MSRM échelles: {sigmas}m")
lrm_stack = []
for sigma in sigmas:
sigma_px = sigma / resolution
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
# Std normalization: x / std — preserves sign and contrast better than z-score
valid_lrm = lrm[~nan_mask]
lrm_std = max(np.nanstd(valid_lrm), 0.01) if len(valid_lrm) > 0 else 0.01
lrm = lrm / lrm_std
lrm_stack.append(lrm.astype(np.float32))
# Weighted combination — preserve sign for RdBu_r colormap
# Positive = elevated (red), Negative = depression (blue)
lrm_array = np.array(lrm_stack)
weights_3d = weights[:, np.newaxis, np.newaxis]
with np.errstate(invalid='ignore', divide='ignore'):
with warnings.catch_warnings():
warnings.filterwarnings('ignore', message='Mean of empty slice')
# Signed RMS: magnitude from RMS, sign from weighted mean
signed_mean = np.nansum(lrm_array * weights_3d, axis=0) / np.sum(weights)
rms_magnitude = np.sqrt(np.nansum((lrm_array ** 2) * weights_3d, axis=0) / np.sum(weights))
mslrm = np.sign(signed_mean) * rms_magnitude
mslrm[nan_mask] = np.nan
_save_tif(output, mslrm.astype(np.float32), transform, crs)
logger.info(f" ✓ MSRM terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur MSRM: {e}", exc_info=True)
return None
# ============================================================
# SAILORE
# ============================================================
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,
steep areas get smaller kernels. Scales adapt to resolution.
Reuses shared.lrm_15 when available to avoid recomputation.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → SAILORE (LRM adaptatif){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_sailore.tif"
try:
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
# Fixed physical scales (independent of resolution)
sigma_min_m = 2.0 # 2m — fine detail
sigma_max_m = 25.0 # 25m — broad relief
sigma_min = sigma_min_m / resolution
sigma_max = sigma_max_m / resolution
slope_norm = np.clip(slope_deg / 30.0, 0, 1)
# LRM fine (2m) — always compute
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 (13.5m) — reuse shared.lrm_15 (σ=15m) when available
sigma_mid = (sigma_min + sigma_max) / 2
if shared and abs(15.0 / resolution - sigma_mid) < 2.0 / resolution:
# shared.lrm_15 is close enough to medium scale
lrm_medium = shared.lrm_15.copy()
else:
if shared:
lrm_medium = dem_np - _filter_nanaware_from_filled(shared, xp_gaussian_filter, sigma=sigma_mid)
else:
lrm_medium = dem_np - _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_mid)
lrm_medium[nan_mask] = np.nan
# LRM coarse (25m) — always compute
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
w_medium = 1 - 2 * np.abs(slope_norm - 0.5)
w_coarse = 1 - slope_norm
w_total = w_fine + w_medium + w_coarse
w_total[w_total == 0] = 1
sailore = (w_fine * lrm_fine + w_medium * lrm_medium + w_coarse * lrm_coarse) / w_total
sailore[nan_mask] = np.nan
_save_tif(output, sailore.astype(np.float32), transform, crs)
logger.info(f" ✓ SAILORE terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur SAILORE: {e}", exc_info=True)
return None
# ============================================================
# Roughness
# ============================================================
def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
"""Surface roughness - multi-scale standard deviation (GPU-accelerated).
Combines fine (3m) and broad (15m) roughness for better detection
of archaeological features at multiple scales.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Rugosité de surface{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_roughness.tif"
try:
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 roughness (3m window)
fine_size = max(3, int(3 / resolution))
if fine_size % 2 == 0:
fine_size += 1
if shared:
fine_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=fine_size)
fine_mean_sq = _filter_nanaware(shared.filled.astype(np.float64)**2, xp_uniform_filter, size=fine_size)
fine_mean_sq[shared.nan_mask] = np.nan
else:
fine_mean = _filter_nanaware(dem_np.astype(np.float64), xp_uniform_filter, size=fine_size)
fine_mean_sq = _filter_nanaware(dem_np.astype(np.float64)**2, xp_uniform_filter, size=fine_size)
roughness_fine = np.sqrt(np.maximum(fine_mean_sq - fine_mean * fine_mean, 0))
roughness_fine[nan_mask] = np.nan
# Broad roughness (15m window)
broad_size = max(3, int(15 / resolution))
if broad_size % 2 == 0:
broad_size += 1
if shared:
broad_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=broad_size)
broad_mean_sq = _filter_nanaware(shared.filled.astype(np.float64)**2, xp_uniform_filter, size=broad_size)
broad_mean_sq[shared.nan_mask] = np.nan
else:
broad_mean = _filter_nanaware(dem_np.astype(np.float64), xp_uniform_filter, size=broad_size)
broad_mean_sq = _filter_nanaware(dem_np.astype(np.float64)**2, xp_uniform_filter, size=broad_size)
roughness_broad = np.sqrt(np.maximum(broad_mean_sq - broad_mean * broad_mean, 0))
roughness_broad[nan_mask] = np.nan
# Std normalization per scale then weighted combination
fine_valid = roughness_fine[~nan_mask]
broad_valid = roughness_broad[~nan_mask]
fine_std = max(np.nanstd(fine_valid), 0.01) if len(fine_valid) > 0 else 0.01
broad_std = max(np.nanstd(broad_valid), 0.01) if len(broad_valid) > 0 else 0.01
roughness = 0.7 * roughness_fine / fine_std + 0.3 * roughness_broad / broad_std
roughness[nan_mask] = np.nan
_save_tif(output, roughness, transform, crs)
logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur rugosité: {e}", exc_info=True)
return None
# ============================================================
# Exposition des surfaces (Éclairage Solaire)
# ============================================================
def generate_solar(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate solar irradiance simulation.
Simulates morning sunlight (azimuth 90°, altitude 30°) to reveal
subtle topographic features through shadow effects.
"""
logger.info(" → Exposition des surfaces (Éclairage Solaire)...")
t0 = time.time()
output = vis_dir / f"{basename}_solar.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
nan_mask = shared.nan_mask
dx = shared.dx
dy = shared.dy
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
dem_filled, _ = _fill_nans(dem_np)
dy, dx = np.gradient(dem_filled, resolution, resolution)
# Solar parameters: morning sun (azimuth 90° = east, altitude 30°)
sun_azimuth = np.radians(90)
sun_altitude = np.radians(30)
# Aspect from gradient
aspect = np.degrees(np.arctan2(-dx, -dy))
aspect[aspect < 0] += 360
# Slope in radians
slope_rad = np.arctan(np.sqrt(dx**2 + dy**2))
# Solar irradiance calculation
irradiance = (np.sin(sun_altitude) * np.sin(slope_rad) +
np.cos(sun_altitude) * np.cos(slope_rad) *
np.cos(np.radians(aspect) - sun_azimuth))
# Clip to valid range [0, 1]
irradiance = np.clip(irradiance, 0, 1)
irradiance[nan_mask] = np.nan
_save_tif(output, irradiance.astype(np.float32), transform, crs)
logger.info(f" ✓ Exposition des surfaces terminée ({time.time()-t0:.1f}s)")
return output
except Exception as e:
logger.error(f" ✗ Erreur exposition: {e}", exc_info=True)
return None
# ============================================================
# Wavelet (Mexican Hat)
# ============================================================
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 adapted to resolution.
- At 0.5m/px: [1, 2, 5, 10, 20, 50, 100]m
- At 0.2m/px: [0.5, 1, 2, 5, 10, 20, 50, 100]m
Uses std normalization per scale and weighted RMS combination
with emphasis on archaeologically relevant scales (2-50m).
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_wavelet.tif"
try:
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))
min_scale = max(resolution * 2, 1.0)
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50, 100]
scales = [s for s in candidate_scales if s >= min_scale]
scale_weights = {
0.5: 0.6, 1.0: 0.8, 2.0: 1.5, 5.0: 2.0,
10.0: 1.8, 20.0: 1.5, 50.0: 1.0, 100.0: 0.6,
}
weights = np.array([scale_weights.get(s, 1.0) for s in scales])
logger.info(f" Échelles CWT: {scales}m (résolution {resolution}m/px)")
from scipy.ndimage import gaussian_laplace
wavelet_stack = []
for scale_m in scales:
sigma_px = scale_m / resolution
if _gpu_mod.HAS_GPU:
try:
from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace
response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px)
response = to_cpu(response)
except Exception:
response = -gaussian_laplace(filled, sigma=sigma_px)
else:
response = -gaussian_laplace(filled, sigma=sigma_px)
response[nan_mask] = np.nan
valid = response[~nan_mask]
std_val = max(np.nanstd(valid), 0.01) if len(valid) > 0 else 0.01
response = response / std_val
wavelet_stack.append(response)
stack = np.array(wavelet_stack)
weights_3d = weights[:, np.newaxis, np.newaxis]
with np.errstate(invalid='ignore', divide='ignore'):
with warnings.catch_warnings():
warnings.filterwarnings('ignore', message='Mean of empty slice')
combined = np.sqrt(np.nansum((stack ** 2) * weights_3d, axis=0) / np.sum(weights))
combined[nan_mask] = np.nan
_save_tif(output, combined.astype(np.float32), transform, crs)
logger.info(f" ✓ Ondelette terminée ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur ondelette: {e}", exc_info=True)
return None
# ============================================================
# Flow Accumulation helpers (module-level for numba caching)
# ============================================================
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. NaN cells are treated as
closed (never pushed into the heap).
"""
import heapq
rows, cols = dem.shape
filled = dem.copy()
closed = nodata_mask.copy()
open_queue = []
# Initialize border cells (skip NaN 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 _d8_accumulate_numba(dem_filled, flow_dir, nodata_mask, rows, cols):
"""JIT-compiled D8 flow accumulation (top-down via elevation sort).
Uses numba for ~100x speedup over pure Python loop.
Falls back to pure Python if numba is unavailable.
"""
try:
from numba import njit
@njit(cache=True)
def _accumulate(dem, fdir, nodata, rows, cols):
dx8 = np.array([1, 1, 0, -1, -1, -1, 0, 1], dtype=np.int8)
dy8 = np.array([0, 1, 1, 1, 0, -1, -1, -1], dtype=np.int8)
flow_acc = np.ones((rows, cols), dtype=np.float32)
for r in range(rows):
for c in range(cols):
if nodata[r, c]:
flow_acc[r, c] = 0.0
# Sort cells by elevation descending (highest first)
n_cells = rows * cols
flat_dem = dem.ravel()
sort_idx = np.argsort(-flat_dem)
# Accumulate top-down (highest cell first)
for i in range(n_cells):
cell = sort_idx[i]
r = cell // cols
c = cell % cols
if nodata[r, c]:
continue
d = fdir[r, c]
if d < 0:
continue
nr = r + dy8[d]
nc = c + dx8[d]
if 0 <= nr < rows and 0 <= nc < cols and not nodata[nr, nc]:
flow_acc[nr, nc] += flow_acc[r, c]
return flow_acc
return _accumulate(dem_filled, flow_dir, nodata_mask, rows, cols)
except ImportError:
return None
# ============================================================
# Flow Accumulation
# ============================================================
def generate_flow_accumulation(dem_file, basename, vis_dir, resolution, shared=None):
"""Flow Accumulation — priority-flood sink filling + D8 accumulation.
Detects channels, ditches, and drainage paths by computing how many
upstream cells flow through each cell. Archaeological ditches and
natural drainage features both accumulate high flow values.
D8 direction is computed via vectorized numpy slicing.
Accumulation uses numba JIT (cached at module level) or pure Python fallback.
"""
logger.info(f" → Accumulation d'écoulement (flow accumulation)...")
t0 = time.time()
output = vis_dir / f"{basename}_flow_acc.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nan_mask = shared.nan_mask
filled = shared.filled
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
rows, cols = dem_np.shape
# Sink filling — priority-flood (O(n log n), NaN-aware)
dem_filled = _priority_flood(filled, nan_mask)
logger.info(f" ✓ Sink filling terminé ({time.time()-t0:.1f}s)")
# D8 flow direction — vectorized via numpy slicing
dx8 = np.array([1, 1, 0, -1, -1, -1, 0, 1], dtype=np.int32)
dy8 = np.array([0, 1, 1, 1, 0, -1, -1, -1], dtype=np.int32)
dist8 = np.array([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)
max_slope = np.zeros((rows, cols), dtype=np.float64)
padded = np.pad(dem_filled, 1, mode='constant',
constant_values=np.nanmax(dem_filled[~np.isnan(dem_filled)]) + 10000)
for d in range(8):
nx = 1 + dx8[d]
ny = 1 + dy8[d]
neighbor_elev = padded[ny:ny + rows, nx:nx + cols]
slope = (dem_filled - neighbor_elev) / (dist8[d] * resolution)
slope[nan_mask] = -1
better = slope > max_slope
flow_dir[better] = d
max_slope[better] = slope[better]
logger.info(f" ✓ Direction D8 terminée ({time.time()-t0:.1f}s)")
# D8 accumulation — numba JIT (module-level cache) or pure Python fallback
result = _d8_accumulate_numba(dem_filled, flow_dir, nan_mask.astype(np.bool_), rows, cols)
if result is not None:
flow_acc = result
logger.info(f" Accumulation D8 via numba")
else:
# Pure Python fallback
logger.info(f" Accumulation D8 via Python (installez numba pour accélérer)")
flat_dem = dem_filled[~nan_mask].flatten()
valid_indices = np.where(~nan_mask.flatten())[0]
sort_order = valid_indices[np.argsort(-flat_dem)]
flow_acc = np.ones((rows, cols), dtype=np.float32)
flow_acc[nan_mask] = 0
for idx in sort_order:
r, c = divmod(idx, cols)
d = flow_dir[r, c]
if d < 0:
continue
nr, nc = r + dy8[d], c + dx8[d]
if 0 <= nr < rows and 0 <= nc < cols and not nan_mask[nr, nc]:
flow_acc[nr, nc] += flow_acc[r, c]
logger.info(f" ✓ D8 accumulation terminé ({time.time()-t0:.1f}s)")
# Log transform
flow_result = np.log1p(flow_acc)
flow_result[nan_mask] = np.nan
_save_tif(output, flow_result, transform, crs)
logger.info(f" ✓ Flow accumulation terminé ({time.time()-t0:.1f}s)")
return output
except Exception as e:
logger.error(f" ✗ Erreur flow accumulation: {e}", exc_info=True)
return None
# ============================================================
# Anisotropic Openness
# ============================================================
def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
"""Anisotropic Openness - weighted directional openness, multi-radius (GPU if available).
Computes positive and negative openness with anisotropic weighting:
NW/SE directions weighted more heavily to enhance detection of structures
aligned NE-SW (common in French archaeological sites: villas, enclosures).
Multi-radius (25, 50, 100m) with equal weight, results std-normalized
for cross-tile comparability.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Openness Anisotropique (multi-rayon){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_aniso_open.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
n_dirs = 8
# Anisotropic weights: emphasize NW-SE and NE-SW directions
weights = np.array([1.0, 1.5, 1.0, 1.5, 1.0, 1.5, 1.0, 1.5])
radii_m = [25, 50, 100]
max_dist = min(int(100 / res), 300)
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
# Weighted combination across directions and radii
weight_total = np.sum(weights)
n_radii = len(radii_m)
pos_combined = np.zeros((rows, cols), dtype=np.float64)
neg_combined = np.zeros((rows, cols), dtype=np.float64)
for r_idx in range(n_radii):
for d_idx in range(n_dirs):
w = weights[d_idx]
pos_combined += pos_angles[d_idx, r_idx] * w / (n_radii * weight_total)
neg_combined += neg_angles[d_idx, r_idx] * w / (n_radii * weight_total)
aniso_result = np.degrees(pos_combined - neg_combined).astype(np.float32)
aniso_result[nan_mask] = np.nan
# Std normalization for cross-tile comparability
valid = aniso_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
aniso_result = aniso_result / std_val
_save_tif(output, aniso_result, transform, crs)
logger.info(f" ✓ Openness anisotropique terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur openness anisotropique: {e}", exc_info=True)
return None
# ============================================================
# Anomaly Mask — automatic threshold detection
# ============================================================
def generate_anomaly_mask(dem_file, basename, vis_dir, resolution, shared=None, n_sigma=2.0):
"""Composite anomaly mask — automatic threshold detection (GPU if available).
Reads pre-computed visualization layers (MSRM, SVF, Wavelet, Openness Neg,
Roughness), normalizes each to z-scores, and combines them into a composite
anomaly score. Pixels beyond `n_sigma` standard deviations of the local mean
are flagged as suspicious.
The output is a continuous score (0–1) where:
- 0 = no anomaly (flat/natural terrain)
- 1 = high anomaly (potential archaeological structure)
This mask is directly usable in GIS for polygon extraction and field survey
planning.
Args:
n_sigma: Number of standard deviations for the anomaly threshold.
Lower = more sensitive (more false positives).
Default 2.0 (good balance for archaeological detection).
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Détection automatique d'anomalies (seuil {n_sigma}σ){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_anomaly.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
nan_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
rows, cols = nan_mask.shape
# Collect available visualization layers from disk
# Each is loaded, converted to |z-score|, and contributes to a weighted sum.
# The weighted sum of |z| acts as a "vote": pixels where multiple layers
# show anomalies get higher scores than pixels where only one layer fires.
layer_configs = [
# (filename_pattern, weight)
("mslrm", 2.5), # Multi-scale relief — strongest signal
("negative_openness", 2.0), # Fossés, dolines
("roughness", 1.8), # Surface irregularity
("wavelet", 1.5), # Circular + linear structures
("svf", 1.3), # Sky-view depressions
("flow_acc", 1.2), # Drainage channels / ditches
("aniso_open", 1.0), # Anisotropic structures
("positive_openness", 0.8), # Surélevations
]
layers = []
for pattern, weight in layer_configs:
layer_path = vis_dir / f"{basename}_{pattern}.tif"
if not layer_path.exists():
layer_path = vis_dir / f"{basename}_negative_openness.tif" if "neg" in pattern else None
if layer_path is None or not layer_path.exists():
continue
try:
with rasterio.open(layer_path) as src:
data = src.read(1).astype(np.float64)
# Absolute z-score (captures both positive and negative deviations)
valid = data[~nan_mask]
if len(valid) == 0:
continue
mean_val = np.nanmean(valid)
std_val = max(np.nanstd(valid), 0.01)
zscore = np.abs(data - mean_val) / std_val
zscore[nan_mask] = 0.0
layers.append((zscore, weight))
except Exception as e:
logger.debug(f" Couche {pattern} non disponible: {e}")
continue
if not layers:
# Fallback: use MSRM + roughness computed on the fly
logger.info(" Aucune couche trouvée — calcul MSRM + rugosité en direct...")
dem_np_safe = shared.dem_np if shared else dem_np
# Quick MSRM (single scale 10m for speed)
sigma_px = max(5, 10.0 / resolution)
local_mean = _filter_nanaware_from_filled(shared, xp_gaussian_filter, sigma=sigma_px) if shared else \
_filter_nanaware(dem_np_safe, xp_gaussian_filter, sigma=sigma_px)
quick_mslrm = np.abs(dem_np_safe - local_mean)
quick_mslrm[nan_mask] = np.nan
valid = quick_mslrm[~nan_mask]
std_val = max(np.nanstd(valid), 0.01) if len(valid) > 0 else 0.01
quick_mslrm = quick_mslrm / std_val
layers.append((quick_mslrm, 2.5))
# Quick roughness
fine_size = max(3, int(3 / resolution))
if fine_size % 2 == 0:
fine_size += 1
if shared:
fine_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=fine_size)
fine_mean_sq = _filter_nanaware(shared.filled.astype(np.float64)**2, xp_uniform_filter, size=fine_size)
else:
fine_mean = _filter_nanaware(dem_np_safe.astype(np.float64), xp_uniform_filter, size=fine_size)
fine_mean_sq = _filter_nanaware(dem_np_safe.astype(np.float64)**2, xp_uniform_filter, size=fine_size)
roughness = np.sqrt(np.maximum(fine_mean_sq - fine_mean * fine_mean, 0))
roughness[nan_mask] = np.nan
valid_r = roughness[~nan_mask]
std_val_r = max(np.nanstd(valid_r), 0.01) if len(valid_r) > 0 else 0.01
roughness = roughness / std_val_r
layers.append((roughness, 1.5))
# Weighted SUM of |z-scores| (not RMS — each layer votes independently)
combined = np.zeros((rows, cols), dtype=np.float64)
total_weight = 0.0
for layer_data, weight in layers:
combined += layer_data * weight
total_weight += weight
if total_weight > 0:
combined = combined / total_weight
# Adaptive threshold: suppress pixels below the (100 - n_sigma*10)th percentile.
# With n_sigma=2.0 → 80th percentile: keep only the top 20% of signal.
# This adapts to each tile's terrain instead of a fixed z-score cutoff.
threshold_pct = max(50, min(95, 100 - n_sigma * 10))
threshold_val = np.percentile(combined[~nan_mask], threshold_pct)
combined = np.clip(combined - threshold_val, 0, None)
# Rescale survivors to 0–1
above_thresh = combined[combined > 0]
if len(above_thresh) > 0:
p95 = np.percentile(above_thresh, 95)
if p95 > 0:
combined = np.clip(combined / p95, 0, 1)
combined[nan_mask] = np.nan
_save_tif(output, combined.astype(np.float32), transform, crs)
n_anomaly = int(np.sum(combined > 0.1)) if np.any(combined > 0) else 0
pct_anomaly = n_anomaly / max(np.sum(~nan_mask), 1) * 100
logger.info(f" ✓ Détection anomalies terminée ({time.time()-t0:.1f}s) — "
f"{pct_anomaly:.1f}% de la zone ({n_anomaly} px) au-delà de {n_sigma}σ")
return output
except Exception as e:
logger.error(f" ✗ Erreur détection anomalies: {e}", exc_info=True)
return None