Files
lidar_rendu/lidar_pipeline/webapp.py
Antoine Jacquin 68272cabf0 Tuiles visibles en direct après régénération et déploiement webapp distante
- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et
  force le rechargement navigateur (cache heuristique contourné), y compris
  en plein run via /api/tiles ; veille permanente 15 s sur la carte
- simulation locale du mode deux machines (docker-compose.local-2m.yml) :
  worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/
- override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output,
  rsync vers le worker, labels Traefik (proxy/websecure/myresolver)
- purge 0,5 m : worker/process et politique générale passés à 0,2 m seul
- intègre le travail parallèle non commité : export mosaïque multi-dalles
  (export.py + /api/export), sous-tuilage intégral des couches, légendes
  VIZ_LEGENDS, docs et tests associés (213 tests verts)
2026-09-12 17:48:37 +02:00

880 lines
38 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)
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)
POST /api/export → assemble des dalles adjacentes en une image (PNG/JPEG/
WebP) ou un PDF multi-couches, consultable sur téléphone
(module export.py — mosaïque sans couture, habillage
titre/échelle/nord) ; GET /api/export/file/{nom} sert le
fichier en téléchargement. Marche aussi sur la webapp
légère : les tuiles viennent du cache local, aucune
délégation à la machine de traitement.
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 logging
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, Request
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)
MAX_EXPORT_TILES = 64 # garde-fou : dalles assemblables par export (mémoire)
# 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éseau autorisé à lancer la (re)génération des tuiles (/api/generate) :
# liste de CIDR séparés par virgules, vide = restriction levée. Par défaut :
# boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte
# Docker (connexions via la passerelle 172.x) et localhost, tout en refusant
# les clients venus d'Internet (IP publiques non routables vers le privé).
# L'IP du client est l'IP de connexion (conservée par le DNAT Docker) ; si
# elle est dans le réseau autorisé, X-Forwarded-For est honoré (reverse proxy
# local qui expose la carte au-dehors — un client distant ne peut pas le
# forger puisque sa connexion directe est déjà hors réseau).
REGEN_CIDR = (os.environ.get(
"LIDAR_REGEN_CIDR",
"127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") or "").strip()
# Cache local périodique : relance la synchronisation + le rebuild toutes
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
# sa copie des tuiles de la machine de traitement.
AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0)
# Résolution générée par /api/generate (cf. _build_command) : 0,2 m seule,
# la 0,5 m n'est plus produite. La détection des tuiles à compléter exige
# cette même résolution : une tuile n'est complète que si elle possède les
# visualisations demandées en 0,2 m.
GENERATE_RESOLUTIONS = (0.2,)
app = FastAPI(title="Carte LiDAR — génération de zones")
# Visibilité de la configuration au démarrage (docker compose logs) : le
# backend de génération est TOUJOURS défini par variable d'environnement.
logger = logging.getLogger("lidar")
if not logger.handlers:
# Hors cli.py (uvicorn seul), le logger 'lidar' n'a aucun handler — en
# ajouter un pour que la config soit visible dans les logs du conteneur.
_h = logging.StreamHandler(sys.stdout)
_h.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(_h)
logger.setLevel(logging.INFO)
if GENERATION_URL:
logger.info(f"Backend de génération des tuiles : {GENERATION_URL} "
f"(LIDAR_GENERATION_URL)")
else:
logger.info("Backend de génération des tuiles : local — consultation "
"autonome du cache, sans LIDAR_GENERATION_URL")
if SYNC_CMD:
logger.info(f"Cache local des tuiles : LIDAR_SYNC_CMD"
+ (f" (rafraîchi toutes les {AUTO_SYNC_SECONDS:g}s)"
if AUTO_SYNC_SECONDS > 0 else " (à la demande)"))
# 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 _ip_in_regen_cidr(ip):
"""Vrai si l'IP appartient à un des CIDR autorisés (LIDAR_REGEN_CIDR).
LIDAR_REGEN_CIDR accepte plusieurs réseaux séparés par des virgules
(ex. « 192.168.1.0/24,10.0.0.0/8 ») ; les entrées invalides sont ignorées.
"""
if not REGEN_CIDR:
return True # restriction désactivée
if not ip:
return False
import ipaddress
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
for part in REGEN_CIDR.split(","):
part = part.strip()
if not part:
continue
try:
if addr in ipaddress.ip_network(part, strict=False):
return True
except ValueError:
continue
return False
def _client_ip(request):
"""IP d'origine du client pour la restriction de génération.
Le pair direct doit être dans le réseau autorisé ; dans ce cas seulement,
X-Forwarded-For (reverse proxy local) désigne le client réel derrière lui.
Un client externe connecté en direct est refusé sur son IP de connexion,
quel que soit le X-Forwarded-For qu'il annonce.
"""
if request is None:
return None
peer = getattr(request, "client", None)
peer_ip = getattr(peer, "host", None) if peer else None
if not _ip_in_regen_cidr(peer_ip):
return peer_ip # hors réseau (ou inconnu) : jugé sur cette IP
xff = request.headers.get("x-forwarded-for") if hasattr(request, "headers") else None
if xff:
first = xff.split(",")[0].strip()
if first:
return first
return peer_ip
def _require_lan_for_generation(request: Request):
"""Réserve /api/generate aux clients du réseau local (LIDAR_REGEN_CIDR)."""
ip = _client_ip(request)
if not _ip_in_regen_cidr(ip):
raise HTTPException(
403, f"génération de tuiles réservée au réseau local"
f"{f' ({REGEN_CIDR})' if REGEN_CIDR else ''} — "
f"client : {ip or 'IP inconnue'}")
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)")
class ExportRequest(BaseModel):
tiles: list = Field(..., description="liste [col, row] des dalles adjacentes "
"à assembler (entiers km L93)")
viz: list = Field(..., description="mots-clés de visualisation (ex: slope, "
"hillshade_multi) ; plusieurs possibles en PDF "
"(une page par visualisation), une seule pour une image")
resolution: float = Field(0.5, description="résolution des dalles en m/px "
"(0.5 ou 0.2)")
format: str = Field("jpeg", description="format de sortie : png, jpeg, "
"webp ou pdf")
max_side: int = Field(0, description="réduire le plus grand côté de la "
"mosaïque à N pixels (0 = pleine résolution) — "
"recommandé sur téléphone (4096)")
# --- É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 à la résolution de
génération (0,2 m).
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(request: Request = None):
# Droit de lancer une génération depuis cette IP (guide l'interface)
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
if GENERATION_URL:
# Webapp légère : l'état vient de la machine de traitement ; le droit
# de génération reste jugé sur l'IP du navigateur (localement).
data = _proxy_api("GET", "/api/status", timeout=10)
data["distant"] = True
data["regen_allowed"] = regen_allowed
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),
# L'interface masque les boutons de génération hors réseau local
"regen_allowed": regen_allowed,
# 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.
Toutes les couches connues du registre (VIZ_LABELS) détectées sur disque.
Permet à la carte de détecter celles apparues depuis le dernier
build_index et de proposer une actualisation.
"""
from .index import VIZ_LABELS
vis = OUTPUT_DIR / "visualisations"
found = {}
if vis.is_dir():
for key in VIZ_LABELS:
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)}
def _auto_sync_once():
"""Un cycle de cache local : synchronisation + rebuild, si rien ne tourne.
Retourne True si un cycle a été lancé, False si un rebuild était déjà en
cours (le prochain cycle rattrapera).
"""
if _rebuild["running"]:
return False
try:
_start_rebuild(with_sync=True)
return True
except HTTPException:
return False # course perdue contre un lancement manuel : tant pis
def _auto_sync_loop():
"""Boucle du cache local : synchronise les tuiles périodiquement."""
while True:
time.sleep(AUTO_SYNC_SECONDS)
_auto_sync_once()
# Conteneur webapp seul (cache local des tuiles) : lancer la boucle de
# synchronisation en arrière-plan si demandé. Sans LIDAR_SYNC_CMD il n'y a
# rien à synchroniser — pas de boucle (juste le rebuild à la demande).
if AUTO_SYNC_SECONDS > 0 and SYNC_CMD:
threading.Thread(target=_auto_sync_loop, daemon=True,
name="cache-tuiles").start()
@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),
Depends(_require_lan_for_generation)])
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)}
# --- Export multi-dalles (mosaïque image/PDF pour téléphone) ---------------
_export_lock = threading.Lock()
@app.post("/api/export")
def export_tiles(req: ExportRequest):
"""Assemble des dalles adjacentes en image ou PDF (export.py).
Local par nature : les tuiles assemblées sont celles du cache output/
(la webapp légère possède sa copie via rsync) — aucune délégation à la
machine de traitement. Un seul export à la fois (CPU/mémoire limités sur
Raspberry Pi).
"""
import re as _re
from .export import EXPORT_FORMATS, build_export
fmt = (req.format or "").lower()
if fmt not in EXPORT_FORMATS:
raise HTTPException(400, f"format invalide : {req.format!r} "
f"(attendus : {', '.join(EXPORT_FORMATS)})")
viz = [str(v) for v in (req.viz or []) if v]
if not viz:
raise HTTPException(400, "aucune visualisation demandée")
cells = []
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])")
cell = (int(pair[0]), int(pair[1]))
if cell not in cells:
cells.append(cell)
if not cells:
raise HTTPException(400, "aucune tuile fournie")
if len(cells) > MAX_EXPORT_TILES:
raise HTTPException(400, f"trop de dalles ({len(cells)}) — max {MAX_EXPORT_TILES}")
if not _export_lock.acquire(blocking=False):
raise HTTPException(409, "un export est déjà en cours — réessayez dans un instant")
try:
out_dir = OUTPUT_DIR / "exports"
result = build_export(OUTPUT_DIR / "visualisations", cells, viz,
float(req.resolution), fmt, out_dir,
max_side=int(req.max_side))
except ValueError as e:
raise HTTPException(400, str(e))
finally:
_export_lock.release()
# Une couche = un fichier image (ou une page du PDF) : la réponse liste
# tous les fichiers générés, chacun avec SA légende.
return {"fichiers": [{
"url": f"/api/export/file/{e['file'].name}",
"nom": e["file"].name,
"taille": e["file"].stat().st_size,
"largeur": e["width"],
"hauteur": e["height"],
"pages": e["pages"],
} for e in result["files"]]}
@app.get("/api/export/file/{name}")
def export_file(name: str):
"""Sert un fichier exporté en téléchargement (pièce jointe)."""
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9_.-]+", name):
raise HTTPException(404, "nom de fichier invalide")
base = (OUTPUT_DIR / "exports").resolve()
path = (base / name).resolve()
if base not in path.parents or not path.is_file():
raise HTTPException(404, f"export introuvable : {name}")
return FileResponse(str(path), filename=name,
headers={"Cache-Control": "no-cache"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)