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 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-14 20:56:52 +02:00
parent ab9d694dd4
commit 6ed4972afc

View File

@ -128,17 +128,21 @@ def validate_laz(laz_file):
"""Quick integrity check for a LAZ/LAS file. """Quick integrity check for a LAZ/LAS file.
Tries laspy first (fast header read), then PDAL as fallback for COPC files 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: Returns:
True if file is readable, False otherwise. True if file is readable and contains points, False otherwise.
""" """
# Try laspy first (fast) # Try laspy first (fast)
import laspy import laspy
try: try:
with laspy.open(str(laz_file)) as f: with laspy.open(str(laz_file)) as f:
header = f.header 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 return True
except Exception: except Exception:
pass pass
@ -151,6 +155,17 @@ def validate_laz(laz_file):
capture_output=True, text=True, timeout=30 capture_output=True, text=True, timeout=30
) )
if result.returncode == 0: 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 return True
logger.error(f" ✗ Fichier illisible: {laz_file.name}") logger.error(f" ✗ Fichier illisible: {laz_file.name}")
logger.error(f" PDAL: {result.stderr.strip()[:200]}") logger.error(f" PDAL: {result.stderr.strip()[:200]}")
@ -200,6 +215,9 @@ def _read_with_pdal(laz_file):
os.unlink(tmp_path) os.unlink(tmp_path)
except Exception: except Exception:
pass pass
if len(las.points) == 0:
logger.warning(f" PDAL: conversion réussie mais 0 points")
return None
return las return las
except Exception as e: except Exception as e:
@ -238,6 +256,10 @@ def detect_ground_method(laz_file):
return 'smrf' return 'smrf'
total_points = len(las.points) 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) z = np.array(las.z)
# Height variance (always available) # Height variance (always available)
@ -321,6 +343,11 @@ def classify_ground(laz_file, temp_dir, method='auto', force=False):
["pdal", "pipeline", str(pipeline_file)], ["pdal", "pipeline", str(pipeline_file)],
capture_output=True, check=True 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") logger.info(f" ✓ Classification sol {method.upper()} terminée")
return output_las return output_las
except subprocess.CalledProcessError as e: 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}") logger.error(f" ✗ Impossible de lire {las_file.name}")
return None return None
if len(las.points) == 0:
logger.error(f" ✗ Fichier vide (0 points): {las_file.name}")
return None
try: try:
min_x, max_x = float(las.header.min[0]), float(las.header.max[0]) min_x, max_x = float(las.header.min[0]), float(las.header.max[0])