Add web map with zone generation API, side job queue, and restore historical DTM hole rendering

- webapp.py: FastAPI serving the continuous map (port 8973) with
  /api/preview, /api/generate and /api/status; tiles are downloaded
  from IGN and processed in a logged subprocess, tracked live in a
  side "File de génération" panel that survives page reloads
- fetch_ign.py: download missing 1 km LiDAR HD tiles from the IGN
  geoplateforme before processing
- index.py: tile thumbnails and 500 m subtiles are now invalidated by
  mtime so regenerating a tile refreshes its cached images; progress
  logging per tile
- dtm.py: back to the historical gap handling (small gaps filled by
  fillnodata only, larger holes left as nodata rendered black);
  lowest-return floor only via --bare-earth, IGN class selection via
  --ign-classes
- cli.py: positional input now optional (--rebuild-index works alone)
- docker-compose.yml: serve (GPU, port 8973) and process services;
  launch via docker compose only (documented in AGENTS.md/AGENTS.md)
- tests: 131 passing, incl. regressions for thumbnail staleness,
  --rebuild-index without input, and nodata rendering
This commit is contained in:
Antoine Jacquin
2026-08-31 18:07:14 +02:00
parent 35bd827790
commit 8ca65155db
19 changed files with 3676 additions and 771 deletions

162
lidar_pipeline/fetch_ign.py Normal file
View File

@ -0,0 +1,162 @@
"""Téléchargement des dalles LiDAR HD de l'IGN pour les tuiles non générées.
Catalogue STAC (à jour) : https://browser.stac.teledetection.fr/collections/lidarhd
API : https://api.stac.teledetection.fr/collections/lidarhd/items
Fichiers (géoplateforme): https://data.geopf.fr/telechargement/download/...
Chaque dalle couvre 1 km × 1 km en Lambert 93 et est nommée par son coin
nord-ouest : LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69.copc.laz
(col = X ouest en km, row = Y nord en km, cf. propriété STAC
"lidarhd:coordonnees_NW" au format "0816-6847").
"""
import json
import logging
import time
import urllib.parse
import urllib.request
from pathlib import Path
logger = logging.getLogger("lidar")
_STAC_ITEMS_URL = "https://api.stac.teledetection.fr/collections/lidarhd/items"
_HEADERS = {"User-Agent": "Mozilla/5.0 (lidar-archeo-pipeline)"}
def parse_tile_specs(args):
"""Convertit des spécifications "col,row" ou "col:row" en liste de tuples.
Args:
args: liste de chaînes (ex: ["1055,6882", "1056:6883"]).
Returns:
Liste de tuples (col, row).
Raises:
ValueError: si une spécification est mal formée.
"""
specs = []
for raw in args:
text = raw.strip().replace(":", ",").replace(";", ",")
parts = [p.strip() for p in text.split(",") if p.strip()]
if len(parts) != 2:
raise ValueError(f"Spécification de tuile invalide: {raw!r} (attendu: col,row)")
try:
col, row = int(parts[0]), int(parts[1])
except ValueError:
raise ValueError(f"Spécification de tuile invalide: {raw!r} (col et row doivent être des entiers)")
specs.append((col, row))
return specs
def tile_filename(col, row):
"""Nom de fichier LAZ standard d'une dalle (col, row)."""
return f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69.copc.laz"
def _bbox_wgs84(col, row):
"""Bbox WGS84 de la dalle (col,row) pour la requête STAC (peut être élargie)."""
try:
from rasterio.warp import transform as warp_transform
xs = [col * 1000, (col + 1) * 1000, col * 1000, (col + 1) * 1000]
ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
except Exception:
from .index import _approx_l93_to_wgs84
pts = [_approx_l93_to_wgs84(x, y)
for x in (col * 1000, (col + 1) * 1000)
for y in ((row - 1) * 1000, row * 1000)]
lons = [p[0] for p in pts]
lats = [p[1] for p in pts]
pad = 0.005 # ~500 m de marge pour éviter les erreurs d'arrondi aux bords
return (min(lons) - pad, min(lats) - pad, max(lons) + pad, max(lats) + pad)
def match_feature(features, col, row):
"""Retourne l'item STAC correspondant à la dalle (col,row), sinon None."""
want = f"{col:04d}-{row:04d}"
for feature in features:
props = feature.get("properties", {})
if props.get("lidarhd:coordonnees_NW") == want:
return feature
return None
def find_tile_url(col, row, timeout=20):
"""Cherche l'URL de téléchargement de la dalle (col,row) dans le catalogue STAC.
Returns:
URL (str) ou None si la dalle n'est pas (encore) publiée par l'IGN.
"""
w, s, e, n = _bbox_wgs84(col, row)
query = urllib.parse.urlencode({"bbox": f"{w:.6f},{s:.6f},{e:.6f},{n:.6f}", "limit": 50})
req = urllib.request.Request(f"{_STAC_ITEMS_URL}?{query}", headers=_HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
feature = match_feature(data.get("features", []), col, row)
if not feature:
return None
return feature.get("assets", {}).get("data", {}).get("href")
def download_file(url, dest_path, timeout=120, chunk=1024 * 1024):
"""Télécharge url vers dest_path en streaming. Retourne la taille en octets."""
req = urllib.request.Request(url, headers=_HEADERS)
t0 = time.time()
with urllib.request.urlopen(req, timeout=timeout) as response, open(dest_path, "wb") as out:
done = 0
while True:
block = response.read(chunk)
if not block:
break
out.write(block)
done += len(block)
elapsed = time.time() - t0
logger.info(f" {done / 1e6:.0f} Mo en {elapsed:.0f}s"
f" ({done / 1e6 / max(elapsed, 0.1):.1f} Mo/s)")
return done
def fetch_tiles(input_dir, specs, output_dir=None):
"""Télécharge les dalles IGN spécifiées, sauf celles déjà présentes/générées.
Args:
input_dir: dossier des fichiers LAZ (écriture autorisée requise).
specs: liste de tuples (col, row).
output_dir: dossier de sortie (optionnel) — permet d'ignorer les
tuiles dont les visualisations existent déjà.
Returns:
Liste des chemins téléchargés.
"""
input_dir = Path(input_dir)
downloaded = []
for col, row in specs:
name = tile_filename(col, row)
dest = input_dir / name
if dest.exists():
logger.info(f" {name} : déjà présent dans input/ — aucun téléchargement")
continue
if output_dir is not None:
vis_dir = Path(output_dir) / "visualisations"
if list(vis_dir.glob(f"LHD_FXX_{col:04d}_{row:04d}_PTS*")):
logger.info(f" {name} : visualisations déjà générées — ignorée")
continue
logger.info(f" {name} : recherche dans le catalogue IGN...")
try:
url = find_tile_url(col, row)
except Exception as e:
logger.warning(f" ✗ {name} : erreur catalogue ({e})")
continue
if not url:
logger.warning(f" ✗ {name} : introuvable dans le catalogue IGN (zone non publiée ?)")
continue
logger.info(f" {name} : téléchargement depuis la géoplateforme...")
try:
download_file(url, dest)
logger.info(f" ✓ {name} téléchargée")
downloaded.append(dest)
except Exception as e:
dest.unlink(missing_ok=True)
logger.warning(f" ✗ {name} : échec du téléchargement ({e})")
return downloaded