Split webapp for Raspberry Pi deployment, remote generation API and sync

La webapp (carte + vignettes) et la génération de tuiles se déploient sur
deux machines : image légère Dockerfile.webapp (FastAPI + Pillow AVIF
natif + pyproj) sur Raspberry Pi, pipeline complet sur la machine de
traitement. LIDAR_GENERATION_URL délègue /api/generate, /api/preview et
/api/status ; /api/sync ramène les tuiles par rsync puis régénère
vignettes et index localement. Token partagé optionnel
(LIDAR_API_TOKEN/LIDAR_REMOTE_TOKEN). Retire du dépôt les journaux
internes (.swival, audit-findings) et les données (data/, notebooks/).
Doc : docs/DEPLOY_WEBAPP.md.
This commit is contained in:
Antoine Jacquin
2026-09-02 19:44:39 +02:00
parent ed3e90ea89
commit 422f58d772
24 changed files with 2390 additions and 329 deletions

View File

@ -97,14 +97,6 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': -3,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
'aniso_open': {
'cmap': 'seismic',
'title': 'Openness Anisotropique (pondération directionnelle)',
'legend': 'Openness positive − négative pondérée (z-score local)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\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': 'fixed', 'vmin_val': -3,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
'positive_openness': {
'cmap': 'YlOrBr',
@ -164,12 +156,26 @@ COLORMAPS = {
'vmax_mode': 'percentile', 'vmax_pct': 98,
},
'wavelet': {
'cmap': 'cividis',
'cmap': 'inferno',
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
'legend': 'Réponse RMS multi-échelles (σ locales)\nÉchelles adaptées à la résolution\n\nClair = Structure détectée à cette échelle\nSombre = Pas de structure\nÉchelle fixe 0–3σ — couleurs homogènes entre tuiles\n\nOptimisé pour formes circulaires:\ntumulus, enclos, fossés annulaires',
'description': 'Transformée en ondelette 2D — excellente pour détecter structures circulaires',
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 3,
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
# Nœuds (percentile → valeur) mesurés sur 20 tuiles réelles par
# résolution avec l'algorithme actuel (détendage 35 m, échelles
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
# aux tuiles très structurées. Distribution plus large qu'avant
# détendage : le fond macro-relief supprimé, les petites structures
# percent bien plus au-dessus du bruit (p98 ≈ 9 vs 1,65 avant).
'knots': {
0.5: ([0.089, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.274,
1.661, 2.32, 3.842, 5.661, 8.936, 11.93, 15.06],
[0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60,
0.70, 0.80, 0.90, 0.95, 0.98, 0.99, 0.995]),
0.2: ([0.088, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.287,
1.694, 2.358, 3.846, 5.687, 8.971, 11.928, 14.971],
[0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60,
0.70, 0.80, 0.90, 0.95, 0.98, 0.99, 0.995]),
},
},
'flow_acc': {
'cmap': 'YlGn',
@ -212,7 +218,7 @@ RGB_LEGENDS = {
}
def _apply_colormap(data, tif_file):
def _apply_colormap(data, tif_file, resolution=None):
"""Apply the registered colormap normalization to data based on filename.
Returns (data, cmap, title, legend_label, description, is_rgb).
@ -238,31 +244,45 @@ def _apply_colormap(data, tif_file):
vmin = vmax = None
# Compute vmin/vmax based on mode
if info['vmin_mode'] == 'fixed':
vmin = info['vmin_val']
elif info['vmin_mode'] == 'percentile':
vmin = np.percentile(valid_data, info['vmin_pct'])
elif info['vmin_mode'] == 'symmetric':
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmin = -vmax_abs
vmax = vmax_abs # symmetric mode sets both vmin and vmax
if vmax is None:
# Only compute vmax if not already set by symmetric mode
if info.get('vmax_mode') == 'fixed':
vmax = info['vmax_val']
elif info.get('vmax_mode') == 'percentile':
vmax = np.percentile(valid_data, info['vmax_pct'])
elif info.get('vmax_mode') == 'symmetric':
knots = info.get('knots')
if knots is not None:
# Étalonnage quantile figé (appariement d'histogramme global,
# cf. normalisation radiométrique des mosaïques) : fonction de
# transfert en nœuds mesurés une fois sur un échantillon de
# tuiles — même valeur → même couleur sur toutes les tuiles,
# toute la palette utilisée, insensible aux queues locales.
if isinstance(knots, dict):
key = min(knots, key=lambda r: abs(float(r) - float(resolution or 0.5)))
knots = knots[key]
kv, kt = knots
data = np.interp(np.asarray(data, dtype=float), kv, kt, left=0.0, right=1.0)
vmin, vmax = kv[0], kv[-1]
else:
# Compute vmin/vmax based on mode
if info['vmin_mode'] == 'fixed':
vmin = info['vmin_val']
elif info['vmin_mode'] == 'percentile':
vmin = np.percentile(valid_data, info['vmin_pct'])
elif info['vmin_mode'] == 'symmetric':
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmax = vmax_abs
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmin = -vmax_abs
vmax = vmax_abs # symmetric mode sets both vmin and vmax
# Apply normalization
if vmin is not None and vmax is not None:
data = np.clip((data - vmin) / max(vmax - vmin, 0.001), 0, 1)
if vmax is None:
# Only compute vmax if not already set by symmetric mode
if info.get('vmax_mode') == 'fixed':
vmax = info['vmax_val']
elif info.get('vmax_mode') == 'percentile':
vmax = np.percentile(valid_data, info['vmax_pct'])
elif info.get('vmax_mode') == 'symmetric':
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmax = vmax_abs
# Apply normalization
if vmin is not None and vmax is not None:
data = np.clip((data - vmin) / max(vmax - vmin, 0.001), 0, 1)
legend = info['legend'].format(vmin=vmin or 0, vmax=vmax or 0)
return data, info['cmap'], info['title'], legend, info['description'], False
@ -463,7 +483,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
nan_mask = nan_mask # keep for later
# Apply colormap
data, cmap, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
data, cmap, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file, resolution=resolution)
# Apply NaN mask: make zones without data transparent
has_nan_mask = nan_mask is not None and not is_rgb_result
@ -818,7 +838,7 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=98, outpu
data = src.read(1)
# Apply colormap normalization
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file, resolution=resolution)
# Convert to RGB using colormap
if is_rgb_result:
@ -907,7 +927,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
# Sort analysis files by archaeological priority
order = ['mslrm', 'svf', 'negative_openness',
'positive_openness', 'aniso_open', 'sailore', 'hillshade_multi',
'positive_openness', 'sailore', 'hillshade_multi',
'flow_acc', 'solar', 'slope', 'roughness', 'wavelet']
def sort_key(f):