Remove LRM, TPI, aspect, curvature, paths + add flow accumulation, directional Gabor wavelets, multi-radius ray-tracing

This commit is contained in:
Antoine Jacquin
2026-05-31 17:19:31 +02:00
parent b5b6787956
commit 929fac9aa0
4 changed files with 409 additions and 582 deletions

View File

@ -57,11 +57,12 @@ _file_filter = FilePrefixFilter()
from .dtm import classify_ground, create_dtm_fast from .dtm import classify_ground, create_dtm_fast
from .visualizations import ( from .visualizations import (
SharedDEM, SharedDEM,
generate_hillshade, generate_slope, generate_aspect, generate_curvature, generate_hillshade, generate_slope,
generate_lrm, generate_openness, generate_openness,
generate_mslrm, generate_tpi, generate_sailore, generate_mslrm, generate_sailore,
generate_roughness, generate_wavelet, generate_roughness, generate_wavelet,
generate_svf, generate_aniso_open, generate_paths, generate_svf, generate_aniso_open,
generate_flow_accumulation,
) )
from .gpu import gpu_cleanup, num_gpus, restrict_gpus, safe_gpu_call from .gpu import gpu_cleanup, num_gpus, restrict_gpus, safe_gpu_call
from .ign import generate_ign_overlay from .ign import generate_ign_overlay
@ -74,19 +75,15 @@ from .rendering import tif_to_png
VIZ_STEPS = [ VIZ_STEPS = [
('hillshade', generate_hillshade), ('hillshade', generate_hillshade),
('slope', generate_slope), ('slope', generate_slope),
('aspect', generate_aspect), ('mslrm', generate_mslrm),
('curvature', generate_curvature), ('sailore', generate_sailore),
('lrm', generate_lrm),
('pos_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=True, shared=shared)), ('pos_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=True, shared=shared)),
('neg_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=False, shared=shared)), ('neg_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=False, shared=shared)),
('mslrm', generate_mslrm),
('tpi', generate_tpi),
('sailore', generate_sailore),
('roughness', generate_roughness),
('svf', generate_svf), ('svf', generate_svf),
('aniso_open', generate_aniso_open), ('aniso_open', generate_aniso_open),
('paths', generate_paths), ('roughness', generate_roughness),
('wavelet', generate_wavelet), ('wavelet', generate_wavelet),
('flow_acc', generate_flow_accumulation),
('ortho', lambda d, b, v, r: generate_ign_overlay( ('ortho', lambda d, b, v, r: generate_ign_overlay(
d, b, v, r, d, b, v, r,
layer='ORTHOIMAGERY.ORTHOPHOTOS', layer='ORTHOIMAGERY.ORTHOPHOTOS',

View File

@ -81,13 +81,6 @@ _FRANCE_OUTLINE_L93 = np.array([
COLORMAPS = { COLORMAPS = {
# === Famille RELIEF : rouge=surélévation, bleu=dépression === # === Famille RELIEF : rouge=surélévation, bleu=dépression ===
# Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat # Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat
'curvature': {
'cmap': 'bwr',
'title': 'Courbure (Convexité/Concavité du terrain)',
'legend': 'Changement de pente (1/m)\nRouge = Convexe (sommet de mur, levée)\nBleu = Concave (fond de fossé, dépression)',
'description': 'Détecte les ruptures de pente — utile pour bords de terrasses et levées',
'vmin_mode': 'symmetric', 'sym_pct': (5, 95),
},
'mslrm': { 'mslrm': {
'cmap': 'seismic', 'cmap': 'seismic',
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)', 'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
@ -95,20 +88,6 @@ COLORMAPS = {
'description': 'Combine LRM à 5 échelles — détecte structures de 5m à 100m simultanément', 'description': 'Combine LRM à 5 échelles — détecte structures de 5m à 100m simultanément',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98), 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
}, },
'lrm': {
'cmap': 'seismic',
'title': 'LRM - Local Relief Model (échelle unique 15m)',
'legend': 'Écart local par rapport au terrain moyen (m)\nRouge = Surélévation (+{vmax:.2f}m)\nBleu = Dépression ({vmin:.2f}m)\nNoyau gaussien unique de 15m',
'description': 'Micro-relief à 15m seulement — voir MSRM pour toutes les échelles',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
},
'tpi': {
'cmap': 'seismic',
'title': 'TPI - Topographic Position Index (4 échelles)',
'legend': 'Position dans le paysage\nRouge = Plus haut que le voisinage (crête, plateau)\nBleu = Plus bas que le voisinage (fossé, vallée)\nCombine 4 échelles : 3m, 15m, 50m, 200m',
'description': 'Identifie la position topographique — utile pour repérer crêtes vs vallées à grande échelle',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
},
'sailore': { 'sailore': {
'cmap': 'seismic', 'cmap': 'seismic',
'title': 'SAILORE - LRM Auto-Adaptatif', 'title': 'SAILORE - LRM Auto-Adaptatif',
@ -123,14 +102,6 @@ COLORMAPS = {
'description': 'Openness avec pondération anisotropique — détecte mieux les structures alignées NW-SE et NE-SW', 'description': 'Openness avec pondération anisotropique — détecte mieux les structures alignées NW-SE et NE-SW',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98), 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
}, },
'paths': {
'cmap': 'inferno',
'title': 'Cheminement (chemins et sentiers)',
'legend': 'Openness directionnelle maximale\nJaune/vif = Chemin ou sentier\nNoir = Terrain plat\n\nDétecte les structures linéaires dans toutes les directions',
'description': 'Différence openness positive-négative maximale sur 8 directions — chemins, sentiers, ornières',
'vmin_mode': 'percentile', 'vmin_pct': 5,
'vmax_mode': 'percentile', 'vmax_pct': 99,
},
# === Famille OUVERTURE : séquentiel, toujours positif === # === Famille OUVERTURE : séquentiel, toujours positif ===
'positive_openness': { 'positive_openness': {
'cmap': 'YlOrBr', 'cmap': 'YlOrBr',
@ -160,7 +131,7 @@ COLORMAPS = {
'hillshade': { 'hillshade': {
'cmap': 'gray', 'cmap': 'gray',
'title': 'Hillshade Multidirectionnel', 'title': 'Hillshade Multidirectionnel',
'legend': 'Illumination combinée de 6 directions\nBlanc = Face éclairée | Noir = Zone d\'ombre', 'legend': 'Illumination combinée de 8 directions\nBlanc = Face éclairée | Noir = Zone d\'ombre',
'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)', 'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)',
'vmin_mode': 'percentile', 'vmin_pct': 1, 'vmin_mode': 'percentile', 'vmin_pct': 1,
'vmax_mode': 'percentile', 'vmax_pct': 99, 'vmax_mode': 'percentile', 'vmax_pct': 99,
@ -173,14 +144,6 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': 0, 'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'percentile', 'vmax_pct': 97, 'vmax_mode': 'percentile', 'vmax_pct': 97,
}, },
'aspect': {
'cmap': 'twilight',
'title': 'Aspect (Direction des pentes)',
'legend': 'Direction vers laquelle le terrain descend\nCycle continu : Nord→Est→Sud→Ouest→Nord\nCouleurs perceptuellement uniformes (pas de saut de teinte)',
'description': 'Orientation des pentes — utile pour distinguer structures des formes naturelles',
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 360,
},
'roughness': { 'roughness': {
'cmap': 'plasma', 'cmap': 'plasma',
'title': 'Rugosité Multi-Échelle (3m + 15m)', 'title': 'Rugosité Multi-Échelle (3m + 15m)',
@ -191,11 +154,19 @@ COLORMAPS = {
}, },
'wavelet': { 'wavelet': {
'cmap': 'cividis', 'cmap': 'cividis',
'title': 'Ondelette Mexican Hat (CWT multi-échelle)', 'title': 'Ondelette Mexican Hat + Gabor directionnelle (multi-échelle)',
'legend': 'Réponse de la transformée en ondelette\nÉchelles adaptées à la résolution\n\nClair = Structure détectée à cette échelle\nSombre = Pas de structure\n\nOptimisé pour formes circulaires:\ntumulus, enclos, fossés annulaires', 'legend': 'Réponse combinée Mexican Hat (circulaire) + Gabor (linéaire)\nÉchelles adaptées à la résolution\n4 orientations Gabor : 0°, 45°, 90°, 135°\n\nClair = Structure détectée\nSombre = Pas de structure',
'description': 'Transformée en ondelette 2D — excellente pour détecter structures circulaires', 'description': 'Mexican Hat pour tumulus/enclos + Gabor pour chemins/murs/fossés',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98), 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
}, },
'flow_acc': {
'cmap': 'YlGn',
'title': 'Accumulation d\'Écoulement (Flow Accumulation)',
'legend': 'Log10 du nombre de cellules amont\nJaune = Fort accumulation (fossé, chenal, drainage)\nVert clair = Faible accumulation\n\nDétection fossés et linéaires hydrologiques',
'description': 'Priority-flood + D8 — détecte fossés archéologiques et drainages',
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'percentile', 'vmax_pct': 98,
},
} }
# RGB entries (ortho/topo) are handled specially # RGB entries (ortho/topo) are handled specially
@ -842,8 +813,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
# Sort analysis files by archaeological priority # Sort analysis files by archaeological priority
order = ['mslrm', 'svf', 'negative_openness', order = ['mslrm', 'svf', 'negative_openness',
'positive_openness', 'aniso_open', 'sailore', 'hillshade_multi', 'positive_openness', 'aniso_open', 'sailore', 'hillshade_multi',
'lrm', 'tpi', 'slope', 'curvature', 'aspect', 'flow_acc', 'slope', 'roughness', 'wavelet']
'roughness', 'wavelet']
def sort_key(f): def sort_key(f):
name = f.stem.lower() name = f.stem.lower()

View File

@ -47,52 +47,8 @@ class TestSlope:
assert np.nanmax(data) <= 90 assert np.nanmax(data) <= 90
class TestAspect:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_aspect
result = generate_aspect(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None
assert result.exists()
def test_aspect_values_0_360(self, synthetic_dem, tmp_output_dir):
import rasterio
from lidar_pipeline.visualizations import generate_aspect
result = generate_aspect(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src:
data = src.read(1)
valid = data[~np.isnan(data)]
assert np.nanmin(valid) >= 0
assert np.nanmax(valid) <= 360
class TestCurvature:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_curvature
result = generate_curvature(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None
assert result.exists()
# --- GPU-accelerated visualizations --- # --- GPU-accelerated visualizations ---
class TestLRM:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_lrm
result = generate_lrm(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None
assert result.exists()
def test_lrm_has_positive_negative(self, synthetic_dem, tmp_output_dir):
import rasterio
from lidar_pipeline.visualizations import generate_lrm
result = generate_lrm(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src:
data = src.read(1)
# LRM should have both positive and negative values
assert np.nanmax(data) > 0
assert np.nanmin(data) < 0
class TestSVF: class TestSVF:
def test_generates_tif(self, synthetic_dem, tmp_output_dir): def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_svf from lidar_pipeline.visualizations import generate_svf
@ -133,15 +89,6 @@ class TestMSLRM:
assert result.exists() assert result.exists()
class TestTPI:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_tpi
result = generate_tpi(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None
assert result.exists()
class TestSAILORE: class TestSAILORE:
def test_generates_tif(self, synthetic_dem, tmp_output_dir): def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_sailore from lidar_pipeline.visualizations import generate_sailore
@ -167,14 +114,6 @@ class TestRoughness:
assert np.nanmin(data) >= 0 assert np.nanmin(data) >= 0
class TestAnomalies:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_anomalies
result = generate_anomalies(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None
assert result.exists()
class TestWavelet: class TestWavelet:
def test_generates_tif(self, synthetic_dem, tmp_output_dir): def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_wavelet from lidar_pipeline.visualizations import generate_wavelet
@ -183,50 +122,38 @@ class TestWavelet:
assert result.exists() assert result.exists()
class TestFlow: class TestFlowAccumulation:
def test_generates_tif(self, synthetic_dem, tmp_output_dir): def test_generates_tif(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_flow from lidar_pipeline.visualizations import generate_flow_accumulation
result = generate_flow(synthetic_dem, "test", tmp_output_dir, 5.0) result = generate_flow_accumulation(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None assert result is not None
assert result.exists() assert result.exists()
def test_flow_log_values(self, synthetic_dem, tmp_output_dir): def test_flow_log_values(self, synthetic_dem, tmp_output_dir):
import rasterio import rasterio
from lidar_pipeline.visualizations import generate_flow from lidar_pipeline.visualizations import generate_flow_accumulation
result = generate_flow(synthetic_dem, "test", tmp_output_dir, 5.0) result = generate_flow_accumulation(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src: with rasterio.open(result) as src:
data = src.read(1) data = src.read(1)
# log1p(x) >= 0 for x >= 0 # log10(x) >= 0 for x >= 1
valid = data[~np.isnan(data)] valid = data[~np.isnan(data)]
assert np.nanmin(valid) >= 0 assert np.nanmin(valid) >= 0
class TestLocalDominance: class TestRayTrace:
def test_generates_tif(self, synthetic_dem, tmp_output_dir): def test_rays_are_traced(self, synthetic_dem, tmp_output_dir):
from lidar_pipeline.visualizations import generate_local_dominance """Verify _ray_trace_horizons returns expected shapes."""
result = generate_local_dominance(synthetic_dem, "test", tmp_output_dir, 5.0) from lidar_pipeline.visualizations import _ray_trace_horizons, _prepare_dem_for_raycast
assert result is not None
assert result.exists()
assert result.suffix == ".tif"
def test_dominance_values_0_1(self, synthetic_dem, tmp_output_dir):
import rasterio import rasterio
from lidar_pipeline.visualizations import generate_local_dominance with rasterio.open(synthetic_dem) as src:
result = generate_local_dominance(synthetic_dem, "test", tmp_output_dir, 5.0) dem_np = src.read(1)
with rasterio.open(result) as src: rows, cols = dem_np.shape
data = src.read(1) # Create a simple filled DEM for testing
valid = data[~np.isnan(data)] import numpy as np
assert np.nanmin(valid) >= 0, "Local dominance should be >= 0" filled = np.nan_to_num(dem_np, nan=0)
assert np.nanmax(valid) <= 1, "Local dominance should be <= 1" # Test with numpy (no GPU)
pos, neg = _ray_trace_horizons(
def test_dominance_nan_mask_preserved(self, synthetic_dem, tmp_output_dir): filled, rows, cols, 5.0, n_dirs=4, max_dist=10, radii_m=[25, 50]
"""Check that NaN zones from original DEM are preserved.""" )
import rasterio assert pos.shape == (4, 2, rows, cols)
from lidar_pipeline.visualizations import generate_local_dominance assert neg.shape == (4, 2, rows, cols)
result = generate_local_dominance(synthetic_dem, "test", tmp_output_dir, 5.0)
# The synthetic DEM has no NaN, so this just verifies the output is valid
with rasterio.open(result) as src:
data = src.read(1)
# Shape should match input
assert data.shape[0] > 0
assert data.shape[1] > 0

File diff suppressed because it is too large Load Diff