Add visualization picker to zone generation and unify tile colors

- Web map: multi-select picker in the generation bar (aspect, slope,
  positive openness, anisotropic openness, wavelet) passed to the API;
  the layer panel is restricted to the same shortlist (PANEL_VIZ) and
  a refresh button rebuilds the index when new layers appear on disk;
  jobs started outside the UI are now adopted into the visible queue
- Uniform colors across tiles: openness/anisotropic/sailore now store
  local z-scores, and all renderers use fixed ranges (0-3 sigma, SVF
  0-1 physical, slope 0-30 deg) instead of per-tile percentile stretches
- Ray-tracing falls back to CPU when VRAM is exhausted so openness and
  SVF no longer fail silently on shared GPUs
- build_index merges visualizations available at only one resolution
  into the displayed tile so in-progress layers stay visible
- 11 new tests (142 passing)
This commit is contained in:
Antoine Jacquin
2026-08-31 19:02:14 +02:00
parent 8ca65155db
commit ed3e90ea89
7 changed files with 412 additions and 37 deletions

View File

@ -54,10 +54,26 @@ VIZ_LABELS = {
# Couche activée par défaut à l'ouverture de la carte.
DEFAULT_VIZ = 'hillshade_multi'
# Couches proposées dans le panneau de couches et le sélecteur de génération
# (liste restreinte : base aspect + visualisations demandées). Les autres
# visualisations restent calculées et stockées mais ne sont pas proposées.
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
# Visualisations proposées dans la barre de génération de zones (noms
# d'étapes --only du pipeline) ; 'aspect' reste le choix par défaut (léger).
GEN_VIZ_CHOICES = [
('aspect', 'Aspect'),
('slope', 'Pente'),
('pos_open', 'Openness positive'),
('aniso_open', 'Openness anisotropique'),
('wavelet', 'Ondelette'),
]
# 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')
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'aniso_open', 'wavelet')
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
@ -617,10 +633,22 @@ def build_index(output_dir, output_format='avif'):
# superposent exactement sur la carte et celle ajoutée en dernier dans le
# DOM (la moins résolue, tri croissant) masque l'autre.
best_by_pos = {}
tiles_by_pos = {}
for t in tiles:
key = (t['col'], t['row'])
tiles_by_pos.setdefault(key, []).append(t)
if key not in best_by_pos or t['resolution'] < best_by_pos[key]['resolution']:
best_by_pos[key] = t
# Complète chaque dalle affichée (résolution la plus fine) avec les
# visualisations produites uniquement à l'autre résolution : sans cela,
# une couche en cours de génération (passe 0,5 m faite, 0,2 m pas encore)
# reste invisible sur la carte et absente du panneau de couches.
for key, best in best_by_pos.items():
for other in tiles_by_pos[key]:
if other is best:
continue
for viz_key, viz_info in other['viz'].items():
best['viz'].setdefault(viz_key, viz_info)
tiles = sorted(best_by_pos.values(),
key=lambda t: (t['resolution'], -t['row'], t['col']))
@ -755,21 +783,34 @@ def _write_assets(output_dir):
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
# 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)
if PANEL_VIZ is not None:
panel_keys &= set(PANEL_VIZ)
# Ordre des couches dans le panneau (selon ordre préféré puis alpha).
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in all_viz_keys]
for v in sorted(all_viz_keys):
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in panel_keys]
for v in sorted(panel_keys):
if v not in ordered_viz:
ordered_viz.append(v)
tiles_json = json.dumps(tiles, ensure_ascii=False)
# Options du sélecteur de visualisations de la barre de génération.
gen_viz_options = "\n".join(
f' <option value="{name}"{" selected" if name == "aspect" else ""}>'
f'{label}</option>'
for name, label in GEN_VIZ_CHOICES)
viz_meta_json = json.dumps({
k: {'label': VIZ_LABELS.get(k, k)}
for k in ordered_viz
}, ensure_ascii=False)
# Couche activée par défaut : DEFAULT_VIZ si proposée, sinon la 1re
default_viz = DEFAULT_VIZ if DEFAULT_VIZ in ordered_viz else (ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
# 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)
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
ensure_ascii=False)
@ -781,6 +822,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
tiles_json=tiles_json,
viz_meta_json=viz_meta_json,
stats_json=stats_json,
gen_viz_options=gen_viz_options,
n_tiles=n_tiles,
grid_w=grid_w,
grid_h=grid_h,
@ -816,6 +858,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<aside id="layerPanel" class="panel">
<div class="panel-head">
<span class="panel-title">Couches</span>
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
<button id="layerCollapse" class="icon-btn" title="Replier / déplier">▾</button>
</div>
<div id="layerBody">
@ -850,6 +893,12 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<label class="gen-opt">
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
</label>
<label class="gen-opt" title="Ctrl+clic pour sélectionner plusieurs visualisations">
Visualisations :
<select id="genViz" class="gen-select" multiple size="4">
{gen_viz_options}
</select>
</label>
<label class="gen-opt">
Classification :
<select id="genClass" class="gen-select">
@ -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
"""

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ''}")

View File

@ -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})