"""Serveur de tuiles XYZ + carte « slippy » + génération (image lidar-maps). Sert la pyramide de tuiles décrite dans `tiles.py` selon le schéma OpenStreetMap/Google Maps, donc directement utilisable comme fond d'imagerie dans JOSM, iD, QGIS, uMap, MapLibre ou OsmAnd : GET /tiles/{couche}/{z}/{x}/{y}.png 256 px, PNG RGBA (canonique) GET /tiles/{couche}/{z}/{x}/{y}@2x.webp 512 px, WebP (interface interne) GET /tiles/{couche}.json TileJSON 3.0.0 GET /tiles/wmts.xml capacités WMTS 1.0.0 (QGIS…) GET /tiles/josm.imagery.xml fichier d'imagerie JOSM GET /api/map/meta couches, zooms, emprise, version GET /api/map/tile?lat&lng dalle sous le point (fiche info) POST /api/map/warm pré-calcul d'une emprise GET / et /assets/* interface de consultation Les tuiles sont rendues à la demande puis mises en cache sur disque (`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un serveur amont : une tuile absente localement y est rapatriée (quelques dizaines de Ko) au lieu de rapatrier les dalles entières. Génération de tuiles (reprise de la webapp historique) : GET /api/status → état du run en cours (ou du dernier), file, tuiles GET /api/tiles → inventaire des dalles (index_tiles.json) — sert de serveur de dalles amont (LIDAR_SOURCE_URL) POST /api/preview → cellules 1 km d'une bbox WGS84 à traiter POST /api/generate → télécharge (géoplateforme IGN) puis traite POST /api/stop → arrête le run en cours POST /api/queue/clear → vide la file d'attente GET /api/cell → dalle L93 sous un point (sélection à la main) Sur l'image complète (worker, machine de traitement), la génération est locale : `python -m lidar_pipeline` en sous-processus, un job à la fois, les demandes concurrentes en file persistante. Sur l'image légère (Raspberry Pi), `LIDAR_GENERATION_URL` délègue tout à la machine de traitement — les dalles produites sont rapatriées à la demande via `LIDAR_SOURCE_URL` (inventaire /api/tiles + montages statiques visualisations/, index_thumbs/, index_subtiles/). `LIDAR_API_TOKEN` (worker) / `LIDAR_REMOTE_TOKEN` (léger) protègent optionnellement les appels ; `LIDAR_REGEN_CIDR` réserve la génération au réseau local (boutons masqués ailleurs). Sur une petite machine (Raspberry Pi), deux réglages inversent la charge : - `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par le navigateur) et apparaît après passage de la maintenance ; - `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles (nouvelles ou régénérées) et met leur pyramide à jour en file d'attente, un rendu à la fois, à basse priorité (`os.nice`) et cadencé (`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan, `POST /api/map/warm` reste le pré-calcul manuel exhaustif. """ import json import logging import math import os import re import signal import subprocess import sys import threading import time import urllib.parse import urllib.request import uuid import xml.sax.saxutils as _xml from collections import deque from contextlib import asynccontextmanager from functools import lru_cache from pathlib import Path from typing import Optional from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles as _StaticFiles from pydantic import BaseModel, Field from starlette.concurrency import run_in_threadpool from . import tiles as tiles_mod from .mapui import ASSETS_DIRNAME, render_html, ui_version, write_map_assets OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output")) INPUT_DIR = Path(os.environ.get("LIDAR_INPUT_DIR", "/data/input")) PORT = int(os.environ.get("LIDAR_PORT", "8975")) # Serveur de tuiles amont (machine de traitement) — mode deux machines. MAPS_URL = (os.environ.get("LIDAR_MAPS_URL") or "").rstrip("/") ATTRIBUTION = os.environ.get( "LIDAR_ATTRIBUTION", 'LiDAR HD © IGN ' '— Licence Ouverte 2.0') ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").replace("—", "—") # Rendus simultanés : plafond bas par défaut — une carte se consulte souvent # depuis une petite machine (Raspberry Pi), et un rendu décode une dalle AVIF # de plusieurs dizaines de Mo. Les requêtes en excès attendent le sémaphore # sans consommer de CPU. `LIDAR_TILE_WORKERS` relève le plafond sur une # machine confortable. TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2)) _render_sem = threading.Semaphore(TILE_WORKERS) # 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 # 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 (passerelles 172.x) et localhost, tout en refusant les clients # venus d'Internet. 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 — 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() # Résolution de génération (politique 0,2 m seule — cf. webapp historique). GENERATE_RESOLUTIONS = (0.2,) GROUND_CLASS_METHODS = ("auto", "ign", "smrf", "csf") 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) LOG_FILE = OUTPUT_DIR / ".generation.log" JOB_FILE = OUTPUT_DIR / ".generation.job.json" QUEUE_FILE = OUTPUT_DIR / ".generation.queue.json" # --------------------------------------------------------------------------- # Maintenance de la pyramide en tâche de fond (petites machines) # --------------------------------------------------------------------------- # # `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge : # la navigation ne rend plus rien (lecture cache seule), une sondeur rescane # les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa # pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la # vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes. # Premier passage = dalle inconnue : toute la pyramide est reconstruite (les # tuiles déjà fraîches ne coûtent qu'un stat). def _env_flag(name): """Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel : testable sans recharger le module.""" return (os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on", "oui")) def _bg_scale(): return 1 if os.environ.get("LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1" else 2 def _bg_conf(): """Réglages de la maintenance (relus au démarrage des fils, cf. doc).""" return { "interval": max(10.0, float(os.environ.get( "LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)), # Défaut : jusqu'au natif (18@2x = 0,2 m/px) — pyramide complète. "max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get( "LIDAR_TILE_BACKGROUND_MAX_Z", "") or (tiles_mod.TILE_MAX_NATIVE_Z - ( 0 if _bg_scale() == 1 else 1)))), "scale": _bg_scale(), "fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "avif") or "avif").strip(), "pause": max(0.0, float(os.environ.get( "LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)), "queue_max": max(1024, int(os.environ.get( "LIDAR_TILE_BACKGROUND_QUEUE_MAX", "65536") or 65536)), } _bg = {"threads": [], "stop": threading.Event(), "queue": deque(), "queued": set(), "guard": threading.Lock(), "snapshot": None, "prefetch": deque(), "incomplete": {}, "running": False, "stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0, "dernier_scan": None, "ajouts_dernier_scan": 0}} def _bg_enqueue(items, front=False, rejected=None): """Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée. rejected : ensemble complété des tuiles refusées faute de place. front=True : en tête de file, dans l'ordre donné (dalles tout juste rendues) ; une tuile déjà en file y est remontée. L'arriéré n'est pas tronqué pour leur faire place (ses dalles ne seraient jamais reprises) : la file déborde alors brièvement de la tête ajoutée. """ added = 0 with _bg["guard"]: if front: fresh = [it for it in items] keep = set(fresh) rest = [it for it in _bg["queue"] if it not in keep] _bg["queue"].clear() _bg["queue"].extend(fresh[:_bg_conf()["queue_max"]]) _bg["queue"].extend(rest) if rejected is not None: rejected.update(fresh[_bg_conf()["queue_max"]:]) added = sum(1 for it in fresh if it not in _bg["queued"]) _bg["queued"] = set(_bg["queue"]) return added for item in items: if item in _bg["queued"]: continue if len(_bg["queue"]) >= _bg_conf()["queue_max"]: if rejected is not None: rejected.add(item) continue _bg["queue"].append(item) _bg["queued"].add(item) added += 1 return added def _bg_pop(): with _bg["guard"]: if _bg["queue"]: item = _bg["queue"].popleft() _bg["queued"].discard(item) return item return None def background_scan(output_dir=None): """Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file. Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le mtime le plus récent des sources de chaque dalle : une dalle régénérée n'invalide que ses propres tuiles (même règle que le cache). """ output_dir = Path(output_dir or OUTPUT_DIR) conf = _bg_conf() first_scan = _bg["snapshot"] is None snapshot = {} changed = {} resume = {} # dalle inchangée, pyramide pas encore toute en file → reprise for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items(): for (col, row), tiers in per_cell.items(): newest = 0.0 for group in tiers: for src in group: m = src.mtime() if m and m > newest: newest = m snapshot[(layer, col, row)] = newest if (_bg["snapshot"] is not None and _bg["snapshot"].get((layer, col, row)) == newest): if (layer, col, row) in _bg["incomplete"]: resume[(layer, col, row)] = _bg["incomplete"][(layer, col, row)] changed.setdefault(layer, []).append((col, row)) continue changed.setdefault(layer, []).append((col, row)) _bg["snapshot"] = snapshot # Carte alimentée par un serveur de dalles amont (Pi) : les sources des # dalles nouvelles ou modifiées sont rapatriées et GARDÉES localement, # sans attendre une visite — les niveaux rendus à la volée restent # disponibles quand le conteneur de rendu est éteint. # Toutes les dalles sont revérifiées (simples tests d'existence) : une # source manquée pendant qu'il était éteint est reprise au scan suivant. if tiles_mod.REMOTE_SOURCE_URL: index = tiles_mod.source_index(output_dir) pending = {str(s_.path) for s_ in _bg["prefetch"]} for per_cell in index.values(): for tiers in per_cell.values(): for src in tiles_mod.sources_to_keep(tiers): key = str(src.path) if src.url is not None and key not in pending and not src.path.is_file(): _bg["prefetch"].append(src) pending.add(key) # File bornée (queue_max) : sur un grand territoire, le premier scan la # remplissait et le reste de la pyramide n'était jamais généré. Chaque # dalle garde donc sa position : les tuiles refusées faute de place sont # reprises aux scans suivants (en queue, derrière l'arriéré), à partir de # la première refusée. Les dalles qui viennent de changer passent en # tête (la pyramide suit le rendu en cours, hors premier scan). order = lambda t: (t[1], t[0], t[2], t[3]) # noqa: E731 — bas niveaux d'abord items, retry, per_cell = [], [], {} for layer, cells in changed.items(): for col, row in cells: cell = (layer, col, row) bbox = tiles_mod.cell_bounds_wgs84(col, row) mine = [] for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1): if not (tiles_mod.zoom_supported(z, conf["scale"]) and tiles_mod.zoom_cached(z, conf["scale"])): continue for x, y in tiles_mod.tiles_in_bounds(bbox, z): mine.append((layer, z, x, y)) mine.sort(key=order) start = resume.get(cell, 0) per_cell[cell] = (start, mine) (retry if cell in resume else items).extend(mine[start:]) items.sort(key=order) retry.sort(key=order) rejected = set() added = _bg_enqueue(items, front=first_scan is False, rejected=rejected) added += _bg_enqueue(retry, rejected=rejected) incomplete = {} for cell, (start, mine) in per_cell.items(): for i in range(start, len(mine)): if mine[i] in rejected: incomplete[cell] = i break _bg["incomplete"] = incomplete _bg["stats"]["scans"] += 1 _bg["stats"]["dernier_scan"] = time.time() _bg["stats"]["ajouts_dernier_scan"] = added cells_changed = sum(len(v) for v in changed.values()) if items: logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) " f"modifiée(s), {added} tuile(s) en file") return added def _bg_process_one(): """Traite une tuile de la file, sinon une source à rapatrier. Vrai si un travail a été fait : "telechargee" pour une tuile téléchargée de l'amont (enchaînée sans pause : le worker encaisse), True pour un rendu local ou un rapatriement de source (suivis de la pause). Tuiles d'abord : légères et visibles tout de suite, elles ne doivent pas attendre des milliers de sources (utiles aux seuls forts zooms rendus à la volée). Les tuiles déjà à jour sont passées sans pause. """ conf = _bg_conf() while True: item = _bg_pop() if item is None: break layer, z, x, y = item _data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]) if state == "pending": break _bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1 if item is None: try: src = _bg["prefetch"].popleft() except IndexError: return False if src.ensure(): _bg["stats"]["sources_locales"] = _bg["stats"].get("sources_locales", 0) + 1 return True return False # amont éteint : repris au prochain scan if MAPS_URL: # Serveur de tuiles amont : la pyramide est TÉLÉCHARGÉE en tâche de # fond (quelques dizaines de Ko, aucun décodage de dalle) ; la carte # se remplit sans visite et reste servie ensuite sans l'amont. suffix = "@2x" if conf["scale"] == 2 else "" data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{conf['fmt']}") if data: tiles_mod._write_atomic( tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]), data) _bg["stats"]["telechargees"] = _bg["stats"].get("telechargees", 0) + 1 return "telechargee" # Amont absent ou éteint : rendu local depuis les sources gardées (autonomie). tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]) _bg["stats"]["rendues"] += 1 return True def _bg_worker(): try: os.nice(10) # consommation minimale : le rendu cède aux autres usages except (AttributeError, OSError): # pragma: no cover — nice indisponible pass while not _bg["stop"].is_set(): try: rendered = _bg_process_one() except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file logger.warning(f"Maintenance des tuiles : rendu impossible ({e})") rendered = False if rendered == "telechargee": continue # téléchargement : aucun calcul local, pas de pause if rendered: _bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause else: _bg["stop"].wait(0.5) def _inventory_mark(): """Signature bon marché de l'inventaire des dalles (fichier local ou charge utile amont), pour déclencher un scan dès qu'une dalle arrive.""" try: return (OUTPUT_DIR / "index_tiles.json").stat().st_mtime except OSError: pass if not tiles_mod.REMOTE_SOURCE_URL: return None try: payload = tiles_mod._remote_payload() return payload.get("stamp") if payload else None except Exception: # noqa: BLE001 return None BG_WATCH_S = 10.0 # surveillance de l'inventaire pendant les runs def _bg_poller(): last_scan, last_mark = 0.0, _inventory_mark() while not _bg["stop"].is_set(): mark = _inventory_mark() due = time.time() - last_scan >= _bg_conf()["interval"] if due or mark != last_mark: try: background_scan() except Exception as e: # noqa: BLE001 — prochain scan au prochain tour logger.warning(f"Maintenance des tuiles : scan impossible ({e})") last_scan, last_mark = time.time(), mark _bg["stop"].wait(min(BG_WATCH_S, _bg_conf()["interval"])) def start_background(): """Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés.""" if _bg["running"]: return _bg["stop"].clear() _bg["running"] = True threads = [threading.Thread(target=_bg_poller, daemon=True, name="lidar-bg-scan")] threads += [threading.Thread(target=_bg_worker, daemon=True, name=f"lidar-bg-render-{i}") for i in range(TILE_WORKERS)] for t in threads: t.start() _bg["threads"] = threads conf = _bg_conf() logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} " f"@{conf['scale']}x {conf['fmt']}, scan toutes les " f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s " f"entre rendus locaux (téléchargements sans pause)") def stop_background(): _bg["stop"].set() _bg["running"] = False @lru_cache(maxsize=8) def _transparent_tile(scale=1, fmt="png"): return tiles_mod.transparent_tile(scale, fmt) @asynccontextmanager async def _lifespan(_app): if _env_flag("LIDAR_TILE_BACKGROUND"): start_background() yield stop_background() _LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$") _NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$") _MAX_UPSTREAM_BYTES = 8 * 1024 * 1024 logger = logging.getLogger("lidar") if not logger.handlers: _h = logging.StreamHandler(sys.stdout) _h.setFormatter(logging.Formatter("%(message)s")) logger.addHandler(_h) logger.setLevel(logging.INFO) app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan) # Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli # (dev, paquet installé sans bake). _assets_dir = Path(__file__).resolve().parent / ASSETS_DIRNAME if not (_assets_dir / "app.js").is_file(): try: write_map_assets(_assets_dir) except OSError: import tempfile _assets_dir = Path(tempfile.gettempdir()) / "lidar_map_assets" write_map_assets(_assets_dir) _pipeline_import_cache = {} def _pipeline_importable(): """Vrai si le pipeline de traitement est importable (image complète).""" if "ok" not in _pipeline_import_cache: try: import lidar_pipeline.pipeline # noqa: F401 _pipeline_import_cache["ok"] = True except Exception: _pipeline_import_cache["ok"] = False return _pipeline_import_cache["ok"] logger.info(f"Carte LiDAR (tuiles XYZ) — sortie {OUTPUT_DIR}" + (f", amont {MAPS_URL}" if MAPS_URL else ", rendu local") + f", {TILE_WORKERS} rendu(s) simultané(s)" + f", {tiles_mod.FETCH_WORKERS} téléchargement(s) simultané(s)" + f", cache sources {tiles_mod.SOURCE_CACHE_BYTES // (1024 * 1024)} Mo") if GENERATION_URL: logger.info(f"Backend de génération des tuiles : {GENERATION_URL} " f"(LIDAR_GENERATION_URL)") elif _pipeline_importable(): logger.info("Backend de génération des tuiles : local (pipeline complet)") else: logger.info("Backend de génération des tuiles : aucun (image légère sans " "LIDAR_GENERATION_URL) — boutons de génération masqués") # --- Dalles servies en statique (inventaire /api/tiles + rapatriement) ------- # Une machine légère (LIDAR_SOURCE_URL pointant ici) liste ces dalles via # /api/tiles puis les télécharge depuis ces montages : vignettes, quadrants et # dalles complètes. Cache navigateur immutable quand l'URL porte ?v= (mtime # posée par build_index), revalidation courte sinon. class _VersionedStaticFiles(_StaticFiles): async def get_response(self, path, scope): resp = await super().get_response(path, scope) qs = urllib.parse.parse_qs( scope.get("query_string", b"").decode("ascii", "replace")) versioned = qs.get("v", [""])[0].isdigit() resp.headers["Cache-Control"] = ( "public, max-age=31536000, immutable" if versioned else "public, max-age=300, must-revalidate") return resp for _name in ("visualisations", "index_thumbs", "index_subtiles", "index_overview"): _dir = OUTPUT_DIR / _name _dir.mkdir(parents=True, exist_ok=True) app.mount(f"/{_name}", _VersionedStaticFiles(directory=str(_dir)), name=_name) # --------------------------------------------------------------------------- # Utilitaires # --------------------------------------------------------------------------- @app.middleware("http") async def _cors(request: Request, call_next): """CORS ouvert sur les tuiles : exigé par iD, uMap et MapLibre.""" if request.method == "OPTIONS" and request.url.path.startswith("/tiles/"): return Response(status_code=204, headers=_cors_headers()) response = await call_next(request) if request.url.path.startswith("/tiles/"): response.headers.update(_cors_headers()) return response def _cors_headers(): return {"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*"} def _base_url(request: Request): """URL publique du service (proxy inverse honoré).""" proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("x-forwarded-host") or request.headers.get("host") if not host: host = f"{request.url.hostname}:{request.url.port or PORT}" return f"{proto}://{host}" def _layer_or_404(layer): if not _LAYER_RE.match(layer or ""): raise HTTPException(404, "couche inconnue") if layer not in tiles_mod.available_layers(OUTPUT_DIR): raise HTTPException(404, f"couche indisponible : {layer}") return layer def _layer_infos(): """Couches disponibles + libellés/descriptions du registre partagé.""" from .index import KEYWORD_TO_STEP, VIZ_LABELS, VIZ_LEGENDS out = [] for key in tiles_mod.available_layers(OUTPUT_DIR): legend = VIZ_LEGENDS.get(key, {}) out.append({"key": key, "label": VIZ_LABELS.get(key, key), "description": legend.get("description", ""), "reading": list(legend.get("reading", ())), # Nom d'étape --only du pipeline (sélecteur de génération) "step": KEYWORD_TO_STEP.get(key, key)}) return out # --- disjoncteur amont (mêmes réglages que webapp.py) ---------------------- _UPSTREAM = {"fails": 0, "until": 0.0} _UPSTREAM_OFFLINE_AFTER = 3 _UPSTREAM_OFFLINE_SECONDS = 120.0 def _upstream_offline(): return time.time() < _UPSTREAM["until"] def _upstream_mark(ok): if ok: _UPSTREAM.update(fails=0, until=0.0) return _UPSTREAM["fails"] += 1 # Réarmé après expiration : un amont resté éteint est resuspendu. if _UPSTREAM["fails"] >= _UPSTREAM_OFFLINE_AFTER and not _upstream_offline(): _UPSTREAM["until"] = time.time() + _UPSTREAM_OFFLINE_SECONDS logger.info("Serveur de tuiles amont injoignable — tentatives suspendues 2 min") def _fetch_upstream(rel_path): """Rapatrie une tuile depuis LIDAR_MAPS_URL (None si indisponible).""" if not MAPS_URL or _upstream_offline(): return None url = f"{MAPS_URL}/{rel_path.lstrip('/')}" try: req = urllib.request.Request(url, headers={"User-Agent": "lidar-maps-cache"}) with urllib.request.urlopen(req, timeout=20) as r: data = r.read(_MAX_UPSTREAM_BYTES + 1) if len(data) > _MAX_UPSTREAM_BYTES: raise IOError("tuile amont trop volumineuse") _upstream_mark(True) return data except Exception as e: # noqa: BLE001 — amont éteint : rendu local/vide _upstream_mark(False) logger.debug(f"Tuile amont indisponible ({url}) : {e}") return None # --- rendu : déduplication des requêtes concurrentes ------------------------ _locks = {} _locks_guard = threading.Lock() def _acquire_lock(key): """Verrou par tuile, compté : deux requêtes simultanées n'en rendent qu'une. Le compteur évite deux écueils : un dictionnaire qui grossit sans fin (des millions de tuiles) et un verrou retiré alors qu'un autre fil l'attend encore — ce qui autoriserait deux rendus concurrents de la même tuile. """ with _locks_guard: entry = _locks.get(key) if entry is None: entry = _locks[key] = [threading.Lock(), 0] entry[1] += 1 return entry def _release_lock(key, entry): with _locks_guard: entry[1] -= 1 if entry[1] <= 0: _locks.pop(key, None) def _tile_bytes(layer, z, x, y, scale, fmt): """Tuile encodée : cache → rendu local → amont. None si zone vide.""" key = (layer, z, x, y, scale, fmt) entry = _acquire_lock(key) try: with entry[0]: try: with _render_sem: data = tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, scale, fmt) except Exception as e: # noqa: BLE001 — une tuile ne casse pas la carte logger.warning(f"Rendu de tuile impossible ({layer} {z}/{x}/{y}) : {e}") data = None # Inventaire venu de l'amont sans aucune dalle sous la tuile : # l'amont n'en sait pas plus, inutile de l'interroger. if data is None and MAPS_URL and not ( tiles_mod.REMOTE_SOURCE_URL and not tiles_mod._contributing(OUTPUT_DIR, layer, z, x, y, scale)): suffix = "@2x" if scale == 2 else "" data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}") if data: tiles_mod._write_atomic( tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y, scale, fmt), data) finally: _release_lock(key, entry) return data _MEDIA = {"png": "image/png", "webp": "image/webp", "avif": "image/avif"} # --------------------------------------------------------------------------- # Tuiles # --------------------------------------------------------------------------- @app.get("/tiles/{layer}/{z}/{x}/{name}") async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None): """Tuile XYZ (schéma OSM). `name` = `{y}.png` ou `{y}@2x.webp`.""" m = _NAME_RE.match(name) if not m: raise HTTPException(404, "nom de tuile invalide") y = int(m.group(1)) scale = 2 if m.group(2) else 1 fmt = m.group(3) _layer_or_404(layer) if not tiles_mod.zoom_supported(z, scale): raise HTTPException(404, f"zoom hors plage ({tiles_mod.TILE_MIN_Z}" f"–{tiles_mod.TILE_MAX_NATIVE_Z})") n = 2 ** z if not (0 <= x < n and 0 <= y < n): raise HTTPException(404, "tuile hors grille") if _env_flag("LIDAR_TILE_CACHE_ONLY") and tiles_mod.zoom_cached(z, scale): # Mode cache seule : aucune navigation ne déclenche de RENDU local. # Une tuile absente (ex. zoom > LIDAR_TILE_BACKGROUND_MAX_Z, jamais # couverte par la maintenance) est rapatriée du serveur amont # LIDAR_MAPS_URL — un simple téléchargement de quelques dizaines de # Ko, mis en cache, sans décodage ni calcul sur cette machine. data, state = await run_in_threadpool( tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt) pending = state == "pending" empty = state == "empty" if pending and MAPS_URL: suffix = "@2x" if scale == 2 else "" key = (layer, z, x, y, scale, fmt) entry = _acquire_lock(key) try: with entry[0]: data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}") if data: tiles_mod._write_atomic( tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y, scale, fmt), data) pending = False empty = False finally: _release_lock(key, entry) if pending: # Tuile périmée (dalle régénérée) : l'ancienne reste affichée en # attendant la nouvelle, plutôt qu'un trou transparent. try: data = tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y, scale, fmt).read_bytes() except OSError: data = None else: data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt) pending = False empty = data is None stale = pending and bool(data) if empty or (pending and not stale): data = _transparent_tile(scale, fmt) headers = dict(_cors_headers()) if pending: # Tuile à venir (rendue par la maintenance) : jamais mémorisée par le # navigateur, la prochaine navigation la redemande et la trouve prête. headers["Cache-Control"] = "no-store" headers["X-Tile-Pending"] = "1" else: headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v else "public, max-age=300, must-revalidate") headers["X-Tile-Empty"] = "1" if (empty or (pending and not stale)) else "0" return Response(content=data, media_type=_MEDIA[fmt], headers=headers) # --------------------------------------------------------------------------- # Découverte : TileJSON, WMTS, JOSM # --------------------------------------------------------------------------- @app.get("/tiles/josm.imagery.xml") def josm_imagery(request: Request): """Fichier d'imagerie JOSM : ajoute toutes les couches en une fois.""" base = _base_url(request) bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) entries = [] for info in _layer_infos(): b = "" if bounds: b = (f' \n') entries.append( " \n" f" LiDAR HD — {_xml.escape(info['label'])}\n" f" lidar-hd-{info['key'].replace('_', '-')}\n" " tms\n" f" \n" f" {tiles_mod.TILE_MIN_Z}\n" f" {tiles_mod.TILE_MAX_NATIVE_Z}\n" f"{b}" f" {_xml.escape(info['description'])}\n" f" {_xml.escape(ATTRIBUTION_TEXT)}\n" " https://geoservices.ign.fr/lidarhd\n" " other\n" " ") xml = ('\n' '\n' + "\n".join(entries) + "\n\n") return Response(content=xml, media_type="application/xml", headers=_cors_headers()) @app.get("/tiles/wmts.xml") def wmts_capabilities(request: Request): """Capacités WMTS 1.0.0 (QGIS, ArcGIS) sur la grille GoogleMapsCompatible.""" base = _base_url(request) bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0] layers = [] for info in _layer_infos(): layers.append(f""" {_xml.escape(info['label'])} {_xml.escape(info['description'])} {bounds[0]:.6f} {bounds[1]:.6f} {bounds[2]:.6f} {bounds[3]:.6f} {info['key']} image/png GoogleMapsCompatible """) matrices = [] for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1): matrices.append(f""" {z} {559082264.0287178 / (2 ** z):.10f} -20037508.3427892 20037508.3427892 256 256 {2 ** z} {2 ** z} """) xml = f""" Carte LiDAR HD {_xml.escape(ATTRIBUTION_TEXT)} OGC WMTS 1.0.0 Licence Ouverte 2.0 {chr(10).join(layers)} GoogleMapsCompatible urn:ogc:def:crs:EPSG:6.18:3:3857 urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible {chr(10).join(matrices)} """ return Response(content=xml, media_type="application/xml", headers=_cors_headers()) @app.get("/tiles/{layer}.json") def tilejson(layer: str, request: Request): """TileJSON 3.0.0 (MapLibre, QGIS, Felt…).""" _layer_or_404(layer) from .index import VIZ_LABELS, VIZ_LEGENDS base = _base_url(request) bounds = tiles_mod.grid_bounds_wgs84(OUTPUT_DIR) or [-180.0, -85.0, 180.0, 85.0] center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2, min(tiles_mod.TILE_MAX_NATIVE_Z, 15)] return JSONResponse({ "tilejson": "3.0.0", "name": f"LiDAR HD — {VIZ_LABELS.get(layer, layer)}", "description": VIZ_LEGENDS.get(layer, {}).get("description", ""), "attribution": ATTRIBUTION, "scheme": "xyz", "tiles": [f"{base}/tiles/{layer}/{{z}}/{{x}}/{{y}}.png"], "minzoom": tiles_mod.TILE_MIN_Z, "maxzoom": tiles_mod.TILE_MAX_NATIVE_Z, "bounds": bounds, "center": center, }, headers=_cors_headers()) # --------------------------------------------------------------------------- # Configuration d'affichage par défaut # --------------------------------------------------------------------------- # L'affichage réglé depuis la carte (couche principale, mode relief / # précision / les deux, opacité de la précision, fond) peut être figé comme # configuration servie à TOUT nouveau navigateur : le réglage utile ne se perd # pas dans un localStorage et se partage sans lien. DEFAULTS_FILE = OUTPUT_DIR / ".map-defaults.json" _defaults_lock = threading.Lock() def _load_defaults(): """Configuration par défaut enregistrée, ou None si aucune.""" try: data = json.loads(DEFAULTS_FILE.read_text(encoding="utf-8")) except (OSError, ValueError): return None return data if isinstance(data, dict) else None def _clamp01(value, fallback=1.0): try: return min(1.0, max(0.0, float(value))) except (TypeError, ValueError): return fallback def _sanitize_defaults(req): """Retient ce qui est connu et borné : couche réelle, mode valide, opacités 0–1.""" from .index import PRECISION_VIZ, VIEW_MODES known = set(tiles_mod.available_layers(OUTPUT_DIR)) - {PRECISION_VIZ} base_in = req.base if isinstance(req.base, dict) else {} base = {"on": bool(base_in.get("on", True)), "opacity": _clamp01(base_in.get("opacity"), 0.85), # None = pas de choix explicite (le fond suit le thème côté carte) ; # seule une valeur booléenne reçue devient un choix figé. "dark": None if base_in.get("dark") is None else bool(base_in.get("dark"))} # v: 3 distingue ce format (dark éventuellement None = pas choisi) de # l'ancien, qui écrivait toujours dark:true (voir map_meta). return {"main": req.main if req.main in known else None, "mode": req.mode if req.mode in VIEW_MODES else "relief", "base": base, "v": 3, "saved_at": time.time()} class DefaultsRequest(BaseModel): main: Optional[str] = Field(None, description="couche d'affichage principal") mode: str = Field("relief", description="relief | precision | compare") precision_opacity: Optional[float] = Field(None, description="ignoré (ancien mode « les deux »)") base: dict = Field(default_factory=dict, description="fond de carte : on, opacity, dark") @app.get("/api/map/defaults") def get_defaults(): """Configuration par défaut servie aux nouveaux navigateurs.""" return {"defaults": _load_defaults()} @app.post("/api/map/defaults") def set_defaults(req: DefaultsRequest): """Fige l'état courant comme configuration par défaut (tous navigateurs).""" data = _sanitize_defaults(req) with _defaults_lock: tmp = DEFAULTS_FILE.with_suffix(".tmp") DEFAULTS_FILE.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") os.replace(tmp, DEFAULTS_FILE) logger.info(f"Configuration d'affichage par défaut enregistrée : " f"{data['main'] or 'principale du registre'}, mode {data['mode']}") return {"enregistré": True, "defaults": data} @app.delete("/api/map/defaults") def clear_defaults(): """Revient aux réglages du registre (index.py).""" with _defaults_lock: existed = DEFAULTS_FILE.exists() DEFAULTS_FILE.unlink(missing_ok=True) return {"supprimé": existed} # --------------------------------------------------------------------------- # API de la carte # --------------------------------------------------------------------------- @app.get("/api/map/meta") def map_meta(): """Couches, zooms, emprise et version — tout l'état initial de l'interface.""" from .index import (DEFAULT_VIEW_MODE, PRECISION_VIZ, VIEW_MODES, default_main_layer) infos = _layer_infos() keys = [i["key"] for i in infos] saved = _load_defaults() or {} # Configuration figée depuis la carte, sinon réglages du registre (index.py). # Couche figée disparue, ou fichier de l'ancienne pile (clés order/on) : # couche principale du registre. main = saved.get("main") if main not in keys or main == PRECISION_VIZ: main = default_main_layer(keys) mode = saved.get("mode") if saved.get("mode") in VIEW_MODES else DEFAULT_VIEW_MODE # Fichier d'avant v3 (ex-webapp) : base.dark:true systématique, jamais un # vrai choix — servi comme "non choisi" (None, le fond suit le thème). base_default = saved.get("base") if isinstance(base_default, dict) and saved.get("v") != 3: base_default = dict(base_default, dark=None) return { "layers": infos, "default_main": main, "precision_layer": PRECISION_VIZ if PRECISION_VIZ in keys else None, "default_mode": mode, "default_base": base_default, "defaults_saved": bool(saved), # L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en # 256 : décisif en HTTP/1.1) ; les clients OSM gardent le 256 canonique. "tile_url": "tiles/{layer}/{z}/{x}/{y}@2x.avif", # Niveaux standard pairs seulement (stockage réduit) : l'interface # réduit ceux du niveau supérieur aux zooms impairs. "even_levels": tiles_mod.TILE_EVEN_LEVELS, "tile_size": tiles_mod.TILE_SIZE * 2, "zoom_offset": -1, "min_zoom": tiles_mod.TILE_MIN_Z, # Numérotation standard, comme le niveau de tuile Leaflet (l'URL @2x # est un cran en dessous via zoom_offset) : 19 = 0,2 m/px natif, # servi en 18@2x. Annoncer 18 plafonnait l'affichage à 0,4 m/px. "max_native_zoom": tiles_mod.TILE_MAX_NATIVE_Z, "bounds": tiles_mod.grid_bounds_wgs84(OUTPUT_DIR), "stamp": tiles_mod.tiles_stamp(OUTPUT_DIR), "attribution": ATTRIBUTION, "attribution_text": ATTRIBUTION_TEXT, "ui_version": ui_version(), } # Fiche IGN (catalogue STAC) mise en cache par dalle : un seul appel réseau, # une dalle non publiée est revérifiée au plus une fois par jour. IGN_META_RETRY_S = 86400 def _ign_tile_meta(col, row): """Métadonnées IGN d'une dalle (acquisition, lien de téléchargement).""" from .fetch_ign import tile_ign_metadata meta_dir = OUTPUT_DIR / "ign_meta" path = meta_dir / f"{col:04d}_{row:04d}.json" try: data = json.loads(path.read_text(encoding="utf-8")) if data.get("found") or time.time() - data.get("checked", 0) < IGN_META_RETRY_S: return data except (OSError, ValueError): pass try: data = tile_ign_metadata(col, row) except Exception as e: logger.warning(f"Catalogue IGN injoignable pour {col}_{row} : {e}") return {"found": False, "error": "catalogue IGN injoignable"} data["checked"] = time.time() try: meta_dir.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") os.replace(tmp, path) except OSError: pass return data def _calage_summary(basename, suffix): """Résumé du recalage vertical des passes (sidecar du MNT), si présent.""" path = OUTPUT_DIR / "DTM" / f"{basename}_dtm{suffix}_stripalign.json" try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None offsets = data.get("offsets") or {} lines = data.get("lines") or {} return { "beams": sorted(set(offsets) | set(lines), key=lambda k: int(k) if str(k).isdigit() else 0), "offsets_cm": {k: round(v * 100, 1) for k, v in offsets.items()}, "lines_rms_cm": {k: round(v.get("rms_m", 0) * 100, 1) for k, v in lines.items()}, "model": data.get("line_model"), } @app.get("/api/map/ign") def map_tile_ign(col: int, row: int): """Fiche IGN d'une dalle (acquisition, capteurs, lien de téléchargement). Séparée de /api/map/tile : la sélection d'une dalle s'affiche tout de suite, même si le catalogue IGN est lent ou injoignable. """ return _ign_tile_meta(col, row) @app.get("/api/map/tile") def map_tile_info(lat: float, lng: float): """Dalle LHD sous un point WGS84 : emprise, génération, recalage. Répond toujours (dalle générée ou non) et sans appel réseau ; la fiche IGN se charge à part (/api/map/ign). """ from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles from .quality import read_quality x, y = tiles_mod.wgs84_to_l93(lng, lat) col, row = int(x // 1000), int(y // 1000) + 1 x0, y0, x1, y1 = col * 1000, (row - 1) * 1000, (col + 1) * 1000, row * 1000 lons, lats = tiles_mod._transformer("EPSG:2154", "EPSG:4326").transform( [x0, x1, x1, x0], [y1, y1, y0, y0]) base = { "col": col, "row": row, "name": f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69", "l93_bbox": [x0, y0, x1, y1], # coins NO, NE, SE, SO (lat, lng) : cadre de sélection sur la carte "corners": [[round(la, 7), round(lo, 7)] for lo, la in zip(lons, lats)], } best = None for t in scan_tiles(OUTPUT_DIR / "visualisations"): if (t["col"], t["row"]) != (col, row): continue if best is None or t["resolution"] < best["resolution"]: best = t # Qualité (densité, dates) : sous le nom réel de la dalle rendue quand il y # en a une (best["basename"] peut différer du nom nominal calculé depuis # col/row) ; nom nominal sinon, faute de mieux. quality_name = best["basename"] if best else base["name"] q = read_quality(OUTPUT_DIR, quality_name) base["quality"] = ({"ground_density": q.get("ground_density"), "acq_start": q.get("acq_start"), "acq_end": q.get("acq_end")} if q else None) if best is None: return {"found": False, **base} meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM") suffix = _res_suffix_str(best["resolution"]) return { "found": True, **base, "name": best["basename"], "resolution": best["resolution"], "method": meta.get("method"), "generated": meta.get("generated"), "layers": sorted(best["viz"]), "suffix": suffix, "calage": _calage_summary(best["basename"], suffix), } class WarmRequest(BaseModel): layers: list = Field(default_factory=list, description="couches à pré-calculer (défaut : toutes)") z_min: int = Field(tiles_mod.TILE_MIN_Z, description="zoom minimal") z_max: int = Field(16, description="zoom maximal (19 = résolution native)") bounds: Optional[list] = Field(None, description="[ouest, sud, est, nord] WGS84 ; " "défaut : emprise des dalles") scale: int = Field(1, description="1 = tuiles 256 px, 2 = 512 px (@2x)") fmt: str = Field("png", description="png, webp ou avif") _warm = {"running": False, "done": None, "result": None} @app.post("/api/map/warm") def map_warm(req: WarmRequest): """Pré-calcule des tuiles en tâche de fond (accélère la 1re consultation).""" if _warm["running"]: raise HTTPException(409, "un pré-chauffage est déjà en cours") layers = [l for l in req.layers if _LAYER_RE.match(str(l))] or \ tiles_mod.available_layers(OUTPUT_DIR) if req.fmt not in _MEDIA: raise HTTPException(400, f"format invalide : {req.fmt}") _warm.update(running=True, done=None, result=None) def _run(): try: _warm["result"] = tiles_mod.warm( OUTPUT_DIR, layers, req.z_min, req.z_max, req.bounds, max(1, min(2, req.scale)), req.fmt) except Exception as e: # noqa: BLE001 — remonté via l'état _warm["result"] = {"erreur": str(e)} finally: _warm["running"] = False _warm["done"] = time.time() threading.Thread(target=_run, daemon=True).start() return {"demarré": True, "couches": layers} @app.get("/api/map/warm") def map_warm_status(): return dict(_warm) @app.get("/api/map/background") def background_status(): """État de la maintenance de fond : file, compteurs, configuration.""" with _bg["guard"]: queued = len(_bg["queue"]) return {"active": _bg["running"], "en_file": queued, "configuration": _bg_conf(), "stats": dict(_bg["stats"])} @app.post("/api/map/background") def background_trigger(): """Force un scan immédiat des dalles (rendu ensuite en tâche de fond).""" if not _env_flag("LIDAR_TILE_BACKGROUND"): raise HTTPException(409, "maintenance de fond inactive " "(LIDAR_TILE_BACKGROUND)") return {"tuiles_en_file": background_scan()} @app.get("/healthz") def healthz(): return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))} # --------------------------------------------------------------------------- # Génération de tuiles (portée de la webapp historique) # --------------------------------------------------------------------------- # Deux modes, mutuellement exclusifs : # - LIDAR_GENERATION_URL défini (webapp légère) : /api/preview, /api/generate, # /api/status, /api/stop et /api/queue/* sont transmis à la machine de # traitement (elle-même servie par ce module, image complète) ; # - sinon, si le pipeline est importable (image complète) : génération locale # — `python -m lidar_pipeline` en sous-processus, un job à la fois, les # demandes concurrentes part en file (persistée, survit aux redémarrages). # Sur l'image légère sans LIDAR_GENERATION_URL, /api/status répond # available=false : l'interface masque les boutons de génération. def _generation_available(): return bool(GENERATION_URL) or _pipeline_importable() def _require_token(x_lidar_token: str = Header(None)): """Protège les routes mutantes si LIDAR_API_TOKEN est défini (sinon no-op).""" if API_TOKEN: import hmac presented = x_lidar_token or "" if not hmac.compare_digest(presented, 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).""" 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_generation(method, path, payload=None, timeout=30): """Transmet un appel de génération à la machine de traitement. 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 l'image 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: from .index import KEYWORD_TO_STEP return list(KEYWORD_TO_STEP.values()) def _panel_viz_steps(): """Couches réellement affichées par la carte, en noms d'étapes --only.""" from .index import panel_steps return panel_steps() or _viz_step_names() def _viz_step_labels(): """Étapes → libellés (journal de progression lisible).""" from .index import VIZ_LABELS, step_to_keyword return {step: VIZ_LABELS.get(step_to_keyword(step), step) for step in _viz_step_names()} class PreviewRequest(BaseModel): bbox: Optional[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: Optional[list] = Field(None, description="Visualisations demandées, noms d'étapes du " "pipeline (ex: aspect, wavelet) ; défaut : les " "couches affichées dans le panneau. " "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 " "(visualisations refaites, classification conservée)") # Classification (IGN, sol seul) et raccord des bords (bande de 100 m prise # aux dalles voisines) sont imposés : plus de réglage côté carte. Les # anciens champs éventuellement envoyés par un client sont ignorés. viz: list = Field(None, description="Visualisations à générer, noms d'étapes du " "pipeline (ex: aspect, wavelet, slope) ; " "défaut : les couches affichées dans le panneau") 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)") 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 pyproj (présent dans toutes les images), repli sur l'approximation affine de l'index. """ from .index import _approx_wgs84_to_l93 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] 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 point_to_cell(lon, lat): """Cellule L93 de 1 km (col, row) contenant un point WGS84.""" from .index import _approx_wgs84_to_l93 try: from pyproj import Transformer transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True) x, y = transformer.transform(lon, lat) except ImportError: x, y = _approx_wgs84_to_l93(lon, lat) return int(math.floor(x / 1000)), int(math.floor(y / 1000)) + 1 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/.""" 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 à 0,2 m.""" 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. 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 def _attach_frame_bounds(tiles): """Ajoute aux tuiles non terminées leurs coins WGS84 (cadre sur la carte). L'interface dessine un cadre coloré autour des dalles du run en cours (orange : rendu en cours, bleu : en attente, rouge : échec) ; les coins viennent de la grille L93 (col, row) → bornes exactes via PROJ. Les dalles terminées n'ont pas de cadre : coins omis pour alléger le sondage /api/status. """ from .index import attach_gps_bounds, parse_basename_coords todo = [] for tile in tiles: if tile.get("state") == "done": continue coords = parse_basename_coords(tile.get("name") or "") if coords: todo.append({"col": coords[0], "row": coords[1]}) attach_gps_bounds(todo) by_cell = {(t["col"], t["row"]): t for t in todo} for tile in tiles: if tile.get("state") == "done": continue coords = parse_basename_coords(tile.get("name") or "") entry = by_cell.get(coords) if coords else None if entry: tile["corners"] = entry.get("corners") or [] def _tail_log(n_lines): """Dernières lignes du journal de génération (.generation.log).""" try: text = LOG_FILE.read_text(encoding="utf-8", errors="replace") except OSError: return [] lines = [l for l in text.splitlines() if l.strip()] return lines[-n_lines:] # --- État du job + file d'attente (persistés sur disque) --------------------- _job: dict = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None, "qid": None, "run_id": None} # RLock (réentrant) : /api/status construit sa réponse SOUS verrou et appelle # _queue_summary() qui le reprend — un Lock simple s'y bloquerait définitivement. _job_lock = threading.RLock() _queue = [] _queue_seq = 0 def _save_job_state(): """Écrit l'état du job sur disque — survit au redémarrage du serveur.""" state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished", "qid", "run_id")} 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") _job["qid"] = state.get("qid") _job["run_id"] = state.get("run_id") _load_job_state() def _save_queue(): """Écrit la file d'attente sur disque (atomique, best-effort).""" try: tmp = QUEUE_FILE.with_suffix(".tmp") tmp.write_text(json.dumps(_queue), encoding="utf-8") os.replace(tmp, QUEUE_FILE) except OSError: pass def _load_queue(): """Recharge la file d'attente au démarrage (et son compteur d'ids).""" global _queue_seq try: data = json.loads(QUEUE_FILE.read_text(encoding="utf-8")) if isinstance(data, list): _queue[:] = [it for it in data if isinstance(it, dict) and isinstance(it.get("req"), dict)] ids = [int(it.get("id") or 0) for it in _queue] _queue_seq = max(ids) if ids else 0 except (OSError, ValueError): pass _load_queue() def _queue_summary(): """Aperçu de la file pour /api/status (requêtes + horodatages).""" with _job_lock: return [{"id": it.get("id"), "tuiles": len(it["req"].get("tiles") or []), "en_file_depuis": it.get("queued_at")} for it in _queue] EDGE_BUFFER_METERS = 100.0 # raccord des bords : largeur de bande (m) def _build_command(tiles, regenerate=False, viz=None): """Commande de génération : téléchargement IGN + traitement des fichiers. Classification IGN (sol seul) et raccord des bords (bande de EDGE_BUFFER_METERS remplie avec les points sol des 8 dalles voisines) toujours appliqués. Avec regenerate=True, les visualisations des tuiles déjà présentes sont refaites (--force), classification conservée. """ 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 _panel_viz_steps()), "--ground-classification", "ign", "--ign-classes", "sol", "--edge-buffer", f"{EDGE_BUFFER_METERS:g}"] if regenerate: cmd += ["--force"] if os.environ.get("LIDAR_GPU", "") == "1": cmd += ["-g", "all"] workers = os.environ.get("LIDAR_WORKERS", "auto").strip() if workers.lower() not in ("auto", ""): cmd += ["-w", workers] # Inventaire régénéré après chaque tuile terminée : la carte le sonde # (/api/tiles) et les nouvelles dalles apparaissent en direct. 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 def _resolve_request(req): """Valide une demande de génération et résout sa liste de tuiles. Retourne (tuiles [(col, row)], viz [noms d'étapes]). Réexécutée au démarrage de chaque demande sortie de file : les dalles présentes dans input/ peuvent avoir changé entre la mise en file et le départ du run. """ names = _viz_step_names() viz = [v for v in (req.viz or []) if v] or _panel_viz_steps() 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: 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/") if len(tiles) > MAX_CELLS_ALL: raise HTTPException( 400, f"trop de tuiles à compléter ({len(tiles)}) — max " f"{MAX_CELLS_ALL} par run ; relancez la passe globale à " f"la fin de celui-ci") 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}") # Génération du nord au sud (row décroissante, puis col croissante) : les # workers prennent les tuiles dans l'ordre de soumission et la carte se # remplit de haut en bas pendant le run. tiles.sort(key=lambda cr: (-cr[1], cr[0])) return tiles, viz def _launch_job(tiles, viz, req, qid=None): """Démarre un run du pipeline. À appeler sous _job_lock, serveur libre.""" cmd = _build_command(tiles, regenerate=req.regenerate, 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 et identifiant de run # transmis au pipeline (LIDAR_RUN_ID) : les événements tardifs d'un run # annulé (workers survivants) sont filtrés à la lecture. from .progress import reset_events reset_events(OUTPUT_DIR) run_id = uuid.uuid4().hex[:12] _job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None, "qid": qid, "run_id": run_id}) _save_job_state() # start_new_session : le pipeline et ses workers/PDAL forment leur propre # groupe de processus — /api/stop peut le tuer en bloc sans toucher le # serveur, et le gestionnaire SIGTERM du pipeline (killpg) reste confiné. p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT, cwd="/app" if Path("/app").exists() else None, env=dict(os.environ, LIDAR_RUN_ID=run_id), start_new_session=True) # proc posé AVANT le fil de veille : un run qui meurt instantanément # déclencherait _watch → _start_next_queued avec _job["proc"] encore à # None (serveur « libre ») et un second run partirait en parallèle. _job["proc"] = p def _watch(): rc = p.wait() log_fh.close() with _job_lock: _job["returncode"] = rc _job["finished"] = time.time() _save_job_state() # Le run est fini : la demande suivante de la file peut partir. _start_next_queued() threading.Thread(target=_watch, daemon=True).start() return cmd def _start_next_queued(): """Démarre la première demande de la file dès que le serveur est libre.""" while True: with _job_lock: proc = _job["proc"] if proc is not None and proc.poll() is None: return False if not _queue: return False item = _queue.pop(0) _save_queue() try: req = GenerateRequest(**item["req"]) tiles, viz = _resolve_request(req) except HTTPException as e: logger.warning("Demande en file ignorée : %s", e.detail) continue with _job_lock: proc = _job["proc"] if proc is not None and proc.poll() is None: _queue.insert(0, item) # un autre run est parti : remise en tête _save_queue() return False _launch_job(tiles, viz, req, qid=item.get("id")) return True @app.get("/api/status") def generation_status(request: Request = None): """État de la génération : run en cours, file, tuiles une par une. available : ce serveur peut générer (pipeline local ou LIDAR_GENERATION_URL). regen_allowed : l'IP du navigateur a le droit de lancer (LIDAR_REGEN_CIDR) — l'interface masque les boutons sinon. Webapp légère : l'état vient de la machine de traitement ; hors ligne : running=null (aucun faux « terminé »). """ regen_allowed = _ip_in_regen_cidr(_client_ip(request)) available = _generation_available() if GENERATION_URL: try: data = _proxy_generation("GET", "/api/status", timeout=10) except HTTPException as e: if e.status_code != 503: raise return {"running": None, "distant": True, "worker_offline": True, "available": available, "regen_allowed": regen_allowed} data["distant"] = True data["available"] = available data["regen_allowed"] = regen_allowed return data from .progress import progress_snapshot tiles = progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels(), run_id=_job.get("run_id")) _attach_frame_bounds(tiles) with _job_lock: pending = bool(_queue) if pending: # File d'attente non vide et serveur libre (ex. redémarrage du # conteneur en cours de file) : la demande suivante repart. _start_next_queued() 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"], "qid": _job.get("qid"), "queue": _queue_summary(), "log": _tail_log(40), "available": available, "regen_allowed": regen_allowed, "tiles": tiles, } @app.get("/api/tiles") def tiles_inventory(stamp: Optional[float] = None): """Inventaire des dalles (index_tiles.json) pour les machines légères. Écrit par le pipeline à chaque passe (et après chaque tuile en mode --incremental-index) : vignettes, sous-tuiles et dalles référencées y sont servies par les montages statiques homonymes. ?stamp=X → réponse allégée si rien n'a changé. """ path = OUTPUT_DIR / "index_tiles.json" try: mtime = path.stat().st_mtime if stamp is not None and abs(mtime - stamp) < 1e-4: return {"stamp": mtime, "tiles": None} data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return {"stamp": None, "tiles": None} data["stamp"] = mtime return data # --- export PDF (planche d'impression terrain) --- # Toujours calculé localement (jamais délégué à LIDAR_GENERATION_URL) : la # carte légère seule doit savoir exporter. Un export à la fois : l'image de # la planche occupe jusqu'à ~40 Mo (Pi, mem_limit 1g). _export_lock = threading.Lock() @app.get("/api/export/frame") def export_frame(lat: float, lon: float, paper: str = "A4", orient: str = "paysage", scale: int = 2000): """Cadre imprimable (coins WGS84) centré sur (lat, lon) pour ces réglages.""" from . import export_pdf try: return export_pdf.frame(lat, lon, paper, orient, scale) except ValueError as e: raise HTTPException(400, str(e)) @app.get("/api/export/pdf") def export_pdf_file(lat: float, lon: float, paper: str = "A4", orient: str = "paysage", scale: int = 2000, title: Optional[str] = None): """Planche PDF du relief orienté centrée sur (lat, lon).""" from . import export_pdf if not _export_lock.acquire(blocking=False): raise HTTPException(429, "un export PDF est déjà en cours, réessayez dans quelques secondes") try: data, name = export_pdf.build_pdf(OUTPUT_DIR, lat, lon, paper, orient, scale, title=title) except ValueError as e: raise HTTPException(400, str(e)) except export_pdf.NoDataError as e: raise HTTPException(404, str(e)) except Exception: # noqa: BLE001 — message propre côté carte logger.exception("Échec de l'export PDF") raise HTTPException(500, "échec de l'export PDF (voir les journaux du serveur)") finally: _export_lock.release() return Response(data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"}) @app.post("/api/preview", dependencies=[Depends(_require_token), Depends(_require_lan_for_generation)]) def preview(req: PreviewRequest): """Cellules 1 km intersectant une bbox WGS84 (ou passe all_missing). Sont incluses les tuiles absentes ET les tuiles existantes incomplètes (il leur manque une des visualisations demandées). Retourne {count, capped, cells:[{col, row, corners}]}. """ if GENERATION_URL: # Webapp légère : le distant connaît input/ et output/ complets return _proxy_generation("POST", "/api/preview", json.loads(req.json())) names = _viz_step_names() viz = [v for v in (req.viz or []) if v] or _panel_viz_steps() 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} @app.get("/api/cell") def cell_at_point(lat: float, lng: float): """Cellule L93 de 1 km contenant un point WGS84 + coins pour la carte.""" if GENERATION_URL: return _proxy_generation("GET", f"/api/cell?lat={lat}&lng={lng}") from .index import attach_gps_bounds col, row = point_to_cell(lng, lat) tile = {"col": col, "row": row} attach_gps_bounds([tile]) return {"col": col, "row": row, "corners": tile.get("corners") or []} @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, met en file et renvoie son propre état. data = _proxy_generation("POST", "/api/generate", json.loads(req.json())) data["distant"] = True return data tiles, viz = _resolve_request(req) with _job_lock: proc = _job["proc"] running = proc is not None and proc.poll() is None if running: # Un run est en cours : la demande part en file d'attente et # démarrera à sa fin — jamais de coupure du travail en place. global _queue_seq _queue_seq += 1 item = {"id": _queue_seq, "req": json.loads(req.json()), "queued_at": time.time()} _queue.append(item) _save_queue() return {"demarré": False, "en_file": len(_queue), "qid": item["id"], "tuiles": len(tiles)} cmd = _launch_job(tiles, viz, req) return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)} @app.post("/api/queue/clear", dependencies=[Depends(_require_token), Depends(_require_lan_for_generation)]) def queue_clear(): """Retire les demandes en attente (le run en cours n'est pas touché).""" if GENERATION_URL: return _proxy_generation("POST", "/api/queue/clear") with _job_lock: n = len(_queue) _queue.clear() _save_queue() return {"retirées": n} @app.post("/api/stop", dependencies=[Depends(_require_token), Depends(_require_lan_for_generation)]) def stop_generation(): """Arrête la génération en cours (SIGTERM au pipeline, SIGKILL en repli).""" if GENERATION_URL: return _proxy_generation("POST", "/api/stop") with _job_lock: proc = _job["proc"] if proc is None or proc.poll() is not None: raise HTTPException(409, "aucune génération en cours") try: proc.terminate() # SIGTERM → nettoyage propre du pipeline except OSError: pass def _escalate(): try: proc.wait(timeout=15) except subprocess.TimeoutExpired: try: os.killpg(proc.pid, signal.SIGKILL) except (OSError, ProcessLookupError): try: proc.kill() except OSError: pass threading.Thread(target=_escalate, daemon=True).start() logger.info("Arrêt de la génération demandé (SIGTERM au pipeline)") return {"arrêt": "demandé"} # --------------------------------------------------------------------------- # Interface # --------------------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) def root(): return HTMLResponse(render_html(), headers={ "Cache-Control": "no-cache, no-store, must-revalidate"}) @app.get("/assets/{file_path:path}") def assets(file_path: str, v: str = None): """Assets de l'interface (revalidés : ils suivent l'image, pas le cache).""" 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"}) if __name__ == "__main__": import uvicorn _cert = os.environ.get("LIDAR_SSL_CERTFILE") _key = os.environ.get("LIDAR_SSL_KEYFILE") _ssl = {"ssl_certfile": _cert, "ssl_keyfile": _key} if _cert and _key else {} uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl)