La webapp (carte + vignettes) et la génération de tuiles se déploient sur deux machines : image légère Dockerfile.webapp (FastAPI + Pillow AVIF natif + pyproj) sur Raspberry Pi, pipeline complet sur la machine de traitement. LIDAR_GENERATION_URL délègue /api/generate, /api/preview et /api/status ; /api/sync ramène les tuiles par rsync puis régénère vignettes et index localement. Token partagé optionnel (LIDAR_API_TOKEN/LIDAR_REMOTE_TOKEN). Retire du dépôt les journaux internes (.swival, audit-findings) et les données (data/, notebooks/). Doc : docs/DEPLOY_WEBAPP.md.
651 lines
28 KiB
Python
651 lines
28 KiB
Python
"""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)
|
||
GET /api/tiles → données de carte (index_tiles.json) pour l'affichage
|
||
en direct ; ?stamp=X → réponse allégée si inchangé
|
||
POST /api/sync → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync
|
||
depuis la machine de traitement) puis régénérer la
|
||
carte en arrière-plan
|
||
POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option
|
||
all_missing=true) toutes les dalles LHD présentes dans
|
||
input/ qui manquent au moins une visualisation demandée.
|
||
Sont incluses les tuiles absentes ET les tuiles
|
||
existantes incomplètes ; option viz = noms d'étapes
|
||
demandées, option regenerate=true pour inclure les
|
||
tuiles complètes
|
||
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 ;
|
||
option all_missing=true traite toutes les dalles présentes
|
||
dans input/ qui manquent les visualisations demandées, sans
|
||
téléchargement ; sans --force, le pipeline ne génère que
|
||
les visualisations manquantes des tuiles existantes)
|
||
|
||
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.
|
||
|
||
Architecture deux machines (cf. docs/DEPLOY_WEBAPP.md) : la webapp légère
|
||
(sans PDAL/GPU, ex. Raspberry Pi) délègue la génération à la machine de
|
||
traitement via LIDAR_GENERATION_URL — /api/generate, /api/preview et
|
||
/api/status sont transmis tels quels au service distant, qui exécute la même
|
||
webapp avec le pipeline complet. Les résultats reviennent par la commande de
|
||
synchronisation LIDAR_SYNC_CMD (rsync), puis /api/sync régénère vignettes et
|
||
carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
|
||
(webapp légère) protègent optionnellement les appels distants.
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from typing import Optional
|
||
|
||
from fastapi import Depends, FastAPI, Header, 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"
|
||
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
|
||
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées)
|
||
MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes)
|
||
|
||
# Machine de traitement distante (webapp légère type Raspberry Pi) : si
|
||
# définie, la génération (téléchargement IGN + traitement) y est déléguée.
|
||
GENERATION_URL = (os.environ.get("LIDAR_GENERATION_URL") or "").rstrip("/")
|
||
# Jeton partagé : la webapp légère le présente à la machine de traitement
|
||
# (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel.
|
||
REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None
|
||
API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
|
||
# Commande shell qui ramène les tuiles traitées dans output/ local (rsync
|
||
# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild.
|
||
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
|
||
|
||
# Résolutions générées par /api/generate (cf. _build_command). La détection des
|
||
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
|
||
# que si elle possède les visualisations demandées aux deux résolutions.
|
||
GENERATE_RESOLUTIONS = (0.5, 0.2)
|
||
|
||
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 _require_token(x_lidar_token: str = Header(None)):
|
||
"""Protège les routes mutantes si LIDAR_API_TOKEN est défini (sinon no-op).
|
||
|
||
Utilisé par la machine de traitement pour n'accepter que la webapp légère
|
||
autorisée (qui présente LIDAR_REMOTE_TOKEN) sur le réseau local.
|
||
"""
|
||
if API_TOKEN and x_lidar_token != API_TOKEN:
|
||
raise HTTPException(401, "token d'API manquant ou invalide")
|
||
|
||
|
||
def _proxy_api(method, path, payload=None, timeout=30):
|
||
"""Transmet un appel d'API à la machine de traitement (mode webapp légère).
|
||
|
||
Retourne la réponse JSON distante. Les erreurs HTTP distantes (ex: 409 une
|
||
génération est déjà en cours) sont relayées telles quelles ; une machine
|
||
injoignable devient un 503 explicite pour l'interface.
|
||
"""
|
||
import urllib.error
|
||
import urllib.request
|
||
req = urllib.request.Request(
|
||
GENERATION_URL + path,
|
||
data=json.dumps(payload).encode("utf-8") if payload is not None else None,
|
||
method=method)
|
||
req.add_header("Content-Type", "application/json")
|
||
if REMOTE_TOKEN:
|
||
req.add_header("X-Lidar-Token", REMOTE_TOKEN)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
try:
|
||
detail = json.loads(e.read().decode("utf-8")).get("detail", str(e))
|
||
except (ValueError, OSError):
|
||
detail = str(e)
|
||
raise HTTPException(e.code, detail)
|
||
except (urllib.error.URLError, OSError) as e:
|
||
raise HTTPException(503, f"machine de traitement injoignable "
|
||
f"({GENERATION_URL}) : {e}")
|
||
|
||
|
||
def _viz_step_names():
|
||
"""Noms des visualisations acceptées (étapes VIZ_STEPS du pipeline).
|
||
|
||
Sur la webapp légère (sans dépendances de traitement), repli sur le
|
||
registre de l'index — le même ordre que VIZ_STEPS.
|
||
"""
|
||
try:
|
||
from .pipeline import VIZ_STEPS
|
||
return [name for name, _ in VIZ_STEPS]
|
||
except ImportError:
|
||
return _fallback_viz_step_names()
|
||
|
||
|
||
def _fallback_viz_step_names():
|
||
"""Noms d'étapes dérivés du registre léger de l'index (sans pipeline)."""
|
||
from .index import KEYWORD_TO_STEP, VIZ_LABELS
|
||
return [KEYWORD_TO_STEP.get(k, k) for k in VIZ_LABELS]
|
||
|
||
|
||
def _viz_step_labels():
|
||
"""Libellés français des étapes de visualisation (clé = nom d'étape).
|
||
|
||
VIZ_LABELS est indexée par mot-clé de nom de fichier ; trois étapes ont un
|
||
nom de sortie différent (cf. _expected_output_path dans le pipeline).
|
||
"""
|
||
from .index import VIZ_LABELS, step_to_keyword
|
||
return {name: VIZ_LABELS.get(step_to_keyword(name), name)
|
||
for name in _viz_step_names()}
|
||
|
||
|
||
class PreviewRequest(BaseModel):
|
||
bbox: list = Field(None, description="[ouest, sud, est, nord] en WGS84 "
|
||
"(requis sauf all_missing=true)")
|
||
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
|
||
viz: list = Field(None,
|
||
description="Visualisations demandées, noms d'étapes du "
|
||
"pipeline (ex: aspect, wavelet) ; défaut : aspect. "
|
||
"Une tuile existante est considérée faite "
|
||
"seulement si elle les possède toutes")
|
||
all_missing: bool = Field(False,
|
||
description="Ignorer la bbox : toutes les dalles LHD "
|
||
"présentes dans input/ qui manquent au moins "
|
||
"une des visualisations demandées (sans "
|
||
"téléchargement)")
|
||
|
||
|
||
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, slope) ; "
|
||
"défaut : aspect")
|
||
all_missing: bool = Field(False,
|
||
description="Ignorer la liste de tuiles : traiter toutes "
|
||
"les dalles LHD présentes dans input/ qui "
|
||
"manquent au moins une des visualisations "
|
||
"demandées (sans téléchargement)")
|
||
|
||
|
||
# --- État du job de génération -------------------------------------------
|
||
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None}
|
||
_job_lock = threading.Lock()
|
||
|
||
|
||
def _save_job_state():
|
||
"""Écrit l'état du job sur disque — survit au redémarrage du serveur.
|
||
|
||
Sans cela, un restart du conteneur perd tout : /api/status ne rapporte
|
||
plus le dernier run et la file de génération reste vide au rechargement.
|
||
Best-effort : n'acquiert pas le verrou (appelé aussi sous verrou).
|
||
"""
|
||
state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished")}
|
||
try:
|
||
JOB_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _load_job_state():
|
||
"""Recharge l'état du dernier job au démarrage du serveur."""
|
||
try:
|
||
state = json.loads(JOB_FILE.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return
|
||
with _job_lock:
|
||
_job["started"] = state.get("started")
|
||
_job["returncode"] = state.get("returncode")
|
||
_job["cmd"] = state.get("cmd")
|
||
_job["finished"] = state.get("finished")
|
||
|
||
|
||
_load_job_state()
|
||
|
||
|
||
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.
|
||
Conversion via rasterio/PROJ si disponible, sinon pyproj, sinon
|
||
l'approximation affine de l'index (webapp légère sans GDAL).
|
||
"""
|
||
from .index import _approx_wgs84_to_l93
|
||
try:
|
||
from rasterio.warp import transform as warp_transform
|
||
xs, ys = warp_transform("EPSG:4326", "EPSG:2154", [w, e, w, e], [s, s, n, n])
|
||
except ImportError:
|
||
try:
|
||
from pyproj import Transformer
|
||
transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True)
|
||
xs, ys = transformer.transform([w, e, w, e], [s, s, n, n])
|
||
except ImportError:
|
||
pts = [_approx_wgs84_to_l93(lon, lat)
|
||
for lon, lat in ((w, s), (e, s), (w, n), (e, n))]
|
||
xs = [p[0] for p in pts]
|
||
ys = [p[1] for p in pts]
|
||
# transform renvoie (xs, ys) dans la CRS cible
|
||
min_x, max_x = min(xs) + 0.5, max(xs) - 0.5 # rétrécit d'1 m : bords exclus
|
||
min_y, max_y = min(ys) + 0.5, max(ys) - 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 laz_cells(input_dir):
|
||
"""Coordonnées (col, row) des dalles LHD présentes dans input/.
|
||
|
||
Repère les fichiers dont le nom est LHD_FXX_{col}_{row}_... (extensions
|
||
.copc.laz/.copc.las/.laz/.las) ; les autres fichiers sont ignorés.
|
||
"""
|
||
from .index import parse_basename_coords
|
||
cells = set()
|
||
for f in list(Path(input_dir).glob("*.laz")) + list(Path(input_dir).glob("*.las")):
|
||
name = f.name
|
||
for ext in ('.copc.laz', '.copc.las', '.laz', '.las'):
|
||
if name.lower().endswith(ext):
|
||
name = name[:-len(ext)]
|
||
break
|
||
coords = parse_basename_coords(name)
|
||
if coords:
|
||
cells.add(coords)
|
||
return sorted(cells)
|
||
|
||
|
||
def complete_cells(output_dir, viz_steps):
|
||
"""Cellules ayant TOUTES les visualisations demandées, aux deux résolutions.
|
||
|
||
viz_steps : noms d'étapes du pipeline (ex: 'aspect', 'pos_open'), convertis
|
||
en mots-clés de fichiers de sortie avant comparaison avec le disque.
|
||
"""
|
||
from .index import cells_with_all_viz, step_to_keyword
|
||
keys = [step_to_keyword(v) for v in viz_steps]
|
||
return cells_with_all_viz(Path(output_dir) / "visualisations", keys,
|
||
GENERATE_RESOLUTIONS)
|
||
|
||
|
||
def missing_cells_with_corners(cells, output_dir, include_done=False, viz=None):
|
||
"""Filtre les cellules à traiter et calcule leurs coins WGS84.
|
||
|
||
Sans viz : une cellule ayant des visualisations est considérée faite.
|
||
Avec viz (noms d'étapes) : seules les cellules disposant de TOUTES les
|
||
visualisations demandées (aux résolutions de la génération) sont faites —
|
||
les tuiles existantes mais incomplètes restent incluses, afin de générer
|
||
les visualisations manquantes sans --force (le pipeline ignore l'existant).
|
||
|
||
Retourne [{col, row, corners: [[lat, lon] × 4 SW,SE,NE,NW}].
|
||
"""
|
||
from .index import attach_gps_bounds
|
||
if viz:
|
||
done = complete_cells(output_dir, viz)
|
||
else:
|
||
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():
|
||
if GENERATION_URL:
|
||
# Webapp légère : l'état vient de la machine de traitement
|
||
data = _proxy_api("GET", "/api/status", timeout=10)
|
||
data["distant"] = True
|
||
return data
|
||
from .progress import progress_snapshot
|
||
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),
|
||
# Tuiles une par une (nom court, type, état) — agrégées depuis les
|
||
# événements écrits par le pipeline (.generation.events.jsonl)
|
||
"tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()),
|
||
}
|
||
|
||
|
||
@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
|
||
|
||
|
||
@app.get("/api/tiles")
|
||
def tiles_data(stamp: Optional[float] = None):
|
||
"""Données de carte (index_tiles.json) — tuiles visibles sans recharger.
|
||
|
||
Le pipeline lancé par /api/generate tourne avec --incremental-index : il
|
||
réécrit ce fichier après chaque tuile terminée. La carte sonde cet
|
||
endpoint pendant un run et fusionne les nouveautés. Avec ?stamp=X : la
|
||
réponse est allégée ({tiles: null}) tant que le fichier n'a pas changé.
|
||
"""
|
||
path = OUTPUT_DIR / "index_tiles.json"
|
||
try:
|
||
current = path.stat().st_mtime
|
||
if stamp is not None and abs(current - stamp) < 1e-4:
|
||
return {"stamp": current, "tiles": None}
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return {"stamp": None, "tiles": None}
|
||
data["stamp"] = current
|
||
return data
|
||
|
||
|
||
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
|
||
_rebuild = {"running": False, "error": None, "done": None, "phase": None}
|
||
|
||
|
||
def _start_rebuild(with_sync):
|
||
"""Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index.
|
||
|
||
Sur la webapp légère, with_sync=True ramène d'abord les tuiles traitées
|
||
par la machine distante (rsync) ; l'échec éventuel de la synchronisation
|
||
est remonté dans l'état sans empêcher le rebuild des données locales.
|
||
"""
|
||
if _rebuild["running"]:
|
||
raise HTTPException(409, "une synchronisation/rebuild est déjà en cours")
|
||
|
||
def _run():
|
||
_rebuild["running"] = True
|
||
_rebuild["error"] = None
|
||
_rebuild["done"] = None
|
||
sync_error = None
|
||
if with_sync and SYNC_CMD:
|
||
_rebuild["phase"] = "sync"
|
||
try:
|
||
r = subprocess.run(SYNC_CMD, shell=True, capture_output=True,
|
||
text=True, timeout=4 * 3600)
|
||
if r.returncode != 0:
|
||
tail = (r.stderr or r.stdout or "").strip()[-500:]
|
||
sync_error = (f"synchronisation échouée (rc={r.returncode})"
|
||
+ (f" : {tail}" if tail else ""))
|
||
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
|
||
sync_error = f"synchronisation échouée : {e}"
|
||
_rebuild["phase"] = "index"
|
||
try:
|
||
from .index import build_index
|
||
build_index(OUTPUT_DIR)
|
||
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
|
||
_rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e)
|
||
else:
|
||
_rebuild["error"] = sync_error
|
||
finally:
|
||
_rebuild["running"] = False
|
||
_rebuild["phase"] = None
|
||
_rebuild["done"] = time.time()
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)}
|
||
|
||
|
||
@app.post("/api/sync", dependencies=[Depends(_require_token)])
|
||
def sync_and_rebuild():
|
||
"""Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan.
|
||
|
||
Sans LIDAR_SYNC_CMD (machine de traitement locale), se réduit à un simple
|
||
rebuild de l'index — même comportement que /api/rebuild.
|
||
"""
|
||
return _start_rebuild(with_sync=True)
|
||
|
||
|
||
@app.get("/api/sync")
|
||
def sync_status():
|
||
return rebuild_status()
|
||
|
||
|
||
@app.post("/api/rebuild", dependencies=[Depends(_require_token)])
|
||
def rebuild_index():
|
||
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
|
||
return _start_rebuild(with_sync=False)
|
||
|
||
|
||
@app.get("/api/rebuild")
|
||
def rebuild_status():
|
||
return {"running": _rebuild["running"], "error": _rebuild["error"],
|
||
"done": _rebuild["done"], "phase": _rebuild["phase"]}
|
||
|
||
|
||
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", dependencies=[Depends(_require_token)])
|
||
def preview(req: PreviewRequest):
|
||
if GENERATION_URL:
|
||
# Webapp légère : le distant connaît input/ et output/ complets
|
||
return _proxy_api("POST", "/api/preview", json.loads(req.json()))
|
||
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)})")
|
||
if req.all_missing:
|
||
# Passe globale : dalles LHD déjà présentes dans input/ (aucun téléchargement)
|
||
cells = laz_cells(INPUT_DIR)
|
||
capped = len(cells) > MAX_CELLS_ALL
|
||
todo = missing_cells_with_corners(cells[:MAX_CELLS_ALL], OUTPUT_DIR,
|
||
include_done=req.regenerate, viz=viz)
|
||
else:
|
||
if not req.bbox or 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, viz=viz)
|
||
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", "slope"] ;
|
||
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", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
|
||
"--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")]
|
||
# Carte régénérée après chaque tuile terminée : la webapp l'affiche en
|
||
# direct via /api/tiles pendant le run.
|
||
cmd += ["--incremental-index"]
|
||
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", dependencies=[Depends(_require_token)])
|
||
def generate(req: GenerateRequest):
|
||
if GENERATION_URL:
|
||
# Webapp légère : téléchargement + traitement sur la machine distante,
|
||
# qui valide les paramètres et renvoie son propre état de job.
|
||
data = _proxy_api("POST", "/api/generate", json.loads(req.json()))
|
||
data["distant"] = True
|
||
return data
|
||
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)})")
|
||
if req.all_missing:
|
||
# Passe globale : les dalles présentes dans input/ qui manquent les
|
||
# visualisations demandées (les complètes incluses seulement si regenerate)
|
||
done = set() if req.regenerate else complete_cells(OUTPUT_DIR, viz)
|
||
tiles = [c for c in laz_cells(INPUT_DIR)
|
||
if c not in done or req.regenerate]
|
||
if not tiles:
|
||
raise HTTPException(400, "aucune tuile à compléter dans input/")
|
||
else:
|
||
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)})")
|
||
|
||
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")
|
||
# Nouveau run : journal d'événements remis à zéro (les tuiles affichées
|
||
# dans la file correspondent au run qui démarre, pas au précédent)
|
||
from .progress import reset_events
|
||
reset_events(OUTPUT_DIR)
|
||
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None})
|
||
_save_job_state()
|
||
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
|
||
_job["finished"] = time.time()
|
||
_save_job_state()
|
||
|
||
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)
|