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

@ -235,7 +235,18 @@ def test_build_index_generates_html(tmp_path):
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
app_js = (assets / "app.js").read_text(encoding='utf-8')
assert "Couches" in app_js or "layers" in app_js
# Barre de génération : sélecteur multi-visualisations (wavelet, aniso_open,
# openness positive, pente...) et envoi du champ viz à /api/generate
assert 'id="genViz"' in content
assert 'value="wavelet"' in content
assert 'value="aniso_open"' in content
assert 'value="pos_open"' in content
assert 'value="slope"' in content
assert "genViz" in app_js and "viz: vizSel.length" in app_js
assert 'assets/app.css' in content
# Bouton d'actualisation des couches (détection des nouveautés sur disque)
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/rebuild" in app_js
assert 'assets/app.js' in content
# Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs"
@ -244,6 +255,22 @@ def test_build_index_generates_html(tmp_path):
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
def test_subtiled_viz_covers_generation_choices():
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'aniso_open', 'wavelet'):
assert key in _CARTO_SUBTILED_VIZ
def test_panel_restricted_to_requested_layers():
"""Le panneau et le sélecteur ne proposent que la base aspect + les 4 couches demandées."""
from lidar_pipeline.index import PANEL_VIZ, GEN_VIZ_CHOICES
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
assert {name for name, _ in GEN_VIZ_CHOICES} == {
'aspect', 'slope', 'pos_open', 'aniso_open', 'wavelet'}
def test_build_index_regenerates_stale_thumbnails(tmp_path):
"""Une tuile recalculée (source plus récente) régénère sa vignette."""
import os

View File

@ -215,3 +215,43 @@ class TestNodataPreserved:
with rasterio.open(out) as src:
data = src.read(1)
assert np.isnan(data[80:120, 80:120]).any(), f"{out.name} : trou disparu"
def test_ray_trace_horizons_cpu_fallback_on_oom(monkeypatch):
"""Sur OOM GPU, le ray-tracing désactive le GPU puis recommence sur CPU."""
import lidar_pipeline.visualizations as viz
import lidar_pipeline.gpu as gpu_mod
calls = {"n": 0}
disabled = []
def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("Out of memory allocating 600,000,000 bytes")
return ("pos", "neg")
monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
monkeypatch.setattr(gpu_mod, "disable_gpu", lambda: disabled.append(True))
result = viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
assert result == ("pos", "neg")
assert calls["n"] == 2
assert disabled == [True]
def test_ray_trace_horizons_reraises_non_oom(monkeypatch):
"""Une erreur non-OOM n'est pas masquée par le repli CPU."""
import lidar_pipeline.visualizations as viz
import lidar_pipeline.gpu as gpu_mod
def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
raise ValueError("autre erreur")
monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
try:
viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
assert False, "ValueError attendue"
except ValueError:
pass

View File

@ -79,3 +79,97 @@ def test_build_command_ground_classification():
assert cmd[i + 1] == method
assert ("--force" in cmd) == regenerate
assert ("--force-classification" in cmd) == regenerate
def test_build_command_default_viz_aspect():
"""Sans choix de visualisation, la commande génère uniquement aspect."""
from lidar_pipeline.webapp import _build_command
cmd = _build_command([(1054, 6882)])
i = cmd.index("--only")
assert cmd[i + 1] == "aspect"
def test_build_command_custom_viz():
"""Les visualisations demandées sont passées à --only dans l'ordre."""
from lidar_pipeline.webapp import _build_command
cmd = _build_command([(1054, 6882)], viz=["aspect", "wavelet", "aniso_open"])
i = cmd.index("--only")
assert cmd[i + 1:i + 4] == ["aspect", "wavelet", "aniso_open"]
def test_viz_step_names_match_pipeline():
"""Les noms acceptés par l'API sont exactement les étapes VIZ_STEPS du pipeline."""
from lidar_pipeline.webapp import _viz_step_names
from lidar_pipeline.pipeline import VIZ_STEPS
assert _viz_step_names() == [n for n, _ in VIZ_STEPS]
def test_generate_rejects_unknown_viz():
"""L'API refuse un nom de visualisation inconnu (HTTPException 400)."""
from fastapi import HTTPException
from lidar_pipeline.webapp import generate, GenerateRequest
req = GenerateRequest(tiles=[[1054, 6882]], viz=["wavelet", "inconnu"])
try:
generate(req)
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 400
assert "inconnu" in e.detail
def test_generate_accepts_wavelet_and_aniso():
"""L'API accepte wavelet + aniso_open et les transmet à --only."""
import subprocess
import lidar_pipeline.webapp as webapp
captured = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
class _P:
def wait(self):
return 0
def poll(self):
return 0
return _P()
webapp.subprocess.Popen = fake_popen
try:
req = webapp.GenerateRequest(tiles=[[1054, 6882]],
viz=["wavelet", "aniso_open"])
result = webapp.generate(req)
assert result["demarré"] is True
i = captured["cmd"].index("--only")
assert captured["cmd"][i + 1:i + 3] == ["wavelet", "aniso_open"]
finally:
webapp.subprocess.Popen = subprocess.Popen
def test_available_layers_scans_disk(tmp_path, monkeypatch):
"""/api/layers retourne les couches présentes (clé → label)."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
vis.mkdir(parents=True)
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_wavelet.avif").write_bytes(b"x")
layers = webapp.available_layers()
assert layers.get("wavelet")
assert "aspect" not in layers
def test_rebuild_index_background(tmp_path, monkeypatch):
"""POST /api/rebuild lance build_index en arrière-plan puis termine."""
import time as _time
import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod
calls = []
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
assert webapp.rebuild_index()["demarré"] is True
for _ in range(100):
if not webapp.rebuild_status()["running"]:
break
_time.sleep(0.05)
assert calls # build_index a bien été appelé
assert webapp.rebuild_status()["running"] is False
assert webapp.rebuild_status()["done"] is not None