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)
This commit is contained in:
Antoine Jacquin
2026-09-12 17:48:37 +02:00
parent 78a2c328c3
commit 68272cabf0
16 changed files with 1769 additions and 165 deletions

View File

@ -24,6 +24,13 @@ le téléchargement IGN). Endpoints :
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.
@ -63,6 +70,7 @@ 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.
@ -75,21 +83,27 @@ API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
# 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()
# 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é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)
# 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")
@ -150,16 +164,30 @@ def _require_token(x_lidar_token: str = Header(None)):
def _ip_in_regen_cidr(ip):
"""Vrai si l'IP appartient au CIDR autorisé (LIDAR_REGEN_CIDR)."""
"""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:
return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False)
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):
@ -297,6 +325,21 @@ class GenerateRequest(BaseModel):
"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()
@ -391,7 +434,8 @@ def laz_cells(input_dir):
def complete_cells(output_dir, viz_steps):
"""Cellules ayant TOUTES les visualisations demandées, aux deux résolutions.
"""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.
@ -475,15 +519,15 @@ def status(request: Request = None):
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.
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 PANEL_VIZ, VIZ_LABELS
from .index import VIZ_LABELS
vis = OUTPUT_DIR / "visualisations"
found = {}
if vis.is_dir():
for key in PANEL_VIZ:
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)
@ -760,6 +804,76 @@ def generate(req: GenerateRequest):
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)