1973 lines
82 KiB
Python
1973 lines
82 KiB
Python
"""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 © <a href="https://geoservices.ign.fr/lidarhd">IGN</a> '
|
||
'— 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' <bounds min-lat="{bounds[1]:.6f}" min-lon="{bounds[0]:.6f}"'
|
||
f' max-lat="{bounds[3]:.6f}" max-lon="{bounds[2]:.6f}"/>\n')
|
||
entries.append(
|
||
" <entry>\n"
|
||
f" <name>LiDAR HD — {_xml.escape(info['label'])}</name>\n"
|
||
f" <id>lidar-hd-{info['key'].replace('_', '-')}</id>\n"
|
||
" <type>tms</type>\n"
|
||
f" <url><![CDATA[{base}/tiles/{info['key']}/{{zoom}}/{{x}}/{{y}}.png]]></url>\n"
|
||
f" <min-zoom>{tiles_mod.TILE_MIN_Z}</min-zoom>\n"
|
||
f" <max-zoom>{tiles_mod.TILE_MAX_NATIVE_Z}</max-zoom>\n"
|
||
f"{b}"
|
||
f" <description lang=\"fr\">{_xml.escape(info['description'])}</description>\n"
|
||
f" <attribution-text>{_xml.escape(ATTRIBUTION_TEXT)}</attribution-text>\n"
|
||
" <attribution-url>https://geoservices.ign.fr/lidarhd</attribution-url>\n"
|
||
" <category>other</category>\n"
|
||
" </entry>")
|
||
xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<imagery xmlns="http://josm.openstreetmap.de/maps-1.0">\n'
|
||
+ "\n".join(entries) + "\n</imagery>\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""" <Layer>
|
||
<ows:Title>{_xml.escape(info['label'])}</ows:Title>
|
||
<ows:Abstract>{_xml.escape(info['description'])}</ows:Abstract>
|
||
<ows:WGS84BoundingBox>
|
||
<ows:LowerCorner>{bounds[0]:.6f} {bounds[1]:.6f}</ows:LowerCorner>
|
||
<ows:UpperCorner>{bounds[2]:.6f} {bounds[3]:.6f}</ows:UpperCorner>
|
||
</ows:WGS84BoundingBox>
|
||
<ows:Identifier>{info['key']}</ows:Identifier>
|
||
<Style isDefault="true"><ows:Identifier>default</ows:Identifier></Style>
|
||
<Format>image/png</Format>
|
||
<TileMatrixSetLink><TileMatrixSet>GoogleMapsCompatible</TileMatrixSet></TileMatrixSetLink>
|
||
<ResourceURL format="image/png" resourceType="tile"
|
||
template="{base}/tiles/{info['key']}/{{TileMatrix}}/{{TileCol}}/{{TileRow}}.png"/>
|
||
</Layer>""")
|
||
matrices = []
|
||
for z in range(tiles_mod.TILE_MIN_Z, tiles_mod.TILE_MAX_NATIVE_Z + 1):
|
||
matrices.append(f""" <TileMatrix>
|
||
<ows:Identifier>{z}</ows:Identifier>
|
||
<ScaleDenominator>{559082264.0287178 / (2 ** z):.10f}</ScaleDenominator>
|
||
<TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner>
|
||
<TileWidth>256</TileWidth>
|
||
<TileHeight>256</TileHeight>
|
||
<MatrixWidth>{2 ** z}</MatrixWidth>
|
||
<MatrixHeight>{2 ** z}</MatrixHeight>
|
||
</TileMatrix>""")
|
||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<Capabilities xmlns="http://www.opengis.net/wmts/1.0"
|
||
xmlns:ows="http://www.opengis.net/ows/1.1"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0.0">
|
||
<ows:ServiceIdentification>
|
||
<ows:Title>Carte LiDAR HD</ows:Title>
|
||
<ows:Abstract>{_xml.escape(ATTRIBUTION_TEXT)}</ows:Abstract>
|
||
<ows:ServiceType>OGC WMTS</ows:ServiceType>
|
||
<ows:ServiceTypeVersion>1.0.0</ows:ServiceTypeVersion>
|
||
<ows:AccessConstraints>Licence Ouverte 2.0</ows:AccessConstraints>
|
||
</ows:ServiceIdentification>
|
||
<Contents>
|
||
{chr(10).join(layers)}
|
||
<TileMatrixSet>
|
||
<ows:Identifier>GoogleMapsCompatible</ows:Identifier>
|
||
<ows:SupportedCRS>urn:ogc:def:crs:EPSG:6.18:3:3857</ows:SupportedCRS>
|
||
<WellKnownScaleSet>urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible</WellKnownScaleSet>
|
||
{chr(10).join(matrices)}
|
||
</TileMatrixSet>
|
||
</Contents>
|
||
<ServiceMetadataURL xlink:href="{base}/tiles/wmts.xml"/>
|
||
</Capabilities>
|
||
"""
|
||
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)
|