Split webapp for Raspberry Pi deployment, remote generation API and sync
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.
This commit is contained in:
@ -5,18 +5,42 @@ 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)
|
||||
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 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
|
||||
@ -25,7 +49,9 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@ -33,7 +59,25 @@ 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
|
||||
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")
|
||||
|
||||
@ -62,15 +106,90 @@ def assets(file_path: str):
|
||||
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)."""
|
||||
from .pipeline import VIZ_STEPS
|
||||
return [name for name, _ in VIZ_STEPS]
|
||||
"""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(..., description="[ouest, sud, est, nord] en WGS84")
|
||||
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):
|
||||
@ -91,25 +210,74 @@ class GenerateRequest(BaseModel):
|
||||
"bas en terrain)")
|
||||
viz: list = Field(None,
|
||||
description="Visualisations à générer, noms d'étapes du "
|
||||
"pipeline (ex: aspect, wavelet, aniso_open) ; "
|
||||
"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}
|
||||
_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 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
|
||||
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)
|
||||
@ -124,13 +292,54 @@ def processed_cells(output_dir):
|
||||
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.
|
||||
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
|
||||
done = processed_cells(output_dir)
|
||||
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:
|
||||
@ -157,6 +366,12 @@ def root():
|
||||
|
||||
@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
|
||||
@ -166,6 +381,9 @@ def status():
|
||||
"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()),
|
||||
}
|
||||
|
||||
|
||||
@ -189,36 +407,99 @@ def available_layers():
|
||||
return found
|
||||
|
||||
|
||||
# --- Rebuild de l'index en arrière-plan -------------------------------------
|
||||
_rebuild = {"running": False, "error": None, "done": None}
|
||||
@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
|
||||
|
||||
|
||||
@app.post("/api/rebuild")
|
||||
def rebuild_index():
|
||||
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
|
||||
# --- 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, "un rebuild est déjà en cours")
|
||||
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'API
|
||||
_rebuild["error"] = str(e)
|
||||
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}
|
||||
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"]}
|
||||
"done": _rebuild["done"], "phase": _rebuild["phase"]}
|
||||
|
||||
|
||||
def _tail_log(n_lines):
|
||||
@ -229,15 +510,32 @@ def _tail_log(n_lines):
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
@app.post("/api/preview", dependencies=[Depends(_require_token)])
|
||||
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)
|
||||
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}
|
||||
|
||||
|
||||
@ -252,14 +550,15 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
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"] ;
|
||||
(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", "0.5,0.2",
|
||||
"-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]
|
||||
@ -269,6 +568,9 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
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"]
|
||||
@ -276,21 +578,14 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
return cmd
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
@app.post("/api/generate", dependencies=[Depends(_require_token)])
|
||||
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)})")
|
||||
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]
|
||||
@ -298,6 +593,28 @@ def generate(req: GenerateRequest):
|
||||
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"]
|
||||
@ -306,7 +623,12 @@ def generate(req: GenerateRequest):
|
||||
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})
|
||||
# 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)
|
||||
|
||||
@ -315,6 +637,8 @@ def generate(req: GenerateRequest):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user