"""Command-line interface for the LiDAR archaeological pipeline. Handles argument parsing, logging configuration, and entry point. """ import argparse import logging import os import shutil import signal import sys from .pipeline import LidarArchaeoPipeline from .gpu import log_gpu_status logger = logging.getLogger("lidar") 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.5", help="Résolution en m/px, ou multiples séparées par virgules (défaut: 0.5, ex: 0.5,0.2)" ) parser.add_argument( "-w", "--workers", type=int, default=1, help="Nombre de workers pour traitement parallèle (défaut: 1)" ) 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 (ex: -g). " "Sans -g : GPU auto (le premier si disponible)." ) 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( "--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", 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=98, help="Qualité image (1-100, défaut: 98). 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 signal.signal(signal.SIGINT, _kill_orphan_pdal) signal.signal(signal.SIGTERM, _kill_orphan_pdal) import atexit atexit.register(_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=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, 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 on interrupt or exit.""" import subprocess # Belt-and-suspenders: os.killpg below is the primary mechanism. # This handles the edge case where a child escapes the process group. try: subprocess.run(["pkill", "-9", "-f", "pdal pipeline"], 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)