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