Couches
+ ↻
▾
@@ -850,6 +893,12 @@ _HTML_TEMPLATE = """
Régénérer les tuiles existantes
+
+ Visualisations :
+
+{gen_viz_options}
+
+
Classification :
@@ -1635,6 +1684,7 @@ const genCancel = document.getElementById('genCancel');
const genRegen = document.getElementById('genRegen');
const genClass = document.getElementById('genClass');
const genIgnClasses = document.getElementById('genIgnClasses');
+const genViz = document.getElementById('genViz');
const genBareEarth = document.getElementById('genBareEarth');
const genQueue = document.getElementById('genQueue');
const genQueueBody = document.getElementById('genQueueBody');
@@ -1719,10 +1769,12 @@ genGo.addEventListener('click', () => {
clearGhosts();
const nTiles = pendingTiles.length;
const isRegen = genRegen.checked || pendingRegen;
+ const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
fetch('/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: pendingTiles,
regenerate: isRegen,
+ viz: vizSel.length ? vizSel : ['aspect'],
ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
bare_earth: genBareEarth ? genBareEarth.checked : false })
@@ -1852,8 +1904,19 @@ genQueueClear.addEventListener('click', () => {
});
renderGenQueue();
-// Reprend le suivi si une tâche tournait avant un rechargement de la page
+// Reprend le suivi si une tâche tournait avant un rechargement de la page,
+// ou adopte une génération lancée hors interface (API directe) pour
+// l'afficher dans la file avec son journal.
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
+else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
+ if (!s || !s.running) return;
+ genTasks.push({
+ id: Date.now(), label: 'Génération en cours (lancée hors interface)',
+ time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
+ state: 'running', returncode: null, log: [], logOpen: false,
+ });
+ saveGenTasks(); renderGenQueue(); pollGenStatus();
+}).catch(() => {});
// ============================================================
// Initialisation
@@ -1865,5 +1928,52 @@ fitView();
layoutTiles();
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
+// ============================================================
+// Détection de nouvelles couches (run de génération en cours)
+// ============================================================
+// Le panneau et les URLs par tuile sont figés au rebuild de l'index :
+// /api/layers liste ce qui existe sur disque, et le bouton ↻ reconstruit
+// la carte (vignettes + sous-tuiles) puis recharge la page.
+const layerRefreshBtn = document.getElementById('layerRefresh');
+function checkNewLayers() {
+ fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => {
+ if (!d || !layerRefreshBtn) return;
+ const news = Object.keys(d).filter(k => !VIZ_META[k]);
+ layerRefreshBtn.hidden = news.length === 0;
+ if (news.length) {
+ layerRefreshBtn.title = 'Nouvelles couches : ' +
+ news.map(k => d[k]).join(', ') + ' — cliquer pour actualiser la carte';
+ }
+ }).catch(() => {});
+}
+checkNewLayers();
+setInterval(checkNewLayers, 60000);
+if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
+ layerRefreshBtn.textContent = '…';
+ layerRefreshBtn.disabled = true;
+ fetch('/api/rebuild', { method: 'POST' })
+ .then(r => r.ok ? r.json() : Promise.reject('rejeté'))
+ .then(() => new Promise(resolve => {
+ const poll = setInterval(() => {
+ fetch('/api/rebuild').then(r => r.json()).then(s => {
+ if (!s.running) { clearInterval(poll); resolve(s.error); }
+ }).catch(() => { clearInterval(poll); resolve(null); });
+ }, 3000);
+ }))
+ .then(err => {
+ if (err) {
+ layerRefreshBtn.textContent = '↻';
+ layerRefreshBtn.disabled = false;
+ layerRefreshBtn.title = 'Erreur rebuild : ' + err;
+ } else {
+ location.reload();
+ }
+ })
+ .catch(() => {
+ layerRefreshBtn.textContent = '↻';
+ layerRefreshBtn.disabled = false;
+ });
+});
+
} // fin guard Leaflet
"""
diff --git a/lidar_pipeline/rendering.py b/lidar_pipeline/rendering.py
index 76fbed2..fe92725 100644
--- a/lidar_pipeline/rendering.py
+++ b/lidar_pipeline/rendering.py
@@ -84,65 +84,68 @@ COLORMAPS = {
'mslrm': {
'cmap': 'seismic',
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
- 'legend': 'Relief combiné multi-échelles (normalisé)\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nÉchelles : 2 à 200m, pondérées 5–20m\nDétecte du micro au macro',
+ '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',
- 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
+ 'vmin_mode': 'fixed', 'vmin_val': -3,
+ 'vmax_mode': 'fixed', 'vmax_val': 3,
},
'sailore': {
'cmap': 'seismic',
'title': 'SAILORE - LRM Auto-Adaptatif',
- 'legend': 'Relief local adaptatif (normalisé)\nRouge = Surélévation | Bleu = Dépression\n\nNoyau adapté à la pente locale\nPlat = grand noyau (25m) | Pente = petit noyau (2m)',
+ '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',
- 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
+ 'vmin_mode': 'fixed', 'vmin_val': -3,
+ 'vmax_mode': 'fixed', 'vmax_val': 3,
},
'aniso_open': {
'cmap': 'seismic',
'title': 'Openness Anisotropique (pondération directionnelle)',
- 'legend': 'Openness positive − négative pondérée (normalisé)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nPondère les directions NW-SE et NE-SW davantage',
+ 'legend': 'Openness positive − négative pondérée (z-score local)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nPondère les directions NW-SE et NE-SW davantage',
'description': 'Openness avec pondération anisotropique — détecte mieux les structures alignées NW-SE et NE-SW',
- 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
+ 'vmin_mode': 'fixed', 'vmin_val': -3,
+ 'vmax_mode': 'fixed', 'vmax_val': 3,
},
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
'positive_openness': {
'cmap': 'YlOrBr',
'title': 'Openness Positive (ouverture vers le haut)',
- 'legend': 'Angle d\'ouverture vers le ciel (normalisé)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)',
+ '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',
- 'vmin_mode': 'percentile', 'vmin_pct': 2,
- 'vmax_mode': 'percentile', 'vmax_pct': 98,
+ 'vmin_mode': 'fixed', 'vmin_val': -3,
+ 'vmax_mode': 'fixed', 'vmax_val': 3,
},
'negative_openness': {
'cmap': 'PuBu',
'title': 'Openness Negative (ouverture vers le bas)',
- 'legend': 'Angle d\'ouverture vers le bas (normalisé)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nMeilleur détecteur de cavités et dolines',
+ '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',
- 'vmin_mode': 'percentile', 'vmin_pct': 2,
- 'vmax_mode': 'percentile', 'vmax_pct': 98,
+ 'vmin_mode': 'fixed', 'vmin_val': -3,
+ 'vmax_mode': 'fixed', 'vmax_val': 3,
},
'svf': {
'cmap': 'hot_r',
'title': 'Sky-View Factor (fraction de ciel visible)',
- 'legend': 'Proportion de ciel visible depuis chaque point\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nLes fossés ressortent en vif — excellent pour structures linéaires',
+ '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',
- 'vmin_mode': 'percentile', 'vmin_pct': 2,
- 'vmax_mode': 'percentile', 'vmax_pct': 98,
+ 'vmin_mode': 'fixed', 'vmin_val': 0,
+ 'vmax_mode': 'fixed', 'vmax_val': 1,
},
# === Famille SCALAIRE : propriétés non divergentes ===
'hillshade': {
'cmap': 'gray',
'title': 'Hillshade Multidirectionnel',
- 'legend': 'Illumination combinée de 8 directions\nBlanc = Face éclairée | Noir = Zone d\'ombre',
+ '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)',
- 'vmin_mode': 'percentile', 'vmin_pct': 1,
- 'vmax_mode': 'percentile', 'vmax_pct': 99,
+ 'vmin_mode': 'fixed', 'vmin_val': 0,
+ 'vmax_mode': 'fixed', 'vmax_val': 1,
},
'slope': {
'cmap': 'inferno',
'title': 'Pente (Inclinaison du terrain)',
- 'legend': 'Inclinaison en degrés\nMin: {vmin:.1f}° | Max: {vmax:.1f}°\nJaune = Forte pente | Violet foncé = Terrain plat',
+ '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',
'vmin_mode': 'fixed', 'vmin_val': 0,
- 'vmax_mode': 'percentile', 'vmax_pct': 97,
+ 'vmax_mode': 'fixed', 'vmax_val': 30,
},
'aspect': {
'cmap': 'twilight',
@@ -163,9 +166,10 @@ COLORMAPS = {
'wavelet': {
'cmap': 'cividis',
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
- 'legend': 'Réponse de la transformée en ondelette\nÉchelles adaptées à la résolution\n\nClair = Structure détectée à cette échelle\nSombre = Pas de structure\n\nOptimisé pour formes circulaires:\ntumulus, enclos, fossés annulaires',
+ 'legend': 'Réponse RMS multi-échelles (σ locales)\nÉchelles adaptées à la résolution\n\nClair = Structure détectée à cette échelle\nSombre = Pas de structure\nÉchelle fixe 0–3σ — couleurs homogènes entre tuiles\n\nOptimisé pour formes circulaires:\ntumulus, enclos, fossés annulaires',
'description': 'Transformée en ondelette 2D — excellente pour détecter structures circulaires',
- 'vmin_mode': 'symmetric', 'sym_pct': (2, 98),
+ 'vmin_mode': 'fixed', 'vmin_val': 0,
+ 'vmax_mode': 'fixed', 'vmax_val': 3,
},
'flow_acc': {
'cmap': 'YlGn',
diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py
index c2525c5..0f1c1e0 100644
--- a/lidar_pipeline/tests/test_index.py
+++ b/lidar_pipeline/tests/test_index.py
@@ -235,7 +235,18 @@ 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
+ assert 'id="genViz"' in content
+ assert 'value="wavelet"' in content
+ assert 'value="aniso_open"' 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 'assets/app.css' in content
+ # Bouton d'actualisation des couches (détection des nouveautés sur disque)
+ assert 'id="layerRefresh"' in content
+ assert "/api/layers" in app_js and "/api/rebuild" in app_js
assert 'assets/app.js' in content
# Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs"
@@ -244,6 +255,22 @@ def test_build_index_generates_html(tmp_path):
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
+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'):
+ 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'}
+
+
def test_build_index_regenerates_stale_thumbnails(tmp_path):
"""Une tuile recalculée (source plus récente) régénère sa vignette."""
import os
diff --git a/lidar_pipeline/tests/test_visualizations.py b/lidar_pipeline/tests/test_visualizations.py
index d5fb758..bb23ee8 100644
--- a/lidar_pipeline/tests/test_visualizations.py
+++ b/lidar_pipeline/tests/test_visualizations.py
@@ -215,3 +215,43 @@ class TestNodataPreserved:
with rasterio.open(out) as src:
data = src.read(1)
assert np.isnan(data[80:120, 80:120]).any(), f"{out.name} : trou disparu"
+
+
+def test_ray_trace_horizons_cpu_fallback_on_oom(monkeypatch):
+ """Sur OOM GPU, le ray-tracing désactive le GPU puis recommence sur CPU."""
+ import lidar_pipeline.visualizations as viz
+ import lidar_pipeline.gpu as gpu_mod
+
+ calls = {"n": 0}
+ disabled = []
+
+ def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise RuntimeError("Out of memory allocating 600,000,000 bytes")
+ return ("pos", "neg")
+
+ monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
+ monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
+ monkeypatch.setattr(gpu_mod, "disable_gpu", lambda: disabled.append(True))
+ result = viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
+ assert result == ("pos", "neg")
+ assert calls["n"] == 2
+ assert disabled == [True]
+
+
+def test_ray_trace_horizons_reraises_non_oom(monkeypatch):
+ """Une erreur non-OOM n'est pas masquée par le repli CPU."""
+ import lidar_pipeline.visualizations as viz
+ import lidar_pipeline.gpu as gpu_mod
+
+ def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
+ raise ValueError("autre erreur")
+
+ monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
+ monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
+ try:
+ viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
+ assert False, "ValueError attendue"
+ except ValueError:
+ pass
diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py
index b1995fe..c51c676 100644
--- a/lidar_pipeline/tests/test_webapp.py
+++ b/lidar_pipeline/tests/test_webapp.py
@@ -79,3 +79,97 @@ def test_build_command_ground_classification():
assert cmd[i + 1] == method
assert ("--force" in cmd) == regenerate
assert ("--force-classification" in cmd) == regenerate
+
+
+def test_build_command_default_viz_aspect():
+ """Sans choix de visualisation, la commande génère uniquement aspect."""
+ from lidar_pipeline.webapp import _build_command
+ cmd = _build_command([(1054, 6882)])
+ i = cmd.index("--only")
+ assert cmd[i + 1] == "aspect"
+
+
+def test_build_command_custom_viz():
+ """Les visualisations demandées sont passées à --only dans l'ordre."""
+ from lidar_pipeline.webapp import _build_command
+ cmd = _build_command([(1054, 6882)], viz=["aspect", "wavelet", "aniso_open"])
+ i = cmd.index("--only")
+ assert cmd[i + 1:i + 4] == ["aspect", "wavelet", "aniso_open"]
+
+
+def test_viz_step_names_match_pipeline():
+ """Les noms acceptés par l'API sont exactement les étapes VIZ_STEPS du pipeline."""
+ from lidar_pipeline.webapp import _viz_step_names
+ from lidar_pipeline.pipeline import VIZ_STEPS
+ assert _viz_step_names() == [n for n, _ in VIZ_STEPS]
+
+
+def test_generate_rejects_unknown_viz():
+ """L'API refuse un nom de visualisation inconnu (HTTPException 400)."""
+ from fastapi import HTTPException
+ from lidar_pipeline.webapp import generate, GenerateRequest
+ req = GenerateRequest(tiles=[[1054, 6882]], viz=["wavelet", "inconnu"])
+ try:
+ generate(req)
+ assert False, "une HTTPException était attendue"
+ except HTTPException as e:
+ assert e.status_code == 400
+ assert "inconnu" in e.detail
+
+
+def test_generate_accepts_wavelet_and_aniso():
+ """L'API accepte wavelet + aniso_open et les transmet à --only."""
+ import subprocess
+ import lidar_pipeline.webapp as webapp
+ captured = {}
+
+ def fake_popen(cmd, **kwargs):
+ captured["cmd"] = cmd
+
+ class _P:
+ def wait(self):
+ return 0
+
+ def poll(self):
+ return 0
+ return _P()
+
+ webapp.subprocess.Popen = fake_popen
+ try:
+ req = webapp.GenerateRequest(tiles=[[1054, 6882]],
+ viz=["wavelet", "aniso_open"])
+ result = webapp.generate(req)
+ assert result["demarré"] is True
+ i = captured["cmd"].index("--only")
+ assert captured["cmd"][i + 1:i + 3] == ["wavelet", "aniso_open"]
+ finally:
+ webapp.subprocess.Popen = subprocess.Popen
+
+
+def test_available_layers_scans_disk(tmp_path, monkeypatch):
+ """/api/layers retourne les couches présentes (clé → label)."""
+ import lidar_pipeline.webapp as webapp
+ monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
+ vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
+ vis.mkdir(parents=True)
+ (vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_wavelet.avif").write_bytes(b"x")
+ layers = webapp.available_layers()
+ assert layers.get("wavelet")
+ assert "aspect" not in layers
+
+
+def test_rebuild_index_background(tmp_path, monkeypatch):
+ """POST /api/rebuild lance build_index en arrière-plan puis termine."""
+ import time as _time
+ import lidar_pipeline.webapp as webapp
+ import lidar_pipeline.index as index_mod
+ calls = []
+ monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
+ assert webapp.rebuild_index()["demarré"] is True
+ for _ in range(100):
+ if not webapp.rebuild_status()["running"]:
+ break
+ _time.sleep(0.05)
+ assert calls # build_index a bien été appelé
+ assert webapp.rebuild_status()["running"] is False
+ assert webapp.rebuild_status()["done"] is not None
diff --git a/lidar_pipeline/visualizations.py b/lidar_pipeline/visualizations.py
index 028d744..cb05068 100644
--- a/lidar_pipeline/visualizations.py
+++ b/lidar_pipeline/visualizations.py
@@ -291,7 +291,7 @@ def _prepare_dem_for_raycast(dem_file, shared, resolution):
return dem, dem_np, rows, cols, res, nan_mask, transform, crs
-def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
+def _ray_trace_horizons_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
"""Core ray-tracing: compute max zenith/nadir angles per direction and radius.
For each pixel, in each direction, traces rays outward up to max_dist steps,
@@ -410,6 +410,25 @@ def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
return pos_angles, neg_angles
+def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
+ """Ray-tracing avec repli CPU automatique si la VRAM est insuffisante.
+
+ Les dalles 0,2 m (5000×5000 px) multi-rayons peuvent dépasser la VRAM
+ disponible (GPU partagé avec d'autres services) : plutôt que d'abandonner
+ la visualisation, on désactive le GPU pour ce worker et on relance le
+ calcul sur CPU.
+ """
+ try:
+ return _ray_trace_horizons_core(dem, rows, cols, res, n_dirs, max_dist, radii_m)
+ except Exception as e:
+ if _gpu_mod.is_gpu_active() and "out of memory" in str(e).lower():
+ logger.warning(" ⚠ VRAM insuffisante (ray-tracing) — repli CPU pour ce worker")
+ _gpu_mod.disable_gpu()
+ gpu_cleanup()
+ return _ray_trace_horizons_core(dem, rows, cols, res, n_dirs, max_dist, radii_m)
+ raise
+
+
# ============================================================
# Core terrain visualizations
# ============================================================
@@ -629,11 +648,12 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
openness_result = np.degrees(openness).astype(np.float32)
openness_result[nan_mask] = np.nan
- # Std normalization for cross-tile comparability
+ # Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
+ # plage de rendu fixe → mosaïque de couleur homogène
valid = openness_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
- openness_result = openness_result / std_val
+ openness_result = (openness_result - np.nanmean(valid)) / std_val
_save_tif(output, openness_result, transform, crs)
logger.info(f" ✓ {name} terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
@@ -791,6 +811,13 @@ def generate_sailore(dem_file, basename, vis_dir, resolution, shared=None):
sailore = (w_fine * lrm_fine + w_medium * lrm_medium + w_coarse * lrm_coarse) / w_total
sailore[nan_mask] = np.nan
+ # Z-score (σ locales) : unités comparables entre tuiles, plage de
+ # rendu fixe ±3σ → mosaïque de couleur homogène
+ valid = sailore[~nan_mask]
+ if len(valid) > 0:
+ std_val = max(np.nanstd(valid), 0.01)
+ sailore = (sailore - np.nanmean(valid)) / std_val
+
_save_tif(output, sailore.astype(np.float32), transform, crs)
logger.info(f" ✓ SAILORE terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
@@ -1248,11 +1275,12 @@ def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
aniso_result = np.degrees(pos_combined - neg_combined).astype(np.float32)
aniso_result[nan_mask] = np.nan
- # Std normalization for cross-tile comparability
+ # Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
+ # plage de rendu fixe → mosaïque de couleur homogène
valid = aniso_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
- aniso_result = aniso_result / std_val
+ aniso_result = (aniso_result - np.nanmean(valid)) / std_val
_save_tif(output, aniso_result, transform, crs)
logger.info(f" ✓ Openness anisotropique terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py
index 5391726..155cb6f 100644
--- a/lidar_pipeline/webapp.py
+++ b/lidar_pipeline/webapp.py
@@ -62,6 +62,12 @@ def assets(file_path: str):
GROUND_CLASS_METHODS = ("auto", "ign", "smrf", "csf")
+def _viz_step_names():
+ """Noms des visualisations acceptées (étapes VIZ_STEPS du pipeline)."""
+ from .pipeline import VIZ_STEPS
+ return [name for name, _ in VIZ_STEPS]
+
+
class PreviewRequest(BaseModel):
bbox: list = Field(..., description="[ouest, sud, est, nord] en WGS84")
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
@@ -83,6 +89,10 @@ class GenerateRequest(BaseModel):
description="Sol nu : DTM au retour le plus bas de "
"chaque cellule (requalifie le point le plus "
"bas en terrain)")
+ viz: list = Field(None,
+ description="Visualisations à générer, noms d'étapes du "
+ "pipeline (ex: aspect, wavelet, aniso_open) ; "
+ "défaut : aspect")
# --- État du job de génération -------------------------------------------
@@ -159,6 +169,58 @@ def status():
}
+@app.get("/api/layers")
+def available_layers():
+ """Couches proposées dans le panneau (clé → label), sans rebuild de l'index.
+
+ Restreintes à PANEL_VIZ (base aspect + visualisations demandées) pour
+ rester cohérent avec le menu. Permet à la carte de détecter celles
+ apparues depuis le dernier build_index et de proposer une actualisation.
+ """
+ from .index import PANEL_VIZ, VIZ_LABELS
+ vis = OUTPUT_DIR / "visualisations"
+ found = {}
+ if vis.is_dir():
+ for key in PANEL_VIZ:
+ for ext in ("avif", "webp"):
+ if next(vis.glob(f"*/*_{key}.{ext}"), None) is not None:
+ found[key] = VIZ_LABELS.get(key, key)
+ break
+ return found
+
+
+# --- Rebuild de l'index en arrière-plan -------------------------------------
+_rebuild = {"running": False, "error": None, "done": None}
+
+
+@app.post("/api/rebuild")
+def rebuild_index():
+ """Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
+ if _rebuild["running"]:
+ raise HTTPException(409, "un rebuild est déjà en cours")
+
+ def _run():
+ _rebuild["running"] = True
+ _rebuild["error"] = None
+ try:
+ from .index import build_index
+ build_index(OUTPUT_DIR)
+ except Exception as e: # noqa: BLE001 — remonté à l'UI via l'API
+ _rebuild["error"] = str(e)
+ finally:
+ _rebuild["running"] = False
+ _rebuild["done"] = time.time()
+
+ threading.Thread(target=_run, daemon=True).start()
+ return {"demarré": True}
+
+
+@app.get("/api/rebuild")
+def rebuild_status():
+ return {"running": _rebuild["running"], "error": _rebuild["error"],
+ "done": _rebuild["done"]}
+
+
def _tail_log(n_lines):
try:
lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
@@ -179,7 +241,7 @@ def preview(req: PreviewRequest):
return {"count": len(todo), "capped": capped, "cells": todo}
-def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol"):
+def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None):
"""Commande de génération : téléchargement IGN + traitement des fichiers.
La classification du sol est choisie via `ground_class` (défaut : "ign",
@@ -189,13 +251,16 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
sans retouche). Avec regenerate=True, force la reclassification et la
régénération des visualisations des tuiles déjà présentes. Avec
bare_earth=True, le DTM est ramené au retour le plus bas de chaque
- cellule (sol nu).
+ cellule (sol nu). Avec viz, on choisit les visualisations générées
+ (noms d'étapes du pipeline, ex. ["aspect", "wavelet", "aniso_open"] ;
+ défaut : aspect).
"""
from .fetch_ign import tile_filename
# -u : sortie non bufferisée — le journal .generation.log doit être
# lu en temps réel par /api/status (progression affichée dans l'UI).
cmd = [sys.executable, "-u", "-m", "lidar_pipeline", str(INPUT_DIR),
- "-o", str(OUTPUT_DIR), "-r", "0.5,0.2", "--only", "aspect",
+ "-o", str(OUTPUT_DIR), "-r", "0.5,0.2",
+ "--only", *(viz or ["aspect"]),
"--ground-classification", ground_class,
"--ign-classes", ign_classes]
if bare_earth:
@@ -226,12 +291,19 @@ def generate(req: GenerateRequest):
raise HTTPException(
400, f"méthode de classification invalide : {req.ground_class!r} "
f"(attendu : {', '.join(GROUND_CLASS_METHODS)})")
+ names = _viz_step_names()
+ viz = [v for v in (req.viz or []) if v] or ["aspect"]
+ invalid = [v for v in viz if v not in names]
+ if invalid:
+ raise HTTPException(
+ 400, f"visualisation invalide : {', '.join(invalid)} "
+ f"(attendues : {', '.join(names)})")
with _job_lock:
proc = _job["proc"]
if proc is not None and proc.poll() is None:
raise HTTPException(409, "une génération est déjà en cours")
- cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes)
+ cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(LOG_FILE, "w", encoding="utf-8")
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd})