Files
lidar_rendu/lidar_pipeline/cli.py

364 lines
13 KiB
Python

"""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",
help="Dossier contenant les fichiers LAZ/LAS"
)
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 le fichier .las existe déjà"
)
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", "smrf", "csf"],
default="auto",
help="Méthode de classification du sol : auto (détection), smrf, csf (défaut: auto)"
)
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(
"-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"
)
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
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(',')]
pipeline = LidarArchaeoPipeline(
input_dir=args.input,
output_dir=args.output,
resolution=args.resolution,
workers=args.workers,
force=args.force,
ground_method=args.ground_classification,
force_classify=args.force_classification,
keep_tif=args.keep_tif,
quality=quality,
only_viz=only_viz,
skip_viz=skip_viz,
output_format=args.format,
gpu_ids=gpu_ids,
no_index=args.no_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}")
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}")
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
try:
subprocess.run(["pkill", "-9", "-f", "pdal"], 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)