Caler les faisceaux de vol, décimer l'openness et cibler les couches affichées

Trois chantiers liés à la qualité et au coût des rendus :

- Calage vertical des faisceaux : les passes d'une tuile peuvent être biaisées
  de quelques cm (±2,5 cm mesurés sur 1000_6882), créant des marches aux
  recouvrements. Les offsets par PointSourceId sont mesurés sur les points
  sol de la tuile (réf. médiane itérée) et retranchés ≥ 0,5 cm avant
  rastérisation, avec sidecar de cache et application au plancher bare-earth.
- Openness décimée ×2 : lancé de rayons sur grille par blocs (max/min) puis
  rééchantillonnage bilinéaire — 532 s → 40 s par tuile à 0,2 m sur CPU,
  signal archéologique préservé. Réglable --openness-downsample.
- Génération webapp concentrée sur les couches affichées : les défauts
  /api/generate, /api/preview et le sélecteur génèrent le panneau complet
  (slope, aspect, pos_open) au lieu d'aspect seul.
This commit is contained in:
Antoine Jacquin
2026-09-19 00:53:22 +02:00
parent 796e68c870
commit 41206f65de
11 changed files with 455 additions and 25 deletions

View File

@ -55,7 +55,7 @@ class FilePrefixFilter(logging.Filter):
_file_filter = FilePrefixFilter()
from .progress import report_event
from .dtm import classify_ground, create_dtm_fast
from .dtm import classify_ground, create_dtm_fast, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD
from .visualizations import (
SharedDEM,
generate_hillshade, generate_slope, generate_aspect,
@ -109,7 +109,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', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False):
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False, strip_align=True, openness_downsample=None):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
# Accept single float or comma-separated string for multi-resolution
@ -134,6 +134,8 @@ class LidarArchaeoPipeline:
self.gpu_ids = gpu_ids
self.no_index = no_index
self.incremental_index = incremental_index
self.strip_align = strip_align
self.openness_downsample = openness_downsample
self._last_index_rebuild = 0.0
self.temp_dir = self.output_dir / "temp"
@ -368,6 +370,27 @@ class LidarArchaeoPipeline:
recorded = self._dtm_method_name(basename, res_suffix)
return recorded is None or recorded == self._effective_ground_method()
def _strip_align_matches(self, basename, res_suffix):
"""True si le sidecar de calage des faisceaux correspond à la config.
Un DTM sans sidecar (antérieur au calage) est régénéré pour mesurer
et consigner ses offsets ; un sidecar de version ou de seuil
différents aussi. Calage désactivé : tout DTM porteur d'un sidecar
(donc calé) est régénéré non calé.
"""
sidecar = self.dtm_dir / f"{basename}_dtm{res_suffix}_stripalign.json"
if not self.strip_align:
return not sidecar.exists()
if not sidecar.exists():
return False
try:
import json
data = json.loads(sidecar.read_text(encoding="utf-8"))
return (data.get("version") == STRIP_ALIGN_VERSION
and abs(float(data.get("threshold", -1)) - STRIP_ALIGN_THRESHOLD) < 1e-9)
except Exception:
return False
def _write_dtm_method(self, basename, res_suffix):
"""Record the ground classification method used to build a DTM."""
try:
@ -416,7 +439,10 @@ class LidarArchaeoPipeline:
res_suffix = self._res_suffix(res)
dtm_path = self.dtm_dir / f"{basename}_dtm{res_suffix}.tif"
if dtm_path.exists() and not self.force_classify:
if method_matches:
if not self._strip_align_matches(basename, res_suffix):
logger.info(f" DTM{res_suffix} sans calage de faisceaux conforme — régénération (offsets verticaux mesurés et appliqués)")
dtm_path.unlink()
elif method_matches:
import rasterio
try:
with rasterio.open(dtm_path) as src:
@ -468,7 +494,8 @@ class LidarArchaeoPipeline:
output_suffix=res_suffix,
source_laz=laz_file,
bare_earth=self.bare_earth,
pure=pure_ign)
pure=pure_ign,
strip_align=self.strip_align)
t_dtm = time.time() - t2
if not dtm_file:
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
@ -483,6 +510,12 @@ class LidarArchaeoPipeline:
self._write_dtm_method(basename, res_suffix)
# Process each resolution: visualizations + PDF
# Option de calcul (surcharge le défaut du module) appliquée ICI car les
# workers (spawn) réimportent les modules à froid : c'est le seul endroit
# qui s'exécute dans le processus qui fait le calcul.
if self.openness_downsample is not None:
from . import visualizations as _viz_mod
_viz_mod.OPENNESS_DOWNSAMPLE = max(1, int(self.openness_downsample))
all_vis_results = {}
for res in self.resolutions:
res_suffix = self._res_suffix(res)
@ -580,7 +613,7 @@ class LidarArchaeoPipeline:
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids()
resolutions_str = ','.join(str(r) for r in self.resolutions)
future_to_file = {
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.ign_classes, self.force_classify, self.keep_tif, self.bare_earth, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None): laz_file
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.ign_classes, self.force_classify, self.keep_tif, self.bare_earth, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None, self.openness_downsample): laz_file
for file_idx, laz_file in enumerate(files)
}
done = 0
@ -682,7 +715,7 @@ class LidarArchaeoPipeline:
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', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None):
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None):
"""Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts.
@ -709,7 +742,7 @@ 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, ign_classes=ign_classes, force_classify=force_classify, keep_tif=keep_tif, bare_earth=bare_earth, quality=quality, only_viz=only_viz, skip_viz=skip_viz, output_format=output_format)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, ign_classes=ign_classes, force_classify=force_classify, keep_tif=keep_tif, bare_earth=bare_earth, quality=quality, only_viz=only_viz, skip_viz=skip_viz, output_format=output_format, openness_downsample=openness_downsample)
basename = _file_basename(laz_file_str)
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
pipeline.temp_dir.mkdir(exist_ok=True)