La carte à tuiles XYZ devient la seule interface : l'API de génération (preview, generate, status, stop, file, cell), le job unique + file persistante, la délégation LIDAR_GENERATION_URL et les garde-fous (token, LIDAR_REGEN_CIDR) passent de webapp.py à mapserve.py ; l'interface gagne les boutons + Zone / ⤒ Compléter / régénération à la dalle, les options de run et la progression cadre par cadre. mapserve sert aussi l'inventaire /api/tiles et les dalles en statique versionné : le worker image complète remplace la webapp sur le 8973 du générateur, le Pi n'exécute plus que lidar-maps. index.py se réduit aux registres partagés + vignettes/sous-tuiles/inventaire (l'UI HTML/JS et les mosaïques d'overview partent avec export.py et les compose/scripts de la webapp).
452 lines
18 KiB
Python
452 lines
18 KiB
Python
"""Command-line interface for the LiDAR archaeological pipeline.
|
||
|
||
Handles argument parsing, logging configuration, and entry point.
|
||
"""
|
||
|
||
import argparse
|
||
import logging
|
||
import signal
|
||
import sys
|
||
|
||
from .pipeline import resolve_workers
|
||
from .pipeline import LidarArchaeoPipeline
|
||
from .gpu import log_gpu_status
|
||
|
||
logger = logging.getLogger("lidar")
|
||
|
||
# Motif pkill des processus PDAL de CE run (renseigné dans main) — cf.
|
||
# _kill_orphan_pdal : borné à notre output pour épargner les autres runs.
|
||
_pdal_kill_pattern = None
|
||
|
||
|
||
def setup_logging(verbose=False, debug=False):
|
||
"""Configure the 'lidar' logger.
|
||
|
||
Args:
|
||
verbose: If True, include timestamps and level names.
|
||
debug: If True, set level to DEBUG and add file:line info.
|
||
"""
|
||
# Ensure UTF-8 output for French messages (é, è, ê, etc.)
|
||
if hasattr(sys.stdout, 'reconfigure'):
|
||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||
if hasattr(sys.stderr, 'reconfigure'):
|
||
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
||
|
||
if debug:
|
||
level = logging.DEBUG
|
||
fmt = "%(asctime)s.%(msecs)03d %(levelname)-5s [%(filename)s:%(lineno)d] %(message)s"
|
||
elif verbose:
|
||
level = logging.INFO
|
||
fmt = "%(asctime)s %(levelname)-5s %(message)s"
|
||
else:
|
||
level = logging.INFO
|
||
fmt = "%(message)s"
|
||
|
||
handler = logging.StreamHandler(sys.stdout)
|
||
handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S"))
|
||
logger.setLevel(level)
|
||
logger.handlers.clear()
|
||
logger.addHandler(handler)
|
||
logger.propagate = False # Prevent double logging via root logger
|
||
|
||
# Also configure the root logger so worker processes log properly
|
||
root_logger = logging.getLogger()
|
||
root_logger.setLevel(level)
|
||
if not root_logger.handlers:
|
||
root_handler = logging.StreamHandler(sys.stdout)
|
||
root_handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S"))
|
||
root_logger.addHandler(root_handler)
|
||
|
||
return logger
|
||
|
||
|
||
def main():
|
||
"""Entry point for the LiDAR archaeological pipeline."""
|
||
parser = argparse.ArgumentParser(
|
||
description="Pipeline LiDAR pour détection archéologique",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""\
|
||
Exemples:
|
||
Traitement standard:
|
||
python -m lidar_pipeline /data/input -o /data/output
|
||
|
||
Haute résolution avec accélération GPU:
|
||
python -m lidar_pipeline /data/input -o /data/output -r 0.2 -g
|
||
|
||
Sélectionner un GPU spécifique (index 0):
|
||
python -m lidar_pipeline /data/input -o /data/output -g 0
|
||
|
||
Sélectionner plusieurs GPUs (indices 0 et 2):
|
||
python -m lidar_pipeline /data/input -o /data/output -g 0,2
|
||
|
||
Utiliser tous les GPUs disponibles:
|
||
python -m lidar_pipeline /data/input -o /data/output -g all
|
||
|
||
Mode verbeux (timestamps):
|
||
python -m lidar_pipeline /data/input -o /data/output -v
|
||
|
||
Mode debug (détails internes):
|
||
python -m lidar_pipeline /data/input -o /data/output --debug
|
||
|
||
Forcer la régénération de tous les fichiers:
|
||
python -m lidar_pipeline /data/input -o /data/output --force
|
||
|
||
Traiter un seul fichier (pour tests):
|
||
python -m lidar_pipeline /data/input -o /data/output --file LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc
|
||
|
||
Traitement parallèle (4 workers):
|
||
python -m lidar_pipeline /data/input -o /data/output -w 4
|
||
"""
|
||
)
|
||
parser.add_argument(
|
||
"input",
|
||
nargs="?",
|
||
default="/data/input",
|
||
help="Dossier contenant les fichiers LAZ/LAS (défaut: /data/input ; "
|
||
"optionnel pour --rebuild-index)"
|
||
)
|
||
parser.add_argument(
|
||
"-o", "--output",
|
||
default="/data/output",
|
||
help="Dossier de sortie (défaut: /data/output)"
|
||
)
|
||
parser.add_argument(
|
||
"-r", "--resolution",
|
||
type=str,
|
||
default="0.2",
|
||
help="Résolution en m/px, ou multiples séparées par virgules (défaut: 0.2)"
|
||
)
|
||
parser.add_argument(
|
||
"-w", "--workers",
|
||
type=str,
|
||
default="auto",
|
||
help="Nombre de workers pour traitement parallèle, ou « auto » = "
|
||
"cœurs - 2 évalué au lancement du run (défaut: auto)"
|
||
)
|
||
parser.add_argument(
|
||
"-g", "--gpu",
|
||
nargs="?",
|
||
const="all",
|
||
default=None,
|
||
metavar="LISTE",
|
||
help="Sélectionner le(s) GPU(s) à utiliser : un index (ex: -g 0), "
|
||
"une liste (ex: -g 0,2), ou 'all' pour tous. "
|
||
"Sans -g : tous les GPU disponibles, un par worker (round-robin)."
|
||
)
|
||
parser.add_argument(
|
||
"-f", "--force",
|
||
action="store_true",
|
||
help="Régénérer tous les fichiers même si les WebP existent déjà"
|
||
)
|
||
parser.add_argument(
|
||
"--force-classification",
|
||
action="store_true",
|
||
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(
|
||
"--openness-downsample",
|
||
type=int,
|
||
default=None,
|
||
metavar="FACTEUR",
|
||
help="Facteur de sous-échantillonnage du calcul d'openness (lancé de rayons) : "
|
||
"grille décimée par blocs puis rééchantillonnage. Défaut : 2 (~8× plus "
|
||
"rapide, rendu quasi identique) ; 1 = pleine résolution"
|
||
)
|
||
parser.add_argument(
|
||
"--edge-buffer",
|
||
type=float,
|
||
default=0.0,
|
||
metavar="METRES",
|
||
help="Raccord des bords : étendre le MNT d'une bande de N mètres remplie avec "
|
||
"les points sol des 8 tuiles LAZ voisines, pour que les rendus à grand "
|
||
"noyau (openness, SVF, LRM) soient continus d'une tuile à l'autre. "
|
||
"100 m couvre tous les rayons ; les images finales sont recadrées sur "
|
||
"la dalle 1 km exacte. 0 = désactivé (défaut). Un changement de valeur "
|
||
"régénère les DTM concernés."
|
||
)
|
||
parser.add_argument(
|
||
"--no-strip-align",
|
||
action="store_true",
|
||
help="Désactiver le calage vertical des faisceaux de vol. Par défaut, les écarts "
|
||
"verticaux ≥ 0,5 cm entre lignes de vol (PointSourceId) d'une tuile sont mesurés "
|
||
"sur les points sol et corrigés avant rastérisation du MNT, puis la gigue "
|
||
"intra-faisceau (décalages aléatoires des lignes de balayage successives d'une "
|
||
"même passe, type vibration capteur) est corrigée par fenêtres de temps GPS "
|
||
"(offsets et séries consignés dans DTM/*_stripalign.json)"
|
||
)
|
||
parser.add_argument(
|
||
"--keep-tif",
|
||
action="store_true",
|
||
help="Conserver les fichiers TIFF (DTM + visualisations) pour pouvoir régénérer les WebP sans recalculer"
|
||
)
|
||
parser.add_argument(
|
||
"--ground-classification",
|
||
choices=["auto", "ign", "smrf", "csf"],
|
||
default="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",
|
||
type=int,
|
||
default=60,
|
||
help="Qualité image (1-100, défaut: 60). Utilisez 100 pour lossless."
|
||
)
|
||
parser.add_argument(
|
||
"--lossless",
|
||
action="store_true",
|
||
help="Forcer la compression lossless (équivalent à --quality 100)"
|
||
)
|
||
parser.add_argument(
|
||
"--format",
|
||
choices=["webp", "avif"],
|
||
default="avif",
|
||
help="Format de sortie : avif (défaut, meilleure qualité) ou webp"
|
||
)
|
||
parser.add_argument(
|
||
"--only",
|
||
nargs="+",
|
||
type=str,
|
||
default=None,
|
||
help="Générer uniquement ces visualisations (ex: --only hillshade svf lrm)"
|
||
)
|
||
parser.add_argument(
|
||
"--skip",
|
||
nargs="+",
|
||
type=str,
|
||
default=None,
|
||
help="Exclure ces visualisations (ex: --skip ortho topo)"
|
||
)
|
||
parser.add_argument(
|
||
"--file",
|
||
nargs="+",
|
||
type=str,
|
||
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",
|
||
help="Mode verbeux : affiche les timestamps et niveaux"
|
||
)
|
||
parser.add_argument(
|
||
"--debug",
|
||
action="store_true",
|
||
help="Mode debug : affiche les détails internes (fichier:ligne)"
|
||
)
|
||
parser.add_argument(
|
||
"--rebuild-index",
|
||
action="store_true",
|
||
help="Régénérer uniquement le catalogue des tuiles traitées (vignettes, sous-tuiles, inventaire — sans retraiter)"
|
||
)
|
||
parser.add_argument(
|
||
"--no-index",
|
||
action="store_true",
|
||
help="Ne pas générer le catalogue (vignettes + inventaire) à la fin du traitement"
|
||
)
|
||
parser.add_argument(
|
||
"--incremental-index",
|
||
action="store_true",
|
||
help="Régénérer le catalogue après chaque tuile terminée (affichage en direct "
|
||
"sur la carte, sans attendre la fin du run)"
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Configure logging before any other output
|
||
setup_logging(verbose=args.verbose, debug=args.debug)
|
||
|
||
# Add file prefix filter for parallel processing
|
||
from .pipeline import _file_filter
|
||
logger.addFilter(_file_filter)
|
||
|
||
logger.info("=" * 60)
|
||
logger.info("Pipeline LiDAR Archéologique")
|
||
logger.info("=" * 60)
|
||
|
||
# Parse --gpu into a list of GPU IDs
|
||
gpu_ids = None
|
||
gpu_arg = args.gpu
|
||
if gpu_arg is not None:
|
||
if gpu_arg == 'all':
|
||
gpu_ids = None # Don't restrict — use all GPUs
|
||
else:
|
||
try:
|
||
gpu_ids = [int(g.strip()) for g in gpu_arg.split(',')]
|
||
except ValueError:
|
||
parser.error(f"GPU invalide: {gpu_arg!r}. Utilisez un index, une liste (0,2) ou 'all'.")
|
||
if gpu_ids is not None:
|
||
from .gpu import restrict_gpus
|
||
restrict_gpus(gpu_ids)
|
||
|
||
# Kill orphan PDAL processes on interrupt or termination. Le motif est
|
||
# borné à NOTRE répertoire temporaire (pdal pipeline <output>/temp/…) : un
|
||
# pkill global massacrerient les PDAL des autres runs de la machine (ex.
|
||
# job de génération concurrent). Pas de hook atexit : une sortie normale n'a
|
||
# aucune raison de tuer quoi que ce soit.
|
||
global _pdal_kill_pattern
|
||
_pdal_kill_pattern = f"pdal pipeline {args.output}/temp/"
|
||
signal.signal(signal.SIGINT, _kill_orphan_pdal)
|
||
signal.signal(signal.SIGTERM, _kill_orphan_pdal)
|
||
|
||
log_gpu_status()
|
||
|
||
try:
|
||
# Mode --rebuild-index : régénère uniquement le catalogue, sans retraiter
|
||
if args.rebuild_index:
|
||
from .index import build_index
|
||
index_path = build_index(args.output, args.format)
|
||
if index_path:
|
||
logger.info(f"Catalogue généré : {index_path}")
|
||
else:
|
||
logger.warning("Aucune tuile traitée trouvée — catalogue non généré")
|
||
return
|
||
|
||
# Nouveau run : remise à zéro du journal d'événements (file de
|
||
# génération). Ici, avant le téléchargement — les événements download
|
||
# survivent au démarrage du traitement (process_all ne tronque plus).
|
||
from .progress import reset_events
|
||
reset_events(args.output)
|
||
|
||
quality = 100 if args.lossless else args.quality
|
||
# Parse --only and --skip: accept comma-separated values
|
||
only_viz = None
|
||
if args.only:
|
||
only_viz = [v.strip() for item in args.only for v in item.split(',')]
|
||
skip_viz = None
|
||
if args.skip:
|
||
skip_viz = [v.strip() for item in args.skip for v in item.split(',')]
|
||
resolutions = [float(r.strip()) for r in args.resolution.split(',') if r.strip()]
|
||
|
||
# Téléchargement des dalles IGN manquantes avant le traitement.
|
||
# only_viz/resolutions : une tuile n'est ignorée que si elle possède
|
||
# déjà toutes les visualisations demandées — une tuile incomplète est
|
||
# (re)téléchargée pour générer ses visualisations manquantes.
|
||
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,
|
||
only_viz=only_viz, resolutions=resolutions,
|
||
force=args.force or args.force_classification)
|
||
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)")
|
||
pipeline = LidarArchaeoPipeline(
|
||
input_dir=args.input,
|
||
output_dir=args.output,
|
||
resolution=args.resolution,
|
||
workers=resolve_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,
|
||
strip_align=not args.no_strip_align,
|
||
openness_downsample=args.openness_downsample,
|
||
edge_buffer=args.edge_buffer,
|
||
quality=quality,
|
||
only_viz=only_viz,
|
||
skip_viz=skip_viz,
|
||
output_format=args.format,
|
||
gpu_ids=gpu_ids,
|
||
no_index=args.no_index,
|
||
incremental_index=args.incremental_index,
|
||
)
|
||
|
||
# If --file is specified, process only matching files
|
||
if args.file:
|
||
from pathlib import Path
|
||
input_dir = Path(args.input)
|
||
# Each pattern is the full filename without extension (e.g. LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc)
|
||
# Also supports bare name without .copc (e.g. LHD_FXX_1000_6882_PTS_LAMB93_IGN69)
|
||
selected_files = []
|
||
for pattern in args.file:
|
||
# Try exact filename first (e.g. LHD_FXX_...copc.laz)
|
||
exact_match = input_dir / pattern
|
||
if exact_match.exists() and exact_match.is_file():
|
||
matches = [exact_match]
|
||
else:
|
||
# Try with added extensions (e.g. pattern=LHD_FXX_...IGN69)
|
||
matches = (list(input_dir.glob(f"{pattern}.laz"))
|
||
+ list(input_dir.glob(f"{pattern}.las"))
|
||
+ list(input_dir.glob(f"{pattern}.copc.laz"))
|
||
+ list(input_dir.glob(f"{pattern}.copc.las")))
|
||
# Remove duplicates
|
||
matches = list(dict.fromkeys(matches))
|
||
if not matches:
|
||
logger.warning(f"Aucun fichier trouvé pour: {pattern}")
|
||
continue
|
||
selected_files.extend(matches)
|
||
# Remove duplicates across patterns
|
||
seen = set()
|
||
unique_files = []
|
||
for f in selected_files:
|
||
if f not in seen:
|
||
seen.add(f)
|
||
unique_files.append(f)
|
||
if not unique_files:
|
||
logger.error("Aucun fichier trouvé pour les motifs spécifiés")
|
||
sys.exit(1)
|
||
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}")
|
||
# 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:
|
||
logger.error(f"Erreur fatale: {e}", exc_info=True)
|
||
sys.exit(1)
|
||
|
||
|
||
def _kill_orphan_pdal(signum=None, frame=None):
|
||
"""Kill orphan PDAL processes of THIS run on interrupt or termination."""
|
||
import subprocess
|
||
# Belt-and-suspenders: os.killpg below is the primary mechanism.
|
||
# This handles the edge case where a child escapes the process group.
|
||
# Motif borné à notre output (cf. main) pour ne pas tuer les PDAL des
|
||
# autres runs de la machine.
|
||
if _pdal_kill_pattern:
|
||
try:
|
||
import re
|
||
subprocess.run(["pkill", "-9", "-f", re.escape(_pdal_kill_pattern)],
|
||
capture_output=True, timeout=3)
|
||
except Exception:
|
||
pass
|
||
if signum is not None:
|
||
logger.info("Interruption — nettoyage des processus")
|
||
# Force-kill all child processes immediately
|
||
try:
|
||
import os
|
||
os.killpg(os.getpgrp(), signal.SIGKILL)
|
||
except Exception:
|
||
pass
|
||
sys.exit(130) |