Un clic sélectionne toujours la dalle sous le curseur (cadre sur la carte), générée ou non : emprise, rendu et recalage des passes s'affichent tout de suite ; la fiche IGN (date et heure du scan LiDAR, capteurs, mission, édition, nombre de points, lien de téléchargement du .copc.laz) arrive à part via /api/map/ign, mise en cache, sans jamais bloquer la sélection. Une rose des vents donne la couleur de chaque orientation de pente du relief orienté, avec la même formule CIELAB que le rendu. La génération impose la classification IGN (sol) et le raccord de 100 m avec les dalles voisines : plus de réglage dans la carte, défauts CLI alignés. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
453 lines
18 KiB
Python
453 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=100.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. Défaut : 100 (toujours appliqué en production) ; "
|
||
"0 = désactivé. 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="ign",
|
||
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: ign, imposé en production)"
|
||
)
|
||
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) |