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:
@ -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
|
||||
"""
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ''}")
|
||||
|
||||
@ -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})
|
||||
|
||||
Reference in New Issue
Block a user