Fix 12 bugs: D8 flow accumulation, PDF AVIF support, GPU memory leaks, dead code, SAILORE sigma scaling

This commit is contained in:
Antoine Jacquin
2026-05-31 15:13:11 +02:00
parent 30122c71ed
commit 266214fe3e
4 changed files with 207 additions and 465 deletions

View File

@ -157,7 +157,6 @@ def validate_laz(laz_file):
pass pass
# Fallback: try PDAL (handles COPC v1.1 that laspy can't read) # Fallback: try PDAL (handles COPC v1.1 that laspy can't read)
import subprocess
try: try:
result = subprocess.run( result = subprocess.run(
["pdal", "info", str(laz_file), "--summary"], ["pdal", "info", str(laz_file), "--summary"],

View File

@ -4,7 +4,7 @@ LidarArchaeoPipeline coordinates the full processing chain:
1. Ground classification (PDAL/SMRF) 1. Ground classification (PDAL/SMRF)
2. DTM generation 2. DTM generation
3. Visualization generation (17 products) 3. Visualization generation (17 products)
4. Rendering (WebP + PDF report) 4. Rendering (AVIF/WebP conversion)
""" """
import logging import logging
@ -61,9 +61,9 @@ from .visualizations import (
generate_lrm, generate_openness, generate_lrm, generate_openness,
generate_mslrm, generate_tpi, generate_sailore, generate_mslrm, generate_tpi, generate_sailore,
generate_roughness, generate_wavelet, generate_roughness, generate_wavelet,
generate_svf, generate_aniso_open, generate_svf, generate_aniso_open, generate_paths,
) )
from .gpu import gpu_cleanup, num_gpus from .gpu import gpu_cleanup, num_gpus, safe_gpu_call
from .ign import generate_ign_overlay from .ign import generate_ign_overlay
from .rendering import tif_to_png from .rendering import tif_to_png
@ -85,6 +85,7 @@ VIZ_STEPS = [
('roughness', generate_roughness), ('roughness', generate_roughness),
('svf', generate_svf), ('svf', generate_svf),
('aniso_open', generate_aniso_open), ('aniso_open', generate_aniso_open),
('paths', generate_paths),
('wavelet', generate_wavelet), ('wavelet', generate_wavelet),
('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,
@ -278,10 +279,11 @@ class LidarArchaeoPipeline:
t0 = time.time() t0 = time.time()
try: try:
# IGN overlays don't use SharedDEM (they download external data) # IGN overlays don't use SharedDEM (they download external data)
# Non-IGN visualizations use safe_gpu_call for GPU→CPU fallback
if name in ('ortho', 'topo'): if name in ('ortho', 'topo'):
result = func(dtm_file, basename, file_vis_dir, resolution) result = func(dtm_file, basename, file_vis_dir, resolution)
else: else:
result = func(dtm_file, basename, file_vis_dir, resolution, shared=shared) result = safe_gpu_call(func, dtm_file, basename, file_vis_dir, resolution, shared=shared)
vis_results[name] = result vis_results[name] = result
elapsed = time.time() - t0 elapsed = time.time() - t0
if result: if result:
@ -524,10 +526,6 @@ class LidarArchaeoPipeline:
try: try:
if self.temp_dir.exists(): if self.temp_dir.exists():
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)
# Also clean up any subdirectories inside temp/
temp_base = self.output_dir / "temp"
if temp_base.exists():
shutil.rmtree(temp_base)
logger.info(" ✓ Fichiers temporaires supprimés") logger.info(" ✓ Fichiers temporaires supprimés")
except Exception as e: except Exception as e:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}") logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")

View File

@ -80,57 +80,64 @@ _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 ===
# Roma (Crameri): perceptually uniform, CVD-friendly, dark center → near-zero values visible # Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat
# Falls back to RdBu_r if cmcrameri unavailable
'curvature': { 'curvature': {
'cmap': 'roma' if HAS_CMCRAmeri else 'RdBu_r', 'cmap': 'bwr',
'title': 'Courbure (Convexité/Concavité du terrain)', '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)', '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', 'description': 'Détecte les ruptures de pente — utile pour bords de terrasses et levées',
'vmin_mode': 'symmetric', 'sym_pct': (5, 95), 'vmin_mode': 'symmetric', 'sym_pct': (5, 95),
}, },
'mslrm': { 'mslrm': {
'cmap': 'roma' if HAS_CMCRAmeri else 'RdBu_r', 'cmap': 'seismic',
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)', 'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
'legend': 'Relief combiné multi-échelles\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nLRM = 1 échelle (15m)\nMSRM = échelles combinées pondérées\nDétecte du micro au macro', 'legend': 'Relief combiné multi-échelles\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nLRM = 1 échelle (15m)\nMSRM = échelles combinées pondérées\nDétecte du micro au macro',
'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': { 'lrm': {
'cmap': 'roma' if HAS_CMCRAmeri else 'RdBu_r', 'cmap': 'seismic',
'title': 'LRM - Local Relief Model (échelle unique 15m)', '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', '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', 'description': 'Micro-relief à 15m seulement — voir MSRM pour toutes les échelles',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98), 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
}, },
'tpi': { 'tpi': {
'cmap': 'roma' if HAS_CMCRAmeri else 'RdBu_r', 'cmap': 'seismic',
'title': 'TPI - Topographic Position Index (4 échelles)', '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', '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', 'description': 'Identifie la position topographique — utile pour repérer crêtes vs vallées à grande échelle',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98), 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
}, },
'sailore': { 'sailore': {
'cmap': 'roma' if HAS_CMCRAmeri else 'RdBu_r', 'cmap': 'seismic',
'title': 'SAILORE - LRM Auto-Adaptatif', 'title': 'SAILORE - LRM Auto-Adaptatif',
'legend': 'Relief local adaptatif\nRouge = Surélévation | Bleu = Dépression\n\nLRM = noyau fixe 15m\nMSRM = 5 noyaux fixes\nSAILORE = noyau adapté à la pente\nPlat=grand noyau | Pente=petit noyau', 'legend': 'Relief local adaptatif\nRouge = Surélévation | Bleu = Dépression\n\nLRM = noyau fixe 15m\nMSRM = 5 noyaux fixes\nSAILORE = noyau adapté à la pente\nPlat=grand noyau | Pente=petit noyau',
'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau', 'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98), 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
}, },
'aniso_open': { 'aniso_open': {
'cmap': 'roma' if HAS_CMCRAmeri else 'RdBu_r', 'cmap': 'seismic',
'title': 'Openness Anisotropique (pondération directionnelle)', 'title': 'Openness Anisotropique (pondération directionnelle)',
'legend': 'Openness positive - négative pondérée (degrés)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nPondère les directions NW-SE et NE-SW davantage', 'legend': 'Openness positive - négative pondérée (degrés)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nPondère les directions NW-SE et NE-SW davantage',
'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),
}, },
# === Famille OUVERTURE : clair=ouvert, sombre=fermé === '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 ===
'positive_openness': { 'positive_openness': {
'cmap': 'YlOrBr', 'cmap': 'YlOrBr',
'title': 'Openness Positive (ouverture vers le haut)', 'title': 'Openness Positive (ouverture vers le haut)',
'legend': 'Angle d\'ouverture vers le haut (deg)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)', 'legend': 'Angle d\'ouverture vers le ciel (deg)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)',
'description': 'Ray-tracing 8 directions — complémentaire de la négative pour détecter crêtes', 'description': 'Ray-tracing 8 directions — complémentaire de la négative pour détecter crêtes',
'vmin_mode': 'percentile', 'vmin_pct': 10, 'vmin_mode': 'percentile', 'vmin_pct': 2,
'vmax_mode': 'percentile', 'vmax_pct': 98, 'vmax_mode': 'percentile', 'vmax_pct': 98,
}, },
'negative_openness': { 'negative_openness': {
@ -138,14 +145,14 @@ COLORMAPS = {
'title': 'Openness Negative (ouverture vers le bas)', 'title': 'Openness Negative (ouverture vers le bas)',
'legend': 'Angle d\'ouverture vers le bas (deg)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nMeilleur détecteur de cavités et dolines', 'legend': 'Angle d\'ouverture vers le bas (deg)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nMeilleur détecteur de cavités et dolines',
'description': 'Ray-tracing 8 directions — détecte fossés, dolines, souterrains', 'description': 'Ray-tracing 8 directions — détecte fossés, dolines, souterrains',
'vmin_mode': 'percentile', 'vmin_pct': 10, 'vmin_mode': 'percentile', 'vmin_pct': 2,
'vmax_mode': 'percentile', 'vmax_pct': 98, 'vmax_mode': 'percentile', 'vmax_pct': 98,
}, },
'svf': { 'svf': {
'cmap': 'bone_r', 'cmap': 'hot_r',
'title': 'Sky-View Factor (fraction de ciel visible)', 'title': 'Sky-View Factor (fraction de ciel visible)',
'legend': 'Proportion de ciel visible depuis chaque point\nClair = Ciel dégagé (sommet, plateau, levée)\nSombre = Ciel masqué (vallée, fossé, tranchée)\nContraste adapté aux valeurs réelles (percentiles 2-98)', 'legend': 'Proportion de ciel visible depuis chaque point\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nLes fossés ressortent en vif — excellent pour structures linéaires',
'description': 'Détection de micro-relief — fossés sombres, levées claires, complémentaire de l\'openness', 'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre',
'vmin_mode': 'percentile', 'vmin_pct': 2, 'vmin_mode': 'percentile', 'vmin_pct': 2,
'vmax_mode': 'percentile', 'vmax_pct': 98, 'vmax_mode': 'percentile', 'vmax_pct': 98,
}, },
@ -155,16 +162,16 @@ COLORMAPS = {
'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 6 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': 'fixed', 'vmin_val': 0, 'vmin_mode': 'percentile', 'vmin_pct': 1,
'vmax_mode': 'fixed', 'vmax_val': 1, 'vmax_mode': 'percentile', 'vmax_pct': 99,
}, },
'slope': { 'slope': {
'cmap': 'inferno', 'cmap': 'inferno',
'title': 'Pente (Inclinaison du terrain)', 'title': 'Pente (Inclinaison du terrain)',
'legend': 'Inclinaison en degrés\nMin: {vmin:.1f}° | Max: {vmax:.1f}°\nClair = Forte pente | Sombre = Terrain plat', 'legend': 'Inclinaison en degrés\nMin: {vmin:.1f}° | Max: {vmax:.1f}°\nJaune = Forte pente | Violet foncé = Terrain plat',
'description': 'Murs, talus et bords ressortent en clair — terrain plat en sombre', 'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre',
'vmin_mode': 'fixed', 'vmin_val': 0, 'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'percentile', 'vmax_pct': 95, 'vmax_mode': 'percentile', 'vmax_pct': 97,
}, },
'aspect': { 'aspect': {
'cmap': 'twilight', 'cmap': 'twilight',
@ -175,12 +182,12 @@ COLORMAPS = {
'vmax_mode': 'fixed', 'vmax_val': 360, 'vmax_mode': 'fixed', 'vmax_val': 360,
}, },
'roughness': { 'roughness': {
'cmap': 'magma', 'cmap': 'plasma',
'title': 'Rugosité Multi-Échelle (3m + 15m)', 'title': 'Rugosité Multi-Échelle (3m + 15m)',
'legend': 'Irrégularité du terrain combinée fine + large\nSombre = Surface lisse (route, mur, sol plat)\nClair = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)', 'legend': 'Irrégularité du terrain combinée fine + large\nViolet foncé = Surface lisse (route, mur, sol plat)\nJaune vif = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)',
'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses', 'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses',
'vmin_mode': 'fixed', 'vmin_val': 0, 'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'percentile', 'vmax_pct': 97, 'vmax_mode': 'percentile', 'vmax_pct': 98,
}, },
'wavelet': { 'wavelet': {
'cmap': 'cividis', 'cmap': 'cividis',
@ -345,12 +352,15 @@ def _nice_scale(extent_m):
Returns (scale_m, label) where label is like '100 m' or '500 m' or '1 km'. Returns (scale_m, label) where label is like '100 m' or '500 m' or '1 km'.
""" """
nice_scales = [50, 100, 200, 500, 1000, 2000, 5000, 10000] nice_scales = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]
# Pick the largest scale <= 20% of extent # Pick the largest scale that fits within 20% of extent
max_scale = extent_m * 0.20
chosen = nice_scales[0] chosen = nice_scales[0]
for s in nice_scales: for s in nice_scales:
if s <= extent_m * 0.20: if s <= max_scale:
chosen = s chosen = s
else:
break
if chosen >= 1000: if chosen >= 1000:
return chosen, f"{chosen // 1000} km" return chosen, f"{chosen // 1000} km"
return chosen, f"{chosen} m" return chosen, f"{chosen} m"
@ -496,10 +506,11 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
im = ax.imshow(data, cmap=cmap, aspect='equal', origin='upper', im = ax.imshow(data, cmap=cmap, aspect='equal', origin='upper',
interpolation='bilinear') interpolation='bilinear')
ax.set_title(f"{title}", fontsize=14, fontweight='bold', pad=8) ax.set_title(f"{title}", fontsize=14, fontweight='bold', pad=10)
ax.text(0.5, 1.01, description, transform=ax.transAxes, if description:
fontsize=10, fontstyle='italic', color='#555555', ax.text(0.5, 1.04, description, transform=ax.transAxes,
ha='center', va='bottom') fontsize=10, fontstyle='italic', color='#555555',
ha='center', va='bottom')
# Colorbar/legend area — full height alongside data # Colorbar/legend area — full height alongside data
cbar_left = data_left + data_width_frac + 0.02 cbar_left = data_left + data_width_frac + 0.02
@ -569,34 +580,35 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
spine.set_color('black') spine.set_color('black')
spine.set_linewidth(0.8) spine.set_linewidth(0.8)
# North arrow — compass rose style, inside the data area (top-right corner) # North arrow — compass rose in bottom-right corner of data area
# Semi-transparent background for readability over any data # Semi-transparent background for readability over any data
north_ax = fig.add_axes([data_left + data_width_frac - 0.06, north_ax = fig.add_axes([data_left + data_width_frac - 0.07,
data_bottom + data_height_frac - 0.12, data_bottom + 0.01,
0.05, 0.10], 0.06, 0.14],
facecolor='none') facecolor='none')
north_ax.set_xlim(-1.2, 1.2) north_ax.set_xlim(-1.5, 1.5)
north_ax.set_ylim(-0.3, 1.5) north_ax.set_ylim(-1.5, 1.5)
north_ax.axis('off') north_ax.axis('off')
north_ax.set_aspect('equal') north_ax.set_aspect('equal')
# Compass rose centered at (0, 0) — all 4 cardinals equidistant from center
# Semi-transparent white background circle # Semi-transparent white background circle
circle_bg = plt.Circle((0, 0.5), 0.85, facecolor='white', edgecolor='#888888', circle_bg = plt.Circle((0, 0), 1.0, facecolor='white', edgecolor='#888888',
linewidth=0.5, alpha=0.7, zorder=1) linewidth=0.5, alpha=0.7, zorder=1)
north_ax.add_patch(circle_bg) north_ax.add_patch(circle_bg)
# N arrow # N arrow (pointing up = North)
north_ax.annotate('N', xy=(0, 1.3), fontsize=9, fontweight='bold', north_ax.annotate('N', xy=(0, 1.35), fontsize=9, fontweight='bold',
ha='center', va='bottom', color='#b22222', zorder=10) ha='center', va='bottom', color='#b22222', zorder=10)
north_ax.plot([0, 0], [0.0, 1.0], color='#b22222', linewidth=2.0, zorder=10) north_ax.plot([0, 0], [-0.5, 1.0], color='#b22222', linewidth=2.0, zorder=10)
north_ax.add_patch(MplPolygon([[0, 0.3], [-0.2, 0.7], [0, 1.0], [0.2, 0.7]], north_ax.add_patch(MplPolygon([[0, 0.5], [-0.2, 0.7], [0, 1.0], [0.2, 0.7]],
closed=True, facecolor='#b22222', edgecolor='#b22222', zorder=9)) closed=True, facecolor='#b22222', edgecolor='#b22222', zorder=9))
# Cardinal ticks # Cardinal ticks — all centered at (0, 0)
for angle, label in [(90, ''), (0, 'E'), (180, 'O'), (270, 'S')]: for angle, label in [(90, 'N'), (0, 'E'), (180, 'O'), (270, 'S')]:
rad = np.radians(angle) rad = np.radians(angle)
north_ax.plot([0.85*np.cos(rad), 1.05*np.cos(rad)], north_ax.plot([1.0*np.cos(rad), 1.2*np.cos(rad)],
[0.85*np.sin(rad), 1.05*np.sin(rad)], [1.0*np.sin(rad), 1.2*np.sin(rad)],
color='#555555', linewidth=0.8, zorder=5) color='#555555', linewidth=0.8, zorder=5)
if label: if label:
north_ax.text(1.15*np.cos(rad), 1.15*np.sin(rad), label, north_ax.text(1.35*np.cos(rad), 1.35*np.sin(rad), label,
fontsize=6, ha='center', va='center', color='#555555', zorder=5) fontsize=6, ha='center', va='center', color='#555555', zorder=5)
# Bottom info bar — enriched with source, method, date # Bottom info bar — enriched with source, method, date
@ -621,7 +633,9 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
else: else:
line1_parts.append(f"X: {min_x:.0f}–{max_x:.0f} Y: {min_y:.0f}–{max_y:.0f}") line1_parts.append(f"X: {min_x:.0f}–{max_x:.0f} Y: {min_y:.0f}–{max_y:.0f}")
line1_parts.append(f"EPSG:2154") line1_parts.append(f"EPSG:2154")
line1_parts.append(f"Res: {resolution}m/px") # Round resolution to avoid ugly decimals like 0.499999959
res_display = round(resolution, 2) if resolution < 1 else round(resolution, 1)
line1_parts.append(f"Res: {res_display}m/px")
line1_parts.append(f"Emprise: {extent_km_x:.1f}×{extent_km_y:.1f}km") line1_parts.append(f"Emprise: {extent_km_x:.1f}×{extent_km_y:.1f}km")
if not is_rgb: if not is_rgb:
line1_parts.append(f"Alt: {alt_min:.1f}–{alt_max:.1f}m") line1_parts.append(f"Alt: {alt_min:.1f}–{alt_max:.1f}m")
@ -793,15 +807,24 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
logger.info(f" → Génération rapport PDF A3: {pdf_file.name}") logger.info(f" → Génération rapport PDF A3: {pdf_file.name}")
t0 = time.time() t0 = time.time()
# Look for WebPs in per-file subdirectory first, then fallback to main dir # Look for images in per-file subdirectory first, then fallback to main dir
file_vis_dir = vis_dir / basename file_vis_dir = vis_dir / basename
png_files = []
if file_vis_dir.exists(): if file_vis_dir.exists():
png_files = sorted(file_vis_dir.glob("*.webp")) png_files = sorted(file_vis_dir.glob("*.avif")) + sorted(file_vis_dir.glob("*.webp"))
else: else:
png_files = sorted(vis_dir.glob(f"{basename}_*.webp")) png_files = sorted(vis_dir.glob(f"{basename}_*.avif")) + sorted(vis_dir.glob(f"{basename}_*.webp"))
if not png_files: # Deduplicate in case both formats exist
seen = set()
unique_files = []
for f in png_files:
if f not in seen:
seen.add(f)
unique_files.append(f)
if not unique_files:
logger.warning(f" ✗ Aucune image trouvée pour {basename}") logger.warning(f" ✗ Aucune image trouvée pour {basename}")
return None return None
png_files = unique_files
# Categorize # Categorize
situ_files = [] situ_files = []

View File

@ -15,19 +15,41 @@ from pathlib import Path
import numpy as np import numpy as np
import rasterio import rasterio
from scipy.ndimage import generic_filter
from scipy.stats import binned_statistic_2d
from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter, xp_maximum_filter, gpu_cleanup from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter, xp_maximum_filter, gpu_cleanup
from . import gpu as _gpu_mod
logger = logging.getLogger("lidar") logger = logging.getLogger("lidar")
# Use CuPy array module when available # CuPy module reference — lazily imported on first GPU use.
if HAS_GPU: # If disable_gpu() is called at runtime, HAS_GPU becomes False
import cupy as cp # and xp delegates to numpy instead.
xp = cp _cp = None
else:
xp = np
class _XPProxy:
"""Proxy that delegates array operations to cupy or numpy.
Checks HAS_GPU on every attribute access so that disable_gpu()
(called on CUDA errors) takes effect immediately, without needing
to change every call site in visualizations.py.
"""
def __getattr__(self, name):
global _cp
from . import gpu as _gpu_mod
if _gpu_mod.HAS_GPU:
if _cp is None:
try:
import cupy
_cp = cupy
except ImportError:
pass
if _cp is not None:
return getattr(_cp, name)
return getattr(np, name)
xp = _XPProxy()
class SharedDEM: class SharedDEM:
@ -118,14 +140,14 @@ class SharedDEM:
@property @property
def filled_gpu(self): def filled_gpu(self):
"""Lazy GPU copy of the filled DEM.""" """Lazy GPU copy of the filled DEM."""
if self._filled_gpu is None and HAS_GPU: if self._filled_gpu is None and _gpu_mod.HAS_GPU:
self._filled_gpu = to_gpu(self.filled) self._filled_gpu = to_gpu(self.filled)
return self._filled_gpu return self._filled_gpu
@property @property
def dem_gpu(self): def dem_gpu(self):
"""Lazy GPU copy of the DEM.""" """Lazy GPU copy of the DEM."""
if self._dem_gpu is None and HAS_GPU: if self._dem_gpu is None and _gpu_mod.HAS_GPU:
self._dem_gpu = to_gpu(self.dem_np) self._dem_gpu = to_gpu(self.dem_np)
return self._dem_gpu return self._dem_gpu
@ -134,10 +156,14 @@ def _filter_nanaware_from_filled(shared, filter_func, *args, **kwargs):
"""Apply filter on pre-filled DEM data (skips expensive _fill_nans). """Apply filter on pre-filled DEM data (skips expensive _fill_nans).
Uses the SharedDEM.filled array directly, then restores NaN mask. Uses the SharedDEM.filled array directly, then restores NaN mask.
If GPU is available, uses the lazy GPU copy to avoid CPU↔GPU transfers. If GPU is available, reuses the lazy GPU copy to avoid redundant transfers.
""" """
if HAS_GPU and shared.filled_gpu is not None: if _gpu_mod.HAS_GPU:
filled_gpu = to_gpu(shared.filled) filled_gpu = shared.filled_gpu
else:
filled_gpu = None
if filled_gpu is not None:
result_gpu = filter_func(filled_gpu, *args, **kwargs) result_gpu = filter_func(filled_gpu, *args, **kwargs)
result = to_cpu(result_gpu) result = to_cpu(result_gpu)
gpu_cleanup() gpu_cleanup()
@ -227,15 +253,16 @@ def _filter_nanaware(arr, filter_func, *args, use_gpu=True, **kwargs):
Returns: Returns:
Filtered array with original NaN positions preserved. Filtered array with original NaN positions preserved.
""" """
is_gpu_arr = HAS_GPU and isinstance(arr, cp.ndarray) 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 arr_np = to_cpu(arr) if is_gpu_arr else arr
filled, nan_mask = _fill_nans(arr_np) filled, nan_mask = _fill_nans(arr_np)
if use_gpu and HAS_GPU: if use_gpu and _gpu_mod.HAS_GPU:
filled_gpu = to_gpu(filled) filled_gpu = to_gpu(filled)
result_gpu = filter_func(filled_gpu, *args, **kwargs) result_gpu = filter_func(filled_gpu, *args, **kwargs)
result = to_cpu(result_gpu) result = to_cpu(result_gpu)
gpu_cleanup()
else: else:
result = filter_func(filled, *args, **kwargs) result = filter_func(filled, *args, **kwargs)
@ -254,7 +281,7 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
Applies percentile normalization and gamma correction to restore Applies percentile normalization and gamma correction to restore
contrast lost by averaging multiple azimuths. contrast lost by averaging multiple azimuths.
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Hillshade multidirectionnel{gpu_tag}...") logger.info(f" → Hillshade multidirectionnel{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_hillshade_multi.tif" output = vis_dir / f"{basename}_hillshade_multi.tif"
@ -264,9 +291,9 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
transform = shared.transform transform = shared.transform
crs = shared.crs crs = shared.crs
dem = to_gpu(shared.dem_np) dem = to_gpu(shared.dem_np)
dy = to_gpu(shared.dy) if HAS_GPU else shared.dy dy = to_gpu(shared.dy) if _gpu_mod.HAS_GPU else shared.dy
dx = to_gpu(shared.dx) if HAS_GPU else shared.dx dx = to_gpu(shared.dx) if _gpu_mod.HAS_GPU else shared.dx
slope = to_gpu(shared.slope_rad) if HAS_GPU else shared.slope_rad slope = to_gpu(shared.slope_rad) if _gpu_mod.HAS_GPU else shared.slope_rad
aspect = xp.arctan2(dy, dx) aspect = xp.arctan2(dy, dx)
sin_slope = xp.sin(slope) sin_slope = xp.sin(slope)
cos_slope = xp.cos(slope) cos_slope = xp.cos(slope)
@ -299,7 +326,7 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
# Contrast enhancement: percentile stretch + gamma # Contrast enhancement: percentile stretch + gamma
combined_np = to_cpu(combined) 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) nan_mask = shared.nan_mask if shared else np.isnan(dem_np)
valid = combined_np[~nan_mask] valid = combined_np[~nan_mask]
if len(valid) > 0: if len(valid) > 0:
p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98) p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98)
@ -319,7 +346,7 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution, shared=None):
def generate_slope(dem_file, basename, vis_dir, resolution, shared=None): def generate_slope(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate slope map (degrees) — GPU if available.""" """Generate slope map (degrees) — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Pente (Slope){gpu_tag}...") logger.info(f" → Pente (Slope){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_slope.tif" output = vis_dir / f"{basename}_slope.tif"
@ -330,7 +357,7 @@ def generate_slope(dem_file, basename, vis_dir, resolution, shared=None):
crs = shared.crs crs = shared.crs
slope = shared.slope_deg slope = shared.slope_deg
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
if HAS_GPU: if _gpu_mod.HAS_GPU:
slope = to_gpu(slope) slope = to_gpu(slope)
else: else:
dem_np, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
@ -338,7 +365,7 @@ def generate_slope(dem_file, basename, vis_dir, resolution, shared=None):
dy, dx = xp.gradient(dem) dy, dx = xp.gradient(dem)
slope = xp.arctan(xp.sqrt(dx**2 + dy**2)) * 180 / xp.pi slope = xp.arctan(xp.sqrt(dx**2 + dy**2)) * 180 / xp.pi
nan_mask = np.isnan(dem_np) nan_mask = np.isnan(dem_np)
_save_tif(output, to_cpu(slope) if HAS_GPU else slope, transform, crs, nan_mask=nan_mask) _save_tif(output, to_cpu(slope) if _gpu_mod.HAS_GPU else slope, transform, crs, nan_mask=nan_mask)
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){gpu_tag}") logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
@ -348,7 +375,7 @@ def generate_slope(dem_file, basename, vis_dir, resolution, shared=None):
def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None): def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate aspect (slope orientation) map — GPU if available.""" """Generate aspect (slope orientation) map — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Aspect (Orientation){gpu_tag}...") logger.info(f" → Aspect (Orientation){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_aspect.tif" output = vis_dir / f"{basename}_aspect.tif"
@ -359,7 +386,7 @@ def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None):
crs = shared.crs crs = shared.crs
aspect = shared.aspect aspect = shared.aspect
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
if HAS_GPU: if _gpu_mod.HAS_GPU:
aspect = to_gpu(aspect) aspect = to_gpu(aspect)
else: else:
dem_np, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
@ -368,7 +395,7 @@ def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None):
aspect = xp.arctan2(dy, dx) * 180 / xp.pi aspect = xp.arctan2(dy, dx) * 180 / xp.pi
aspect = xp.mod(aspect, 360) aspect = xp.mod(aspect, 360)
nan_mask = np.isnan(dem_np) nan_mask = np.isnan(dem_np)
_save_tif(output, to_cpu(aspect) if HAS_GPU else aspect, transform, crs, nan_mask=nan_mask) _save_tif(output, to_cpu(aspect) if _gpu_mod.HAS_GPU else aspect, transform, crs, nan_mask=nan_mask)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){gpu_tag}") logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
@ -378,7 +405,7 @@ def generate_aspect(dem_file, basename, vis_dir, resolution, shared=None):
def generate_curvature(dem_file, basename, vis_dir, resolution, shared=None): def generate_curvature(dem_file, basename, vis_dir, resolution, shared=None):
"""Generate curvature (terrain concavity/convexity) map — GPU if available.""" """Generate curvature (terrain concavity/convexity) map — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Courbure (Curvature){gpu_tag}...") logger.info(f" → Courbure (Curvature){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_curvature.tif" output = vis_dir / f"{basename}_curvature.tif"
@ -390,7 +417,7 @@ def generate_curvature(dem_file, basename, vis_dir, resolution, shared=None):
dx = shared.dx dx = shared.dx
dy = shared.dy dy = shared.dy
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
if HAS_GPU: if _gpu_mod.HAS_GPU:
dx = to_gpu(dx) dx = to_gpu(dx)
dy = to_gpu(dy) dy = to_gpu(dy)
else: else:
@ -420,7 +447,7 @@ def generate_lrm(dem_file, basename, vis_dir, resolution, shared=None):
Kernel sigma adapts to resolution: finer kernel at higher resolution 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. to capture micro-relief details. At 0.5m/px: 15m, at 0.2m/px: ~5m.
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Local Relief Model{gpu_tag}...") logger.info(f" → Local Relief Model{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_lrm.tif" output = vis_dir / f"{basename}_lrm.tif"
@ -454,7 +481,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
angle in each direction, then SVF = (1/N) * sum(cos²(horizon_angle)). angle in each direction, then SVF = (1/N) * sum(cos²(horizon_angle)).
Valleys/crevices have low SVF (obstructed sky), ridges/peaks have high SVF. Valleys/crevices have low SVF (obstructed sky), ridges/peaks have high SVF.
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Sky-View Factor (ray-tracing){gpu_tag}...") logger.info(f" → Sky-View Factor (ray-tracing){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_svf.tif" output = vis_dir / f"{basename}_svf.tif"
@ -466,7 +493,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
dem_np = shared.dem_np dem_np = shared.dem_np
rows, cols = dem_np.shape rows, cols = dem_np.shape
res = resolution res = resolution
dem = to_gpu(shared.filled) if HAS_GPU else shared.filled dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
else: else:
dem_np, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
@ -474,7 +501,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
res = resolution res = resolution
nan_mask = np.isnan(dem_np) nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np) filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if HAS_GPU else filled dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled
n_dirs = 16 n_dirs = 16
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False) angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
@ -509,7 +536,8 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
horizon = xp.where(xp.isnan(angle), horizon, horizon = xp.where(xp.isnan(angle), horizon,
xp.maximum(horizon, xp.nan_to_num(angle, nan=0))) xp.maximum(horizon, xp.nan_to_num(angle, nan=0)))
svf += xp.cos(xp.pi / 2 - horizon) ** 2 # SVF uses cos²(horizon angle) — fraction of visible sky
svf += xp.cos(horizon) ** 2
svf /= n_dirs svf /= n_dirs
svf_np = to_cpu(svf).astype(np.float32) svf_np = to_cpu(svf).astype(np.float32)
@ -532,7 +560,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
Ray radius adapts to resolution: 100m for better detection of large enclosures. Ray radius adapts to resolution: 100m for better detection of large enclosures.
""" """
name = "positive_openness" if positive else "negative_openness" name = "positive_openness" if positive else "negative_openness"
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → {name.replace('_', ' ').title()} (ray-tracing){gpu_tag}...") logger.info(f" → {name.replace('_', ' ').title()} (ray-tracing){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_{name}.tif" output = vis_dir / f"{basename}_{name}.tif"
@ -544,7 +572,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
dem_np = shared.dem_np dem_np = shared.dem_np
rows, cols = dem_np.shape rows, cols = dem_np.shape
res = resolution res = resolution
dem = to_gpu(shared.filled) if HAS_GPU else shared.filled dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
else: else:
dem_np, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
@ -552,7 +580,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
res = resolution res = resolution
nan_mask = np.isnan(dem_np) nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np) filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if HAS_GPU else filled dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled
n_dirs = 8 n_dirs = 8
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False) angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
@ -597,71 +625,13 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
return None return None
def generate_local_dominance(dem_file, basename, vis_dir, resolution, shared=None,
radius=15, pmin=2, pmax=98):
"""Local Dominance — proportion of neighborhood below center point.
LD = (dem - local_min) / (local_max - local_min + epsilon)
High values = locally dominant (peak, ridge)
Low values = locally recessed (valley, pit)
Uses minimum/maximum filters on the filled DEM, then restores NaN mask.
Complements openness by measuring local height position rather than angular extent.
"""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Dominance Locale (rayon {radius}m){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_local_dominance.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
nan_mask = shared.nan_mask
dem_np = shared.dem_np
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
radius_px = max(1, int(radius / resolution))
if radius_px % 2 == 0:
radius_px += 1
local_min = _filter_nanaware_from_filled(
shared, xp_minimum_filter, size=radius_px
) if shared else _filter_nanaware(
dem_np, xp_minimum_filter, size=radius_px
)
local_max_data = _filter_nanaware_from_filled(
shared, xp_maximum_filter, size=radius_px
) if shared else _filter_nanaware(
dem_np, xp_maximum_filter, size=radius_px
)
# Local dominance ratio
epsilon = 0.01 # Avoid division by zero on flat terrain
local_range = local_max_data - local_min + epsilon
dominance = (dem_np - local_min) / local_range
dominance = np.clip(dominance, 0, 1)
dominance[nan_mask] = np.nan
_save_tif(output, dominance.astype(np.float32), transform, crs, nan_mask=nan_mask)
logger.info(f" ✓ Dominance Locale terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur local_dominance: {e}", exc_info=True)
return None
def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=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). """Multi-Scale Relief Model (MSRM) - LRM at adaptive scales combined (GPU if available).
Scales adapt to resolution. Std normalization per scale. Scales adapt to resolution. Std normalization per scale.
Weighted combination favoring archaeologically relevant scales (5-25m). Weighted combination favoring archaeologically relevant scales (5-25m).
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Multi-Scale Relief Model (MSRM){gpu_tag}...") logger.info(f" → Multi-Scale Relief Model (MSRM){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_mslrm.tif" output = vis_dir / f"{basename}_mslrm.tif"
@ -731,7 +701,7 @@ def generate_tpi(dem_file, basename, vis_dir, resolution, shared=None):
Computed at 4 scales with std normalization and weighted combination. Computed at 4 scales with std normalization and weighted combination.
Weights favor fine and medium scales (archaeologically relevant). Weights favor fine and medium scales (archaeologically relevant).
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → TPI multi-échelle{gpu_tag}...") logger.info(f" → TPI multi-échelle{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_tpi.tif" output = vis_dir / f"{basename}_tpi.tif"
@ -796,7 +766,7 @@ def generate_sailore(dem_file, basename, vis_dir, resolution, shared=None):
Kernel size adapts to local slope: flat areas get larger kernels, Kernel size adapts to local slope: flat areas get larger kernels,
steep areas get smaller kernels. Scales adapt to resolution. steep areas get smaller kernels. Scales adapt to resolution.
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → SAILORE (LRM adaptatif){gpu_tag}...") logger.info(f" → SAILORE (LRM adaptatif){gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_sailore.tif" output = vis_dir / f"{basename}_sailore.tif"
@ -816,10 +786,9 @@ def generate_sailore(dem_file, basename, vis_dir, resolution, shared=None):
slope_deg = np.degrees(slope) slope_deg = np.degrees(slope)
slope_deg[nan_mask] = np.nan slope_deg[nan_mask] = np.nan
# Adaptive scales: finer at higher resolution # Fixed physical scales (independent of resolution)
sigma_min_m = max(1.0, 2.0 * 0.5 / resolution) # 2m at 0.5, ~5m at 0.2 sigma_min_m = 2.0 # 2m — fine detail
sigma_mid_m = max(5.0, 13.5 * 0.5 / resolution) # 13.5m at 0.5, ~33m at 0.2 sigma_max_m = 25.0 # 25m — broad relief
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_min = sigma_min_m / resolution
sigma_max = sigma_max_m / resolution sigma_max = sigma_max_m / resolution
sigma_mid = (sigma_min + sigma_max) / 2 sigma_mid = (sigma_min + sigma_max) / 2
@ -870,7 +839,7 @@ def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
Combines fine (3m) and broad (15m) roughness for better detection Combines fine (3m) and broad (15m) roughness for better detection
of archaeological features at multiple scales. of archaeological features at multiple scales.
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Rugosité de surface{gpu_tag}...") logger.info(f" → Rugosité de surface{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_roughness.tif" output = vis_dir / f"{basename}_roughness.tif"
@ -924,7 +893,6 @@ def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
roughness = 0.7 * roughness_fine / fine_std + 0.3 * roughness_broad / broad_std roughness = 0.7 * roughness_fine / fine_std + 0.3 * roughness_broad / broad_std
roughness[nan_mask] = np.nan roughness[nan_mask] = np.nan
roughness = to_cpu(roughness)
_save_tif(output, roughness, transform, crs) _save_tif(output, roughness, transform, crs)
logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s){gpu_tag}") logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
@ -933,90 +901,6 @@ def generate_roughness(dem_file, basename, vis_dir, resolution, shared=None):
return None return None
# ============================================================
# Anomalies
# ============================================================
def generate_anomalies(dem_file, basename, vis_dir, resolution, shared=None):
"""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()
output = vis_dir / f"{basename}_anomalies.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)
# 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
lrm_stack.append(lrm_norm.astype(np.float32))
# 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
# 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_z = _filter_nanaware_from_filled(shared, xp_uniform_filter, size=window)
else:
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
_save_tif(output, anomaly_score.astype(np.float32), transform, crs)
logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur anomalies: {e}", exc_info=True)
return None
# ============================================================ # ============================================================
# Wavelet # Wavelet
# ============================================================ # ============================================================
@ -1032,7 +916,7 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
Uses std normalization per scale and weighted combination Uses std normalization per scale and weighted combination
with emphasis on archaeologically relevant scales (2-50m). with emphasis on archaeologically relevant scales (2-50m).
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...") logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_wavelet.tif" output = vis_dir / f"{basename}_wavelet.tif"
@ -1072,10 +956,14 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
for scale_m in scales: for scale_m in scales:
sigma_px = scale_m / resolution sigma_px = scale_m / resolution
if HAS_GPU: if _gpu_mod.HAS_GPU:
from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace try:
response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px) from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace
response = to_cpu(response) response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px)
response = to_cpu(response)
except Exception:
from scipy.ndimage import gaussian_laplace
response = -gaussian_laplace(filled, sigma=sigma_px)
else: else:
from scipy.ndimage import gaussian_laplace from scipy.ndimage import gaussian_laplace
response = -gaussian_laplace(filled, sigma=sigma_px) response = -gaussian_laplace(filled, sigma=sigma_px)
@ -1106,200 +994,28 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
# ============================================================ # ============================================================
# Flow accumulation # Anisotropic Openness
# ============================================================
# Path Detection (chemins et sentiers)
# ============================================================ # ============================================================
def _d8_accumulate_numba(flow_dir, nodata_mask, rows, cols): def generate_paths(dem_file, basename, vis_dir, resolution, shared=None):
"""JIT-compiled D8 flow accumulation loop. """Cheminement — openness directionnelle maximale pour détecter chemins et sentiers.
Uses numba for ~100x speedup over pure Python loop. Pour chaque direction (8 directions), calcule openness positive - négative,
Falls back to pure Python if numba is unavailable. puis prend le maximum sur toutes les directions. Les chemins et sentiers
ressortent en valeurs élevées quelle que soit leur orientation.
Contrairement à l'openness anisotropique qui privilégie NW-SE et NE-SW,
cette visualisation traite toutes les directions de manière égale et
combine positive et négative en une seule image. Les chemins perpendiculaires
à une direction auront une forte différence dans cette direction, donc
le maximum sur toutes les directions les fait ressortir.
""" """
try: gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
from numba import njit logger.info(f" → Cheminement (chemins et sentiers){gpu_tag}...")
@njit(cache=True)
def _accumulate(flow_dir, nodata_mask, 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)
# Sort cells by elevation (high to low) — walk downhill
# We use the fact that flow_dir already encodes steepest descent
# Process from highest to lowest elevation
for r in range(rows):
for c in range(cols):
if nodata_mask[r, c]:
flow_acc[r, c] = 0.0
continue
# Iterative accumulation: process cells in top-down order
# Multiple passes until convergence
for _pass in range(10):
changed = 0
for r in range(rows):
for c in range(cols):
if nodata_mask[r, c]:
continue
d = flow_dir[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_mask[nr, nc]:
old_acc = flow_acc[nr, nc]
flow_acc[nr, nc] += flow_acc[r, c]
if flow_acc[nr, nc] != old_acc:
changed += 1
if changed == 0:
break
return flow_acc
return _accumulate(flow_dir, nodata_mask, rows, cols)
except ImportError:
# Fallback: pure Python
return None
def _priority_flood(dem, nodata_mask):
"""Priority-flood algorithm for sink filling (Wang & Liu 2006).
O(n log n) compared to 50 iterations of minimum_filter.
Fills pits so water can flow downhill.
"""
import heapq
rows, cols = dem.shape
filled = dem.copy()
closed = nodata_mask.copy()
open_queue = []
# Initialize border cells
for r in range(rows):
for c in [0, cols - 1]:
if not closed[r, c]:
heapq.heappush(open_queue, (filled[r, c], r, c))
closed[r, c] = True
for c in range(1, cols - 1):
for r in [0, rows - 1]:
if not closed[r, c]:
heapq.heappush(open_queue, (filled[r, c], r, c))
closed[r, c] = True
dx8 = [1, 1, 0, -1, -1, -1, 0, 1]
dy8 = [0, 1, 1, 1, 0, -1, -1, -1]
while open_queue:
elev, r, c = heapq.heappop(open_queue)
for d in range(8):
nr, nc = r + dy8[d], c + dx8[d]
if 0 <= nr < rows and 0 <= nc < cols and not closed[nr, nc]:
if filled[nr, nc] < elev:
filled[nr, nc] = elev # Fill the pit
closed[nr, nc] = True
heapq.heappush(open_queue, (filled[nr, nc], nr, nc))
return filled
def generate_flow(dem_file, basename, vis_dir, resolution, shared=None):
"""Flow accumulation using D8 algorithm — priority-flood sink filling, accumulation via numba."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Accumulation de flux D8{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_flow.tif" output = vis_dir / f"{basename}_paths.tif"
try:
if shared:
transform = shared.transform
crs = shared.crs
dem_np = shared.dem_np
nodata_mask = shared.nan_mask
else:
dem_np, transform, crs = _read_dem(dem_file)
nodata_mask = np.isnan(dem_np)
rows, cols = dem_np.shape
# Sink filling — priority-flood (O(n log n), faster than 50× minimum_filter)
dem_filled_np = _priority_flood(dem_np, nodata_mask)
# D8 slope — vectorized
dx8 = np.array([1, 1, 0, -1, -1, -1, 0, 1], dtype=np.int32)
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_np, 1, mode='constant',
constant_values=np.nanmax(dem_filled_np[~np.isnan(dem_filled_np)]) + 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_np - neighbor_elev) / (dist8[d] * resolution)
slope[nodata_mask] = -1
better = slope > max_slope
flow_dir[better] = d
max_slope[better] = slope[better]
# D8 accumulation — try numba first, fallback to Python
result = _d8_accumulate_numba(flow_dir, nodata_mask.astype(np.bool_), rows, cols)
if result is not None:
flow_acc = result
logger.info(f" Accumulation D8 via numba")
else:
# Pure Python fallback (slow for large DEMs)
logger.info(f" Accumulation D8 via Python (installez numba pour accélérer)")
flat_dem = dem_filled_np[~nodata_mask].flatten()
valid_indices = np.where(~nodata_mask.flatten())[0]
sort_order = valid_indices[np.argsort(-flat_dem)]
flow_acc = np.ones((rows, cols), dtype=np.float32)
flow_acc[nodata_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 nodata_mask[nr, nc]:
flow_acc[nr, nc] += flow_acc[r, c]
flow_log = np.log1p(flow_acc)
_save_tif(output, flow_log, transform, crs)
logger.info(f" ✓ Flux terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur flux: {e}", exc_info=True)
return None
# ============================================================
# Sky-View Factor (SVF)
# ============================================================
def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
"""Sky-View Factor - fraction of sky visible from each point (GPU if available).
SVF = average of cos²(horizon_angle) across 16 directions.
High SVF (near 1) = open sky (ridgetop, plateau)
Low SVF (near 0) = enclosed sky (valley, deep trench)
Excellent for detecting archaeological earthworks: ditches appear dark,
embankments appear bright. Complements openness which uses raw angles.
"""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Sky-View Factor{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_svf.tif"
try: try:
if shared: if shared:
@ -1308,7 +1024,7 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
dem_np = shared.dem_np dem_np = shared.dem_np
rows, cols = dem_np.shape rows, cols = dem_np.shape
res = resolution res = resolution
dem = to_gpu(shared.filled) if HAS_GPU else shared.filled dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
else: else:
dem_np, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
@ -1316,21 +1032,24 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
res = resolution res = resolution
nan_mask = np.isnan(dem_np) nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np) filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if HAS_GPU else filled dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled
n_dirs = 16 # More directions for smoother SVF n_dirs = 8
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False) angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
dx_dir = np.cos(angles) dx_dir = np.cos(angles)
dy_dir = np.sin(angles) dy_dir = np.sin(angles)
max_dist = min(int(100 / res), 300) max_dist = min(int(100 / res), 300)
padded = xp.pad(dem, max_dist, mode='constant', constant_values=xp.nan) padded = xp.pad(dem, max_dist, mode='constant', constant_values=xp.nan)
svf_sum = xp.zeros_like(dem) max_diff = xp.full_like(dem, -1e6) # Will track max over all directions
for d_idx in range(n_dirs): for d_idx in range(n_dirs):
ddx, ddy = dx_dir[d_idx], dy_dir[d_idx] ddx, ddy = dx_dir[d_idx], dy_dir[d_idx]
# Find maximum horizon elevation angle in this direction
max_horizon_angle = xp.zeros_like(dem) # Positive openness: max zenith angle in this direction
max_pos_angle = xp.zeros_like(dem)
# Negative openness: max nadir angle in this direction
max_neg_angle = xp.zeros_like(dem)
for step in range(1, max_dist + 1): for step in range(1, max_dist + 1):
px = int(round(ddx * step)) px = int(round(ddx * step))
@ -1342,27 +1061,30 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
elev_diff = padded[max_dist + py:max_dist + py + rows, elev_diff = padded[max_dist + py:max_dist + py + rows,
max_dist + px:max_dist + px + cols] - dem max_dist + px:max_dist + px + cols] - dem
# Horizon angle from horizontal (positive = terrain above viewer) # Positive: angle to terrain above viewer
angle = xp.arctan2(elev_diff, dist_m) pos_angle = xp.arctan2(xp.maximum(elev_diff, 0), dist_m)
max_horizon_angle = xp.where(xp.isnan(angle), max_horizon_angle, max_pos_angle = xp.where(xp.isnan(pos_angle), max_pos_angle,
xp.maximum(max_horizon_angle, xp.nan_to_num(angle, nan=0))) xp.maximum(max_pos_angle, xp.nan_to_num(pos_angle, nan=0)))
# SVF uses cos²(horizon angle) — fraction of visible sky in this direction # Negative: angle to terrain below viewer
cos2 = xp.cos(max_horizon_angle) ** 2 neg_angle = xp.arctan2(xp.maximum(-elev_diff, 0), dist_m)
svf_sum += cos2 max_neg_angle = xp.where(xp.isnan(neg_angle), max_neg_angle,
xp.maximum(max_neg_angle, xp.nan_to_num(neg_angle, nan=0)))
svf_result = to_cpu(svf_sum / n_dirs).astype(np.float32) # Difference highlights linear features perpendicular to this direction
svf_result[nan_mask] = np.nan diff = max_pos_angle - max_neg_angle
_save_tif(output, svf_result, transform, crs) max_diff = xp.maximum(max_diff, diff)
logger.info(f" ✓ SVF terminé ({time.time()-t0:.1f}s){gpu_tag}")
paths_result = to_cpu(max_diff).astype(np.float32)
paths_result[nan_mask] = np.nan
_save_tif(output, paths_result, transform, crs)
logger.info(f" ✓ Cheminement terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output return output
except Exception as e: except Exception as e:
logger.error(f" ✗ Erreur SVF: {e}", exc_info=True) logger.error(f" ✗ Erreur cheminement: {e}", exc_info=True)
return None return None
# ============================================================
# Anisotropic Openness
# ============================================================ # ============================================================
def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None): def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
@ -1375,7 +1097,7 @@ def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
The anisotropic weighting makes subtle linear features more visible than The anisotropic weighting makes subtle linear features more visible than
standard isotropic openness which averages all directions equally. standard isotropic openness which averages all directions equally.
""" """
gpu_tag = " [GPU]" if HAS_GPU else "" gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Openness Anisotropique{gpu_tag}...") logger.info(f" → Openness Anisotropique{gpu_tag}...")
t0 = time.time() t0 = time.time()
output = vis_dir / f"{basename}_aniso_open.tif" output = vis_dir / f"{basename}_aniso_open.tif"
@ -1387,7 +1109,7 @@ def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
dem_np = shared.dem_np dem_np = shared.dem_np
rows, cols = dem_np.shape rows, cols = dem_np.shape
res = resolution res = resolution
dem = to_gpu(shared.filled) if HAS_GPU else shared.filled dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled
nan_mask = shared.nan_mask nan_mask = shared.nan_mask
else: else:
dem_np, transform, crs = _read_dem(dem_file) dem_np, transform, crs = _read_dem(dem_file)
@ -1395,7 +1117,7 @@ def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
res = resolution res = resolution
nan_mask = np.isnan(dem_np) nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np) filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if HAS_GPU else filled dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled
n_dirs = 8 n_dirs = 8
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False) angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)