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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user