From 6ed4972afc20b7f5c52f1236988e764e7e564f9a Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Thu, 14 May 2026 20:56:52 +0200 Subject: [PATCH] Handle empty point clouds gracefully at every pipeline stage Fixes "zero-size array to reduction operation" crash on corrupt/incomplete LAZ files. Added checks at each step: - validate_laz(): check point_count > 0 via laspy header, parse PDAL info JSON for point count when using PDAL fallback - detect_ground_method(): return 'smrf' default if point cloud is empty after PDAL conversion instead of crashing on np.max(empty_array) - _read_with_pdal(): log warning and return None if converted file has 0 points - create_dtm_fast(): fail gracefully if ground file has 0 points - classify_ground(): check output file size after PDAL pipeline to catch empty ground classifications early Co-Authored-By: Claude Opus 4.6 --- lidar_pipeline/dtm.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/lidar_pipeline/dtm.py b/lidar_pipeline/dtm.py index caf73a0..10e28ad 100644 --- a/lidar_pipeline/dtm.py +++ b/lidar_pipeline/dtm.py @@ -128,17 +128,21 @@ def validate_laz(laz_file): """Quick integrity check for a LAZ/LAS file. Tries laspy first (fast header read), then PDAL as fallback for COPC files - that laspy cannot read. + that laspy cannot read. Also checks that the file contains points. Returns: - True if file is readable, False otherwise. + True if file is readable and contains points, False otherwise. """ # Try laspy first (fast) import laspy try: with laspy.open(str(laz_file)) as f: header = f.header - _ = header.point_count + point_count = header.point_count + if point_count == 0: + logger.error(f" ✗ Fichier vide (0 points): {laz_file.name}") + logger.error(f" → Re-télécharger depuis https://ign.fr/lidar-hd") + return False return True except Exception: pass @@ -151,6 +155,17 @@ def validate_laz(laz_file): capture_output=True, text=True, timeout=30 ) if result.returncode == 0: + # Check point count from PDAL info output + import json as _json + try: + info = _json.loads(result.stdout) + count = info.get('summary', {}).get('num_points', 0) + if count == 0: + logger.error(f" ✗ Fichier vide (0 points PDAL): {laz_file.name}") + logger.error(f" → Re-télécharger depuis https://ign.fr/lidar-hd") + return False + except Exception: + pass # Can't parse — assume valid return True logger.error(f" ✗ Fichier illisible: {laz_file.name}") logger.error(f" PDAL: {result.stderr.strip()[:200]}") @@ -200,6 +215,9 @@ def _read_with_pdal(laz_file): os.unlink(tmp_path) except Exception: pass + if len(las.points) == 0: + logger.warning(f" PDAL: conversion réussie mais 0 points") + return None return las except Exception as e: @@ -238,6 +256,10 @@ def detect_ground_method(laz_file): return 'smrf' total_points = len(las.points) + if total_points == 0: + logger.warning(f" Nuage vide (0 points) — méthode par défaut: SMRF") + return 'smrf' + z = np.array(las.z) # Height variance (always available) @@ -321,6 +343,11 @@ def classify_ground(laz_file, temp_dir, method='auto', force=False): ["pdal", "pipeline", str(pipeline_file)], capture_output=True, check=True ) + # Verify that ground file has points + if output_las.exists() and output_las.stat().st_size < 100: + logger.error(f" ✗ Fichier ground vide (taille < 100 octets)") + output_las.unlink(missing_ok=True) + return None logger.info(f" ✓ Classification sol {method.upper()} terminée") return output_las except subprocess.CalledProcessError as e: @@ -409,6 +436,10 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False): logger.error(f" ✗ Impossible de lire {las_file.name}") return None + if len(las.points) == 0: + logger.error(f" ✗ Fichier vide (0 points): {las_file.name}") + return None + try: min_x, max_x = float(las.header.min[0]), float(las.header.max[0])