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

@ -121,6 +121,77 @@ class TestWavelet:
assert result is not None
assert result.exists()
def test_output_median_centered(self, synthetic_dem, tmp_output_dir):
"""Recentrage robuste : médiane ≈ 1 quel que soit le terrain.
C'est la condition pour qu'un étirement couleur global fixe donne
des couleurs homogènes entre tuiles (cf. knots dans rendering.py).
"""
import rasterio
from lidar_pipeline.visualizations import generate_wavelet
result = generate_wavelet(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src:
data = src.read(1)
valid = data[np.isfinite(data)]
assert abs(np.median(valid) - 1.0) < 0.05
def test_ditch_on_hilltop_not_amplified(self, tmp_path, tmp_output_dir):
"""Un fossé en sommet de colline ne ressort pas plus qu'à plat.
Le détendage (moyenne locale gaussienne ~35 m) doit neutraliser la
position topographique :
- le pic d'indice sur le fossé en sommet reste comparable au même
fossé sur terrain plat ;
- le fond du sommet (sans structure) reste comparable au fond plat.
MNT synthétique bruité (σ=8 cm) : sans détendage, le fond sommet
ressort ~1,7× le fond plat (sommets jaunes sur la carte).
"""
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline.visualizations import generate_wavelet
size = 600
res = 1.0
x = np.arange(size) * res
y = np.arange(size) * res
X, Y = np.meshgrid(x, y)
dem = 100.0 + 0.01 * X
# Colline réaliste (sigma 80 m, 25 m de haut) à gauche + bruit capteur
dem += 25.0 * np.exp(-((X - 150)**2 + (Y - 300)**2) / (2 * 80**2))
rng = np.random.default_rng(42)
dem += rng.normal(0, 0.08, dem.shape)
# Deux fossés identiques : l'un au sommet de la colline, l'autre à plat
for xc in (150, 480):
dem -= 1.5 * np.exp(-((X - xc)**2) / (2 * 1.2**2))
dem_file = tmp_path / "ditch_dem.tif"
transform = from_bounds(660000, 6700000, 660600, 6700600, size, size)
with rasterio.open(
dem_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
) as dst:
dst.write(dem.astype('float32'), 1)
result = generate_wavelet(dem_file, "ditch", tmp_output_dir, res)
assert result is not None and result.exists()
with rasterio.open(result) as src:
data = src.read(1)
# Pics sur une bande verticale autour de chaque fossé
peak_hilltop = np.nanmax(data[:, 145:156])
peak_flat = np.nanmax(data[:, 475:486])
assert peak_flat > 5.0 # le fossé ressort nettement au-dessus du fond
# Pas d'amplification du fossé par la position topographique
assert peak_hilltop < 1.5 * peak_flat
# Fond du sommet (35 m à l'est du fossé sommital) vs fond plat
bg_hilltop = np.nanmedian(data[250:350, 185:226])
bg_flat = np.nanmedian(data[250:350, 500:561])
assert bg_hilltop < 1.4 * bg_flat # sans détendage : ~1,7×
class TestFlowAccumulation:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):