Add Leaflet interactive map, tile generator compose, auto-sync cache, deploy docs

index.py rewritten as a continuous Leaflet map: rotated L93 tiles, stackable
visualization layers (per-layer opacity, drag-reorder persisted in
localStorage), tile info panel, live rebuild after each tile during a run.
Leaflet is vendored in assets/vendor/ so the map works fully offline;
georeferencing falls back rasterio -> pyproj -> affine so the lightweight
webapp (no GDAL) is supported.

docker-compose.worker.yml adds the tile generator service (full image + GPU)
that remote webapps call via LIDAR_GENERATION_URL, plus a one-shot process
profile. webapp.py gains LIDAR_AUTO_SYNC_SECONDS periodic cache refresh and
LIDAR_REGEN_CIDR restricting generation to the local network. run.sh
--serve-webapp now mounts ~/.ssh read-only so the rsync sync works.

docs/DEPLOY_WEBAPP.md completed for Raspberry Pi deployment: prerequisites,
git clone install, SSH key setup, first sync, update procedure and
troubleshooting.
This commit is contained in:
Antoine Jacquin
2026-09-04 21:36:14 +02:00
parent 422f58d772
commit 23969c9e14
17 changed files with 1828 additions and 336 deletions

View File

@ -41,6 +41,7 @@ carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
"""
import json
import logging
import math
import os
import subprocess
@ -51,7 +52,7 @@ from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
@ -73,6 +74,17 @@ 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) :
# CIDR en notation standard, vide = restriction levée. 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", "192.168.1.0/24") 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é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
@ -81,6 +93,27 @@ GENERATE_RESOLUTIONS = (0.5, 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"
@ -116,6 +149,51 @@ def _require_token(x_lidar_token: str = Header(None)):
raise HTTPException(401, "token d'API manquant ou invalide")
def _ip_in_regen_cidr(ip):
"""Vrai si l'IP appartient au CIDR autorisé (LIDAR_REGEN_CIDR)."""
if not REGEN_CIDR:
return True # restriction désactivée
if not ip:
return False
import ipaddress
try:
return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False)
except ValueError:
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).
@ -365,11 +443,15 @@ def root():
@app.get("/api/status")
def 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
# 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:
@ -381,6 +463,8 @@ def status():
"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()),
@ -475,6 +559,36 @@ def _start_rebuild(with_sync):
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.
@ -578,7 +692,8 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
return cmd
@app.post("/api/generate", dependencies=[Depends(_require_token)])
@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,