"""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 la carte globale HTML des tuiles déjà traitées (sans retraiter)" ) parser.add_argument( "--no-index", action="store_true", help="Ne pas générer la carte globale à la fin du traitement" ) parser.add_argument( "--incremental-index", action="store_true", help="Régénérer la carte après chaque tuile terminée (affichage en direct " "dans la webapp, 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 /temp/…) : un # pkill global massacrerient les PDAL des autres runs de la machine (ex. # job webapp 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 la carte globale, 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"Carte globale générée : {index_path}") else: logger.warning("Aucune tuile traitée trouvée — carte globale non générée") 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)