Porter la génération de tuiles dans lidar-maps et retirer l'ancienne webapp

La carte à tuiles XYZ devient la seule interface : l'API de génération
(preview, generate, status, stop, file, cell), le job unique + file
persistante, la délégation LIDAR_GENERATION_URL et les garde-fous
(token, LIDAR_REGEN_CIDR) passent de webapp.py à mapserve.py ; l'interface
gagne les boutons + Zone / ⤒ Compléter / régénération à la dalle, les
options de run et la progression cadre par cadre. mapserve sert aussi
l'inventaire /api/tiles et les dalles en statique versionné : le worker
image complète remplace la webapp sur le 8973 du générateur, le Pi
n'exécute plus que lidar-maps. index.py se réduit aux registres partagés
+ vignettes/sous-tuiles/inventaire (l'UI HTML/JS et les mosaïques
d'overview partent avec export.py et les compose/scripts de la webapp).
This commit is contained in:
Antoine Jacquin
2026-09-23 23:21:05 +02:00
parent 81defa85c9
commit 0a2ccee958
31 changed files with 1774 additions and 8465 deletions

View File

@ -207,8 +207,8 @@ def test_collect_tile_metadata_fallback_date(tmp_path):
assert meta['generated'] is not None
def test_build_index_generates_html(tmp_path):
"""build_index génère index.html et les vignettes."""
def test_build_index_generates_inventory(tmp_path):
"""build_index génère l'inventaire index_tiles.json et les vignettes."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
@ -221,105 +221,20 @@ def test_build_index_generates_html(tmp_path):
result = build_index(output_dir)
assert result is not None
html_path = Path(result)
assert html_path.exists()
assert html_path.name == "index.html"
inv_path = Path(result)
assert inv_path.exists()
assert inv_path.name == "index_tiles.json"
# L'ancienne interface HTML a été retirée : plus de coquille ni d'assets
assert not (output_dir / "index.html").exists()
assert not (output_dir / "assets").exists()
content = html_path.read_text(encoding='utf-8')
# Vérifie la présence des éléments clés
assert "Carte LiDAR" in content
assert "LHD_FXX_1000_6881" in content
assert "LHD_FXX_1001_6881" in content
# Vérifie que le JSON intégré est valide
assert "const TILES" in content
# Vérifie les assets de l'interface (CSS/JS séparés)
assets = output_dir / "assets"
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 restreint au panneau (PANEL_VIZ =
# DEFAULT_LAYERS ; valeurs = noms d'étapes --only). Les couches hors
# panneau (svf, hillshade, wavelet, roughness) ne sont plus proposées.
assert 'id="genViz"' in content
assert 'value="slope"' in content
assert 'value="pos_open"' in content
assert 'value="aspect"' in content
assert 'value="roughness"' not in content
assert 'value="wavelet"' not in content
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content
assert 'value="aniso_open"' 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
# Vidage de la file : les runs retirés sont mémorisés (par horodatage
# serveur) pour que le rechargement ne réadopte pas la « dernière
# génération » du serveur et ne refasse pas réapparaître les tâches finies.
assert "dismissedRuns" in app_js
assert "saveDismissed()" in app_js
# Bouton « Compléter » : visualisations manquantes des dalles présentes
# dans input/ (scope all_missing envoyé au preview et au generate).
# « + Zone » et « Compléter » ne vivent que dans l'onglet Génération,
# jamais dans la barre d'outils flottante de la carte.
assert 'id="btnComplete"' in content
map_tools = content.split('id="mapTools"', 1)[1].split('</nav>', 1)[0]
assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools
tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0]
assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
# Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs
# Leaflet, pane 600) pour rectangle et cellules fantômes.
assert "createPane('genDraw')" in app_js
assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules
assert 'assets/app.css' in content
# 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/sync" in app_js
# Préconfigurations de couches (onglet Couches) : le sélecteur applique
# des presets intégrés (couches visibles, niveaux de transparence —
# visualisations + fond de carte OSM —, ordre de pile) et 💾/✕ gèrent
# les presets personnels persistés en localStorage.
assert 'id="presetSelect"' in content
assert 'id="presetSave"' in content
assert "BUILTIN_PRESETS" in app_js and "applyPreset" in app_js
assert "presetDetail" in app_js and "lidarPresets_v1" in app_js
assert "osm: { on: true, opacity: 0.3, dark: true }" in app_js # preset porte le fond OSM
assert "p.osm" in app_js # l'application y inclut le fond
# Partage de la vue : bouton « Partager » + lien contenant vue, couches
# et transparences (hash #zoom,lat,lng&L=...&O=...) — le destinataire
# reçoit l'état exact, indépendamment de son localStorage local.
assert 'id="btnShare"' in content
assert "buildShareHash" in app_js and "parseSharedHash" in app_js
assert 'assets/app.js' in content
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
assert 'assets/vendor/leaflet/leaflet.js' in content
assert 'assets/vendor/leaflet/leaflet.css' in content
assert 'unpkg.com' not in content
assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file()
# 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
# Garde anti-page périmée : un onglet ouvert depuis plus de 15 min
# (JS ancien après un déploiement) se recharge au retour au premier
# plan — l'ancienne interface n'est jamais affichée.
assert "reloadStalePage" in app_js and "visibilitychange" 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'))
# Inventaire servi par /api/tiles aux machines légères : tuiles avec coins,
# libellés de couches, compteur.
data = json.loads(inv_path.read_text(encoding='utf-8'))
assert data['tiles'] and data['tiles'][0]['corners']
assert 'aspect' in data['viz_meta']
assert data['viz_meta']['aspect']['label']
assert data['stats']['n_tiles'] == len(data['tiles'])
# Version de l'interface exportée : la webapp légère détecte une image
# périmée (assets bâchés au build, non reconstruite après évolution)
from lidar_pipeline.index import ui_version
assert data['stats']['ui_version'] == ui_version()
# Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs"
assert thumb_dir.is_dir()
@ -327,27 +242,6 @@ def test_build_index_generates_html(tmp_path):
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
def test_write_ui_assets(tmp_path):
"""_write_ui_assets écrit app.js/app.css dans le dossier cible (bâchés image).
app.js a __UI_VERSION__ substitué par la version réelle (ui_version) :
le JS compare cette version à celle du serveur de données et signale
une webapp périmée (bandeau, cf. checkUiVersion).
"""
from lidar_pipeline.index import _write_ui_assets, _APP_JS, ui_version
dst = tmp_path / "ui"
_write_ui_assets(dst)
v = ui_version()
assert v == ui_version() and len(v) == 10
js = (dst / "app.js").read_text(encoding="utf-8")
assert js == _APP_JS.replace("__UI_VERSION__", v)
assert v in js and "__UI_VERSION__" not in js
# Le placeholder et le check sont embarqués dans la source
assert '"__UI_VERSION__"' in _APP_JS and "checkUiVersion" in _APP_JS
assert (dst / "app.css").read_text(encoding="utf-8").startswith("/*")
def test_mid_thumbnails_generated(tmp_path):
"""Vignette intermédiaire 640 px générée et référencée dans l'index."""
from lidar_pipeline.index import build_index
@ -410,25 +304,6 @@ def test_panel_restricted_to_kept_layers():
assert KEYWORD_TO_STEP.get(key, key) in steps
def test_panel_selection_lists_kept_layers():
"""Le panneau propose uniquement les couches conservées (PANEL_VIZ).
Même sans données sur disque, le panneau affiche les couches conservées ;
une couche absente du panneau n'est plus proposée, même présente sur
disque.
"""
from lidar_pipeline.index import _panel_selection, PANEL_VIZ
ordered, default = _panel_selection({'aspect'})
assert set(ordered) == set(PANEL_VIZ)
assert default == 'aspect'
ordered, default = _panel_selection(set())
assert set(ordered) == set(PANEL_VIZ)
assert default # toujours une couche par défaut
# Une couche présente sur disque mais hors panneau est exclue
ordered, default = _panel_selection({'aniso_open'})
assert 'aniso_open' not in ordered
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.
@ -602,35 +477,6 @@ def test_build_index_empty_returns_none(tmp_path):
assert result is None
def test_build_index_embeds_valid_json(tmp_path):
"""Le JSON embarqué dans le HTML est valide et contient les tuiles."""
from lidar_pipeline.index import build_index
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',))
build_index(output_dir)
content = (output_dir / "index.html").read_text(encoding='utf-8')
# Extrait le JSON entre "const TILES = " et la fin de déclaration
start = content.index("const TILES = ") + len("const TILES = ")
# Trouve le ; de fin de déclaration
depth = 0
end = start
for i, ch in enumerate(content[start:], start):
if ch in ('{', '['):
depth += 1
elif ch in ('}', ']'):
depth -= 1
if depth == 0:
end = i + 1
break
data = json.loads(content[start:end])
assert len(data) > 0
assert data[0]['col'] == 1000
assert data[0]['row'] == 6881
def test_attach_gps_bounds():
"""attach_gps_bounds ajoute des bounds GPS ordonnées (France métropolitaine)."""
@ -718,74 +564,7 @@ def test_approx_wgs84_to_l93_roundtrip():
x2, y2 = _approx_wgs84_to_l93(lon, lat)
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6
def test_overview_generated_for_large_grids(tmp_path):
"""Grille >= 6 dalles : une mosaïque d'overview par couche + le payload."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
for col in (1000, 1001, 1002):
for row in (6881, 6882):
_make_fake_viz_dir(vis_dir, "t", col, row, ('hillshade_multi', 'svf'))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
ov = data['overview']
assert 'hillshade_multi' in ov and 'svf' in ov
items = ov['hillshade_multi']['items']
assert len(items) == 1 # une seule zone géographique
meta = items[0]
assert (output_dir / meta['url'].split('?')[0]).is_file()
assert meta['km_w'] == 3 and meta['km_h'] == 2
assert meta['w'] == 3 * meta['px_per_km']
assert meta['h'] == 2 * meta['px_per_km']
assert len(meta['corners']) == 4
# La coquille HTML embarque la constante pour l'interface
html = (output_dir / "index.html").read_text(encoding='utf-8')
assert "const OVERVIEW" in html
assert "index_overview/hillshade_multi_" in html
def test_overview_one_mosaic_per_zone(tmp_path):
"""Zones géographiques distantes : une mosaïque PAR ZONE (pas une grille
globale étirée sur des centaines de km aux coins introuvables)."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
# Deux clusters de 2×3 dalles, à ~400 km l'un de l'autre
for col in (1000, 1001, 1002):
for row in (6881, 6882):
_make_fake_viz_dir(vis_dir, "a", col, row, ('hillshade_multi',))
for col in (1400, 1401, 1402):
for row in (6481, 6482):
_make_fake_viz_dir(vis_dir, "b", col, row, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
items = data['overview']['hillshade_multi']['items']
assert len(items) == 2
assert items[0]['km_w'] == 3 and items[0]['km_h'] == 2
assert items[1]['km_w'] == 3 and items[1]['km_h'] == 2
# Les deux mosaïques sont distinctes (coins différents)
assert items[0]['corners'] != items[1]['corners']
def test_overview_skipped_for_small_grids(tmp_path):
"""Grille < 6 dalles : pas d'overview, les vignettes suffisent."""
from lidar_pipeline.index import build_index
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',))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
assert data['overview'] == {}
def test_subtile_corners_shared_edges_exact():