Layout uniforme WebP: axes fixes + aspect='equal' pour superposition géolocalisée

- Positions d'axes fixes (data_left/bottom/width/height_frac) pour alignement
  pixel-parfait entre terrain et ortho/topo
- aspect='equal' au lieu de 'auto' pour conserver les proportions géographiques
- Colorbar descriptive pour les visualisations RGB (ortho/topo)
- Comblage des petits trous DTM (< 1m) via rasterio.fill.fillnodata
- Suppression de la visualisation "dépressions"
- Hillshade composite: 0.7*hillshade + 0.3*cos(slope)
- D8 flow accumulation accéléré par numba JIT (fallback Python)
- Flag --keep-tif pour conserver les TIFF intermédiaires
- --force supprime aussi les TIF existants avant régénération
- ETA affiché pendant la génération des visualisations
- Répertoires temp dans temp/ pour traitement parallèle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-10 14:46:31 +02:00
parent e31d3f0e2b
commit 2986400a0a
12 changed files with 243 additions and 151 deletions

View File

@ -3,7 +3,7 @@
LidarArchaeoPipeline coordinates the full processing chain:
1. Ground classification (PDAL/SMRF)
2. DTM generation
3. Visualization generation (18 products)
3. Visualization generation (17 products)
4. Rendering (WebP + PDF report)
"""
@ -57,7 +57,7 @@ from .dtm import classify_ground, create_dtm_fast
from .visualizations import (
generate_hillshade, generate_slope, generate_aspect, generate_curvature,
generate_lrm, generate_svf, generate_openness,
generate_mslrm, generate_tpi, generate_depressions, generate_sailore,
generate_mslrm, generate_tpi, generate_sailore,
generate_roughness, generate_anomalies, generate_wavelet,
generate_flow,
)
@ -80,7 +80,6 @@ VIZ_STEPS = [
('neg_open', lambda d, b, v, r: generate_openness(d, b, v, r, positive=False)),
('mslrm', generate_mslrm),
('tpi', generate_tpi),
('depressions', generate_depressions),
('sailore', generate_sailore),
('roughness', generate_roughness),
('anomalies', generate_anomalies),
@ -106,7 +105,7 @@ VIZ_STEPS = [
class LidarArchaeoPipeline:
"""Orchestrates the LiDAR archaeological analysis pipeline."""
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False):
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False, keep_tif=False):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
self.resolution = resolution
@ -114,6 +113,7 @@ class LidarArchaeoPipeline:
self.force = force
self.ground_method = ground_method
self.force_classify = force_classify
self.keep_tif = keep_tif
self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists():
@ -137,6 +137,7 @@ class LidarArchaeoPipeline:
logger.info(f" Force : {'OUI' if self.force else 'non (skip existing)'}")
logger.info(f" Classification sol : {self.ground_method}")
logger.info(f" Force classif.: {'OUI' if self.force_classify else 'non'}")
logger.info(f" Keep TIFF : {'OUI' if self.keep_tif else 'non'}")
def find_laz_files(self):
"""Find all LAZ/LAS files in input directory."""
@ -171,8 +172,24 @@ class LidarArchaeoPipeline:
vis_results = {}
total = len(VIZ_STEPS)
elapsed_times = []
for idx, (name, func) in enumerate(VIZ_STEPS, 1):
# When --force, delete existing TIF to ensure clean regeneration
if self.force:
for tif in file_vis_dir.glob(f"{basename}_{name}.tif"):
tif.unlink(missing_ok=True)
# Special cases for differently-named TIFs
if name == 'pos_open':
for tif in file_vis_dir.glob(f"{basename}_positive_openness.tif"):
tif.unlink(missing_ok=True)
elif name == 'neg_open':
for tif in file_vis_dir.glob(f"{basename}_negative_openness.tif"):
tif.unlink(missing_ok=True)
elif name == 'hillshade':
for tif in file_vis_dir.glob(f"{basename}_hillshade_multi.tif"):
tif.unlink(missing_ok=True)
# Check if output WebP already exists (skip unless --force)
if not self.force:
# Determine expected WebP filename from the viz name
@ -199,8 +216,14 @@ class LidarArchaeoPipeline:
result = func(dtm_file, basename, file_vis_dir, self.resolution)
vis_results[name] = result
elapsed = time.time() - t0
elapsed_times.append(elapsed)
if result:
logger.info(f" [{idx}/{total}] ✓ {name} ({elapsed:.1f}s)")
eta = ""
if len(elapsed_times) > 1:
avg_time = sum(elapsed_times) / len(elapsed_times)
remaining = (total - idx) * avg_time
eta = f" — ETA: {remaining:.0f}s"
logger.info(f" [{idx}/{total}] ✓ {name} ({elapsed:.1f}s){eta}")
else:
logger.warning(f" [{idx}/{total}] ✗ {name} — no output ({elapsed:.1f}s)")
except Exception as e:
@ -214,7 +237,7 @@ class LidarArchaeoPipeline:
logger.info(" Conversion images WebP:")
for name, tif_file in vis_results.items():
if tif_file and isinstance(tif_file, Path) and tif_file.suffix == '.tif' and tif_file.exists():
webp_file = tif_to_png(tif_file, file_vis_dir, self.resolution)
webp_file = tif_to_png(tif_file, file_vis_dir, self.resolution, keep_tif=self.keep_tif)
if webp_file:
logger.info(f"{webp_file.name}")
@ -293,7 +316,7 @@ class LidarArchaeoPipeline:
logger.info(f"Fichiers: {len(files)}")
with ProcessPoolExecutor(max_workers=self.workers) as executor:
future_to_file = {
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), self.resolution, self.force, self.ground_method, self.force_classify): laz_file
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), self.resolution, self.force, self.ground_method, self.force_classify, self.keep_tif): laz_file
for laz_file in files
}
done = 0
@ -346,16 +369,16 @@ class LidarArchaeoPipeline:
try:
if self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
# Also clean up per-file temp directories from parallel workers
for d in self.output_dir.glob("temp_*"):
if d.is_dir():
shutil.rmtree(d, ignore_errors=True)
# Also clean up any subdirectories inside temp/
temp_base = self.output_dir / "temp"
if temp_base.exists():
shutil.rmtree(temp_base)
logger.info(" ✓ Fichiers temporaires supprimés")
except Exception as e:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', force_classify=False):
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', force_classify=False, keep_tif=False):
"""Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts.
@ -371,9 +394,9 @@ def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, fo
worker_logger.addHandler(handler)
worker_logger.addFilter(_file_filter)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, force_classify=force_classify)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, force_classify=force_classify, keep_tif=keep_tif)
basename = _file_basename(laz_file_str)
pipeline.temp_dir = pipeline.output_dir / f"temp_{basename}"
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
pipeline.temp_dir.mkdir(exist_ok=True)
laz_file = Path(laz_file_str)
result = pipeline.process_file(laz_file)