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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user