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.
|
# Couche activée par défaut à l'ouverture de la carte.
|
||||||
DEFAULT_VIZ = 'hillshade_multi'
|
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
|
# 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.
|
# carte — les autres viz restent disponibles en repli dalle entière.
|
||||||
# Liste vide = découper toutes les visualisations disponibles.
|
# 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 :
|
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
|
||||||
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
|
# 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
|
# superposent exactement sur la carte et celle ajoutée en dernier dans le
|
||||||
# DOM (la moins résolue, tri croissant) masque l'autre.
|
# DOM (la moins résolue, tri croissant) masque l'autre.
|
||||||
best_by_pos = {}
|
best_by_pos = {}
|
||||||
|
tiles_by_pos = {}
|
||||||
for t in tiles:
|
for t in tiles:
|
||||||
key = (t['col'], t['row'])
|
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']:
|
if key not in best_by_pos or t['resolution'] < best_by_pos[key]['resolution']:
|
||||||
best_by_pos[key] = t
|
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(),
|
tiles = sorted(best_by_pos.values(),
|
||||||
key=lambda t: (t['resolution'], -t['row'], t['col']))
|
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):
|
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
||||||
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
|
"""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).
|
# 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]
|
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in panel_keys]
|
||||||
for v in sorted(all_viz_keys):
|
for v in sorted(panel_keys):
|
||||||
if v not in ordered_viz:
|
if v not in ordered_viz:
|
||||||
ordered_viz.append(v)
|
ordered_viz.append(v)
|
||||||
|
|
||||||
tiles_json = json.dumps(tiles, ensure_ascii=False)
|
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({
|
viz_meta_json = json.dumps({
|
||||||
k: {'label': VIZ_LABELS.get(k, k)}
|
k: {'label': VIZ_LABELS.get(k, k)}
|
||||||
for k in ordered_viz
|
for k in ordered_viz
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
# Couche activée par défaut : DEFAULT_VIZ si proposée, sinon la 1re
|
# Couche activée par défaut : préférence globale, sinon aspect (base
|
||||||
default_viz = DEFAULT_VIZ if DEFAULT_VIZ in ordered_viz else (ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
|
# 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)},
|
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
|
||||||
ensure_ascii=False)
|
ensure_ascii=False)
|
||||||
|
|
||||||
@ -781,6 +822,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
|||||||
tiles_json=tiles_json,
|
tiles_json=tiles_json,
|
||||||
viz_meta_json=viz_meta_json,
|
viz_meta_json=viz_meta_json,
|
||||||
stats_json=stats_json,
|
stats_json=stats_json,
|
||||||
|
gen_viz_options=gen_viz_options,
|
||||||
n_tiles=n_tiles,
|
n_tiles=n_tiles,
|
||||||
grid_w=grid_w,
|
grid_w=grid_w,
|
||||||
grid_h=grid_h,
|
grid_h=grid_h,
|
||||||
@ -816,6 +858,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
<aside id="layerPanel" class="panel">
|
<aside id="layerPanel" class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="panel-title">Couches</span>
|
<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>
|
<button id="layerCollapse" class="icon-btn" title="Replier / déplier">▾</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="layerBody">
|
<div id="layerBody">
|
||||||
@ -850,6 +893,12 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
<label class="gen-opt">
|
<label class="gen-opt">
|
||||||
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
|
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
|
||||||
</label>
|
</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">
|
<label class="gen-opt">
|
||||||
Classification :
|
Classification :
|
||||||
<select id="genClass" class="gen-select">
|
<select id="genClass" class="gen-select">
|
||||||
@ -1635,6 +1684,7 @@ const genCancel = document.getElementById('genCancel');
|
|||||||
const genRegen = document.getElementById('genRegen');
|
const genRegen = document.getElementById('genRegen');
|
||||||
const genClass = document.getElementById('genClass');
|
const genClass = document.getElementById('genClass');
|
||||||
const genIgnClasses = document.getElementById('genIgnClasses');
|
const genIgnClasses = document.getElementById('genIgnClasses');
|
||||||
|
const genViz = document.getElementById('genViz');
|
||||||
const genBareEarth = document.getElementById('genBareEarth');
|
const genBareEarth = document.getElementById('genBareEarth');
|
||||||
const genQueue = document.getElementById('genQueue');
|
const genQueue = document.getElementById('genQueue');
|
||||||
const genQueueBody = document.getElementById('genQueueBody');
|
const genQueueBody = document.getElementById('genQueueBody');
|
||||||
@ -1719,10 +1769,12 @@ genGo.addEventListener('click', () => {
|
|||||||
clearGhosts();
|
clearGhosts();
|
||||||
const nTiles = pendingTiles.length;
|
const nTiles = pendingTiles.length;
|
||||||
const isRegen = genRegen.checked || pendingRegen;
|
const isRegen = genRegen.checked || pendingRegen;
|
||||||
|
const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
||||||
fetch('/api/generate', {
|
fetch('/api/generate', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tiles: pendingTiles,
|
body: JSON.stringify({ tiles: pendingTiles,
|
||||||
regenerate: isRegen,
|
regenerate: isRegen,
|
||||||
|
viz: vizSel.length ? vizSel : ['aspect'],
|
||||||
ground_class: genClass ? genClass.value : 'ign',
|
ground_class: genClass ? genClass.value : 'ign',
|
||||||
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
|
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
|
||||||
bare_earth: genBareEarth ? genBareEarth.checked : false })
|
bare_earth: genBareEarth ? genBareEarth.checked : false })
|
||||||
@ -1852,8 +1904,19 @@ genQueueClear.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
renderGenQueue();
|
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();
|
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
|
// Initialisation
|
||||||
@ -1865,5 +1928,52 @@ fitView();
|
|||||||
layoutTiles();
|
layoutTiles();
|
||||||
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
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
|
} // fin guard Leaflet
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -84,65 +84,68 @@ COLORMAPS = {
|
|||||||
'mslrm': {
|
'mslrm': {
|
||||||
'cmap': 'seismic',
|
'cmap': 'seismic',
|
||||||
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
|
'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',
|
'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': {
|
'sailore': {
|
||||||
'cmap': 'seismic',
|
'cmap': 'seismic',
|
||||||
'title': 'SAILORE - LRM Auto-Adaptatif',
|
'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',
|
'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': {
|
'aniso_open': {
|
||||||
'cmap': 'seismic',
|
'cmap': 'seismic',
|
||||||
'title': 'Openness Anisotropique (pondération directionnelle)',
|
'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',
|
'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 ===
|
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
|
||||||
'positive_openness': {
|
'positive_openness': {
|
||||||
'cmap': 'YlOrBr',
|
'cmap': 'YlOrBr',
|
||||||
'title': 'Openness Positive (ouverture vers le haut)',
|
'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',
|
'description': 'Ray-tracing 8 directions, multi-rayon — détecte crêtes et sommets',
|
||||||
'vmin_mode': 'percentile', 'vmin_pct': 2,
|
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||||
},
|
},
|
||||||
'negative_openness': {
|
'negative_openness': {
|
||||||
'cmap': 'PuBu',
|
'cmap': 'PuBu',
|
||||||
'title': 'Openness Negative (ouverture vers le bas)',
|
'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',
|
'description': 'Ray-tracing 8 directions, multi-rayon — détecte fossés, dolines, souterrains',
|
||||||
'vmin_mode': 'percentile', 'vmin_pct': 2,
|
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||||
},
|
},
|
||||||
'svf': {
|
'svf': {
|
||||||
'cmap': 'hot_r',
|
'cmap': 'hot_r',
|
||||||
'title': 'Sky-View Factor (fraction de ciel visible)',
|
'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',
|
'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre',
|
||||||
'vmin_mode': 'percentile', 'vmin_pct': 2,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
'vmax_mode': 'fixed', 'vmax_val': 1,
|
||||||
},
|
},
|
||||||
# === Famille SCALAIRE : propriétés non divergentes ===
|
# === Famille SCALAIRE : propriétés non divergentes ===
|
||||||
'hillshade': {
|
'hillshade': {
|
||||||
'cmap': 'gray',
|
'cmap': 'gray',
|
||||||
'title': 'Hillshade Multidirectionnel',
|
'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)',
|
'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)',
|
||||||
'vmin_mode': 'percentile', 'vmin_pct': 1,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 99,
|
'vmax_mode': 'fixed', 'vmax_val': 1,
|
||||||
},
|
},
|
||||||
'slope': {
|
'slope': {
|
||||||
'cmap': 'inferno',
|
'cmap': 'inferno',
|
||||||
'title': 'Pente (Inclinaison du terrain)',
|
'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',
|
'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre',
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 97,
|
'vmax_mode': 'fixed', 'vmax_val': 30,
|
||||||
},
|
},
|
||||||
'aspect': {
|
'aspect': {
|
||||||
'cmap': 'twilight',
|
'cmap': 'twilight',
|
||||||
@ -163,9 +166,10 @@ COLORMAPS = {
|
|||||||
'wavelet': {
|
'wavelet': {
|
||||||
'cmap': 'cividis',
|
'cmap': 'cividis',
|
||||||
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
|
'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',
|
'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': {
|
'flow_acc': {
|
||||||
'cmap': 'YlGn',
|
'cmap': 'YlGn',
|
||||||
|
|||||||
@ -235,7 +235,18 @@ def test_build_index_generates_html(tmp_path):
|
|||||||
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
||||||
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
||||||
assert "Couches" in app_js or "layers" in app_js
|
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
|
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
|
assert 'assets/app.js' in content
|
||||||
# Vérifie les vignettes générées
|
# Vérifie les vignettes générées
|
||||||
thumb_dir = output_dir / "index_thumbs"
|
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
|
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):
|
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||||
"""Une tuile recalculée (source plus récente) régénère sa vignette."""
|
"""Une tuile recalculée (source plus récente) régénère sa vignette."""
|
||||||
import os
|
import os
|
||||||
|
|||||||
@ -215,3 +215,43 @@ class TestNodataPreserved:
|
|||||||
with rasterio.open(out) as src:
|
with rasterio.open(out) as src:
|
||||||
data = src.read(1)
|
data = src.read(1)
|
||||||
assert np.isnan(data[80:120, 80:120]).any(), f"{out.name} : trou disparu"
|
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 cmd[i + 1] == method
|
||||||
assert ("--force" in cmd) == regenerate
|
assert ("--force" in cmd) == regenerate
|
||||||
assert ("--force-classification" 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
|
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.
|
"""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,
|
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
|
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
|
# 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 = np.degrees(openness).astype(np.float32)
|
||||||
openness_result[nan_mask] = np.nan
|
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]
|
valid = openness_result[~nan_mask]
|
||||||
if len(valid) > 0:
|
if len(valid) > 0:
|
||||||
std_val = max(np.nanstd(valid), 0.01)
|
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)
|
_save_tif(output, openness_result, transform, crs)
|
||||||
logger.info(f" ✓ {name} terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
|
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 = (w_fine * lrm_fine + w_medium * lrm_medium + w_coarse * lrm_coarse) / w_total
|
||||||
sailore[nan_mask] = np.nan
|
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)
|
_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 ''}")
|
logger.info(f" ✓ SAILORE terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
|
||||||
return output
|
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 = np.degrees(pos_combined - neg_combined).astype(np.float32)
|
||||||
aniso_result[nan_mask] = np.nan
|
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]
|
valid = aniso_result[~nan_mask]
|
||||||
if len(valid) > 0:
|
if len(valid) > 0:
|
||||||
std_val = max(np.nanstd(valid), 0.01)
|
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)
|
_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 ''}")
|
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")
|
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):
|
class PreviewRequest(BaseModel):
|
||||||
bbox: list = Field(..., description="[ouest, sud, est, nord] en WGS84")
|
bbox: list = Field(..., description="[ouest, sud, est, nord] en WGS84")
|
||||||
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
|
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 "
|
description="Sol nu : DTM au retour le plus bas de "
|
||||||
"chaque cellule (requalifie le point le plus "
|
"chaque cellule (requalifie le point le plus "
|
||||||
"bas en terrain)")
|
"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 -------------------------------------------
|
# --- É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):
|
def _tail_log(n_lines):
|
||||||
try:
|
try:
|
||||||
lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
|
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}
|
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.
|
"""Commande de génération : téléchargement IGN + traitement des fichiers.
|
||||||
|
|
||||||
La classification du sol est choisie via `ground_class` (défaut : "ign",
|
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
|
sans retouche). Avec regenerate=True, force la reclassification et la
|
||||||
régénération des visualisations des tuiles déjà présentes. Avec
|
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
|
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
|
from .fetch_ign import tile_filename
|
||||||
# -u : sortie non bufferisée — le journal .generation.log doit être
|
# -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).
|
# lu en temps réel par /api/status (progression affichée dans l'UI).
|
||||||
cmd = [sys.executable, "-u", "-m", "lidar_pipeline", str(INPUT_DIR),
|
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,
|
"--ground-classification", ground_class,
|
||||||
"--ign-classes", ign_classes]
|
"--ign-classes", ign_classes]
|
||||||
if bare_earth:
|
if bare_earth:
|
||||||
@ -226,12 +291,19 @@ def generate(req: GenerateRequest):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
400, f"méthode de classification invalide : {req.ground_class!r} "
|
400, f"méthode de classification invalide : {req.ground_class!r} "
|
||||||
f"(attendu : {', '.join(GROUND_CLASS_METHODS)})")
|
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:
|
with _job_lock:
|
||||||
proc = _job["proc"]
|
proc = _job["proc"]
|
||||||
if proc is not None and proc.poll() is None:
|
if proc is not None and proc.poll() is None:
|
||||||
raise HTTPException(409, "une génération est déjà en cours")
|
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_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
log_fh = open(LOG_FILE, "w", encoding="utf-8")
|
log_fh = open(LOG_FILE, "w", encoding="utf-8")
|
||||||
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd})
|
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd})
|
||||||
|
|||||||
Reference in New Issue
Block a user