Fix corrupted COPC detection, add CSF→SMRF fallback, improve MSRM colormap, add SVF and anisotropic openness

- validate_laz: verify point data accessibility (not just headers) to detect
  corrupted COPC files that pass header checks but fail on data reads
- classify_ground: fallback from CSF to SMRF when CSF produces no ground
  points or PDAL errors (fixes 2/9 failing tiles)
- MSRM: preserve sign in weighted combination so RdBu_r colormap shows
  both red (elevated) and blue (depressed) instead of red only
- Add Sky-View Factor (SVF) visualization: cos²(horizon angle) over 16
  directions, excellent for archaeological earthwork detection
- Add Anisotropic Openness: directional weighting (NW-SE/NE-SW) enhances
  linear feature detection aligned with common settlement patterns
- Remove anomalies and flow visualizations (replaced by SVF + aniso_open)
- Location inset: use IGN topographic map at zoom 10 instead of simplified
  France outline, with red rectangle marker and fallback
- Remove flow (hydrological accumulation) from VIZ_STEPS
This commit is contained in:
Antoine Jacquin
2026-05-15 01:38:09 +02:00
parent da454bd23e
commit c634db573a
4 changed files with 437 additions and 28 deletions

View File

@ -21,12 +21,14 @@ try:
except ImportError:
HAS_WARP = False
# Cache for IGN location map tiles (avoid re-downloading for each visualization)
_location_map_cache = {}
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import rcParams
from matplotlib.patches import Polygon as MplPolygon, Rectangle as RectPatch
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
from matplotlib.patches import Polygon as MplPolygon, Rectangle as RectPatch, FancyBboxPatch
rcParams['figure.dpi'] = 150
rcParams['savefig.dpi'] = 300
@ -35,6 +37,32 @@ rcParams['font.size'] = 10
logger = logging.getLogger("lidar")
# ============================================================
# Simplified France outline in Lambert 93 (EPSG:2154)
# Used for location inset map on each visualization
# ============================================================
_FRANCE_OUTLINE_L93 = np.array([
[109000, 6385000], [134000, 6410000], [153000, 6430000], [173000, 6445000],
[200000, 6460000], [250000, 6475000], [300000, 6490000], [350000, 6500000],
[400000, 6505000], [450000, 6510000], [500000, 6510000], [550000, 6510000],
[600000, 6505000], [650000, 6500000], [700000, 6495000], [750000, 6485000],
[800000, 6470000], [840000, 6460000], [880000, 6450000], [920000, 6435000],
[950000, 6425000], [980000, 6415000], [1010000, 6405000], [1040000, 6395000],
[1060000, 6385000], [1080000, 6370000], [1100000, 6355000], [1120000, 6340000],
[1140000, 6320000], [1160000, 6300000], [1175000, 6280000], [1185000, 6260000],
[1190000, 6240000], [1195000, 6220000], [1198000, 6200000], [1196000, 6180000],
[1192000, 6160000], [1185000, 6140000], [1175000, 6120000], [1160000, 6100000],
[1140000, 6085000], [1120000, 6070000], [1095000, 6060000], [1070000, 6050000],
[1040000, 6040000], [1000000, 6035000], [950000, 6035000], [900000, 6035000],
[850000, 6040000], [800000, 6045000], [750000, 6050000], [700000, 6055000],
[650000, 6060000], [600000, 6065000], [550000, 6070000], [500000, 6075000],
[450000, 6080000], [400000, 6085000], [350000, 6095000], [300000, 6110000],
[250000, 6125000], [200000, 6145000], [160000, 6170000], [130000, 6200000],
[110000, 6230000], [100000, 6260000], [95000, 6290000], [100000, 6310000],
[105000, 6340000], [109000, 6385000],
])
# ============================================================
# Colormap registry
# ============================================================
@ -126,12 +154,20 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'percentile', 'vmax_pct': 97,
},
'anomalies': {
'cmap': 'coolwarm',
'title': 'Anomalies Statistiques (MSRM multi-échelle + Moran\'s I)',
'legend': 'Anomalies topographiques significatives\nRouge vif = Surélévation anormale (mur, tumulus)\nBleu vif = Dépression anormale (fossé, doline)\nBlanc/gris = Normal\n\nCombine MSRM normalisé (intensité) et\nMoran\'s I (regroupement spatial)',
'description': 'Détecte uniquement les anomalies statistiquement significatives — filtre le bruit de fond',
'vmin_mode': 'symmetric', 'sym_pct': (5, 95),
'svf': {
'cmap': 'gray_r',
'title': 'Sky-View Factor (fraction de ciel visible)',
'legend': 'Proportion de ciel visible depuis chaque point\nBlanc = Ciel dégagé (sommet, plateau, levée)\nNoir = Ciel masqué (vallée, fossé, tranchée)\nMoyenne de cos²(angle horizon) sur 16 directions',
'description': 'Détection de micro-relief — fossés sombres, levées claires, complémentaire de l\'openness',
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
'aniso_open': {
'cmap': 'RdBu_r',
'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',
'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),
},
'wavelet': {
'cmap': 'cividis',
@ -231,6 +267,66 @@ def _apply_colormap(data, tif_file):
return data, 'terrain', title, 'Altitude normalisée', '', False
def _download_location_map(min_x, max_x, min_y, max_y):
"""Download a wide-area IGN topographic map for location context.
Downloads a zoomed-out IGN PLANIGNV2 tile covering 5-10x the processed
zone extent, giving a wider geographic context. Results are cached to
avoid re-downloading for each visualization in the same tile.
Args:
min_x, max_x, min_y, max_y: DTM bounds in Lambert 93.
Returns:
numpy array (H, W, 3) uint8, or None on failure.
"""
import hashlib
# Cache key based on rounded coordinates (1km grid)
cache_key = (round(min_x, -3), round(max_x, -3), round(min_y, -3), round(max_y, -3))
if cache_key in _location_map_cache:
return _location_map_cache[cache_key]
from .ign import download_ign_tiles, _optimal_zoom_level
if not HAS_WARP:
return None
try:
# Compute center coordinates for zoom calculation
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
clons, clats = warp_transform('EPSG:2154', 'EPSG:4326', [center_x], [center_y])
center_lat = clats[0]
center_lon = clons[0]
# Use a much lower zoom level for context (wider view)
# Zoom 10 gives ~150km per 256px tile — perfect for a small location map
context_zoom = 10
# Expand bounds by 3x in each direction for wider context
extent_x = max_x - min_x
extent_y = max_y - min_y
context_min_x = center_x - extent_x * 2
context_max_x = center_x + extent_x * 2
context_min_y = center_y - extent_y * 2
context_max_y = center_y + extent_y * 2
result = download_ign_tiles(
context_min_x, context_max_x, context_min_y, context_max_y,
layer='GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2',
zoom_level=context_zoom
)
if result is not None:
_location_map_cache[cache_key] = result
return result
except Exception as e:
logger.debug(f" Carte de localisation IGN non disponible: {e}")
return None
def _nice_scale(extent_m):
"""Choose a nice round scale distance that fits well in the image.
@ -397,12 +493,16 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
ax.set_title(f"{title}\n{description}", fontsize=15, fontweight='bold', pad=10)
# Colorbar/legend area — always at the same position for consistent layout
# Colorbar/legend area — reduced height to leave room for compass rose above
cbar_left = data_left + data_width_frac + 0.02
cbar_width = 0.04
compass_height = 0.07
compass_gap = 0.02
cbar_bottom = data_bottom
cbar_height = data_height_frac - compass_height - compass_gap
if is_rgb:
# RGB: descriptive text label instead of gradient colorbar
cbar_ax = fig.add_axes([cbar_left, data_bottom, cbar_width, data_height_frac])
cbar_ax = fig.add_axes([cbar_left, cbar_bottom, cbar_width, cbar_height])
cbar_ax.set_xticks([])
cbar_ax.set_yticks([])
cbar_ax.text(0.5, 0.5, legend_label, transform=cbar_ax.transAxes,
@ -411,7 +511,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
wrap=True)
cbar_ax.set_frame_on(False)
elif is_rgba and saved_cmap is not None:
cbar_ax = fig.add_axes([cbar_left, data_bottom, cbar_width, data_height_frac])
cbar_ax = fig.add_axes([cbar_left, cbar_bottom, cbar_width, cbar_height])
sm = plt.cm.ScalarMappable(cmap=saved_cmap,
norm=plt.Normalize(vmin=saved_vmin, vmax=saved_vmax))
sm.set_array([])
@ -420,7 +520,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
cbar.outline.set_linewidth(1.5)
cbar.set_label(legend_label, fontsize=10, fontweight='bold')
else:
cbar_ax = fig.add_axes([cbar_left, data_bottom, cbar_width, data_height_frac])
cbar_ax = fig.add_axes([cbar_left, cbar_bottom, cbar_width, cbar_height])
cbar = plt.colorbar(im, cax=cbar_ax)
cbar.ax.tick_params(labelsize=9, width=1.5)
cbar.outline.set_linewidth(1.5)
@ -461,13 +561,16 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
spine.set_color('black')
spine.set_linewidth(0.8)
# North arrow — compass rose style
north_ax = inset_axes(ax, width="5%", height="9%", loc='upper right',
bbox_to_anchor=(-0.03, 0.08, 1, 1), bbox_transform=ax.transAxes)
# North arrow — compass rose style, positioned above the colorbar
compass_bottom = data_bottom + data_height_frac + 0.02
compass_height = 0.07
compass_width = cbar_width + 0.03
north_ax = fig.add_axes([cbar_left, compass_bottom, compass_width, compass_height])
north_ax.set_xlim(-1.2, 1.2)
north_ax.set_ylim(-0.5, 1.5)
north_ax.axis('off')
north_ax.set_aspect('equal')
north_ax.set_facecolor('white')
# N arrow
north_ax.annotate('N', xy=(0, 1.3), fontsize=11, fontweight='bold',
ha='center', va='bottom', color='#b22222')
@ -566,6 +669,54 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
[bar_bottom_y - 0.05, bar_top_y + 0.05],
color='black', linewidth=1, transform=info_ax.transAxes, clip_on=False)
# Location inset map — IGN topographic background with processed zone marker
map_ax = fig.add_axes([0.84, 0.02, 0.14, 0.12])
# Try to download a wide-area IGN topo map for location context
location_map = _download_location_map(min_x, max_x, min_y, max_y)
if location_map is not None:
# Draw IGN topo map as background
map_ax.imshow(location_map, aspect='auto', extent=[
min_x - (max_x - min_x) * 2, max_x + (max_x - min_x) * 2,
min_y - (max_y - min_y) * 2, max_y + (max_y - min_y) * 2
])
# Mark the processed zone with a red rectangle
rect_x1, rect_x2 = min_x, max_x
rect_y1, rect_y2 = min_y, max_y
map_ax.add_patch(RectPatch((rect_x1, rect_y1),
rect_x2 - rect_x1, rect_y2 - rect_y1,
facecolor='#ff3333', edgecolor='#cc0000',
linewidth=1.5, alpha=0.6, zorder=5))
else:
# Fallback: simplified France outline
map_ax.set_facecolor('#e8e8e8')
france = _FRANCE_OUTLINE_L93
map_ax.fill(france[:, 0] / 1000, france[:, 1] / 1000,
facecolor='#f5f0e6', edgecolor='#888888', linewidth=0.8)
rect_x1, rect_x2 = min_x / 1000, max_x / 1000
rect_y1, rect_y2 = min_y / 1000, max_y / 1000
map_ax.add_patch(RectPatch((rect_x1, rect_y1),
rect_x2 - rect_x1, rect_y2 - rect_y1,
facecolor='#ff3333', edgecolor='#cc0000',
linewidth=1.2, alpha=0.7, zorder=5))
map_ax.set_xlim(france[:, 0].min() / 1000 - 50, france[:, 0].max() / 1000 + 50)
map_ax.set_ylim(france[:, 1].min() / 1000 - 50, france[:, 1].max() / 1000 + 50)
map_ax.set_aspect('equal')
map_ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
for spine in map_ax.spines.values():
spine.set_edgecolor('#aaaaaa')
spine.set_linewidth(0.5)
# Label with coordinates
if gps_coords:
nw_lat, nw_lon = gps_coords['NW']
se_lat, se_lon = gps_coords['SE']
map_ax.set_title(f"{nw_lat:.2f}°N {nw_lon:.2f}°E",
fontsize=6, pad=1, color='#333333')
else:
map_ax.set_title(f"X:{min_x/1000:.0f} Y:{min_y/1000:.0f} km L93",
fontsize=6, pad=1, color='#333333')
fig.patch.set_facecolor('white')
# Save as PNG then convert to final format — fixed layout, no bbox_inches='tight'