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

@ -214,7 +214,9 @@ def test_build_index_generates_html(tmp_path):
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
_make_fake_viz_dir(vis_dir, "a", 1000, 6881,
('aspect', 'slope', 'positive_openness',
'wavelet', 'hillshade_multi', 'svf'))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
result = build_index(output_dir)
@ -235,19 +237,46 @@ def test_build_index_generates_html(tmp_path):
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
app_js = (assets / "app.js").read_text(encoding='utf-8')
assert "Couches" in app_js or "layers" in app_js
# Barre de génération : sélecteur multi-visualisations (wavelet, aniso_open,
# openness positive, pente...) et envoi du champ viz à /api/generate
# Barre de génération : sélecteur limité aux couches proposées dans le
# panneau de visualisation (aspect + slope + openness + ondelette,
# valeurs = noms d'étapes --only). La sélection est envoyée à /api/preview
# (détection des tuiles incomplètes) comme à /api/generate.
# Les couches hors panneau (svf, hillshade) n'y figurent pas.
assert 'id="genViz"' in content
assert 'value="wavelet"' in content
assert 'value="aniso_open"' in content
assert 'value="aniso_open"' not in content
assert 'value="pos_open"' in content
assert 'value="slope"' in content
assert "genViz" in app_js and "viz: vizSel.length" in app_js
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
# Adoption du dernier run au chargement (redémarrage du serveur) :
# un run terminé réapparaît dans la file avec ses tuiles, sans doublon.
assert "Dernière génération" in app_js
assert "t.started === s.started" in app_js
# Bouton « Compléter » : visualisations manquantes des dalles présentes
# dans input/ (scope all_missing envoyé au preview et au generate).
assert 'id="btnComplete"' in content
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
assert 'assets/app.css' in content
# Bouton d'actualisation des couches (détection des nouveautés sur disque)
# Bouton d'actualisation des couches (détection des nouveautés sur disque) :
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/rebuild" in app_js
assert "/api/layers" in app_js and "/api/sync" in app_js
assert 'assets/app.js' in content
# Fin d'un run de génération : synchronisation puis rechargement (le
# rechargement direct reste en secours si /api/sync est absent).
assert "syncThenReload" in app_js
# Tuiles en direct : la carte sonde /api/tiles pendant un run et fusionne
# les tuiles terminées sans recharger (mode incrémental du pipeline).
assert "/api/tiles" in app_js and "pollLiveTiles" in app_js
assert "registerTile" in app_js and "mergeTilesData" in app_js
# Export des données de carte (servi par /api/tiles)
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
assert data['tiles'] and data['tiles'][0]['corners']
assert 'aspect' in data['viz_meta']
assert data['stats']['n_tiles'] == len(data['tiles'])
# Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs"
assert thumb_dir.is_dir()
@ -259,16 +288,53 @@ def test_subtiled_viz_covers_generation_choices():
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'aniso_open', 'wavelet'):
'wavelet'):
assert key in _CARTO_SUBTILED_VIZ
def test_panel_restricted_to_requested_layers():
"""Le panneau et le sélecteur ne proposent que la base aspect + les 4 couches demandées."""
from lidar_pipeline.index import PANEL_VIZ, GEN_VIZ_CHOICES
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
assert {name for name, _ in GEN_VIZ_CHOICES} == {
'aspect', 'slope', 'pos_open', 'aniso_open', 'wavelet'}
"""Le panneau et le sélecteur ne proposent que la base aspect + les 3 couches demandées."""
from lidar_pipeline.index import PANEL_VIZ, KEYWORD_TO_STEP
from lidar_pipeline.pipeline import VIZ_STEPS
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'wavelet')
# Chaque couche du panneau correspond à une étape --only valide du pipeline
steps = {name for name, _ in VIZ_STEPS}
for key in PANEL_VIZ:
assert KEYWORD_TO_STEP.get(key, key) in steps
def test_build_index_merges_cross_resolution_viz(tmp_path):
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
Le dernier run peut être interrompu entre les deux passes de résolution :
la fusion inter-résolutions doit pointer vignettes et URLs vers le dossier
d'origine de chaque fichier (dir_name), pas vers dir_path de la dalle
affichée — sinon les vignettes échouent et la couche disparaît.
"""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
base = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
# Passe 0,5 m : aspect + slope ; passe 0,2 m : aspect seulement
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect', 'slope'))
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect',), res_suffix='_r0p2')
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
# Une seule position affichée (résolution la plus fine), deux couches
tiles = [t for t in data['tiles'] if (t['col'], t['row']) == (1000, 6881)]
keys = set()
for t in tiles:
keys.update(t['viz'].keys())
assert {'aspect', 'slope'} <= keys
# Vignette slope générée + chaque thumb/full existe réellement sur disque
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
for t in tiles:
for v in t['viz'].values():
assert (output_dir / v['thumb']).exists(), v['thumb']
assert (output_dir / v['full']).exists(), v['full']
def test_build_index_regenerates_stale_thumbnails(tmp_path):
@ -449,3 +515,12 @@ def test_subtile_corners_grid():
se_quad = _subtile_corners(corners, 1, 0, k)
assert sw_quad[1] == se_quad[0]
assert sw_quad[2] == se_quad[3]
def test_approx_wgs84_to_l93_roundtrip():
"""L'approximation affine WGS84→L93 est l'inverse exacte de L93→WGS84."""
from lidar_pipeline.index import _approx_l93_to_wgs84, _approx_wgs84_to_l93
for x, y in ((1054000.0, 6882000.0), (700000.0, 6600000.0), (950123.0, 6410456.0)):
lon, lat = _approx_l93_to_wgs84(x, y)
x2, y2 = _approx_wgs84_to_l93(lon, lat)
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6