- Scope pkill to 'pdal pipeline' (avoids killing unrelated PDAL jobs) - Inline _strip_lidar_ext in dtm.py to remove circular import from pipeline - _res_suffix_str now delegates to LidarArchaeoPipeline._res_suffix
413 lines
16 KiB
Python
413 lines
16 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",
|
|
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) |