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

@ -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})