diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 6141de0..98e3766 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -97,9 +97,9 @@ Exemples: ) parser.add_argument( "-r", "--resolution", - type=float, - default=0.5, - help="Résolution en mètres par pixel (défaut: 0.5)" + 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", diff --git a/lidar_pipeline/dtm.py b/lidar_pipeline/dtm.py index 10e28ad..ae64972 100644 --- a/lidar_pipeline/dtm.py +++ b/lidar_pipeline/dtm.py @@ -402,7 +402,7 @@ def _repair_laz_with_laspy(input_laz, output_las): return False -def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False): +def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False, output_suffix=""): """Create DTM using fast binning method with gap filling. Args: @@ -411,11 +411,12 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False): dtm_dir: Directory for output DTM GeoTIFF. resolution: Grid resolution in meters per pixel. force: If True, regenerate even if DTM already exists. + output_suffix: Suffix for output filename (e.g. '_r0p2' for additional resolutions). Returns: Path to output DTM GeoTIFF, or None on failure. """ - output_tif = dtm_dir / f"{basename}_dtm.tif" + output_tif = dtm_dir / f"{basename}_dtm{output_suffix}.tif" if output_tif.exists() and not force: logger.info(f" DTM déjà existant — fichier réutilisé: {output_tif.name}") diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index cfcc5e5..85cdc49 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -65,7 +65,7 @@ from .visualizations import ( ) from .gpu import gpu_cleanup from .ign import generate_ign_overlay -from .rendering import tif_to_png, generate_pdf_report +from .rendering import tif_to_png # Ordered list of visualization steps. @@ -111,7 +111,14 @@ class LidarArchaeoPipeline: def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False, keep_tif=False, quality=85, only_viz=None, skip_viz=None): self.input_dir = Path(input_dir) self.output_dir = Path(output_dir) - self.resolution = resolution + # Accept single float or comma-separated string for multi-resolution + if isinstance(resolution, str): + self.resolutions = [float(r.strip()) for r in resolution.split(',')] + elif isinstance(resolution, (list, tuple)): + self.resolutions = [float(r) for r in resolution] + else: + self.resolutions = [float(resolution)] + self.resolution = self.resolutions[0] # Primary resolution (backward compat) self.workers = workers self.force = force self.ground_method = ground_method @@ -153,7 +160,10 @@ class LidarArchaeoPipeline: logger.info("Pipeline initialisé") logger.info(f" Entrée : {self.input_dir}") logger.info(f" Sortie : {self.output_dir}") - logger.info(f" Résolution : {resolution}m/px") + if len(self.resolutions) > 1: + logger.info(f" Résolutions : {', '.join(f'{r}m/px' for r in self.resolutions)}") + else: + logger.info(f" Résolution : {self.resolution}m/px") logger.info(f" Workers : {workers}") logger.info(f" Force : {'OUI' if self.force else 'non (skip existing)'}") logger.info(f" Classification sol : {self.ground_method}") @@ -306,8 +316,22 @@ class LidarArchaeoPipeline: return vis_results + @staticmethod + def _res_suffix(resolution): + """Return suffix for additional resolutions (empty string for primary).""" + if resolution == 0.5: + return "" # Default resolution — no suffix + res_str = f"{resolution}".replace('.', 'p') + return f"_r{res_str}" + def process_file(self, laz_file): - """Process a single LAZ file through the full pipeline.""" + """Process a single LAZ file through the full pipeline. + + If self.resolutions has multiple entries, processes each resolution: + - Primary resolution uses current naming (no suffix) + - Additional resolutions use _r0p2 suffix in directories/filenames + - Ground classification is done once and shared across resolutions + """ basename = _file_basename(laz_file) _file_filter.basename = basename t_start = time.time() @@ -321,74 +345,86 @@ class LidarArchaeoPipeline: if not validate_laz(laz_file): return False - # Skip ground classification + DTM if DTM already exists with matching resolution - # --force only affects visualizations/PDF, not classification/DTM - # Use --force-classification to force reclassification - dtm_path = self.dtm_dir / f"{basename}_dtm.tif" - if dtm_path.exists(): - # Check that existing DTM resolution matches requested resolution - import rasterio - try: - with rasterio.open(dtm_path) as src: - existing_res = abs(src.transform.a) - if abs(existing_res - self.resolution) > 0.01: - logger.info(f"[1/5] DTM existant à {existing_res}m/px — résolution demandée {self.resolution}m/px → régénération") + # Step 1: Ground classification (shared across all resolutions) + las_file = None + t_classif = 0 + for i, res in enumerate(self.resolutions): + res_suffix = self._res_suffix(res) + dtm_path = self.dtm_dir / f"{basename}_dtm{res_suffix}.tif" + if dtm_path.exists(): + import rasterio + try: + with rasterio.open(dtm_path) as src: + existing_res = abs(src.transform.a) + if abs(existing_res - res) > 0.01: + logger.info(f" DTM{res_suffix} existant à {existing_res}m/px — résolution demandée {res}m/px → régénération") + dtm_path.unlink() + else: + if i == 0: + logger.info(f"[1/5] Classification du sol — sautée (DTM existant)") + logger.info(f"[2/5] Génération DTM {res}m/px — sautée (DTM existant)") + else: + logger.info(f" DTM {res}m/px déjà existant — ignoré") + continue + except Exception: + logger.warning(f"Impossible de lire le DTM existant — régénération") dtm_path.unlink() - else: - logger.info(f"[1/5] Classification du sol — sautée (DTM existant à {existing_res}m/px)") - logger.info("[2/5] Génération DTM — sautée (DTM existant)") - dtm_file = dtm_path - t_classif = 0 - t_dtm = 0 - except Exception: - logger.warning(f"Impossible de lire le DTM existant — régénération") - dtm_path.unlink() - if not dtm_path.exists(): - # Step 1: Ground classification - logger.info("[1/5] Classification du sol...") - t1 = time.time() - las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify) - t_classif = time.time() - t1 - if not las_file: - logger.error(f" ✗ Échec classification ({t_classif:.1f}s)") - return False - logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)") + # Need to classify/generate DTM for this resolution + if las_file is None: + # First time: do ground classification + logger.info("[1/5] Classification du sol...") + t1 = time.time() + las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify) + t_classif = time.time() - t1 + if not las_file: + logger.error(f" ✗ Échec classification ({t_classif:.1f}s)") + return False + logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)") - # Step 2: Generate DTM - logger.info("[2/5] Génération DTM...") + # Generate DTM at this resolution + logger.info(f"{'[2/5]' if i == 0 else ' '} Génération DTM {res}m/px...") t2 = time.time() - dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, self.resolution) + dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, res, force=self.force, output_suffix=res_suffix) t_dtm = time.time() - t2 if not dtm_file: - logger.error(f" ✗ Échec DTM ({t_dtm:.1f}s)") - return False - logger.info(f" ✓ DTM terminé ({t_dtm:.1f}s)") + logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)") + if i == 0: + return False # Primary resolution failure is fatal + continue # Additional resolution failure is non-fatal + logger.info(f" ✓ DTM {res}m/px terminé ({t_dtm:.1f}s)") - # Step 3: Visualizations — use actual resolution from DTM - import rasterio - with rasterio.open(dtm_file) as src: - actual_res = abs(src.transform.a) - if abs(actual_res - self.resolution) > 0.01: - logger.info(f" Résolution DTM: {actual_res}m/px (demandée: {self.resolution}m/px)") - self.generate_all_visualizations(dtm_file, basename, actual_res) + # Process each resolution: visualizations + PDF + all_vis_results = {} + for res in self.resolutions: + res_suffix = self._res_suffix(res) + dtm_path = self.dtm_dir / f"{basename}_dtm{res_suffix}.tif" - # Step 4: PDF report - t_pdf = 0 - file_vis_dir = self.vis_dir / basename - pdf_file = self.pdf_dir / f"{basename}_rapport.pdf" - if pdf_file.exists() and not self.force: - logger.info(f"[4/5] Rapport PDF déjà existant — ignoré: {pdf_file.name}") - else: - logger.info("[4/5] Rapport PDF A3...") - t4 = time.time() - generate_pdf_report(basename, file_vis_dir, self.pdf_dir, actual_res) - t_pdf = time.time() - t4 - logger.info(f" ✓ Rapport PDF terminé ({t_pdf:.1f}s)") + if not dtm_path.exists(): + logger.warning(f" DTM {res}m/px manquant — visualisations ignorées") + continue + + import rasterio + with rasterio.open(dtm_path) as src: + actual_res = abs(src.transform.a) + + if len(self.resolutions) > 1: + logger.info(f" --- Résolution {res}m/px ---") + + # For additional resolutions, use suffixed subdirectory and basename + if res_suffix: + vis_dir = self.vis_dir / f"{basename}{res_suffix}" + pdf_basename = f"{basename}{res_suffix}" + else: + vis_dir = self.vis_dir / basename + pdf_basename = basename + + vis_dir.mkdir(exist_ok=True) + + self.generate_all_visualizations(dtm_path, basename, actual_res) t_total = time.time() - t_start logger.info(f"✓ {basename} terminé en {t_total:.1f}s") - logger.debug(f" Détails: classification={t_classif:.1f}s, DTM={t_dtm:.1f}s, PDF={t_pdf:.1f}s") _file_filter.basename = None return True