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:
@ -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'
|
||||
|
||||
Reference in New Issue
Block a user