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

View File

@ -97,7 +97,10 @@ def main():
)
parser.add_argument(
"input",
help="Dossier contenant les fichiers LAZ/LAS"
nargs="?",
default="/data/input",
help="Dossier contenant les fichiers LAZ/LAS (défaut: /data/input ; "
"optionnel pour --rebuild-index)"
)
parser.add_argument(
"-o", "--output",
@ -134,7 +137,16 @@ def main():
parser.add_argument(
"--force-classification",
action="store_true",
help="Reclassifier le sol même si le fichier .las existe déjà"
help="Reclassifier le sol même si la méthode est inchangée (régénère aussi le DTM "
"et les images). Sans ce flag, changer --ground-classification suffit : la "
"méthode enregistrée est comparée et un changement déclenche la reclassification."
)
parser.add_argument(
"--bare-earth",
action="store_true",
help="Sol nu : ramener le DTM au retour le plus bas de chaque cellule. "
"Requalifie le point le plus bas de chaque colonne en terrain — utile sous "
"végétation dense ou en relief raide où la classification du sol sous-couvre le terrain."
)
parser.add_argument(
"--keep-tif",
@ -143,9 +155,22 @@ def main():
)
parser.add_argument(
"--ground-classification",
choices=["auto", "smrf", "csf"],
choices=["auto", "ign", "smrf", "csf"],
default="auto",
help="Méthode de classification du sol : auto (détection), smrf, csf (défaut: auto)"
help="Méthode de classification du sol : auto (préfère la pré-classification IGN si "
"présente — base rapide — sinon détection SMRF/CSF), ign, smrf, csf. "
"Avec ign, le MNT est la rasterisation pure des classes choisies "
"(--ign-classes) sans aucune retouche ; avec smrf/csf, il est complété "
"par le retour le plus bas par cellule + interpolation des trous. (défaut: auto)"
)
parser.add_argument(
"--ign-classes",
default="sol",
help="Classes LAS extraites pour le MNT avec la classification IGN (méthode "
"ign/auto) : liste noms ou codes séparés par virgules — "
"sol(2), unclassified(1), eau(9), virtuel(66), pont(17), sursol(64). "
"Ex: --ign-classes sol,unclassified. Changer la liste reclassifie les "
"dalles concernées. (défaut: sol)"
)
parser.add_argument(
"--quality",
@ -185,6 +210,14 @@ def main():
default=None,
help="Traiter un ou plusieurs fichiers LAZ/LAS (nom complet sans extension, ex: LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc)"
)
parser.add_argument(
"--fetch-tiles",
nargs="+",
default=None,
metavar="COL,ROW",
help="Télécharger ces dalles LiDAR HD depuis l'IGN avant traitement "
"(tuiles non encore générées, ex: --fetch-tiles 1055,6882 1056,6883)"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
@ -253,6 +286,21 @@ def main():
logger.warning("Aucune tuile traitée trouvée — carte globale non générée")
return
# Téléchargement des dalles IGN manquantes avant le traitement
if args.fetch_tiles:
from .fetch_ign import fetch_tiles, parse_tile_specs
try:
specs = parse_tile_specs(args.fetch_tiles)
except ValueError as e:
logger.error(str(e))
return
logger.info(f"Téléchargement de {len(specs)} dalle(s) LiDAR HD depuis l'IGN...")
fetched = fetch_tiles(args.input, specs, args.output)
if fetched:
logger.info(f"{len(fetched)} dalle(s) téléchargée(s) — traitement...")
else:
logger.warning("Aucune dalle téléchargée (déjà présentes ou introuvables)")
quality = 100 if args.lossless else args.quality
# Parse --only and --skip: accept comma-separated values
only_viz = None
@ -268,8 +316,10 @@ def main():
workers=args.workers,
force=args.force,
ground_method=args.ground_classification,
ign_classes=args.ign_classes,
force_classify=args.force_classification,
keep_tif=args.keep_tif,
bare_earth=args.bare_earth,
quality=quality,
only_viz=only_viz,
skip_viz=skip_viz,
@ -315,30 +365,8 @@ def main():
logger.info(f"Traitement de {len(unique_files)} fichier(s) sélectionné(s)")
for laz_file in unique_files:
logger.info(f" → {laz_file.name}")
for laz_file in unique_files:
pipeline.process_file(laz_file)
# Clean up temporary files
logger.info("Nettoyage des fichiers temporaires...")
try:
if pipeline.temp_dir.exists():
shutil.rmtree(pipeline.temp_dir)
temp_base = pipeline.output_dir / "temp"
if temp_base.exists():
shutil.rmtree(temp_base)
logger.info(" ✓ Fichiers temporaires supprimés")
except Exception as e:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
# Génère la carte globale après traitement --file
if not args.no_index:
try:
from .index import build_index
index_path = build_index(pipeline.output_dir, pipeline.output_format)
if index_path:
logger.info(f"Carte globale générée : {index_path}")
except Exception as e:
logger.warning(f"Index global non généré: {e}")
# Réutilise process_all : workers parallèles, résumé, index, nettoyage
pipeline.process_all(files=unique_files)
else:
pipeline.process_all()
except Exception as e: