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

@ -14,6 +14,9 @@ Fonctionnalités :
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
dessin d'un rectangle → téléchargement IGN + traitement.
- Onglet Export : sélection de dalles sur la carte → mosaïque jointive
(image ou PDF multi-couches) téléchargeable pour consultation sur
téléphone (/api/export de la webapp, module export.py).
Intégration:
- Appelé automatiquement à la fin de process_all() dans pipeline.py
@ -50,13 +53,158 @@ VIZ_LABELS = {
'topo': 'Carte topographique IGN',
}
# Légendes des couches : titre, lecture du rendu (couleurs), méthode de calcul
# et dégradé de colormap. Source unique partagée entre rendering.py (images
# unitaires) et export.py (bandeau légende des mosaïques multi-dalles) — ce
# module reste volontairement sans dépendance lourde (webapp légère : ni
# matplotlib, ni GDAL).
# 'gradient' : 9 arrêts échantillonnés sur la colormap matplotlib (plt.get_cmap
# à i/8) pour dessiner une barre de dégradé sans matplotlib ; la cohérence
# avec rendering.COLORMAPS est vérifiée par les tests (test_export.py).
# 'ticks' : libellés des extrémités du dégradé (None = pas de barre).
VIZ_LEGENDS = {
'hillshade_multi': {
'title': 'Hillshade Multidirectionnel',
'legend': 'Illumination combinée de 8 directions (échelle fixe 0–1)\nBlanc = Face éclairée | Noir = Zone d\'ombre\nCouleurs homogènes entre tuiles',
'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)',
'cmap': 'gray',
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
'ticks': ('0', '1'),
},
'slope': {
'title': 'Pente (Inclinaison du terrain)',
'legend': 'Inclinaison en degrés\nÉchelle fixe 0–30° — couleurs homogènes entre tuiles\nJaune = Forte pente | Violet foncé = Terrain plat',
'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre',
'cmap': 'inferno',
'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754',
'#e45a31', '#f98e09', '#f9cb35', '#fcffa4'),
'ticks': ('0°', '30°'),
},
'aspect': {
'title': 'Aspect (Direction des pentes)',
'legend': 'Direction vers laquelle le terrain descend\nCycle continu : Nord→Est→Sud→Ouest→Nord\nCouleurs perceptuellement uniformes (pas de saut de teinte)',
'description': 'Orientation des pentes — utile pour distinguer structures des formes naturelles',
'cmap': 'twilight',
'gradient': ('#e2d9e2', '#95b5c7', '#6276ba', '#592a8f', '#2f1436',
'#741e4f', '#b25652', '#cca389', '#e2d9e2'),
'ticks': ('Nord 0°', 'Nord 360°'),
},
'mslrm': {
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
'legend': 'Relief combiné multi-échelles (σ locales, échelle fixe ±3σ)\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nÉchelles : 2 à 200m, pondérées 5–20m\nCouleurs homogènes entre tuiles\nDétecte du micro au macro',
'description': 'Combine LRM à 5–7 échelles — détecte structures de 5m à 100m simultanément',
'cmap': 'seismic',
'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd',
'#ff7d7d', '#fe0000', '#be0000', '#800000'),
'ticks': ('-3σ', '+3σ'),
},
'sailore': {
'title': 'SAILORE - LRM Auto-Adaptatif',
'legend': 'Relief local adaptatif (σ locales, échelle fixe ±3σ)\nRouge = Surélévation | Bleu = Dépression\nCouleurs homogènes entre tuiles\n\nNoyau adapté à la pente locale\nPlat = grand noyau (25m) | Pente = petit noyau (2m)',
'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau',
'cmap': 'seismic',
'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd',
'#ff7d7d', '#fe0000', '#be0000', '#800000'),
'ticks': ('-3σ', '+3σ'),
},
'positive_openness': {
'title': 'Openness Positive (ouverture vers le haut)',
'legend': 'Angle d\'ouverture vers le ciel (z-score local)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles',
'description': 'Ray-tracing 8 directions, multi-rayon — détecte crêtes et sommets',
'cmap': 'YlOrBr',
'gradient': ('#ffffe5', '#fff7bc', '#fee390', '#fec34f', '#fe9829',
'#eb6f14', '#cb4b02', '#983404', '#662506'),
'ticks': ('-3σ', '+3σ'),
},
'negative_openness': {
'title': 'Openness Negative (ouverture vers le bas)',
'legend': 'Angle d\'ouverture vers le bas (z-score local)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nMeilleur détecteur de cavités et dolines',
'description': 'Ray-tracing 8 directions, multi-rayon — détecte fossés, dolines, souterrains',
'cmap': 'PuBu',
'gradient': ('#fff7fb', '#ece7f2', '#d0d1e6', '#a5bddb', '#73a9cf',
'#358fc0', '#056faf', '#04598c', '#023858'),
'ticks': ('-3σ', '+3σ'),
},
'svf': {
'title': 'Sky-View Factor (fraction de ciel visible)',
'legend': 'Proportion de ciel visible (échelle physique fixe 0–1)\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nCouleurs homogènes entre tuiles\nLes fossés ressortent en vif — excellent pour structures linéaires',
'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre',
'cmap': 'hot_r',
'gradient': ('#ffffff', '#ffff81', '#ffff03', '#ffad00', '#ff5900',
'#ff0500', '#b00000', '#5c0000', '#0b0000'),
'ticks': ('0', '1'),
},
'roughness': {
'title': 'Rugosité Multi-Échelle (3m + 15m)',
'legend': 'Irrégularité du terrain combinée fine + large\nViolet foncé = Surface lisse (route, mur, sol plat)\nJaune vif = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)',
'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses',
'cmap': 'plasma',
'gradient': ('#0d0887', '#4c02a1', '#7e03a8', '#aa2395', '#cc4778',
'#e66c5c', '#f89540', '#fdc527', '#f0f921'),
'ticks': ('lisse', 'rugueux'),
},
'wavelet': {
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
'cmap': 'inferno',
'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754',
'#e45a31', '#f98e09', '#f9cb35', '#fcffa4'),
'ticks': ('bruit', 'structure'),
},
'flow_acc': {
'title': 'Accumulation d\'Écoulement (Flow Accumulation)',
'legend': 'Log10 du nombre de cellules amont\nVert foncé = Forte accumulation (fossé, chenal, drainage)\nJaune = Faible accumulation (terrain plat)\n\nDétection fossés et linéaires hydrologiques',
'description': 'Priority-flood + D8 — détecte fossés archéologiques et drainages',
'cmap': 'YlGn',
'gradient': ('#ffffe5', '#f7fcb9', '#d9f0a3', '#acdd8e', '#77c679',
'#40aa5c', '#228343', '#006737', '#004529'),
'ticks': ('faible', 'forte'),
},
'solar': {
'title': 'Éclairage Solaire',
'legend': "Illumination solaire (azimut 90°, altitude 30°)\nClair = Face éclairée | Sombre = Zone d'ombre",
'description': "Simulation de l'éclairage solaire matinal",
'cmap': 'gray',
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
'ticks': ('0', '1'),
},
'anomaly': {
'title': 'Carte d\'Anomalies (détection automatique)',
'legend': 'Score d\'anomalie composite (0–1)\nRouge = Haute anomalie (structures suspectes)\nJaune = Anomalie modérée\nBlanc = Aucun signal (terrain naturel)\n\nSeuil auto: pixels > 2σ de la moyenne locale\nCombiné: MSRM, SVF, Ondelette, Openness, Rugosité',
'description': 'Détection automatique — cible à vérifier sur terrain',
'cmap': 'YlOrRd',
'gradient': ('#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8c3c',
'#fc4d2a', '#e2191c', '#bb0026', '#800026'),
'ticks': ('0', '1'),
},
'ortho': {
'title': 'Photographie Aérienne IGN',
'legend': 'Orthophotographie\nImage aérienne',
'description': 'Photographie aérienne IGN (Orthophoto)',
'cmap': None,
'gradient': None,
'ticks': None,
},
'topo': {
'title': 'Carte Topographique IGN',
'legend': 'Carte IGN\nPlan topographique',
'description': 'Carte topographique IGN (Plan IGN)',
'cmap': None,
'gradient': None,
'ticks': None,
},
}
# Couche activée par défaut à l'ouverture de la carte.
DEFAULT_VIZ = 'hillshade_multi'
# Visualisations proposées dans le panneau de couches et le sélecteur de
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
# les couches réellement proposées à l'affichage (voir _render_html).
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
# Restriction optionnelle du panneau de couches : None = proposer toutes
# les visualisations présentes sur disque (aucune restriction). Le sélecteur
# de génération/régénération est piloté par le même registre (voir _render_html).
PANEL_VIZ = None
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
@ -95,11 +243,10 @@ def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
return {cell for cell, per_res in by_cell.items()
if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)}
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
# carte — les autres viz restent disponibles en repli dalle entière.
# Liste vide = découper toutes les visualisations disponibles.
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'wavelet')
# Toutes les visualisations sont découpées en sous-tuiles (quadrants 500 m)
# pour alléger la carte. Renseigner un tuple pour restreindre la découpe —
# les viz exclues retombent en repli dalle entière.
_CARTO_SUBTILED_VIZ = ()
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
@ -108,6 +255,12 @@ _CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness'
_SUBTILE_AVIF_QUALITY = 55
_SUBTILE_AVIF_SPEED = 8
# Couches à contenu photographique ou traits fins (orthophoto, carte topo) :
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
# relevée pour ces couches uniquement.
_SUBTILE_AVIF_QUALITY_DETAIL = 75
_SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'})
# Vignette intermédiaire (px) : pallier entre la vignette 256 px et l'image
# pleine résolution, pour éviter d'étirer la vignette ou de décoder l'AVIF
# complet dès qu'une tuile dépasse ~300 px à l'écran.
@ -423,6 +576,20 @@ def _cached_file_fresh(path, src_mtime):
return src_mtime is None or cached_mtime >= src_mtime
def _url_version(mtime):
"""Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu.
Les images de la carte sont servies par des montages statiques sans
Cache-Control : après une régénération, le cache heuristique du navigateur
peut continuer d'afficher l'ancien rendu (même URL = image considérée
fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un
recalcul change l'URL et force le rechargement — y compris en direct
pendant un run : pollLiveTiles fusionne les nouvelles données et
updateImages() voit un src différent.
"""
return f"?v={int(mtime * 1000)}" if mtime is not None else ""
def generate_thumbnail(src_path, thumb_path, max_size=256, mid_path=None,
mid_size=640):
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
@ -513,6 +680,13 @@ def _subtile_corners(corners, i, j, k):
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
def _fallback_full_dalle(entries, viz_key, info):
"""Repli dalle entière pour une couche non découpable : elle reste
superposable en couche (images plus lourdes, mais fonctionnelles)."""
for entry in entries.values():
entry['viz'][viz_key] = dict(info)
def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
"""Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive.
@ -555,11 +729,15 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
info = tile['viz'].get(viz_key)
if not info:
continue
avif_quality = (_SUBTILE_AVIF_QUALITY_DETAIL
if viz_key in _SUBTILE_DETAIL_VIZ
else _SUBTILE_AVIF_QUALITY)
stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}"
for key in entries}
# Régénère si au moins un fichier manque ou est périmé (dalle
# source recalculée depuis — comme les vignettes)
src = output_dir / info['full']
# source recalculée depuis — comme les vignettes). L'URL pleine
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
src = output_dir / info['full'].split('?')[0]
src_mtime = _mtime(src)
all_exist = all(
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
@ -574,10 +752,12 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
img.load()
except Exception as e:
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
return None
_fallback_full_dalle(entries, viz_key, info)
continue
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
W, H = img.size
cut_failed = False
for (i, j), stem in stems.items():
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
left, right = round(W * i / k), round(W * (i + 1) / k)
@ -586,12 +766,13 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
quad = img.crop((left, top, right, bottom))
try:
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
format='AVIF', quality=_SUBTILE_AVIF_QUALITY,
format='AVIF', quality=avif_quality,
speed=_SUBTILE_AVIF_SPEED)
except Exception as e:
logger.warning(f"Encodage AVIF impossible ({stem}), "
f"sous-tuilage abandonné : {e}")
return None
f"repli dalle entière pour {viz_key} : {e}")
cut_failed = True
break
# Supprime l'ancien crop .webp d'une génération précédente
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
@ -607,11 +788,15 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
max(1, int(quad.size[1] * scale))), resample)
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
format='WEBP', quality=80)
if cut_failed:
_fallback_full_dalle(entries, viz_key, info)
continue
v = _url_version(src_mtime)
for (i, j), stem in stems.items():
entries[(i, j)]['viz'][viz_key] = {
'thumb': f"{sub_dir_name}/{stem}_thumb.webp",
'mid': f"{sub_dir_name}/{stem}_mid.webp",
'full': f"{sub_dir_name}/{stem}.avif",
'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
'full': f"{sub_dir_name}/{stem}.avif{v}",
}
except Exception as e:
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
@ -620,13 +805,12 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
usable = [e for e in entries.values() if e['viz']]
if not usable:
return None
# Repli dalle entière pour les visualisations non découpées : elles restent
# superposables en couche (images plus lourdes, mais fonctionnelles).
# Repli dalle entière pour les visualisations hors sélection : elles
# restent superposables en couche (images plus lourdes, mais fonctionnelles).
sub_keys = set(offered_viz_keys)
for entry in usable:
for viz_key, info in tile['viz'].items():
if viz_key not in sub_keys:
entry['viz'][viz_key] = dict(info)
for viz_key, info in tile['viz'].items():
if viz_key not in sub_keys:
_fallback_full_dalle(entries, viz_key, info)
return usable
@ -799,12 +983,13 @@ def build_index(output_dir, output_format='avif'):
else:
thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name
v = _url_version(src_mtime)
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
'thumb': f"index_thumbs/{thumb_name}{v}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}",
}
if mid_path.is_file():
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}"
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}{v}"
if regen:
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
@ -909,9 +1094,11 @@ def _panel_selection(all_viz_keys):
Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit
décrire le panneau exactement comme la page au moment du rebuild).
"""
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
# les autres visualisations existent sur disque mais ne sont pas proposées.
panel_keys = set(all_viz_keys)
# Panneau complet : toutes les couches du registre (VIZ_LABELS), même
# absentes du disque — activables dès leur apparition — plus les couches
# historiques éventuellement trouvées sur disque (libellé = clé brute).
# PANEL_VIZ permet toujours de restreindre volontairement la sélection.
panel_keys = set(VIZ_LABELS) | set(all_viz_keys)
if PANEL_VIZ is not None:
panel_keys &= set(PANEL_VIZ)
# Ordre des couches dans le panneau (selon ordre préféré puis alpha).
@ -919,10 +1106,12 @@ def _panel_selection(all_viz_keys):
for v in sorted(panel_keys):
if v not in ordered_viz:
ordered_viz.append(v)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
# Couche activée par défaut : préférence globale puis aspect, à condition
# d'exister sur le disque ; sinon la 1re proposée présente, sinon la 1re.
default_viz = next(
(v for v in (DEFAULT_VIZ, 'aspect') if v in all_viz_keys),
next((v for v in ordered_viz if v in all_viz_keys),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ))
return ordered_viz, default_viz
@ -932,13 +1121,13 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
tiles_json = json.dumps(tiles, ensure_ascii=False)
# Options du sélecteur de génération/régénération : uniquement les couches
# réellement proposées dans le panneau de visualisation (mêmes couches,
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Toutes
# pré-sélectionnées : une régénération refait la dalle complète du panneau,
# l'utilisateur pouvant désélectionner pour cibler. Repli sur aspect si
# aucune couche n'est encore disponible.
gen_keys = ordered_viz if ordered_viz else ['aspect']
# Options du sélecteur de génération/régénération : TOUTES les couches du
# registre (mêmes libellés que le panneau ; valeurs = noms d'étapes --only
# du pipeline), y compris celles absentes du disque — pour pouvoir les
# générer. Toutes pré-sélectionnées : une régénération refait la dalle
# complète, l'utilisateur pouvant désélectionner pour cibler.
gen_keys = [v for v in _VIZ_FALLBACK_ORDER if v in VIZ_LABELS]
gen_keys += sorted(k for k in VIZ_LABELS if k not in gen_keys)
gen_viz_options = "\n".join(
f' <option value="{KEYWORD_TO_STEP.get(v, v)}" selected>'
f'{VIZ_LABELS.get(v, v)}</option>'
@ -996,6 +1185,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<button class="tab active" data-tab="layers">Couches</button>
<button class="tab" data-tab="gen">Génération<span id="genBadge" class="tab-badge" hidden></span></button>
<button class="tab" data-tab="tile">Tuile</button>
<button class="tab" data-tab="export">Export</button>
</div>
<div class="tab-page" id="tab-layers">
@ -1013,9 +1203,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
</div>
<div class="tab-page" id="tab-gen" hidden>
<!-- Actions Zone / Compléter : réservées à cet onglet -->
<!-- Actions Zone / Sélection / Compléter : réservées à cet onglet -->
<div class="gen-actions">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnGenSel" title="Régénérer des tuiles précises : cliquer des dalles sur la carte pour les ajouter ou les retirer" hidden>↻ Sélectionner des tuiles</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
</div>
@ -1027,7 +1218,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
</label>
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
<span>Visualisations :</span>
<select id="genViz" class="gen-select" multiple size="4">
<select id="genViz" class="gen-select" multiple size="8">
{gen_viz_options}
</select>
</label>
@ -1070,6 +1261,53 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<div class="tile-empty">Cliquez sur une tuile de la carte pour afficher ses informations de génération.</div>
</div>
</div>
<div class="tab-page" id="tab-export" hidden>
<div class="export-intro">
Assemblez des dalles adjacentes en une image ou un PDF consultable
hors ligne sur téléphone (collage bord à bord, sans séparation).
</div>
<div class="gen-actions">
<button id="btnExportSel" title="Activer la sélection : cliquer des dalles sur la carte pour les ajouter ou les retirer">⤓ Sélectionner des dalles</button>
<button id="exportClear" hidden>Effacer la sélection</button>
</div>
<div id="exportBar">
<div id="exportCount" class="export-count"></div>
<label class="gen-opt col">
<span>Résolution :</span>
<select id="exportRes" class="gen-select"></select>
</label>
<label class="gen-opt col">
<span>Visualisations <em>(une image/page par couche)</em> :</span>
<select id="exportViz" class="gen-select" multiple size="5"></select>
</label>
<label class="gen-opt col">
<span>Format :</span>
<select id="exportFormat" class="gen-select">
<option value="jpeg" selected>JPEG — image légère</option>
<option value="png">PNG — image sans perte</option>
<option value="webp">WebP — image</option>
<option value="pdf">PDF — une page par visualisation</option>
</select>
</label>
<label class="gen-opt col">
<span>Taille (plus grand côté) :</span>
<select id="exportSize" class="gen-select">
<option value="0">Pleine résolution</option>
<option value="4096" selected>4096 px — téléphone</option>
<option value="2048">2048 px — aperçu</option>
</select>
</label>
<div class="gen-run">
<button id="exportGo" class="primary">Exporter</button>
</div>
<div id="exportMsg"></div>
</div>
<div id="exportDone" hidden>
<div class="tile-info-sub">Exports de la session</div>
<div id="exportDoneList"></div>
</div>
</div>
</div>
</aside>
@ -1377,8 +1615,8 @@ body.zooming .ltile { will-change: transform; }
/* Actions Zone / Compléter : propres à l'onglet, pleine largeur */
.gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.gen-actions button { width: 100%; font-size: 12.5px; }
#btnGenZone { background: var(--accent-dim); border-color: var(--accent-dim); }
#btnGenZone:hover { background: var(--accent); border-color: var(--accent); }
#btnGenZone, #btnGenSel { background: var(--accent-dim); border-color: var(--accent-dim); }
#btnGenZone:hover, #btnGenSel:hover { background: var(--accent); border-color: var(--accent); }
#btnComplete {
font-weight: 600; letter-spacing: 0.02em;
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
@ -1474,6 +1712,46 @@ body.zooming .ltile { will-change: transform; }
.gen-tile.done .gen-tile-state { color: #39d98a; }
.gen-tile.failed .gen-tile-state { color: var(--danger); }
/* --- Onglet Export : mosaïque de dalles adjacentes (téléphone) --- */
.export-intro {
font-size: 12px; color: var(--muted); line-height: 1.5; padding: 4px 2px 0;
}
#btnExportSel { background: var(--accent-dim); border-color: var(--accent-dim); }
#btnExportSel:hover { background: var(--accent); border-color: var(--accent); }
.export-count { font-size: 12.5px; font-weight: 600; }
.export-count .sub { font-weight: 400; color: var(--muted); font-size: 11px; }
#exportBar {
display: none; flex-direction: column; gap: 8px;
margin-top: 8px; padding: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--panel-border); border-radius: 8px;
}
#exportBar.show { display: flex; }
#exportMsg { font-size: 12.5px; line-height: 1.45; }
#exportDone { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; }
#exportDoneList { display: flex; flex-direction: column; gap: 6px; }
#exportDoneList a {
font-size: 11.5px; color: var(--accent); text-decoration: none;
word-break: break-all;
}
#exportDoneList a:hover { text-decoration: underline; }
#exportDoneList .dl-ic { margin-right: 4px; }
/* Surlignage vert des dalles retenues pour l'export (surcouche écran
uniquement — l'image exportée colle les dalles sans aucun trait). */
.ltile.exportSel {
border-color: #39d98a;
box-shadow: 0 0 0 1px #39d98a, 0 0 12px rgba(57, 217, 138, 0.5);
}
body.exporting, body.exporting #viewport { cursor: pointer; }
/* Surlignage bleu des dalles retenues pour la régénération (mode
« Sélectionner des tuiles » de l'onglet Génération). */
.ltile.genSel {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5);
}
body.genSel, body.genSel #viewport { cursor: pointer; }
/* Barres de défilement fines et discrètes */
.tab-page::-webkit-scrollbar,
.gen-task-log::-webkit-scrollbar { width: 8px; }
@ -1617,7 +1895,11 @@ function registerTile(t) {
interactive: true,
keyboard: false
}).addTo(map);
mk.on('click', () => { if (!genMode) showTileInfo(t); });
mk.on('click', () => {
if (exportMode) { toggleExportSel(t); return; }
if (genSelMode) { toggleGenSel(t); return; }
if (!genMode) showTileInfo(t);
});
const el = mk.getElement();
markers.set(tileKey(t), {
mk: mk, t: t,
@ -2105,7 +2387,11 @@ function hideTileInfo() {
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') hideTileInfo();
if (e.key === 'Escape') {
hideTileInfo();
if (exportMode) setExportMode(false);
if (genSelMode) setGenSelMode(false);
}
});
function regenerateCurrentTile() {
@ -2126,6 +2412,7 @@ function regenerateCurrentTile() {
let apiAvailable = false;
let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR)
const genBtn = document.getElementById('btnGenZone');
const btnGenSel = document.getElementById('btnGenSel');
const btnComplete = document.getElementById('btnComplete');
const genBar = document.getElementById('genBar');
const genMsg = document.getElementById('genMsg');
@ -2144,6 +2431,8 @@ const genQueueCount = document.getElementById('genQueueCount');
let genMode = false, drawStart = null, drawRect = null;
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
const ghostCells = [];
let genSelMode = false;
const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération
// Affiche la barre de confirmation de génération, dans l'onglet Génération
// (bascule automatique sur cet onglet).
@ -2155,17 +2444,16 @@ function showGenBar(msg) {
fetch('/api/status').then(r => {
apiAvailable = r.ok;
if (!apiAvailable) hideExportTab();
return r.ok ? r.json() : null;
}).then(d => {
if (!d) { if (apiAvailable) { genBtn.hidden = false; btnComplete.hidden = false; } return; }
const genBtns = [genBtn, btnGenSel, btnComplete];
if (!d) { if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = false; }); return; }
// Génération réservée au réseau local (LIDAR_REGEN_CIDR, cf. webapp.py) :
// les boutons de traitement restent masqués pour les autres clients.
regenAllowed = d.regen_allowed !== false;
if (apiAvailable) {
genBtn.hidden = !regenAllowed;
btnComplete.hidden = !regenAllowed;
}
}).catch(() => {});
if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = !regenAllowed; });
}).catch(() => { if (!apiAvailable) hideExportTab(); });
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
@ -2187,6 +2475,7 @@ function exitGenMode() {
genBtn.addEventListener('click', () => {
if (genMode) { exitGenMode(); return; }
clearGhosts();
clearGenSel();
genBar.classList.remove('show');
genMode = true;
map.dragging.disable();
@ -2239,12 +2528,92 @@ map.on('mouseup', e => {
}).catch(() => { showGenBar('API indisponible'); });
});
// « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une
// dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues
// (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de
// confirmation permet ensuite de choisir les couches et les options avant de
// lancer la régénération (--force --force-classification) sur la sélection.
function genSelKey(col, row) { return col + ',' + row; }
function refreshGenSelHighlights() {
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (!div) continue;
div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row)));
}
}
function genSelCells() {
return [...genSel].map(k => k.split(',').map(Number));
}
function updateGenSelBar() {
showGenBar(genSel.size
? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'
: 'Mode sélection : clic sur dalle = ajouter / retirer');
genGo.style.display = genSel.size ? '' : 'none';
}
function toggleGenSel(t) {
const key = genSelKey(t.col, t.row);
if (genSel.has(key)) genSel.delete(key);
else genSel.add(key);
refreshGenSelHighlights();
pendingTiles = genSelCells();
pendingRegen = true;
pendingCompleteAll = false;
updateGenSelBar();
}
function setGenSelMode(on) {
genSelMode = on;
document.body.classList.toggle('genSel', on);
if (btnGenSel) {
btnGenSel.classList.toggle('primary', on);
btnGenSel.innerHTML = on
? '✓ Sélection en cours — cliquer des dalles'
: '↻ Sélectionner des tuiles';
}
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider).
if (on && document.body.classList.contains('menu-open')) {
document.body.classList.remove('menu-open');
}
if (hintChip) {
hintChip.innerHTML = on
? 'Mode sélection : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
: hintDefaultHtml;
}
if (on) {
if (genMode) exitGenMode();
clearGhosts();
pendingCompleteAll = false;
genRegen.checked = true; // régénération ciblée : retraitement forcé
switchTab('gen');
updateGenSelBar();
}
}
// Quitte le mode sélection et vide la sélection (changement de mode).
function clearGenSel() {
if (genSelMode) setGenSelMode(false);
if (genSel.size) {
genSel.clear();
refreshGenSelHighlights();
}
}
if (btnGenSel) btnGenSel.addEventListener('click', () => {
setGenSelMode(!genSelMode);
});
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
// moins une des visualisations choisies (pas de téléchargement) — la barre de
// confirmation reste utilisable (choix des couches, classification, Lancer).
btnComplete.addEventListener('click', () => {
if (!apiAvailable) return;
clearGhosts();
clearGenSel();
genGo.style.display = 'none';
showGenBar('Analyse des tuiles présentes dans input/...');
fetch('/api/preview', {
@ -2274,6 +2643,7 @@ genGo.addEventListener('click', () => {
clearGhosts();
const nTiles = pendingTiles.length;
const isRegen = genRegen.checked || pendingRegen;
const fromSel = genSel.size > 0;
fetch('/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: pendingTiles,
@ -2288,6 +2658,8 @@ genGo.addEventListener('click', () => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
pendingRegen = false;
if (fromSel) clearGenSel();
return startGenTask(nTiles + ' tuile(s) — ' + verb);
})
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
@ -2297,9 +2669,219 @@ genCancel.addEventListener('click', () => {
genBar.classList.remove('show');
clearGhosts();
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
clearGenSel();
if (genMode) exitGenMode();
});
// ============================================================
// Export : mosaïque de dalles adjacentes (image ou PDF téléphone)
// ============================================================
// Mode sélection : le clic sur une dalle l'ajoute ou la retire (surlignage
// vert), les 4 sous-tuiles 0,2 m comptant pour une seule dalle. La
// validation assemble les images pleine résolution côté serveur
// (/api/export) : collage bord à bord, aucun trait entre les dalles.
const btnExportSel = document.getElementById('btnExportSel');
const exportClearBtn = document.getElementById('exportClear');
const exportBarEl = document.getElementById('exportBar');
const exportCountEl = document.getElementById('exportCount');
const exportResSel = document.getElementById('exportRes');
const exportVizSel = document.getElementById('exportViz');
const exportFormatSel = document.getElementById('exportFormat');
const exportSizeSel = document.getElementById('exportSize');
const exportGoBtn = document.getElementById('exportGo');
const exportMsgEl = document.getElementById('exportMsg');
const exportDoneEl = document.getElementById('exportDone');
const exportDoneList = document.getElementById('exportDoneList');
const hintChip = document.getElementById('hint');
const hintDefaultHtml = hintChip ? hintChip.innerHTML : '';
let exportMode = false;
const exportSel = new Set(); // clés "col,row,résolution" des dalles retenues
// L'export passe par l'API : sans serveur (carte statique), l'onglet disparaît.
function hideExportTab() {
const b = document.querySelector('.tab[data-tab="export"]');
if (b) b.style.display = 'none';
}
function exportKey(col, row, res) { return col + ',' + row + ',' + res; }
function refreshExportHighlights() {
for (const entry of markers.values()) {
const div = entryDiv(entry);
if (!div) continue;
const t = entry.t;
div.classList.toggle('exportSel',
exportSel.has(exportKey(t.col, t.row, t.resolution)));
}
}
function toggleExportSel(t) {
const key = exportKey(t.col, t.row, t.resolution);
if (exportSel.has(key)) exportSel.delete(key);
else exportSel.add(key);
refreshExportHighlights();
updateExportPanel();
}
function setExportMode(on) {
exportMode = on;
document.body.classList.toggle('exporting', on);
btnExportSel.classList.toggle('primary', on);
btnExportSel.innerHTML = on
? '✓ Sélection en cours — cliquer des dalles'
: '⤓ Sélectionner des dalles';
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider l'export).
if (on && document.body.classList.contains('menu-open')) {
document.body.classList.remove('menu-open');
}
if (hintChip) {
hintChip.innerHTML = on
? 'Mode export : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
: hintDefaultHtml;
}
}
// Dalles retenues pour une résolution donnée
function exportSelCells(res) {
const cells = [];
for (const key of exportSel) {
const p = key.split(',');
if (Number(p[2]) === res) cells.push([Number(p[0]), Number(p[1])]);
}
return cells;
}
// Couches communes aux dalles sélectionnées (à la résolution choisie) :
// seules celles proposées par le panneau (VIZ_META) sont exportables.
function exportCommonViz(res) {
let common = null;
for (const key of exportSel) {
const p = key.split(',');
if (Number(p[2]) !== res) continue;
const keys = new Set();
for (const t of TILES) {
if (t.col === Number(p[0]) && t.row === Number(p[1])
&& t.resolution === res) {
for (const k in t.viz) if (VIZ_META[k]) keys.add(k);
}
}
common = common === null ? keys : new Set([...common].filter(k => keys.has(k)));
}
return common || new Set();
}
function updateExportResOptions() {
const resList = [...new Set([...exportSel].map(k => Number(k.split(',')[2])))].sort();
const prev = exportResSel.value !== '' ? Number(exportResSel.value) : null;
exportResSel.innerHTML = '';
for (const r of resList) {
const o = document.createElement('option');
o.value = String(r);
o.textContent = String(r).replace('.', ',') + ' m/px';
exportResSel.appendChild(o);
}
// Résolution conservée si encore valide, sinon la plus fine
exportResSel.value = (prev !== null && resList.includes(prev))
? String(prev) : String(resList[resList.length - 1]);
}
function updateExportVizOptions() {
const res = Number(exportResSel.value);
const common = [...exportCommonViz(res)].sort((a, b) => layerPos(a) - layerPos(b));
const prev = Array.from(exportVizSel.selectedOptions).map(o => o.value)
.filter(k => common.includes(k));
exportVizSel.innerHTML = '';
for (const k of common) {
const o = document.createElement('option');
o.value = k;
o.textContent = VIZ_META[k] ? VIZ_META[k].label : k;
exportVizSel.appendChild(o);
}
// Défaut : la couche active la plus haute, sinon la première proposée
const sel = prev.length ? prev
: [activeLayers().filter(k => common.includes(k)).pop() || common[0]].filter(Boolean);
[...exportVizSel.options].forEach(o => { o.selected = sel.includes(o.value); });
}
function updateExportPanel() {
const has = exportSel.size > 0;
exportBarEl.classList.toggle('show', has);
exportClearBtn.hidden = !has;
exportMsgEl.textContent = '';
if (!has) return;
updateExportResOptions();
updateExportVizOptions();
updateExportCount();
}
function updateExportCount() {
const res = Number(exportResSel.value);
const n = exportSelCells(res).length;
const cols = exportSelCells(res).map(c => c[0]);
const rows = exportSelCells(res).map(c => c[1]);
let sub = '';
if (n) {
sub = ' · X ' + Math.min(...cols) + '–' + (Math.max(...cols) + 1)
+ ' · Y ' + (Math.min(...rows) - 1) + '–' + Math.max(...rows) + ' km';
}
exportCountEl.innerHTML = n + ' dalle' + (n > 1 ? 's' : '')
+ ' sélectionnée' + (n > 1 ? 's' : '')
+ ' <span class="sub">' + sub + '</span>';
}
btnExportSel.addEventListener('click', () => {
setExportMode(!exportMode);
if (exportMode) switchTab('export');
});
exportClearBtn.addEventListener('click', () => {
exportSel.clear();
refreshExportHighlights();
updateExportPanel();
});
exportResSel.addEventListener('change', () => { updateExportVizOptions(); updateExportCount(); });
// Plusieurs couches sélectionnées : une image par couche (comme le PDF fait
// une page par couche) — chaque fichier porte la légende de SA couche.
exportGoBtn.addEventListener('click', () => {
const res = Number(exportResSel.value);
const cells = exportSelCells(res);
const viz = Array.from(exportVizSel.selectedOptions).map(o => o.value);
if (!cells.length || !viz.length) {
exportMsgEl.textContent = 'Sélectionnez au moins une dalle et une visualisation.';
return;
}
exportGoBtn.disabled = true;
exportMsgEl.textContent = 'Assemblage en cours… (' + cells.length + ' dalle' + (cells.length > 1 ? 's' : '') + ')';
fetch('/api/export', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: cells, viz: viz, resolution: res,
format: exportFormatSel.value,
max_side: Number(exportSizeSel.value) })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(d => {
const files = d.fichiers || [];
exportMsgEl.textContent = files.length
? '✓ Export prêt' + (files.length > 1
? ' — ' + files.length + ' fichiers'
: (files[0].pages > 1 ? ' — ' + files[0].pages + ' pages' : ''))
: '✓ Export prêt';
files.forEach((f, i) => {
const a = document.createElement('a');
a.href = f.url;
a.download = f.nom;
a.title = 'Télécharger (' + (f.largeur || '?') + '×' + (f.hauteur || '?') + ' px)';
a.textContent = '⤓ ' + f.nom + ' · ' + humanSize(f.taille);
exportDoneList.prepend(a);
if (i === 0) a.click(); // premier fichier : téléchargement direct
});
exportDoneEl.hidden = false;
})
.catch(msg => { exportMsgEl.textContent = 'Erreur : ' + msg; })
.finally(() => { exportGoBtn.disabled = false; });
});
// ============================================================
// File de génération : panneau latéral persistant (sessionStorage).
// Les tâches survivent au rechargement de la carte : une tâche finie
@ -2440,7 +3022,10 @@ function finishRunningTasks(d) {
// Pendant un run, le pipeline régénère l'index après chaque tuile terminée
// (--incremental-index) et la webapp sert ces données via /api/tiles. On
// fusionne les nouveautés dans la carte : la tuile est visible dès la fin
// de son rendu, sans attendre la fin de la file.
// de son rendu, sans attendre la fin de la file. Les URLs images portent
// un suffixe ?v= (mtime de la source) : une tuile régénérée change d'URL,
// l'<img> se recharge et le nouveau rendu remplace l'ancien à l'écran —
// cache navigateur compris.
let liveTilesStamp = null;
function mergeTilesData(data) {
if (!data || !Array.isArray(data.tiles)) return;
@ -2607,6 +3192,16 @@ syncTileLayers();
layoutTiles();
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
// Veille permanente : même sans file affichée dans cet onglet (génération
// lancée depuis un autre onglet, un autre appareil, ou index reconstruit
// côté serveur), la carte sonde /api/tiles toutes les 15 s — la réponse
// allégée par ?stamp= la rend quasi gratuite quand rien n'a changé — et
// fusionne les tuiles nouvelles ou régénérée dès que l'index bouge. Pendant
// un run suivi par cet onglet, pollGenStatus() sonde déjà toutes les 2 s.
setInterval(() => {
if (!document.hidden && !genPollTimer) pollLiveTiles();
}, 15000);
// ============================================================
// Détection de nouvelles couches (run de génération en cours)
// ============================================================