Files
lidar_rendu/lidar_pipeline/visualizations.py
Antoine fb892ea9f2 Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:16:45 +02:00

2076 lines
84 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 to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_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
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 15 m kernel (reused by 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(" → Computing filled DEM (NaN interpolation)...")
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(" → Computing 15 m LRM...")
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(" → Computing gradient...")
dy = dx = None
if _gpu_mod.is_gpu_active() and self.filled_gpu is not None:
try:
g = self.filled_gpu
dy = to_cpu(xp.gradient(g, self.resolution, axis=0))
dx = to_cpu(xp.gradient(g, self.resolution, axis=1))
except Exception as e:
logger.warning(f" GPU gradient failed ({e}) — falling back to CPU")
dy = dx = None
if dy is None:
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.
Uses a distance transform (O(n), vectorized): the indices of the nearest
valid neighbor come out in a single pass. NearestNDInterpolator built a
cKDTree over ALL valid points (25 M at 0.2 m) — several seconds per tile
with holes, paid on the first access to SharedDEM.filled.
"""
nan_mask = np.isnan(arr)
if not np.any(nan_mask):
return arr, nan_mask
# GPU (cupyx): the distance transform over 25 M pixels costs ~3 s on CPU,
# the longest preparation step once everything else runs on GPU.
if _gpu_mod.is_gpu_active():
try:
from cupyx.scipy.ndimage import distance_transform_edt as edt_gpu
cp = _gpu_mod._cp
_, idx = edt_gpu(cp.asarray(nan_mask), return_distances=False, return_indices=True)
iy, ix = cp.asnumpy(idx[0]), cp.asnumpy(idx[1])
del idx
gpu_cleanup()
filled = arr.copy()
filled[nan_mask] = arr[iy[nan_mask], ix[nan_mask]]
return filled, nan_mask
except Exception as e:
logger.warning(f" GPU hole filling failed ({e}) — falling back to CPU")
from scipy.ndimage import distance_transform_edt
_, (iy, ix) = distance_transform_edt(nan_mask, return_indices=True)
filled = arr.copy()
filled[nan_mask] = arr[iy[nan_mask], ix[nan_mask]]
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_filled, dem_np, rows, cols, res, nan_mask,
transform, crs) ready for ray-tracing.
dem_filled is a CPU numpy array (filled, no NaN).
"""
if shared:
dem_np = shared.dem_np
nan_mask = shared.nan_mask
transform = shared.transform
crs = shared.crs
dem = shared.filled
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
dem = filled
res = resolution
rows, cols = dem_np.shape
return dem, dem_np, rows, cols, res, nan_mask, transform, crs
def _ray_trace_horizons_core(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.
Optimization: the TANGENT of the angle (dz/dist) is accumulated instead
of the angle itself — atan being strictly increasing, max(angles) =
atan(max(tangents)). The (costly, full-image) arctan is therefore only
applied at radius checkpoints, not at every ray step. A single pair of
running maxima is kept and snapshotted at each checkpoint (radii are
nested; previously each checkpoint redid the same computation). fmax
ignores the padding NaNs: no more nan_to_num/where.
Padding 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: CPU numpy array — filled DEM (no NaN), shape (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).
padded_np = np.pad(dem, max_dist, mode='constant', constant_values=np.nan)
# Transfer padded DEM to GPU for computation
padded = to_gpu(padded_np)
# GPU view of central region — reference elevation for ray-tracing
dem = padded[max_dist:max_dist+rows, max_dist:max_dist+cols]
# Free the CPU copy — we don't need it anymore
del padded_np
# Checkpoints sorted by step: (step, r_idx). Snapshots are taken when the
# current step reaches the checkpoint step — rays therefore never go past
# the largest requested checkpoint (equivalent to the former break).
checkpoints = sorted((radii_steps[r_idx], r_idx) for r_idx in range(n_radii))
last_step = checkpoints[-1][0]
# 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 (up to the last checkpoint)
valid_steps = []
for step in range(1, last_step + 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 of the tangents (float32: half the VRAM of float64)
running_pos = xp.zeros((rows, cols), dtype=np.float32)
running_neg = xp.zeros((rows, cols), dtype=np.float32)
snapshots = {}
cp_queue = list(checkpoints)
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
# Angle tangents (positive: terrain above, negative: below). fmax
# keeps the non-NaN operand: the padding border does not count,
# as with the former where(isnan) — in a single operation.
running_pos = xp.fmax(running_pos,
xp.maximum(elev_diff, 0) / dist_m)
running_neg = xp.fmax(running_neg,
xp.maximum(-elev_diff, 0) / dist_m)
del elev_diff # free intermediate
# Snapshot of the reached checkpoint: converted to an angle ONCE
while cp_queue and step >= cp_queue[0][0]:
_, r_idx = cp_queue.pop(0)
snapshots[r_idx] = (xp.arctan(running_pos),
xp.arctan(running_neg))
if not cp_queue:
break
# Checkpoints never reached (invalid steps): final state of the sweep
while cp_queue:
_, r_idx = cp_queue.pop(0)
snapshots[r_idx] = (xp.arctan(running_pos),
xp.arctan(running_neg))
# Store results on CPU, free GPU memory before next direction
pos_results[d_idx] = to_cpu(xp.stack([snapshots[r][0] for r in range(n_radii)]))
neg_results[d_idx] = to_cpu(xp.stack([snapshots[r][1] for r in range(n_radii)]))
del running_pos, running_neg, snapshots
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
def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
"""Ray-tracing with automatic CPU fallback when VRAM runs out.
Multi-radius 0.2 m tiles (5000×5000 px) can exceed the available VRAM
(GPU shared with other services): rather than giving up on the
visualization, the GPU is disabled for this worker and the computation
is rerun on CPU.
"""
try:
return _ray_trace_horizons_core(dem, rows, cols, res, n_dirs, max_dist, radii_m)
except Exception as e:
if _gpu_mod.is_gpu_active() and "out of memory" in str(e).lower():
logger.warning(" ⚠ Not enough VRAM (ray-tracing) — falling back to CPU for this worker")
_gpu_mod.disable_gpu()
gpu_cleanup()
return _ray_trace_horizons_core(dem, rows, cols, res, n_dirs, max_dist, radii_m)
raise
# ============================================================
# 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" → Multi-directional hillshade{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
# No GPU copy of the raw DEM here: only gradient/slope/aspect are
# used (already shared) — saves ~100 MB of VRAM per tile
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)
# Spacing = resolution (m/px): without it the slope is wrong
dy, dx = xp.gradient(dem, float(resolution) if resolution else 1.0)
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 done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Hillshade error: {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" → 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)
# Spacing = resolution (m/px): without it the slope is wrong
dy, dx = xp.gradient(dem, float(resolution) if resolution else 1.0)
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" ✓ Slope done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Slope error: {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 (slope orientation){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)
# Spacing = resolution (m/px): without it the slope is wrong
dy, dx = xp.gradient(dem, float(resolution) if resolution else 1.0)
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 done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Aspect error: {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 (multi-radius ray-tracing){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 = int(max(radii_m) / res) # true radius in pixels, not truncated
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 done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ SVF error: {e}", exc_info=True)
return None
# Downsampling of the openness computation: ray-tracing is the most
# expensive step of the pipeline (100 m radius = 500 steps at 0.2 m/px).
# Openness being a smooth angular field (mean of horizons up to 100 m), it is
# computed on a block-decimated grid — local max for positive openness, min
# for negative, to preserve the relief that bounds the horizon angle (banks,
# walls) — then resampled to the requested resolution. Cost ÷ factor³ (cells
# ÷ factor², ray steps ÷ factor); factor 2 ≈ 8× faster, near-identical
# rendering. 1 = full resolution.
OPENNESS_DOWNSAMPLE = 2
# Ray-tracing radii (meters), averaged with equal weights.
OPENNESS_RADII_M = (25, 50, 100)
# Openness normalization references (degrees: mean, standard deviation).
# A per-tile z-score made the scale non-seamless — same physical openness,
# different color from one tile to the next depending on the surrounding
# relief. Cross-tile medians measured at 0.2 m (×2 decimation) on 15 tiles
# spread across the territory (plain, bocage, forest, mountain, volcanoes,
# delta, urban, coast). FROZEN references: same openness = same color on
# every tile.
OPENNESS_POS_REF = (5.571, 4.112)
OPENNESS_NEG_REF = (5.974, 4.070)
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) on a block-decimated
grid (cf. OPENNESS_DOWNSAMPLE), then bilinearly resamples the result back
to the requested resolution. Results are combined with equal weight across
radii, then normalized by FIXED references (OPENNESS_POS_REF /
OPENNESS_NEG_REF, degrees) so that adjacent tiles share one colour scale.
"""
name = "positive_openness" if positive else "negative_openness"
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → {name.replace('_', ' ').title()} (multi-radius ray-tracing){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 = list(OPENNESS_RADII_M)
n_dirs = 8
full_rows, full_cols = rows, cols
# Block-decimated grid (see OPENNESS_DOWNSAMPLE)
factor = max(1, int(OPENNESS_DOWNSAMPLE))
if factor > 1 and rows >= 2 * factor and cols >= 2 * factor:
r2 = (rows // factor) * factor
c2 = (cols // factor) * factor
blocks = dem[:r2, :c2].reshape(r2 // factor, factor, c2 // factor, factor)
dem = blocks.max(axis=(1, 3)) if positive else blocks.min(axis=(1, 3))
rows, cols = dem.shape
res = res * factor
logger.info(f" Grid decimated ×{factor} ({rows}×{cols}) — resampled at the end")
max_dist = int(max(radii_m) / res) # true radius in pixels, not truncated
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)
# Back to the full-resolution grid (bilinear; the edge is padded by
# edge replication if the dimensions were not divisible by the factor)
if (rows, cols) != (full_rows, full_cols):
from scipy.ndimage import zoom
zoomed = zoom(openness_result, factor, order=1)
if zoomed.shape != (full_rows, full_cols):
zoomed = np.pad(zoomed,
((0, full_rows - zoomed.shape[0]),
(0, full_cols - zoomed.shape[1])),
mode='edge')
openness_result = zoomed.astype(np.float32)
openness_result[nan_mask] = np.nan
# Deviation from the frozen references, in sigmas: same physical
# openness = same value on every tile → seamless color mosaic
ref_mean, ref_std = OPENNESS_POS_REF if positive else OPENNESS_NEG_REF
openness_result = (openness_result - ref_mean) / ref_std
_save_tif(output, openness_result, transform, crs)
logger.info(f" ✓ {name} done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Openness error: {e}", exc_info=True)
return None
# ============================================================
# Oriented relief: local openness × aspect in a single RGB image
# ============================================================
#
# Lightness (CIELAB L*) = micro-relief: mean positive horizon angle computed
# on the detrended DTM (DTM − 10 m Gaussian), short radii 5/10/20 m (low
# angle = open = light), plus a light directional shading. Hue = slope
# orientation (aspect) on the CIELAB hue circle, at constant lightness: no
# color creates false relief. No per-tile statistic (FIXED log scale) and a
# total support (20 m radius + 4σ = 40 m detrend window, ~60 m in all)
# narrower than the 100 m edge buffer: adjacent tiles join without seams.
#
# Cost kept under control: detrending and ray-tracing run on a grid
# decimated to ~0.8 m (block max for the rays, block mean for the trend) —
# only the final openness is upsampled; a dedicated kernel accumulates only
# the mean of the angles (CuPy RawKernel on GPU, parallel numba on CPU,
# numpy as a last resort); colors come from a precomputed table (L* × hue)
# instead of a per-pixel Lab → sRGB conversion.
RELIEF_DETREND_M = 10.0 # σ of the Gaussian trend removed from the DTM
RELIEF_RADII_M = (5, 10, 20) # ray-tracing radii, equal weights
RELIEF_N_DIRS = 16 # 16 directions: no 8-pointed star artifacts
RELIEF_GRID_M = 0.8 # step of the decimated computation grid
RELIEF_OPEN_RANGE = (0.5, 12.0) # degrees, fixed log scale (seamless)
RELIEF_CHROMA = 60.0 # max CIELAB chroma (reached around L* = 50)
RELIEF_SHADE_WEIGHT = 0.35 # share of directional shading in L*
# Shading azimuth, expressed in the aspect frame used below
# (arctan2(dy, dx), dy pointing south) — tuned on the rendering gallery.
RELIEF_SHADE_AZIMUTH = 315.0
RELIEF_SHADE_ALTITUDE = 45.0
RELIEF_NODATA_RGB = (38, 38, 41)
_RELIEF_LUT = None
def _lab_to_srgb(L, a, b):
"""CIELAB (D65) → sRGB [0, 1], clipped."""
fy = (L + 16) / 116
fx = fy + a / 500
fz = fy - b / 200
finv = lambda t: np.where(t > 6 / 29, t ** 3, 3 * (6 / 29) ** 2 * (t - 4 / 29))
X, Y, Z = 0.95047 * finv(fx), finv(fy), 1.08883 * finv(fz)
rgb = np.stack([3.2406 * X - 1.5372 * Y - 0.4986 * Z,
-0.9689 * X + 1.8758 * Y + 0.0415 * Z,
0.0557 * X - 0.2040 * Y + 1.0570 * Z], -1)
rgb = np.where(rgb <= 0.0031308, 12.92 * rgb,
1.055 * np.clip(rgb, 0, None) ** (1 / 2.4) - 0.055)
return np.clip(rgb, 0, 1)
def _relief_lut():
"""Table (256 L* levels, 360 hues) → sRGB uint8, computed once.
Chroma decreases toward black and white (L*(100−L*)/2500): colors stay
inside the sRGB gamut instead of being clipped.
"""
global _RELIEF_LUT
if _RELIEF_LUT is None:
L = np.linspace(0, 100, 256)[:, None]
h = np.radians(np.arange(360))[None, :]
C = RELIEF_CHROMA * np.clip(L * (100 - L) / 2500.0, 0, 1)
_RELIEF_LUT = (_lab_to_srgb(np.broadcast_to(L, (256, 360)), C * np.cos(h), C * np.sin(h))
* 255 + 0.5).astype(np.uint8)
return _RELIEF_LUT
def _horizon_rays(res, n_dirs, radii_m):
"""(col, row) offsets of every ray step, distances and checkpoints.
Same geometry as _ray_trace_horizons_core: direction k at angle 2πk/n,
steps rounded to the pixel, distance = step × resolution.
"""
max_step = max(1, int(max(radii_m) / res))
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
steps = np.arange(1, max_step + 1)
offs = np.empty((n_dirs, max_step, 2), dtype=np.int32)
offs[:, :, 0] = np.rint(np.cos(angles)[:, None] * steps)
offs[:, :, 1] = np.rint(np.sin(angles)[:, None] * steps)
dist = (steps * res).astype(np.float32)
cps = np.array(sorted(max(1, min(int(r / res), max_step)) for r in radii_m), dtype=np.int32)
return offs, dist, cps
_HORIZON_CUDA_SRC = r'''
extern "C" __global__
void mean_horizon(const float* dem, const int* offs, const float* dist,
const int* cps, const int rows, const int cols,
const int nd, const int ns, const int nr, float* out) {
long idx = (long)blockDim.x * blockIdx.x + threadIdx.x;
if (idx >= (long)rows * cols) return;
int i = idx / cols, j = idx % cols;
float z0 = dem[idx], acc = 0.f;
for (int d = 0; d < nd; ++d) {
float run = 0.f;
int k = 0;
for (int s = 0; s < ns; ++s) {
int ii = i + offs[(d * ns + s) * 2 + 1];
int jj = j + offs[(d * ns + s) * 2];
if (ii >= 0 && ii < rows && jj >= 0 && jj < cols)
run = fmaxf(run, (dem[(long)ii * cols + jj] - z0) / dist[s]);
while (k < nr && s + 1 >= cps[k]) { acc += atanf(run); ++k; }
}
while (k < nr) { acc += atanf(run); ++k; }
}
out[idx] = acc / (float)(nd * nr);
}
'''
_horizon_cuda_kernel = None
_horizon_numba_kernel = None
def _mean_horizon_gpu(dem, offs, dist, cps):
"""One CUDA thread per pixel; everything stays in registers (no
intermediate array per direction or per radius)."""
global _horizon_cuda_kernel
cp = _gpu_mod._cp
if _horizon_cuda_kernel is None:
_horizon_cuda_kernel = cp.RawKernel(_HORIZON_CUDA_SRC, 'mean_horizon')
rows, cols = dem.shape
d_dem = cp.ascontiguousarray(cp.asarray(dem, dtype=cp.float32))
out = cp.empty((rows, cols), dtype=cp.float32)
n = rows * cols
threads = 256
_horizon_cuda_kernel(((n + threads - 1) // threads,), (threads,),
(d_dem, cp.asarray(offs), cp.asarray(dist), cp.asarray(cps),
np.int32(rows), np.int32(cols), np.int32(offs.shape[0]),
np.int32(offs.shape[1]), np.int32(len(cps)), out))
return out
def _mean_horizon_numba(dem, offs, dist, cps):
"""Same kernel in parallel numba (one pixel row per CPU thread)."""
global _horizon_numba_kernel
if _horizon_numba_kernel is None:
from numba import njit, prange
@njit(parallel=True, cache=True, fastmath=True)
def _kernel(dem, offs, dist, cps):
rows, cols = dem.shape
nd, ns, nr = offs.shape[0], offs.shape[1], cps.shape[0]
out = np.empty((rows, cols), dtype=np.float32)
for i in prange(rows):
for j in range(cols):
z0 = dem[i, j]
acc = 0.0
for d in range(nd):
run = 0.0
k = 0
for s in range(ns):
ii = i + offs[d, s, 1]
jj = j + offs[d, s, 0]
if ii >= 0 and ii < rows and jj >= 0 and jj < cols:
t = (dem[ii, jj] - z0) / dist[s]
if t > run:
run = t
while k < nr and s + 1 >= cps[k]:
acc += math.atan(run)
k += 1
while k < nr:
acc += math.atan(run)
k += 1
out[i, j] = acc / (nd * nr)
return out
_horizon_numba_kernel = _kernel
return _horizon_numba_kernel(np.ascontiguousarray(dem, dtype=np.float32), offs, dist, cps)
def _mean_horizon_numpy(dem, offs, dist, cps):
"""Vectorized fallback (no numba, no GPU): one array shift per step."""
rows, cols = dem.shape
pad = int(np.abs(offs).max())
padded = np.pad(dem.astype(np.float32), pad, constant_values=np.nan)
acc = np.zeros((rows, cols), dtype=np.float64)
for d in range(offs.shape[0]):
run = np.zeros((rows, cols), dtype=np.float32)
k = 0
for s in range(offs.shape[1]):
px, py = offs[d, s]
view = padded[pad + py:pad + py + rows, pad + px:pad + px + cols]
run = np.fmax(run, (view - dem) / dist[s])
while k < len(cps) and s + 1 >= cps[k]:
acc += np.arctan(run)
k += 1
while k < len(cps):
acc += np.arctan(run)
k += 1
return (acc / (offs.shape[0] * len(cps))).astype(np.float32)
def _mean_horizon_angle(dem, res, n_dirs, radii_m):
"""Mean (directions × radii) of the positive horizon angle, in radians.
GPU (CuPy RawKernel) if available — the result then stays on the GPU —,
otherwise numba, otherwise numpy. A GPU failure (NVRTC compilation, VRAM)
switches this computation to CPU without losing the visualization.
Returns (angle, engine name).
"""
offs, dist, cps = _horizon_rays(res, n_dirs, radii_m)
if _gpu_mod.is_gpu_active():
try:
return _mean_horizon_gpu(dem, offs, dist, cps), "GPU"
except Exception as e:
logger.warning(f" ⚠ Oriented-relief GPU kernel unavailable ({e}) — falling back to CPU")
dem = to_cpu(dem)
try:
return _mean_horizon_numba(dem, offs, dist, cps), "numba"
except ImportError:
return _mean_horizon_numpy(dem, offs, dist, cps), "numpy"
def _pad_to(m, arr, rows, cols):
"""Pad by edge replication (dimensions not a multiple of the factor)."""
if arr.shape == (rows, cols):
return arr
return m.pad(arr[:rows, :cols], ((0, max(0, rows - arr.shape[0])), (0, max(0, cols - arr.shape[1]))),
mode='edge')
def _relief_params():
"""Scalar rendering constants (shading without per-pixel trigonometry).
cos(slope) = 1/√(1+g²) and sin(slope)·cos(az − aspect) = (cos az·dx +
sin az·dy)/√(1+g²), with aspect = arctan2(dy, dx): shading costs only
one square root per pixel.
"""
zen = math.radians(90.0 - RELIEF_SHADE_ALTITUDE)
az = math.radians(RELIEF_SHADE_AZIMUTH)
lo, hi = RELIEF_OPEN_RANGE
return (math.cos(zen), math.sin(zen) * math.cos(az), math.sin(zen) * math.sin(az),
lo, 1.0 / math.log(hi / lo), RELIEF_SHADE_WEIGHT)
def _relief_colorize_xp(m, openness, dx, dy, lut):
"""Vectorized colorization (CuPy on GPU, numpy as fallback)."""
cz, sa_x, sa_y, lo, inv_log, w = _relief_params()
inv = 1.0 / m.sqrt(1.0 + dx * dx + dy * dy)
shade = m.clip((cz + sa_x * dx + sa_y * dy) * inv / cz, 0, 1.25) / 1.25
t = m.clip(m.log(m.maximum(openness, 1e-3) / lo) * inv_log, 0, 1)
L = 12.0 + 84.0 * ((1 - w) * (1 - t) + w * shade)
del inv, shade, t
li = m.clip(m.rint(L * 2.55), 0, 255).astype(m.int32)
hue = m.mod(m.rint(m.degrees(m.arctan2(dy, dx))), 360).astype(m.int32)
return lut[li, hue]
_relief_color_numba_kernel = None
def _relief_colorize_numba(open_c, f, dx, dy, lut):
"""Single-pass CPU colorization (parallel numba): openness is read from
the decimated grid by bilinear interpolation aligned on pixel centers
(equivalent to xp_zoom), with no intermediate full-resolution array."""
global _relief_color_numba_kernel
if _relief_color_numba_kernel is None:
from numba import njit, prange
@njit(parallel=True, cache=True, fastmath=True)
def _kernel(open_c, f, dx, dy, lut, cz, sa_x, sa_y, lo, inv_log, w):
rows, cols = dx.shape
rc, cc = open_c.shape
out = np.empty((rows, cols, 3), dtype=np.uint8)
for i in prange(rows):
u = (i + 0.5) / f - 0.5
u = min(max(u, 0.0), rc - 1.0)
i0 = int(u)
i1 = min(i0 + 1, rc - 1)
fu = u - i0
for j in range(cols):
v = (j + 0.5) / f - 0.5
v = min(max(v, 0.0), cc - 1.0)
j0 = int(v)
j1 = min(j0 + 1, cc - 1)
fv = v - j0
o = ((1 - fu) * ((1 - fv) * open_c[i0, j0] + fv * open_c[i0, j1])
+ fu * ((1 - fv) * open_c[i1, j0] + fv * open_c[i1, j1]))
gx = dx[i, j]
gy = dy[i, j]
inv = 1.0 / math.sqrt(1.0 + gx * gx + gy * gy)
sh = (cz + sa_x * gx + sa_y * gy) * inv / cz
sh = min(max(sh, 0.0), 1.25) / 1.25
t = math.log(max(o, 1e-3) / lo) * inv_log
t = min(max(t, 0.0), 1.0)
L = 12.0 + 84.0 * ((1 - w) * (1 - t) + w * sh)
li = min(max(int(round(L * 2.55)), 0), 255)
h = int(round(math.degrees(math.atan2(gy, gx)))) % 360
out[i, j, 0] = lut[li, h, 0]
out[i, j, 1] = lut[li, h, 1]
out[i, j, 2] = lut[li, h, 2]
return out
_relief_color_numba_kernel = _kernel
return _relief_color_numba_kernel(np.ascontiguousarray(open_c), float(f),
np.ascontiguousarray(dx, dtype=np.float32),
np.ascontiguousarray(dy, dtype=np.float32),
lut, *_relief_params())
def generate_relief_oriente(dem_file, basename, vis_dir, resolution, shared=None):
"""Oriented relief: a single RGB image merging local openness and aspect.
L* (lightness) = local positive openness (detrended DTM, short radii)
65 % + directional shading 35 %; hue = slope orientation; fixed CIELAB
chroma. Output: RGB uint8 GeoTIFF (rendered as is, like the IGN base
layers).
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Oriented relief (local openness × aspect){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_relief_oriente.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
res = float(res)
use_gpu = _gpu_mod.is_gpu_active()
filled = (shared.filled_gpu if shared is not None and use_gpu else None)
if filled is None:
filled = to_gpu(dem) if use_gpu else dem.astype(np.float32)
# 1. Decimated grid: block max (preserves the relief that bounds the
# horizon) and block mean (support of the trend to remove)
f = max(1, int(round(RELIEF_GRID_M / res)))
if rows < 2 * f or cols < 2 * f:
f = 1
r2, c2 = (rows // f) * f, (cols // f) * f
blocks = filled[:r2, :c2].reshape(r2 // f, f, c2 // f, f)
coarse_max = blocks.max(axis=(1, 3))
coarse_mean = blocks.mean(axis=(1, 3))
res_c = res * f
# 2. Detrending (DTM − Gaussian) on the decimated grid
trend = xp_gaussian_filter(coarse_mean, RELIEF_DETREND_M / res_c)
detrended = coarse_max - trend
del blocks, coarse_max, coarse_mean, trend
t_prep = time.time() - t0
# 3. Local openness: mean horizon angle (radians) on the decimated grid
t1 = time.time()
angle, engine = _mean_horizon_angle(detrended, res_c, RELIEF_N_DIRS, RELIEF_RADII_M)
t_ray = time.time() - t1
on_gpu = engine == "GPU"
del detrended
# 4. Colors: openness upsampling, shading, L* × hue table
t2 = time.time()
if shared is not None:
dy, dx = shared.dy, shared.dx
else:
dy, dx = np.gradient(to_cpu(filled).astype(np.float32), res)
lut = _relief_lut()
if on_gpu:
openness = _gpu_mod.xp_zoom(xp.degrees(angle), f, order=1) if f > 1 else xp.degrees(angle)
openness = _pad_to(xp, xp.asarray(openness), rows, cols)
rgb = to_cpu(_relief_colorize_xp(xp, openness, to_gpu(dx), to_gpu(dy), xp.asarray(lut)))
del openness
gpu_cleanup()
engine_color = "GPU"
else:
angle = to_cpu(angle)
try:
rgb = _relief_colorize_numba(np.degrees(angle).astype(np.float32), f, dx, dy, lut)
engine_color = "numba"
except ImportError:
openness = _gpu_mod.xp_zoom(np.degrees(angle), f, order=1) if f > 1 else np.degrees(angle)
rgb = _relief_colorize_xp(np, _pad_to(np, openness, rows, cols), dx, dy, lut)
engine_color = "numpy"
del angle, dx, dy
t_color = time.time() - t2
rgb[nan_mask] = RELIEF_NODATA_RGB
_save_tif(output, np.moveaxis(rgb, -1, 0), transform, crs, dtype='uint8', count=3)
logger.info(f" ✓ Oriented relief done ({time.time()-t0:.1f}s: "
f"preparation {t_prep:.1f}s, rays {t_ray:.1f}s [{engine}], "
f"colors {t_color:.1f}s [{engine_color}])")
return output
except Exception as e:
logger.error(f" ✗ Oriented relief error: {e}", exc_info=True)
return None
# Ground point density: 16 levels on a fixed log scale (same gray = same
# density everywhere, seamless mosaic). Level k starts at
# DENSITY_BASE × 2^(k/2) pts/m²: 0 = < 0.35 (black, including no point at
# all), 15 = ≥ 45 (white); two levels up = density doubled.
DENSITY_LEVELS = 16
DENSITY_BASE = 0.25
def density_levels(density):
"""Level 0..15 of a density (pts/m²)."""
with np.errstate(divide="ignore", invalid="ignore"):
k = np.floor(2.0 * np.log2(np.asarray(density, dtype=np.float64) / DENSITY_BASE))
return np.clip(np.nan_to_num(k, nan=0.0, neginf=0.0), 0, DENSITY_LEVELS - 1).astype(np.uint8)
def generate_densite_sol(dem_file, basename, vis_dir, resolution, shared=None):
"""Density of the ground points kept for the DTM, in 16 gray levels.
Read from the sidecar file written during rasterization
(dtm.density_path): 1 m grid, kept as is (density carries no finer
detail — an image 25× lighter than at 0.2 m). Output: level 0..15
(float32).
"""
logger.info(" → Ground point density...")
t0 = time.time()
output = vis_dir / f"{basename}_densite_sol.tif"
try:
from .dtm import density_path
src_path = density_path(dem_file)
if not src_path.exists():
logger.error(f" ✗ Density file missing ({src_path.name}): regenerate the DTM")
return None
with rasterio.open(src_path) as src:
density = src.read(1)
transform, crs = src.transform, src.crs
_save_tif(output, density_levels(density).astype(np.float32), transform, crs)
logger.info(f" ✓ Point density done ({time.time()-t0:.1f}s)")
return output
except Exception as e:
logger.error(f" ✗ Point density error: {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)
# Archaeological scales: focus on 2-50m range.
# Small features (ditches, walls, post-holes) need 2-10m.
# Medium features (enclosures, roundhouses) need 10-25m.
# Large scales (50m+) are kept only for context with low weight.
candidate_scales = [2, 3, 5, 8, 10, 15, 25, 50]
sigmas = [s for s in candidate_scales if s >= min_scale]
# Weights: favor small-to-medium scales where archaeo features live
scale_weights = {
2: 1.5, 3: 1.8, 5: 2.0, 8: 1.8, 10: 1.5, 15: 1.3, 25: 1.0, 50: 0.5,
}
weights = np.array([scale_weights.get(s, 1.0) for s in sigmas])
logger.info(f" MSRM scales: {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
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
# Clip |z| to 3.0 to prevent large-scale outliers from drowning small features
lrm = np.clip(lrm, -3.0, 3.0)
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 done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ MSRM error: {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 (adaptive LRM){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
# Per-tile z-score: units comparable between tiles, fixed ±3σ
# rendering range → homogeneous color mosaic
valid = sailore[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
sailore = (sailore - np.nanmean(valid)) / std_val
_save_tif(output, sailore.astype(np.float32), transform, crs)
logger.info(f" ✓ SAILORE done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ SAILORE error: {e}", exc_info=True)
return None
# ============================================================
# Roughness
# ============================================================
def _integral_sums(x):
"""2D integral sums: S[i,j] = sum of x[0:i, 0:j] (float64).
Row/column 0 are zeros — gives the sum of any window from 4 corners,
including against the border (index 0).
"""
rows, cols = x.shape
S = xp.zeros((rows + 1, cols + 1), dtype=np.float64)
S[1:, 1:] = xp.cumsum(xp.cumsum(x.astype(np.float64), axis=0), axis=1)
return S
def _box_std_from_integral(Sx, Sx2, size):
"""Local standard deviation over a size×size window via integral sums.
Cost independent of the window size (4 lookups per pixel) — versus a
uniform_filter whose cost grows with the window (75 px at 0.2 m for the
broad scale). At the borders the window is truncated and normalized by
the actual number of elements (tiles overlap through the edge buffer, so
the border has no visible effect).
"""
rows = Sx.shape[0] - 1
cols = Sx.shape[1] - 1
r = size // 2
iy0 = xp.maximum(xp.arange(rows) - r, 0)
iy1 = xp.minimum(xp.arange(rows) + r + 1, rows)
ix0 = xp.maximum(xp.arange(cols) - r, 0)
ix1 = xp.minimum(xp.arange(cols) + r + 1, cols)
# Actual number of elements in the window (truncated at the borders)
counts = ((iy1 - iy0)[:, None] * (ix1 - ix0)[None, :]).astype(np.float64)
def box_sum(S):
return (S[iy1][:, ix1] - S[iy0][:, ix1]
- S[iy1][:, ix0] + S[iy0][:, ix0])
mean = box_sum(Sx) / counts
mean_sq = box_sum(Sx2) / counts
return xp.sqrt(xp.maximum(mean_sq - mean * mean, 0))
# Roughness normalization references (meters of local standard deviation).
# Cross-tile medians measured on 20 real tiles at 0.2 m: per-tile
# normalization (z-score) made the scale non-seamless — the deviation ranged
# from 0.05 to 0.58 m depending on the tile for the fine scale. FROZEN
# references: same physical roughness = same value on every tile.
ROUGHNESS_FINE_REF_M = 0.156
ROUGHNESS_BROAD_REF_M = 0.475
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. Local standard deviations
are computed from integral sums: two cumsums shared by both scales,
4-corner extraction — constant cost whatever the window (a
uniform_filter costs in proportion to its size, 75 px at 0.2 m for the
broad scale).
Normalized by FIXED references (measured medians), not per tile: mosaics
are seamless, same value = same color.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Surface roughness{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
if _gpu_mod.HAS_GPU:
filled = shared.filled_gpu
else:
filled = shared.filled
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
if _gpu_mod.HAS_GPU:
filled = to_gpu(filled)
# Integral sums shared by both scales (X and X²)
Sx = _integral_sums(filled)
Sx2 = _integral_sums(filled.astype(np.float64) ** 2)
fine_size = max(3, int(3 / resolution))
if fine_size % 2 == 0:
fine_size += 1
broad_size = max(3, int(15 / resolution))
if broad_size % 2 == 0:
broad_size += 1
roughness_fine = to_cpu(_box_std_from_integral(Sx, Sx2, fine_size))
roughness_broad = to_cpu(_box_std_from_integral(Sx, Sx2, broad_size))
del Sx, Sx2
gpu_cleanup()
roughness_fine[nan_mask] = np.nan
roughness_broad[nan_mask] = np.nan
# Weighted combination on a common physical scale (fixed references:
# seamless between tiles — see module constants)
roughness = (0.7 * roughness_fine / ROUGHNESS_FINE_REF_M
+ 0.3 * roughness_broad / ROUGHNESS_BROAD_REF_M)
roughness[nan_mask] = np.nan
_save_tif(output, roughness, transform, crs)
logger.info(f" ✓ Roughness done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Roughness error: {e}", exc_info=True)
return None
# ============================================================
# Surface exposure (solar illumination)
# ============================================================
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(" → Surface exposure (solar illumination)...")
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" ✓ Surface exposure done ({time.time()-t0:.1f}s)")
return output
except Exception as e:
logger.error(f" ✗ Surface exposure error: {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).
Focused on small archaeological structures (paths, ditches, ramparts).
CWT 2D at scales [1, 2, 5, 10, 20, 50] m (scales below max(2 × resolution,
1 m) are dropped, so the 0.5 m candidate is never used).
The 100m scale was dropped: it mostly responds to landforms (hills,
valleys), not to structures.
Large landforms are removed first by subtracting a Gaussian local-mean
trend (~35m). The residual is analyzed relative to its ~35m neighborhood,
so a ditch on a hilltop or slope does not stand out more than the same
ditch on flat ground (topographic-position independence). A Gaussian
smoothing preserves locally planar slopes, so slopes are removed too.
Uses robust per-scale normalization (MAD) and median-centered weighted RMS
combination with emphasis on small scales (1-10m).
The output is an index relative to the tile's own median level (≈ 1):
comparable from tile to tile, so a single fixed color stretch works.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Multi-scale Mexican Hat wavelet{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)
# 100 m dropped: it mostly responds to large landforms, not to
# structures. Emphasis on 1-10 m (small structures).
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50]
scales = [s for s in candidate_scales if s >= min_scale]
scale_weights = {
0.5: 0.7, 1.0: 1.2, 2.0: 1.8, 5.0: 2.2,
10.0: 2.0, 20.0: 1.5, 50.0: 0.8,
}
weights = np.array([scale_weights.get(s, 1.0) for s in scales])
logger.info(f" CWT scales: {scales} m (resolution {resolution} m/px)")
from scipy.ndimage import gaussian_laplace, gaussian_filter
# Removal of large landforms (hills, valleys): a Gaussian local mean
# is subtracted before the CWT. Gaussian smoothing preserves planar
# slopes, so the residual is analyzed relative to its ~35 m
# neighborhood: a ditch on a hilltop or hillside does not stand out
# more than the same ditch on flat ground.
# Fraction kept for a Gaussian structure of width σ_f:
# σ_t²/(σ_f²+σ_t²) → 10 m: 92%, 20 m: 75%, 150 m hill: 5%.
# Measured on a noisy synthetic DTM: the contrast of small structures
# is insensitive to σ_t; only the hilltop/flat background ratio
# changes (1.71 without detrending → 1.21 at 35 m).
detrend_sigma_m = 35.0
detrend_sigma_px = detrend_sigma_m / resolution
if _gpu_mod.HAS_GPU:
try:
from cupyx.scipy.ndimage import gaussian_filter as gpu_gaussian_filter
trend = to_cpu(gpu_gaussian_filter(to_gpu(filled), sigma=detrend_sigma_px))
except Exception:
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
else:
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
residual = filled - trend
del trend
logger.info(f" Large landforms removed (Gaussian trend {detrend_sigma_m:.0f} m)")
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(residual), sigma=sigma_px)
response = to_cpu(response)
except Exception:
response = -gaussian_laplace(residual, sigma=sigma_px)
else:
response = -gaussian_laplace(residual, sigma=sigma_px)
response[nan_mask] = np.nan
valid = response[~nan_mask]
# Robust σ (MAD): the classic std is inflated by the tails
# (strong structures, tile edges) and varies a lot from one tile
# to the next — the main cause of per-tile color casts on the
# map.
mad = np.nanmedian(np.abs(valid - np.nanmedian(valid))) if len(valid) > 0 else 0.0
response = response / max(1.4826 * mad, 0.01)
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
# Rescaling by the tile median: the RMS becomes an index relative to
# the tile's typical level (median = 1). The distribution is then
# comparable from one tile to the next — a prerequisite for a single
# fixed color stretch that is homogeneous across tiles.
finite = combined[np.isfinite(combined)]
if finite.size:
combined = combined / max(float(np.median(finite)), 0.01)
combined[nan_mask] = np.nan
_save_tif(output, combined.astype(np.float32), transform, crs)
logger.info(f" ✓ Wavelet done ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Wavelet error: {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).
"""
result = _priority_flood_numba(dem, nodata_mask)
if result is not None:
return result
return _priority_flood_python(dem, nodata_mask)
def _priority_flood_python(dem, nodata_mask):
"""Pure-Python fallback for _priority_flood (used when numba is unavailable)."""
import heapq
rows, cols = dem.shape
filled = dem.copy()
closed = nodata_mask.copy()
open_queue = []
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
closed[nr, nc] = True
heapq.heappush(open_queue, (filled[nr, nc], nr, nc))
return filled
def _priority_flood_numba(dem, nodata_mask):
"""JIT-compiled priority-flood via binary min-heap (~200x faster than Python).
Returns None if numba is unavailable (caller falls back to Python).
"""
try:
from numba import njit
except ImportError:
return None
@njit(cache=True)
def _flood(dem, nodata):
rows, cols = dem.shape
filled = dem.copy()
flat = filled.ravel()
closed = nodata.copy()
n = rows * cols
heap = np.empty(n, dtype=np.int64)
heap_size = 0
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)
for r in range(rows):
for c in (0, cols - 1):
if not closed[r, c]:
heap[heap_size] = r * cols + c
heap_size += 1
closed[r, c] = True
for c in range(1, cols - 1):
for r in (0, rows - 1):
if not closed[r, c]:
heap[heap_size] = r * cols + c
heap_size += 1
closed[r, c] = True
while heap_size > 0:
cell = heap[0]
elev = flat[cell]
heap_size -= 1
if heap_size > 0:
heap[0] = heap[heap_size]
i = 0
while True:
l = 2 * i + 1
r = 2 * i + 2
smallest = i
if l < heap_size and flat[heap[l]] < flat[heap[smallest]]:
smallest = l
if r < heap_size and flat[heap[r]] < flat[heap[smallest]]:
smallest = r
if smallest == i:
break
heap[i], heap[smallest] = heap[smallest], heap[i]
i = smallest
r = cell // cols
c = cell % cols
for d in range(8):
nr = r + dy8[d]
nc = c + dx8[d]
if 0 <= nr < rows and 0 <= nc < cols and not closed[nr, nc]:
ncell = nr * cols + nc
if flat[ncell] < elev:
flat[ncell] = elev
closed[nr, nc] = True
heap[heap_size] = ncell
heap_size += 1
child = heap_size - 1
while child > 0:
parent = (child - 1) // 2
if flat[heap[child]] < flat[heap[parent]]:
heap[child], heap[parent] = heap[parent], heap[child]
child = parent
else:
break
return filled
return _flood(dem, nodata_mask)
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.
Returns None if numba is unavailable (the caller falls back to Python).
"""
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(" → 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 done ({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" ✓ D8 direction done ({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(" D8 accumulation via numba")
else:
# Pure Python fallback
logger.info(" D8 accumulation via Python (install numba to speed it up)")
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 done ({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 done ({time.time()-t0:.1f}s)")
return output
except Exception as e:
logger.error(f" ✗ Flow accumulation error: {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, Flow accumulation, Openness Pos), normalizes each to absolute
z-scores, and combines them into a weighted composite anomaly score.
Pixels below the (100 − 10 × n_sigma)th percentile of the score (clamped
to 50–95) are zeroed; the rest 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: Threshold control (percentile = 100 − 10 × n_sigma).
Lower = more sensitive (more false positives).
Default 2.0 (80th percentile, top 20% of the score kept).
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Automatic anomaly detection (threshold {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), # Ditches, sinkholes
("roughness", 1.8), # Surface irregularity
("wavelet", 1.5), # Circular + linear structures
("svf", 1.3), # Sky-view depressions
("flow_acc", 1.2), # Drainage channels / ditches
("positive_openness", 0.8), # Raised features
]
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" Layer {pattern} unavailable: {e}")
continue
if not layers:
# Fallback: use MSRM + roughness computed on the fly
logger.info(" No layer found — computing MSRM + roughness on the fly...")
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))
combined_valid = combined[~nan_mask]
if combined_valid.size == 0:
logger.warning(" ✗ Anomaly detection: tile is 100% nodata")
return None
threshold_val = np.percentile(combined_valid, 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" ✓ Anomaly detection done ({time.time()-t0:.1f}s) — "
f"{pct_anomaly:.1f}% of the area ({n_anomaly} px) above the {n_sigma}σ threshold")
return output
except Exception as e:
logger.error(f" ✗ Anomaly detection error: {e}", exc_info=True)
return None