Fix corrupted COPC detection, add CSF→SMRF fallback, improve MSRM colormap, add SVF and anisotropic openness

- validate_laz: verify point data accessibility (not just headers) to detect
  corrupted COPC files that pass header checks but fail on data reads
- classify_ground: fallback from CSF to SMRF when CSF produces no ground
  points or PDAL errors (fixes 2/9 failing tiles)
- MSRM: preserve sign in weighted combination so RdBu_r colormap shows
  both red (elevated) and blue (depressed) instead of red only
- Add Sky-View Factor (SVF) visualization: cos²(horizon angle) over 16
  directions, excellent for archaeological earthwork detection
- Add Anisotropic Openness: directional weighting (NW-SE/NE-SW) enhances
  linear feature detection aligned with common settlement patterns
- Remove anomalies and flow visualizations (replaced by SVF + aniso_open)
- Location inset: use IGN topographic map at zoom 10 instead of simplified
  France outline, with red rectangle marker and fallback
- Remove flow (hydrological accumulation) from VIZ_STEPS
This commit is contained in:
Antoine Jacquin
2026-05-15 01:38:09 +02:00
parent da454bd23e
commit c634db573a
4 changed files with 437 additions and 28 deletions

View File

@ -125,15 +125,15 @@ def create_csf_pipeline(input_laz, output_las):
def validate_laz(laz_file):
"""Quick integrity check for a LAZ/LAS file.
"""Integrity check for a LAZ/LAS file.
Tries laspy first (fast header read), then PDAL as fallback for COPC files
that laspy cannot read. Also checks that the file contains points.
Verifies that both the header AND point data are readable. Some corrupted
COPC files have valid headers but inaccessible point data (LazrsError:
failed to fill whole buffer). Such files must be re-downloaded.
Returns:
True if file is readable and contains points, False otherwise.
True if file is readable and contains accessible points, False otherwise.
"""
# Try laspy first (fast)
import laspy
try:
with laspy.open(str(laz_file)) as f:
@ -143,6 +143,15 @@ def validate_laz(laz_file):
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
# Verify point data is actually accessible (not just header metadata)
try:
for _ in f.chunk_iterator(1000):
break # Read just one chunk to confirm data integrity
except Exception as chunk_err:
logger.error(f" ✗ Données inaccessibles (fichier corrompu?): {laz_file.name}")
logger.error(f" Erreur: {chunk_err}")
logger.error(f" → Re-télécharger depuis https://ign.fr/lidar-hd")
return False
return True
except Exception:
pass
@ -166,6 +175,18 @@ def validate_laz(laz_file):
return False
except Exception:
pass # Can't parse — assume valid
# Verify PDAL can actually read point data (not just header)
try:
test_result = subprocess.run(
["pdal", "info", str(laz_file), "--point", "1"],
capture_output=True, text=True, timeout=60
)
if test_result.returncode != 0:
logger.error(f" ✗ Données inaccessibles (PDAL): {laz_file.name}")
logger.error(f" → Re-télécharger depuis https://ign.fr/lidar-hd")
return False
except (subprocess.TimeoutExpired, FileNotFoundError):
pass # Timeout — assume valid, will fail later if corrupted
return True
logger.error(f" ✗ Fichier illisible: {laz_file.name}")
logger.error(f" PDAL: {result.stderr.strip()[:200]}")
@ -347,6 +368,9 @@ def classify_ground(laz_file, temp_dir, method='auto', force=False):
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)
# Fallback: if CSF produced no ground points, retry with SMRF
if method == 'csf':
return _fallback_to_smrf(laz_file, temp_dir, laz_base, force)
return None
logger.info(f" ✓ Classification sol {method.upper()} terminée")
return output_las
@ -354,6 +378,10 @@ def classify_ground(laz_file, temp_dir, method='auto', force=False):
error_msg = e.stderr.decode() if e.stderr else str(e)
logger.warning(f" ✗ Erreur classification PDAL ({method.upper()}): {error_msg}")
# Fallback: if CSF failed, retry with SMRF
if method == 'csf':
return _fallback_to_smrf(laz_file, temp_dir, laz_base, force)
# Try repairing file with laspy if PDAL fails on EVLR/VLR
if 'VLR' in error_msg or 'Invalid' in error_msg:
logger.info(f" → Tentative de réparation du fichier avec laspy...")
@ -378,6 +406,58 @@ def classify_ground(laz_file, temp_dir, method='auto', force=False):
return None
def _fallback_to_smrf(laz_file, temp_dir, laz_base, force=False):
"""Retry ground classification with SMRF when CSF fails.
CSF (Cloth Simulation Filter) can fail on certain terrain types where
SMRF (Simple Morphological Filter) succeeds. This fallback ensures
processing continues even when auto-detection selects CSF incorrectly.
Args:
laz_file: Path to input LAZ/LAS file.
temp_dir: Directory for temporary files.
laz_base: Base name for the file.
force: If True, reclassify even if output exists.
Returns:
Path to classified ground LAS file, or None on failure.
"""
logger.info(f" → Basculement CSF → SMRF (fallback)")
# Clean up failed CSF output if it exists
csf_output = temp_dir / f"{laz_base}_ground_csf.las"
if csf_output.exists():
csf_output.unlink(missing_ok=True)
output_las = temp_dir / f"{laz_base}_ground_smrf.las"
if output_las.exists() and not force:
logger.info(f" Classification SMRF déjà existante — fichier réutilisé")
return output_las
pipeline_json = _create_ground_pipeline(laz_file, output_las, 'smrf')
pipeline_file = temp_dir / "pipeline_smrf.json"
with open(pipeline_file, 'w') as f:
f.write(pipeline_json)
try:
subprocess.run(
["pdal", "pipeline", str(pipeline_file)],
capture_output=True, check=True
)
if output_las.exists() and output_las.stat().st_size < 100:
logger.error(f" ✗ Fichier ground SMRF vide (taille < 100 octets)")
output_las.unlink(missing_ok=True)
return None
logger.info(f" ✓ Classification sol SMRF terminée (fallback depuis CSF)")
return output_las
except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() if e.stderr else str(e)
logger.error(f" ✗ Échec classification SMRF (fallback): {error_msg}")
return None
def _repair_laz_with_laspy(input_laz, output_las):
"""Try to repair a corrupt LAZ file by re-reading with laspy and saving as LAS.