"""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_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)),
"max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get(
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
"scale": 1 if (os.environ.get(
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
"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(),
"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):
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée.
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.
"""
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[:max(0, _bg_conf()["queue_max"] - len(_bg["queue"]))])
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"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
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 = {}
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):
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)
items = []
for layer, cells in changed.items():
for col, row in cells:
bbox = tiles_mod.cell_bounds_wgs84(col, row)
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):
items.append((layer, z, x, y))
items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord
# Dalles qui viennent de changer : en tête de file, devant l'arriéré —
# la pyramide suit le rendu en cours (hors premier scan, qui construit tout).
added = _bg_enqueue(items, front=first_scan is False)
_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 (téléchargement, rendu ou rapatriement).
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 True
# 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:
_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")
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", ""),
# 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 par défaut de la pile de couches
# ---------------------------------------------------------------------------
# L'état réglé depuis la carte (ordre, visibilité, opacité, fusion) 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
BLEND_MODES = ("normal", "multiply", "screen", "overlay", "soft-light",
"hard-light", "difference", "luminosity")
def _sanitize_defaults(req):
"""Retient ce qui est connu et borné : couches réelles, opacités 0–1, fusions valides."""
known = set(tiles_mod.available_layers(OUTPUT_DIR))
order = [k for k in (req.order or []) if k in known]
for key in sorted(known - set(order)):
order.append(key)
on = [k for k in (req.on or []) if k in known]
opacity = {k: _clamp01(v) for k, v in (req.opacity or {}).items() if k in known}
blend = {k: v for k, v in (req.blend or {}).items()
if k in known and v in BLEND_MODES}
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),
"dark": bool(base_in.get("dark", True))}
stack = req.stack_blend if req.stack_blend in BLEND_MODES else "normal"
return {"order": order, "on": on, "opacity": opacity, "blend": blend,
"base": base, "stack_blend": stack, "saved_at": time.time()}
class DefaultsRequest(BaseModel):
order: list = Field(default_factory=list,
description="ordre de pile, du bas vers le haut")
on: list = Field(default_factory=list, description="couches allumées")
opacity: dict = Field(default_factory=dict, description="opacité par couche, 0–1")
blend: dict = Field(default_factory=dict, description="mode de fusion par couche")
base: dict = Field(default_factory=dict,
description="fond de carte : on, opacity, dark")
stack_blend: str = Field("normal", description="fusion de la pile sur le fond")
@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 de pile par défaut enregistrée : "
f"{len(data['on'])} couche(s) allumée(s)")
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_BLEND, DEFAULT_OPACITY, default_layers_present
infos = _layer_infos()
keys = [i["key"] for i in infos]
saved = _load_defaults()
# Configuration figée depuis la carte, sinon réglages du registre (index.py).
# Couches figées toutes disparues (ex. passage au seul relief orienté) :
# registre, sinon un nouveau navigateur arrive sur une carte sans LiDAR.
saved_on = [k for k in saved["on"] if k in keys] if saved else []
if saved and (saved_on or not saved["on"]):
default_layers = saved_on
else:
default_layers = default_layers_present(keys)
default_opacity = saved["opacity"] if saved else dict(DEFAULT_OPACITY)
default_blend = saved["blend"] if saved else dict(DEFAULT_BLEND)
return {
"layers": infos,
"default_layers": default_layers,
"default_opacity": default_opacity,
"default_blend": default_blend,
"default_order": saved["order"] if saved else None,
"default_base": saved["base"] if saved else None,
"default_stack_blend": saved["stack_blend"] if saved else "normal",
"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
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
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
@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)