Layout uniforme WebP: axes fixes + aspect='equal' pour superposition géolocalisée

- Positions d'axes fixes (data_left/bottom/width/height_frac) pour alignement
  pixel-parfait entre terrain et ortho/topo
- aspect='equal' au lieu de 'auto' pour conserver les proportions géographiques
- Colorbar descriptive pour les visualisations RGB (ortho/topo)
- Comblage des petits trous DTM (< 1m) via rasterio.fill.fillnodata
- Suppression de la visualisation "dépressions"
- Hillshade composite: 0.7*hillshade + 0.3*cos(slope)
- D8 flow accumulation accéléré par numba JIT (fallback Python)
- Flag --keep-tif pour conserver les TIFF intermédiaires
- --force supprime aussi les TIF existants avant régénération
- ETA affiché pendant la génération des visualisations
- Répertoires temp dans temp/ pour traitement parallèle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-10 14:46:31 +02:00
parent e31d3f0e2b
commit 2986400a0a
12 changed files with 243 additions and 151 deletions

View File

@ -24,7 +24,6 @@ import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import rcParams
from matplotlib.gridspec import GridSpec
from matplotlib.patches import Polygon as MplPolygon, Rectangle as RectPatch
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
@ -119,14 +118,6 @@ COLORMAPS = {
'description': 'Identifie la position topographique — utile pour repérer crêtes vs vallées à grande échelle',
'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
},
'depressions': {
'cmap': 'YlOrRd',
'title': 'Dépressions (Remplissage hydrologique)',
'legend': 'Profondeur des cuvettes détectées (m)\nTransparent = Pas de dépression\nJaune = Dépression légère | Rouge = Dépression profonde\nMax: {vmax:.2f}m',
'description': 'Simule le remplissage d\'eau — détecte dolines, sinkholes, cuvettes et zones inondables',
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'percentile', 'vmax_pct': 99,
},
'sailore': {
'cmap': 'seismic',
'title': 'SAILORE - LRM Auto-Adaptatif',
@ -194,8 +185,9 @@ def _apply_colormap(data, tif_file):
info = RGB_LEGENDS[key]
return data, None, info['title'], info['legend'], info['description'], True
# Find matching colormap
for key, info in COLORMAPS.items():
# Find matching colormap — sort by key length descending so 'mslrm' matches before 'lrm'
for key in sorted(COLORMAPS.keys(), key=len, reverse=True):
info = COLORMAPS[key]
if key in name:
valid_data = np.asarray(data.compressed() if hasattr(data, 'compressed') else data.flatten())
valid_data = valid_data[~np.isnan(valid_data)]
@ -246,13 +238,14 @@ def _apply_colormap(data, tif_file):
return data, 'terrain', title, 'Altitude normalisée', '', False
def tif_to_png(tif_file, vis_dir, resolution):
def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False):
"""Convert GeoTIFF to visualization WebP with GPS coordinates, legend, and scale bar.
Args:
tif_file: Path to input GeoTIFF.
vis_dir: Output directory for the WebP file.
resolution: Grid resolution in m/px.
keep_tif: If True, keep the source TIFF after conversion.
Returns:
Path to output WebP file, or None on failure.
@ -359,18 +352,21 @@ def tif_to_png(tif_file, vis_dir, resolution):
saved_vmin = None
saved_vmax = None
# Create figure — adapt width to data resolution for sharp rendering
# At high res (5000+px wide), we need a larger figure to avoid downsampling artifacts
# Create figure with FIXED layout for consistent data area position
# All visualizations use the same axes positions so they can be overlaid
fig_width = max(20, width / 150)
fig_width = min(fig_width, 40) # cap at 40 inches
map_aspect = height / width
fig = plt.figure(figsize=(fig_width, fig_width * map_aspect * 0.7 + 2.5),
facecolor='white')
gs = GridSpec(2, 1, height_ratios=[1.0, 0.06],
hspace=0.04, figure=fig,
left=0.06, right=0.88, top=0.93, bottom=0.08)
fig_width = min(fig_width, 40)
fig_height = fig_width * 0.7 + 2.0 # Fixed header + footer space
fig = plt.figure(figsize=(fig_width, fig_height), facecolor='white')
ax = fig.add_subplot(gs[0])
# Fixed data area position — identical for ALL visualization types
# This ensures overlay/superposition works across all WebP images
data_left = 0.08
data_bottom = 0.12
data_width_frac = 0.74
data_height_frac = 0.78
ax = fig.add_axes([data_left, data_bottom, data_width_frac, data_height_frac])
if is_rgba or is_rgb:
im = ax.imshow(data, aspect='equal', origin='upper',
interpolation='bilinear')
@ -380,22 +376,34 @@ def tif_to_png(tif_file, vis_dir, resolution):
ax.set_title(f"{title}\n{description}", fontsize=15, fontweight='bold', pad=10)
if not is_rgb:
if is_rgba and saved_cmap is not None:
# Create a ScalarMappable for the colorbar from the saved colormap
sm = plt.cm.ScalarMappable(cmap=saved_cmap,
norm=plt.Normalize(vmin=saved_vmin, vmax=saved_vmax))
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax, pad=0.02, shrink=0.85, aspect=30)
else:
cbar = plt.colorbar(im, ax=ax, pad=0.02, shrink=0.85, aspect=30)
# Colorbar/legend area — always at the same position for consistent layout
cbar_left = data_left + data_width_frac + 0.02
cbar_width = 0.04
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.set_xticks([])
cbar_ax.set_yticks([])
cbar_ax.text(0.5, 0.5, legend_label, transform=cbar_ax.transAxes,
fontsize=9, fontweight='bold', rotation=90,
verticalalignment='center', horizontalalignment='center',
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])
sm = plt.cm.ScalarMappable(cmap=saved_cmap,
norm=plt.Normalize(vmin=saved_vmin, vmax=saved_vmax))
sm.set_array([])
cbar = plt.colorbar(sm, cax=cbar_ax)
cbar.ax.tick_params(labelsize=9, width=1.5)
cbar.outline.set_linewidth(1.5)
cbar.set_label(legend_label, fontsize=10, fontweight='bold')
else:
ax.text(1.02, 0.5, legend_label, transform=ax.transAxes,
fontsize=10, fontweight='bold', rotation=90,
verticalalignment='center', horizontalalignment='left')
cbar_ax = fig.add_axes([cbar_left, data_bottom, cbar_width, data_height_frac])
cbar = plt.colorbar(im, cax=cbar_ax)
cbar.ax.tick_params(labelsize=9, width=1.5)
cbar.outline.set_linewidth(1.5)
cbar.set_label(legend_label, fontsize=10, fontweight='bold')
# GPS coordinate ticks
if gps_coords and 'x_ticks' in gps_coords:
@ -444,8 +452,8 @@ def tif_to_png(tif_file, vis_dir, resolution):
north_ax.text(0.5, 0.95, 'N', ha='center', va='top',
fontsize=13, fontweight='bold', color='black', zorder=11)
# Bottom info bar
info_ax = fig.add_subplot(gs[1])
# Bottom info bar — fixed position
info_ax = fig.add_axes([data_left, 0.02, data_width_frac + cbar_width + 0.02, 0.07])
info_ax.axis('off')
extent_km_x = (max_x - min_x) / 1000
@ -496,12 +504,11 @@ def tif_to_png(tif_file, vis_dir, resolution):
fig.patch.set_facecolor('white')
# Save as PNG then convert to WebP — use higher DPI for large data
# Save as PNG then convert to WebP — fixed layout, no bbox_inches='tight'
save_dpi = 200 if width > 3000 else 150
png_temp = vis_dir / f"{tif_file.stem}_temp.png"
try:
plt.savefig(png_temp, dpi=save_dpi, bbox_inches='tight', pad_inches=0.15,
facecolor='white', format='png')
plt.savefig(png_temp, dpi=save_dpi, facecolor='white', format='png')
finally:
plt.close()
@ -509,8 +516,9 @@ def tif_to_png(tif_file, vis_dir, resolution):
img.save(str(webp_file), format='WEBP', lossless=True)
png_temp.unlink(missing_ok=True)
# Delete source TIFF
tif_file.unlink(missing_ok=True)
# Delete source TIFF (unless --keep-tif)
if not keep_tif:
tif_file.unlink(missing_ok=True)
return webp_file
@ -565,7 +573,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
# Sort analysis files by archaeological priority
order = ['mslrm', 'svf', 'negative_openness',
'positive_openness', 'sailore', 'depressions', 'hillshade_multi',
'positive_openness', 'sailore', 'hillshade_multi',
'lrm', 'tpi', 'slope', 'curvature', 'aspect',
'roughness', 'anomalies', 'wavelet', 'flow']