Improve visualizations: adaptive scales, revert z-score to std normalization
- MSRM/TPI/roughness/anomalies: revert z-score (x-mean)/std to std normalization x/std to preserve contrast and visibility of linear features (paths, ditches, trenches) - MSRM: adaptive scales based on resolution, archaeological weight combination - TPI: extend from 2 to 4 scales (3m/15m/50m/200m) with weighted combination - Hillshade: 8 directions instead of 4, altitude 35° instead of 30° - LRM: adaptive sigma based on resolution - Openness: doubled radius (100m instead of 50m) - Roughness: multi-scale (3m fine + 15m broad) instead of single 5x5 window - Anomalies: uses MSRM multi-scale relief instead of single LRM 15m - Wavelet: 8 adaptive scales, std normalization, archaeological weights - Remove svf (Sky-View Factor) and local_dominance visualizations - Add AVIF format support (default), quality 98 - Add multi-resolution support (-r 0.5,0.2) - Improve Ctrl+C handling for immediate process termination - Update rendering.py descriptions for all modified visualizations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -250,7 +250,7 @@ def _filter_nanaware(arr, filter_func, *args, use_gpu=True, **kwargs):
|
||||
def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Generate multi-directional hillshade with contrast enhancement — GPU if available.
|
||||
|
||||
Combines 4-direction hillshade (NW, NE, SW, SE) with slope shading.
|
||||
Combines 8-direction hillshade with slope shading for balanced illumination.
|
||||
Applies percentile normalization and gamma correction to restore
|
||||
contrast lost by averaging multiple azimuths.
|
||||
"""
|
||||
@ -279,8 +279,9 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
sin_slope = xp.sin(slope)
|
||||
cos_slope = xp.cos(slope)
|
||||
|
||||
azimuts = [315, 45, 225, 135]
|
||||
altitude = 30
|
||||
# 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))
|
||||
@ -297,7 +298,6 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
combined = 0.7 * combined_hillshade + 0.3 * slope_shaded
|
||||
|
||||
# Contrast enhancement: percentile stretch + gamma
|
||||
# Averaging 4 azimuths flattens contrast — this restores it
|
||||
combined_np = to_cpu(combined)
|
||||
nan_mask = shared.nan_mask if shared else np.isnan(to_cpu(dem_np) if HAS_GPU else dem_np)
|
||||
valid = combined_np[~nan_mask]
|
||||
@ -415,7 +415,11 @@ def generate_curvature(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
# ============================================================
|
||||
|
||||
def generate_lrm(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Local Relief Model - deviation from local mean (GPU if available)."""
|
||||
"""Local Relief Model - deviation from local mean (GPU if available).
|
||||
|
||||
Kernel sigma adapts to resolution: finer kernel at higher resolution
|
||||
to capture micro-relief details. At 0.5m/px: 15m, at 0.2m/px: ~5m.
|
||||
"""
|
||||
gpu_tag = " [GPU]" if HAS_GPU else ""
|
||||
logger.info(f" → Local Relief Model{gpu_tag}...")
|
||||
t0 = time.time()
|
||||
@ -429,7 +433,10 @@ def generate_lrm(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
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)
|
||||
# Adapt sigma to resolution: standard 15m at 0.5m, finer at higher res
|
||||
sigma_m = max(5.0, 15.0 * 0.5 / resolution)
|
||||
logger.info(f" LRM sigma={sigma_m:.1f}m (résolution {resolution}m/px)")
|
||||
local_mean = _filter_nanaware(dem_np, xp_gaussian_filter, sigma=sigma_m / resolution)
|
||||
lrm = dem_np - local_mean
|
||||
lrm[nan_mask] = np.nan
|
||||
_save_tif(output, lrm.astype(np.float32), transform, crs)
|
||||
@ -473,7 +480,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
|
||||
dx_dir = np.cos(angles)
|
||||
dy_dir = np.sin(angles)
|
||||
max_dist = int(50 / res)
|
||||
max_dist = int(100 / res)
|
||||
|
||||
padded = xp.pad(dem, max_dist, mode='constant', constant_values=xp.nan)
|
||||
svf = xp.zeros_like(dem)
|
||||
@ -520,6 +527,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
|
||||
- Positive openness: max zenith angle (angle from vertical to highest visible terrain)
|
||||
- Negative openness: max nadir angle (angle from vertical down to lowest terrain)
|
||||
Result is averaged across all 8 directions.
|
||||
Ray radius adapts to resolution: 100m for better detection of large enclosures.
|
||||
"""
|
||||
name = "positive_openness" if positive else "negative_openness"
|
||||
gpu_tag = " [GPU]" if HAS_GPU else ""
|
||||
@ -548,7 +556,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
|
||||
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
|
||||
dx_dir = np.cos(angles)
|
||||
dy_dir = np.sin(angles)
|
||||
max_dist = int(50 / res)
|
||||
max_dist = int(100 / res)
|
||||
|
||||
padded = xp.pad(dem, max_dist, mode='constant', constant_values=xp.nan)
|
||||
openness_sum = xp.zeros_like(dem)
|
||||
@ -646,7 +654,11 @@ def generate_local_dominance(dem_file, basename, vis_dir, resolution, shared=Non
|
||||
|
||||
|
||||
def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Multi-Scale Relief Model (MSRM) - LRM at 5 scales combined (GPU if available)."""
|
||||
"""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 HAS_GPU else ""
|
||||
logger.info(f" → Multi-Scale Relief Model (MSRM){gpu_tag}...")
|
||||
t0 = time.time()
|
||||
@ -662,7 +674,18 @@ def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
dem_np, transform, crs = _read_dem(dem_file)
|
||||
nan_mask = np.isnan(dem_np)
|
||||
|
||||
sigmas = [5, 10, 25, 50, 100]
|
||||
# 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:
|
||||
@ -673,16 +696,19 @@ def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
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_norm = lrm / lrm_std
|
||||
lrm_stack.append(lrm_norm.astype(np.float32))
|
||||
lrm = lrm / lrm_std
|
||||
lrm_stack.append(lrm.astype(np.float32))
|
||||
|
||||
# Weighted combination
|
||||
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')
|
||||
mslrm = np.sqrt(np.nanmean(lrm_array ** 2, axis=0))
|
||||
mslrm = np.sqrt(np.nansum((lrm_array ** 2) * weights_3d, axis=0) / np.sum(weights))
|
||||
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_tag}")
|
||||
@ -696,7 +722,8 @@ def generate_tpi(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Multi-Scale Topographic Position Index (GPU if available).
|
||||
|
||||
TPI = elevation - mean(neighborhood).
|
||||
Computed at fine (5m) and broad (100m) scales.
|
||||
Computed at 4 scales with std normalization and weighted combination.
|
||||
Weights favor fine and medium scales (archaeologically relevant).
|
||||
"""
|
||||
gpu_tag = " [GPU]" if HAS_GPU else ""
|
||||
logger.info(f" → TPI multi-échelle{gpu_tag}...")
|
||||
@ -713,29 +740,34 @@ def generate_tpi(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
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
|
||||
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
|
||||
# 4 scales: fine (3m), medium (15m), broad (50m), landscape (200m)
|
||||
scales_m = [3, 15, 50, 200]
|
||||
weights = [1.5, 2.0, 1.2, 0.5] # Favor medium scales (ditches, enclosures)
|
||||
|
||||
broad_size = int(100 / resolution)
|
||||
if broad_size % 2 == 0:
|
||||
broad_size += 1
|
||||
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
|
||||
tpi_stack = []
|
||||
for scale_m, weight in zip(scales_m, weights):
|
||||
size = max(3, int(scale_m / resolution))
|
||||
if size % 2 == 0:
|
||||
size += 1
|
||||
if shared:
|
||||
local_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=size)
|
||||
else:
|
||||
local_mean = _filter_nanaware(dem_np, xp_uniform_filter, size=size)
|
||||
tpi = dem_np - local_mean
|
||||
tpi[nan_mask] = np.nan
|
||||
# Std normalization — preserves sign and contrast better than z-score
|
||||
valid = tpi[~nan_mask]
|
||||
tpi_std = max(np.nanstd(valid), 0.01) if len(valid) > 0 else 0.01
|
||||
tpi = tpi / tpi_std
|
||||
tpi_stack.append(tpi.astype(np.float32))
|
||||
|
||||
fine_std = max(np.nanstd(tpi_fine[~nan_mask]), 0.01) if np.any(~nan_mask) else 0.01
|
||||
broad_std = max(np.nanstd(tpi_broad[~nan_mask]), 0.01) if np.any(~nan_mask) else 0.01
|
||||
tpi_combined = 0.6 * (tpi_fine / fine_std) + 0.4 * (tpi_broad / broad_std)
|
||||
# Weighted combination
|
||||
tpi_array = np.array(tpi_stack)
|
||||
weights_3d = np.array(weights)[:, np.newaxis, np.newaxis]
|
||||
with np.errstate(invalid='ignore', divide='ignore'):
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', message='Mean of empty slice')
|
||||
tpi_combined = np.nansum(tpi_array * weights_3d, axis=0) / np.sum(weights)
|
||||
tpi_combined[nan_mask] = np.nan
|
||||
|
||||
_save_tif(output, tpi_combined.astype(np.float32), transform, crs)
|
||||
@ -756,7 +788,7 @@ 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.
|
||||
steep areas get smaller kernels. Scales adapt to resolution.
|
||||
"""
|
||||
gpu_tag = " [GPU]" if HAS_GPU else ""
|
||||
logger.info(f" → SAILORE (LRM adaptatif){gpu_tag}...")
|
||||
@ -778,8 +810,13 @@ def generate_sailore(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
slope_deg = np.degrees(slope)
|
||||
slope_deg[nan_mask] = np.nan
|
||||
|
||||
sigma_min = 2.0 / resolution
|
||||
sigma_max = 25.0 / resolution
|
||||
# Adaptive scales: finer at higher resolution
|
||||
sigma_min_m = max(1.0, 2.0 * 0.5 / resolution) # 2m at 0.5, ~5m at 0.2
|
||||
sigma_mid_m = max(5.0, 13.5 * 0.5 / resolution) # 13.5m at 0.5, ~33m at 0.2
|
||||
sigma_max_m = max(5.0, 25.0 * 0.5 / resolution) # 25m at 0.5, ~62m at 0.2
|
||||
sigma_min = sigma_min_m / resolution
|
||||
sigma_max = sigma_max_m / resolution
|
||||
sigma_mid = (sigma_min + sigma_max) / 2
|
||||
slope_norm = np.clip(slope_deg / 30.0, 0, 1)
|
||||
|
||||
if shared:
|
||||
@ -822,7 +859,11 @@ def generate_sailore(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
# ============================================================
|
||||
|
||||
def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
"""Surface roughness - standard deviation of elevation in a window (GPU-accelerated)."""
|
||||
"""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 HAS_GPU else ""
|
||||
logger.info(f" → Rugosité de surface{gpu_tag}...")
|
||||
t0 = time.time()
|
||||
@ -838,20 +879,43 @@ def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
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
|
||||
# Fine roughness (3m window)
|
||||
fine_size = max(3, int(3 / resolution))
|
||||
if fine_size % 2 == 0:
|
||||
fine_size += 1
|
||||
|
||||
# Vectorized std: sqrt(E[X²] - (E[X])²) via uniform_filter (NaN-aware)
|
||||
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
|
||||
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:
|
||||
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))
|
||||
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
|
||||
|
||||
roughness = to_cpu(roughness)
|
||||
@ -868,7 +932,11 @@ def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
# ============================================================
|
||||
|
||||
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."""
|
||||
"""Statistical anomaly detection - std-normalized multi-scale relief + Local Moran's I — GPU if available.
|
||||
|
||||
Uses MSRM (multi-scale LRM) instead of single-scale LRM for better detection
|
||||
of anomalies at all scales.
|
||||
"""
|
||||
gpu_tag = " [GPU]" if HAS_GPU else ""
|
||||
logger.info(f" → Détection anomalies statistiques{gpu_tag}...")
|
||||
t0 = time.time()
|
||||
@ -880,30 +948,60 @@ def generate_anomalies(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
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
|
||||
|
||||
# Multi-scale LRM: compute MSRM-like combined relief
|
||||
min_scale = max(2.0, resolution * 4)
|
||||
candidate_scales = [2, 5, 10, 20, 50, 100]
|
||||
sigmas = [s for s in candidate_scales if s >= min_scale]
|
||||
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 — preserves 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_norm = lrm / lrm_std
|
||||
else:
|
||||
lrm_norm = lrm
|
||||
lrm_stack.append(lrm_norm.astype(np.float32))
|
||||
|
||||
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
|
||||
z_score = (lrm - lrm_mean) / lrm_std
|
||||
# Weighted RMS combination (favor 5-25m scales)
|
||||
scale_weights = {2: 0.8, 5: 2.0, 10: 1.8, 20: 1.5, 50: 1.0, 100: 0.6}
|
||||
weights = np.array([scale_weights.get(s, 1.0) for s in sigmas])
|
||||
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')
|
||||
msrm = np.sqrt(np.nansum((lrm_array ** 2) * weights_3d, axis=0) / np.sum(weights))
|
||||
msrm[nan_mask] = np.nan
|
||||
|
||||
window = int(10 / resolution)
|
||||
# Std normalization of MSRM — preserves contrast better than z-score
|
||||
valid_msrm = msrm[~nan_mask]
|
||||
msrm_std = max(np.nanstd(valid_msrm), 0.01) if len(valid_msrm) > 0 else 0.01
|
||||
z_score = msrm / msrm_std
|
||||
|
||||
# Local Moran's I for spatial clustering
|
||||
window = max(3, int(10 / resolution))
|
||||
if window % 2 == 0:
|
||||
window += 1
|
||||
|
||||
if shared:
|
||||
local_mean = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=window)
|
||||
local_mean_z = _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
|
||||
local_mean_z = _filter_nanaware(z_score, xp_uniform_filter, size=window)
|
||||
z_mean_global = np.nanmean(z_score[~nan_mask]) if np.any(~nan_mask) else 0
|
||||
z_std_global = max(np.nanstd(z_score[~nan_mask]), 0.01) if np.any(~nan_mask) else 0.01
|
||||
morans_i = z_score * (local_mean_z - z_mean_global) / z_std_global
|
||||
anomaly_score = np.abs(z_score) * np.sign(morans_i)
|
||||
anomaly_score[nan_mask] = np.nan
|
||||
|
||||
@ -922,7 +1020,13 @@ def generate_anomalies(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
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.
|
||||
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
|
||||
- Higher resolution = more fine scales available
|
||||
|
||||
Uses std normalization per scale and weighted combination
|
||||
with emphasis on archaeologically relevant scales (2-50m).
|
||||
"""
|
||||
gpu_tag = " [GPU]" if HAS_GPU else ""
|
||||
logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...")
|
||||
@ -941,7 +1045,25 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
nan_mask = np.isnan(dem_np)
|
||||
filled, _ = _fill_nans(dem_np.astype(np.float64))
|
||||
|
||||
scales = [2, 5, 10, 20, 50]
|
||||
# Adapt scales to resolution: finer scales available at higher resolution
|
||||
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]
|
||||
|
||||
# Weights favor archaeological scales (2-50m: ditches, enclosures, tumulus)
|
||||
scale_weights = {
|
||||
0.5: 0.6, # Fine texture
|
||||
1.0: 0.8, # Micro-relief
|
||||
2.0: 1.5, # Small ditches, paths — key scale
|
||||
5.0: 2.0, # Fossés, small enclosures — key archaeological scale
|
||||
10.0: 1.8, # Medium structures
|
||||
20.0: 1.5, # Large enclosures, tumulus
|
||||
50.0: 1.0, # Very large enclosures
|
||||
100.0: 0.6, # Landscape-level features
|
||||
}
|
||||
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)")
|
||||
wavelet_stack = []
|
||||
|
||||
for scale_m in scales:
|
||||
@ -954,15 +1076,21 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
|
||||
from scipy.ndimage import gaussian_laplace
|
||||
response = -gaussian_laplace(filled, sigma=sigma_px)
|
||||
response[nan_mask] = np.nan
|
||||
|
||||
# Std normalization: scale by standard deviation to make scales comparable
|
||||
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)
|
||||
|
||||
# Weighted RMS: sqrt(sum(w * x²) / sum(w))
|
||||
# Preserves contrast at key archaeological scales
|
||||
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.nanmean(np.array(wavelet_stack) ** 2, axis=0))
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user