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

@ -960,12 +960,21 @@ def generate_solar(dem_file, basename, vis_dir, resolution, shared=None):
def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
"""Mexican Hat wavelet multi-scale analysis (GPU if available).
CWT 2D at multiple scales adapted to resolution.
- At 0.5m/px: [1, 2, 5, 10, 20, 50, 100]m
- At 0.2m/px: [0.5, 1, 2, 5, 10, 20, 50, 100]m
Focused on small archaeological structures (paths, ditches, ramparts).
CWT 2D at scales [1, 2, 5, 10, 20, 50]m (0.5m added below 0.25m/px).
The 100m scale was dropped: it mostly responds to landforms (hills,
valleys), not to structures.
Uses std normalization per scale and weighted RMS combination
with emphasis on archaeologically relevant scales (2-50m).
Large landforms are removed first by subtracting a Gaussian local-mean
trend (~35m). The residual is analyzed relative to its ~35m neighborhood,
so a ditch on a hilltop or slope does not stand out more than the same
ditch on flat ground (topographic-position independence). A Gaussian
smoothing preserves locally planar slopes, so slopes are removed too.
Uses robust per-scale normalization (MAD) and median-centered weighted RMS
combination with emphasis on small scales (1-10m).
The output is an index relative to the tile's own median level (≈ 1):
comparable from tile to tile, so a single fixed color stretch works.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...")
@ -985,18 +994,44 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
filled, _ = _fill_nans(dem_np.astype(np.float64))
min_scale = max(resolution * 2, 1.0)
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50, 100]
# 100m retirée : elle répond surtout aux grandes formes du terrain,
# pas aux structures. Accent sur 1-10m (petites structures).
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50]
scales = [s for s in candidate_scales if s >= min_scale]
scale_weights = {
0.5: 0.6, 1.0: 0.8, 2.0: 1.5, 5.0: 2.0,
10.0: 1.8, 20.0: 1.5, 50.0: 1.0, 100.0: 0.6,
0.5: 0.7, 1.0: 1.2, 2.0: 1.8, 5.0: 2.2,
10.0: 2.0, 20.0: 1.5, 50.0: 0.8,
}
weights = np.array([scale_weights.get(s, 1.0) for s in scales])
logger.info(f" Échelles CWT: {scales}m (résolution {resolution}m/px)")
from scipy.ndimage import gaussian_laplace
from scipy.ndimage import gaussian_laplace, gaussian_filter
# Retrait des grands volumes (collines, vallées) : on soustrait une
# moyenne locale gaussienne avant la CWT. Un lissage gaussien préserve
# les pentes planes, donc le résidu est analysé par rapport à son
# voisinage ~35m : un fossé en sommet ou en flanc de colline ne
# ressort pas plus que le même fossé à plat.
# Fraction conservée pour une structure gaussienne de largeur σ_f :
# σ_t²/(σ_f²+σ_t²) → 10m : 92%, 20m : 75%, colline 150m : 5%.
# Mesuré sur MNT synthétique bruité : le contraste des petites
# structures est insensible à σ_t ; seul le fond sommet/plat varie
# (1.71 sans détendage → 1.21 à 35m).
detrend_sigma_m = 35.0
detrend_sigma_px = detrend_sigma_m / resolution
if _gpu_mod.HAS_GPU:
try:
from cupyx.scipy.ndimage import gaussian_filter as gpu_gaussian_filter
trend = to_cpu(gpu_gaussian_filter(to_gpu(filled), sigma=detrend_sigma_px))
except Exception:
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
else:
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
residual = filled - trend
del trend
logger.info(f" Retrait des grands volumes (tendance gaussienne {detrend_sigma_m:.0f}m)")
wavelet_stack = []
@ -1005,16 +1040,20 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
if _gpu_mod.HAS_GPU:
try:
from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace
response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px)
response = -gpu_gaussian_laplace(to_gpu(residual), sigma=sigma_px)
response = to_cpu(response)
except Exception:
response = -gaussian_laplace(filled, sigma=sigma_px)
response = -gaussian_laplace(residual, sigma=sigma_px)
else:
response = -gaussian_laplace(filled, sigma=sigma_px)
response = -gaussian_laplace(residual, sigma=sigma_px)
response[nan_mask] = np.nan
valid = response[~nan_mask]
std_val = max(np.nanstd(valid), 0.01) if len(valid) > 0 else 0.01
response = response / std_val
# σ robuste (MAD) : le std classique est gonflé par les queues
# (structures marquées, bords de tuile) et varie fortement d'une
# tuile à l'autre — cause première des dominantes de couleur
# par tuile sur la carte.
mad = np.nanmedian(np.abs(valid - np.nanmedian(valid))) if len(valid) > 0 else 0.0
response = response / max(1.4826 * mad, 0.01)
wavelet_stack.append(response)
stack = np.array(wavelet_stack)
@ -1025,6 +1064,15 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
combined = np.sqrt(np.nansum((stack ** 2) * weights_3d, axis=0) / np.sum(weights))
combined[nan_mask] = np.nan
# Recentrage par la médiane de la tuile : la RMS devient un indice
# relatif au niveau moyen de la tuile (médiane = 1). La distribution
# est alors comparable d'une tuile à l'autre — condition pour un
# étirement couleur global fixe et homogène entre tuiles.
finite = combined[np.isfinite(combined)]
if finite.size:
combined = combined / max(float(np.median(finite)), 0.01)
combined[nan_mask] = np.nan
_save_tif(output, combined.astype(np.float32), transform, crs)
logger.info(f" ✓ Ondelette terminée ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
@ -1227,69 +1275,6 @@ def generate_flow_accumulation(dem_file, basename, vis_dir, resolution, shared=N
return None
# ============================================================
# Anisotropic Openness
# ============================================================
def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
"""Anisotropic Openness - weighted directional openness, multi-radius (GPU if available).
Computes positive and negative openness with anisotropic weighting:
NW/SE directions weighted more heavily to enhance detection of structures
aligned NE-SW (common in French archaeological sites: villas, enclosures).
Multi-radius (25, 50, 100m) with equal weight, results std-normalized
for cross-tile comparability.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Openness Anisotropique (multi-rayon){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_aniso_open.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
n_dirs = 8
# Anisotropic weights: emphasize NW-SE and NE-SW directions
weights = np.array([1.0, 1.5, 1.0, 1.5, 1.0, 1.5, 1.0, 1.5])
radii_m = [25, 50, 100]
max_dist = min(int(100 / res), 300)
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
# Weighted combination across directions and radii
weight_total = np.sum(weights)
n_radii = len(radii_m)
pos_combined = np.zeros((rows, cols), dtype=np.float64)
neg_combined = np.zeros((rows, cols), dtype=np.float64)
for r_idx in range(n_radii):
for d_idx in range(n_dirs):
w = weights[d_idx]
pos_combined += pos_angles[d_idx, r_idx] * w / (n_radii * weight_total)
neg_combined += neg_angles[d_idx, r_idx] * w / (n_radii * weight_total)
aniso_result = np.degrees(pos_combined - neg_combined).astype(np.float32)
aniso_result[nan_mask] = np.nan
# Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
# plage de rendu fixe → mosaïque de couleur homogène
valid = aniso_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
aniso_result = (aniso_result - np.nanmean(valid)) / std_val
_save_tif(output, aniso_result, transform, crs)
logger.info(f" ✓ Openness anisotropique terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur openness anisotropique: {e}", exc_info=True)
return None
# ============================================================
# Anomaly Mask — automatic threshold detection
# ============================================================
@ -1342,7 +1327,6 @@ def generate_anomaly_mask(dem_file, basename, vis_dir, resolution, shared=None,
("wavelet", 1.5), # Circular + linear structures
("svf", 1.3), # Sky-view depressions
("flow_acc", 1.2), # Drainage channels / ditches
("aniso_open", 1.0), # Anisotropic structures
("positive_openness", 0.8), # Surélevations
]