Files
lidar_rendu/lidar_pipeline/webapp.py
Antoine Jacquin ed3e90ea89 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)
2026-08-31 19:02:14 +02:00

327 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Serveur web de la carte LiDAR : sert l'index et expose l'API de génération.
Lancé via `./run.sh --serve [PORT]` (input/ monté en écriture pour permettre
le téléchargement IGN). Endpoints :
GET / → carte interactive (output/index.html)
GET /api/status → état de la génération en cours (ou dernière terminée)
POST /api/preview → cellules 1 km intersectant une bbox WGS84 (option
regenerate=true pour inclure celles déjà générées)
POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules
(option regenerate=true ajoute --force --force-classification ;
option ground_class choisit la méthode de classification du sol)
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
/visualisations, /DTM.
Un seul job à la fois : la génération lance `python -m lidar_pipeline` en
sous-processus avec --fetch-tiles + --file, journalisé dans .generation.log.
"""
import math
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
INPUT_DIR = Path(os.environ.get("LIDAR_INPUT_DIR", "/data/input"))
OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output"))
PORT = int(os.environ.get("LIDAR_PORT", "8973"))
LOG_FILE = OUTPUT_DIR / ".generation.log"
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande
app = FastAPI(title="Carte LiDAR — génération de zones")
# assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur
# statique soit actif même avant la première génération de l'index.
_assets_dir = OUTPUT_DIR / "assets"
_assets_dir.mkdir(parents=True, exist_ok=True)
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
_dir = OUTPUT_DIR / name
if _dir.exists():
from fastapi.staticfiles import StaticFiles
app.mount(f"/{name}", StaticFiles(directory=str(_dir)), name=name)
@app.get("/assets/{file_path:path}")
def assets(file_path: str):
"""Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild)."""
base = _assets_dir.resolve()
p = (_assets_dir / file_path).resolve()
if base not in p.parents or not p.is_file():
raise HTTPException(404, f"asset introuvable : {file_path}")
return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"})
# Méthodes de classification du sol acceptées (mêmes valeurs que --ground-classification).
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")
class GenerateRequest(BaseModel):
tiles: list = Field(..., description="liste [col, row] (entiers km L93)")
regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées")
ground_class: str = Field("ign",
description="Méthode de classification du sol : "
"auto, ign, smrf, csf")
ign_classes: str = Field("sol",
description="Classes LAS pour le MNT IGN : liste noms ou "
"codes séparés par virgules — sol(2), "
"unclassified(1), eau(9), virtuel(66), "
"pont(17), sursol(64). Mode pur, "
"aucune retouche. (défaut: sol)")
bare_earth: bool = Field(False,
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 -------------------------------------------
_job = {"proc": None, "started": None, "returncode": None, "cmd": None}
_job_lock = threading.Lock()
def bbox_to_cells(w, s, e, n):
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.
Une cellule (col, row) couvre X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
"""
from rasterio.warp import transform as warp_transform
lons, lats = warp_transform("EPSG:4326", "EPSG:2154", [w, e, w, e], [s, s, n, n])
# warp_transform renvoie (xs, ys) dans la CRS cible
min_x, max_x = min(lons) + 0.5, max(lons) - 0.5 # rétrécit d'1 m : bords exclus
min_y, max_y = min(lats) + 0.5, max(lats) - 0.5
if max_x <= min_x or max_y <= min_y:
return []
cols = range(int(math.floor(min_x / 1000)), int(math.floor(max_x / 1000)) + 1)
rows = range(int(math.floor(min_y / 1000)) + 1, int(math.floor(max_y / 1000)) + 2)
return [(c, r) for r in rows for c in cols]
def processed_cells(output_dir):
"""Ensemble des cellules (col, row) ayant déjà des visualisations."""
from .index import scan_tiles
tiles = scan_tiles(Path(output_dir) / "visualisations")
return {(t["col"], t["row"]) for t in tiles}
def missing_cells_with_corners(cells, output_dir, include_done=False):
"""Filtre les cellules déjà traitées et calcule leurs coins WGS84.
Retourne [{col, row, corners: [[lat, lon] × 4 SW,SE,NE,NW}].
"""
from .index import attach_gps_bounds
done = processed_cells(output_dir)
todo = [{"col": c, "row": r} for (c, r) in cells
if include_done or (c, r) not in done]
if todo:
attach_gps_bounds(todo)
return todo
@app.get("/")
def root():
index = OUTPUT_DIR / "index.html"
if not index.exists():
return JSONResponse({"erreur": "index.html introuvable — lancez d'abord le pipeline"},
status_code=404)
# index.html est régénéré à chaque passe du pipeline : on interdit le cache
# navigateur pour ne pas servir une version périmée (ex. menu de génération).
return FileResponse(
str(index), media_type="text/html",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
})
@app.get("/api/status")
def status():
with _job_lock:
proc = _job["proc"]
running = proc is not None and proc.poll() is None
return {
"running": running,
"started": _job["started"],
"returncode": _job["returncode"],
"cmd": _job["cmd"],
"log": _tail_log(40),
}
@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()
return lines[-n_lines:]
except Exception:
return []
@app.post("/api/preview")
def preview(req: PreviewRequest):
if len(req.bbox) != 4:
raise HTTPException(400, "bbox attendue : [ouest, sud, est, nord]")
w, s, e, n = (float(v) for v in req.bbox)
cells = bbox_to_cells(w, s, e, n)
capped = len(cells) > MAX_CELLS
todo = missing_cells_with_corners(cells[:MAX_CELLS], OUTPUT_DIR,
include_done=req.regenerate)
return {"count": len(todo), "capped": capped, "cells": todo}
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",
pré-classification IGN ; le pipeline bascule sur SMRF si un fichier ne la
contient pas). Avec ign_classes, on choisit les classes LAS extraites
pour le MNT (mode pur, ex. "sol,unclassified" pour combler les trous
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). 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", *(viz or ["aspect"]),
"--ground-classification", ground_class,
"--ign-classes", ign_classes]
if bare_earth:
cmd += ["--bare-earth"]
if regenerate:
cmd += ["--force", "--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1":
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "2")]
cmd += ["--fetch-tiles"]
cmd += [f"{c},{r}" for (c, r) in tiles]
cmd += ["--file"]
cmd += [tile_filename(c, r) for (c, r) in tiles]
return cmd
@app.post("/api/generate")
def generate(req: GenerateRequest):
tiles = []
for pair in req.tiles:
if not (isinstance(pair, list) and len(pair) == 2):
raise HTTPException(400, f"tuile invalide : {pair!r} (attendu [col, row])")
tiles.append((int(pair[0]), int(pair[1])))
if not tiles:
raise HTTPException(400, "aucune tuile fournie")
if len(tiles) > MAX_CELLS:
raise HTTPException(400, f"trop de tuiles ({len(tiles)}) — max {MAX_CELLS}")
if req.ground_class not in GROUND_CLASS_METHODS:
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, 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})
p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
cwd="/app" if Path("/app").exists() else None)
def _watch():
rc = p.wait()
log_fh.close()
with _job_lock:
_job["returncode"] = rc
threading.Thread(target=_watch, daemon=True).start()
_job["proc"] = p
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)