Tuiles visibles en direct après régénération et déploiement webapp distante

- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et
  force le rechargement navigateur (cache heuristique contourné), y compris
  en plein run via /api/tiles ; veille permanente 15 s sur la carte
- simulation locale du mode deux machines (docker-compose.local-2m.yml) :
  worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/
- override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output,
  rsync vers le worker, labels Traefik (proxy/websecure/myresolver)
- purge 0,5 m : worker/process et politique générale passés à 0,2 m seul
- intègre le travail parallèle non commité : export mosaïque multi-dalles
  (export.py + /api/export), sous-tuilage intégral des couches, légendes
  VIZ_LEGENDS, docs et tests associés (213 tests verts)
This commit is contained in:
Antoine Jacquin
2026-09-12 17:48:37 +02:00
parent 78a2c328c3
commit 68272cabf0
16 changed files with 1769 additions and 165 deletions

View File

@ -237,18 +237,17 @@ 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 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.
# Barre de génération : sélecteur proposant TOUTES les couches du registre
# (valeurs = noms d'étapes --only), y compris celles absentes du panneau
# (svf, hillshade) pour pouvoir les générer. La sélection est envoyée à
# /api/preview (détection des tuiles incomplètes) comme à /api/generate.
assert 'id="genViz"' in content
assert 'value="wavelet"' in content
assert 'value="aniso_open"' not in content
assert 'value="pos_open"' in content
assert 'value="slope"' in content
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content
assert 'value="svf"' in content
assert 'value="hillshade"' 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) :
@ -312,29 +311,67 @@ def test_mid_thumbnails_generated(tmp_path):
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
viz = data['tiles'][0]['viz']['hillshade_multi']
assert 'mid' in viz
assert (output_dir / viz['mid']).exists()
assert viz['mid'].endswith("_mid.jpg")
# L'URL porte un suffixe ?v= (invalidation cache) : chemin sans lui
assert (output_dir / viz['mid'].split('?')[0]).exists()
assert viz['mid'].split('?')[0].endswith("_mid.jpg")
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',
'wavelet'):
assert key in _CARTO_SUBTILED_VIZ
def test_subtiles_cover_all_layers(tmp_path):
"""Toutes les couches d'une dalle 0,2 m sont sous-tuilées (plus de liste courte)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ, build_index
assert _CARTO_SUBTILED_VIZ == ()
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', 'topo'), res_suffix='_r0p2')
assert build_index(output_dir) is not None
sub_dir = output_dir / "index_subtiles"
for viz in ('hillshade_multi', 'svf', 'topo'):
avif = sub_dir / f"LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_{viz}_0_0.avif"
assert avif.exists(), viz
# L'index référence les sous-tuiles pour chaque couche, pas le repli dalle
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
assert len(subs) == 4
for t in subs:
for viz in ('hillshade_multi', 'svf', 'topo'):
assert t['viz'][viz]['full'].startswith("index_subtiles/"), viz
def test_panel_restricted_to_requested_layers():
"""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
def test_panel_offers_all_layers():
"""Le panneau n'est plus restreint : toutes les couches connues sont proposées."""
from lidar_pipeline.index import PANEL_VIZ, VIZ_LABELS, 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
assert PANEL_VIZ is None
# Chaque couche connue du registre correspond à une étape --only valide
steps = {name for name, _ in VIZ_STEPS}
for key in PANEL_VIZ:
assert set(VIZ_LABELS)
for key in VIZ_LABELS:
assert KEYWORD_TO_STEP.get(key, key) in steps
def test_panel_selection_lists_full_registry():
"""Le panneau propose le registre complet, même sans données sur disque.
Une installation neuve (ou une couche jamais générée) doit quand même
afficher toutes les couches connues ; les couches historiques présentes
sur disque restent proposées (libellé = clé brute).
"""
from lidar_pipeline.index import _panel_selection, VIZ_LABELS
ordered, default = _panel_selection({'aspect'})
assert set(VIZ_LABELS) <= set(ordered)
assert default == 'aspect'
ordered, default = _panel_selection(set())
assert set(VIZ_LABELS) <= set(ordered)
assert default # toujours une couche par défaut
ordered, default = _panel_selection({'aniso_open'})
assert 'aniso_open' 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.
@ -365,8 +402,8 @@ def test_build_index_merges_cross_resolution_viz(tmp_path):
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']
assert (output_dir / v['thumb'].split('?')[0]).exists(), v['thumb']
assert (output_dir / v['full'].split('?')[0]).exists(), v['full']
def test_build_index_regenerates_stale_thumbnails(tmp_path):
@ -433,6 +470,72 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect intact
def test_urls_versioned_for_cache_busting(tmp_path):
"""Les URLs images changent quand une tuile est régénérée.
Les images sont servies par des montages statiques sans Cache-Control :
sans version d'URL, le navigateur garde son ancien rendu en cache même
après recalcul. Chaque URL (vignette, intermédiaire, pleine) porte la
mtime de sa source en ?v= — un recalcul change l'URL et force le
rechargement, y compris en direct pendant un run (--incremental-index).
"""
import os
import re
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
assert build_index(output_dir) is not None
def read_urls():
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
viz = data['tiles'][0]['viz']['hillshade_multi']
return viz['thumb'], viz['mid'], viz['full']
thumb1, mid1, full1 = read_urls()
for url in (thumb1, mid1, full1):
assert re.search(r"\?v=\d+$", url), url # versionnée (mtime ms)
assert (output_dir / url.split('?')[0]).exists(), url
# Même source, rebuild → URLs identiques (cache navigateur conservé)
assert build_index(output_dir) is not None
assert read_urls() == (thumb1, mid1, full1)
# Régénération : mtime de la source plus récente → nouvelles URLs
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
m = src.stat().st_mtime
os.utime(src, (m + 10, m + 10))
assert build_index(output_dir) is not None
thumb2, mid2, full2 = read_urls()
assert (thumb2, mid2, full2) != (thumb1, mid1, full1)
assert thumb2.split('?')[0] == thumb1.split('?')[0] # même fichier, version neuve
def test_subtile_urls_versioned(tmp_path):
"""Les URLs des sous-tuiles 0,2 m sont aussi versionnées (?v=)."""
import re
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',),
res_suffix='_r0p2')
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
assert subs
for t in subs:
for v in t['viz'].values():
for url in (v['thumb'], v['mid'], v['full']):
assert re.search(r"\?v=\d+$", url), url
assert (output_dir / url.split('?')[0]).exists(), url
def test_build_index_empty_returns_none(tmp_path):
"""Aucune tuile → build_index retourne None sans crash."""
from lidar_pipeline.index import build_index