Raccorder les bords de tuiles voisines et encadrer les rendus en cours

Le MNT de chaque dalle s'étend d'une bande de 100 m remplie avec les
points sol des 8 tuiles voisines (option « Raccord des bords ») : les
rendus à grand noyau (openness, SVF, LRM) deviennent continus d'une
dalle à l'autre, les images restent recadrées sur le kilomètre exact.

Pendant une génération, la carte encadre les dalles du run : orange
pulsant en cours de rendu, rouge en échec — les coins WGS84 sont portés
par /api/status pour toutes les dalles non terminées.

La file de génération trie les dalles du nord au sud et passe à 10
workers GPU.
This commit is contained in:
Antoine Jacquin
2026-09-19 20:44:44 +02:00
parent 204b2ad111
commit 5f018fe2b6
15 changed files with 1075 additions and 247 deletions

View File

@ -55,7 +55,8 @@ class FilePrefixFilter(logging.Filter):
_file_filter = FilePrefixFilter()
from .progress import report_event
from .dtm import classify_ground, create_dtm_fast, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD
from .dtm import (classify_ground, create_dtm_fast, STRIP_ALIGN_VERSION,
STRIP_ALIGN_THRESHOLD, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
from .visualizations import (
SharedDEM,
generate_hillshade, generate_slope, generate_aspect,
@ -109,7 +110,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, strip_align=True, openness_downsample=None):
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, 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, edge_buffer=0.0):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
# Accept single float or comma-separated string for multi-resolution
@ -125,7 +126,6 @@ class LidarArchaeoPipeline:
self.ground_method = ground_method
self.ign_classes = ign_classes
self.force_classify = force_classify
self.bare_earth = bare_earth
self.keep_tif = keep_tif
self.quality = quality
self.only_viz = only_viz
@ -136,6 +136,7 @@ class LidarArchaeoPipeline:
self.incremental_index = incremental_index
self.strip_align = strip_align
self.openness_downsample = openness_downsample
self.edge_buffer = float(edge_buffer)
self._last_index_rebuild = 0.0
self.temp_dir = self.output_dir / "temp"
@ -178,6 +179,8 @@ class LidarArchaeoPipeline:
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'}")
if self.edge_buffer > 0:
logger.info(f" Raccord bords: {self.edge_buffer:g} m (points sol des tuiles voisines)")
logger.info(f" Qualité {self.output_format.upper()}: {self.quality if self.quality < 100 else 'lossless'}")
if only_viz:
logger.info(f" Visualisations: uniquement {', '.join(only_viz)}")
@ -186,12 +189,29 @@ class LidarArchaeoPipeline:
logger.info(f" Visualisations: {len(self.viz_steps)}/{len(VIZ_STEPS)}")
def find_laz_files(self):
"""Find all LAZ/LAS files in input directory."""
"""Find all LAZ/LAS files in input directory, triés du nord au sud.
Les tuiles LHD_FXX_{col}_{row} sont ordonnées par ligne décroissante
(row = nord en km) puis colonne croissante : les workers prennent les
fichiers dans l'ordre de soumission, la carte se remplit ainsi du nord
vers le sud lors des passes globales. Les fichiers hors pattern LHD
restent triés par nom, en fin de liste.
"""
from .index import parse_basename_coords
files = list(self.input_dir.glob("*.laz")) + list(self.input_dir.glob("*.las"))
logger.info(f"{len(files)} fichier(s) LiDAR trouvé(s)")
for f in sorted(files):
def _north_key(f):
coords = parse_basename_coords(_file_basename(f))
if coords:
col, row = coords
return (0, -row, col, f.name)
return (1, 0, 0, f.name)
files.sort(key=_north_key)
logger.info(f"{len(files)} fichier(s) LiDAR trouvé(s) — triés du nord au sud")
for f in files:
logger.debug(f" {f.name}")
return sorted(files)
return files
def check_tools(self):
"""Check that required external tools are available."""
@ -374,9 +394,9 @@ class LidarArchaeoPipeline:
"""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é.
et consigner ses offsets ; un sidecar de version, de seuil ou de
paramètres de gigue intra-faisceau 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:
@ -387,7 +407,9 @@ class LidarArchaeoPipeline:
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)
and abs(float(data.get("threshold", -1)) - STRIP_ALIGN_THRESHOLD) < 1e-9
and abs(float(data.get("jitter_bin", -1)) - STRIP_JITTER_BIN) < 1e-9
and int(data.get("jitter_smooth", -1)) == STRIP_JITTER_SMOOTH)
except Exception:
return False
@ -399,6 +421,56 @@ class LidarArchaeoPipeline:
except Exception:
pass
def _edge_buffer_matches(self, dtm_path):
"""True si le tampon de raccord du DTM correspond à la config.
Le tampon est inscrit dans le tag GeoTIFF LIDAR_EDGE_BUFFER (dtm.py).
Un DTM sans tag (antérieur au raccord) compte comme tampon 0 :
activer --edge-buffer régénère donc les DTM en cache, le désactiver
régénère les DTM raccordés.
"""
from .dtm import read_dtm_edge_buffer
return abs(read_dtm_edge_buffer(dtm_path) - self.edge_buffer) < 1e-6
def _fetch_edge_neighbors(self, files):
"""Télécharge les dalles LAZ voisines manquantes (raccord des bords).
La bande de raccord lit les 8 LAZ adjacentes de chaque tuile ; celles
absentes de input/ sont téléchargées depuis le catalogue IGN avant de
lancer les workers, pour que la bande soit remplie jusqu'au bord de la
zone même pour des tuiles jamais rendues. La liste est dédupliquée sur
tout le lot : la couronne d'un bloc contigu ne coûte qu'un passage.
Une dalle introuvable (zone non publiée) ou en échec laisse simplement
la bande vide — le rendu continue (best-effort).
"""
if self.edge_buffer <= 0:
return
from .dtm import _tile_coords, _NEIGHBOR_OFFSETS
from .fetch_ign import fetch_tiles, tile_filename
wanted = set()
for laz_file in files:
coords = _tile_coords(Path(laz_file).name)
if coords is None:
continue
col, row = coords
for dcol, drow in _NEIGHBOR_OFFSETS:
wanted.add((col + dcol, row + drow))
missing = sorted(
cr for cr in wanted
if not (self.input_dir / tile_filename(cr[0], cr[1])).exists())
if not missing:
return
logger.info(f"Raccord des bords : {len(missing)} dalle(s) voisine(s) "
f"absente(s) de input/ — téléchargement depuis le catalogue IGN")
t0 = time.time()
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
fetched = [path for batch in pool.map(
lambda spec: fetch_tiles(self.input_dir, [spec]), missing)
for path in batch]
logger.info(f"Raccord des bords : {len(fetched)}/{len(missing)} dalle(s) "
f"voisine(s) téléchargée(s) ({time.time() - t0:.0f} s)")
def _report(self, basename, phase, state, detail=None, res=None):
"""Émet un événement de progression (file de génération, best-effort)."""
report_event(self.output_dir, basename, phase, state, detail=detail, res=res)
@ -442,6 +514,12 @@ class LidarArchaeoPipeline:
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 not self._edge_buffer_matches(dtm_path):
from .dtm import read_dtm_edge_buffer
recorded = read_dtm_edge_buffer(dtm_path)
logger.info(f" DTM{res_suffix} avec raccord de {recorded:g} m ≠ {self.edge_buffer:g} m "
f"demandé — régénération (bande de bord des tuiles voisines)")
dtm_path.unlink()
elif method_matches:
import rasterio
try:
@ -487,15 +565,21 @@ class LidarArchaeoPipeline:
self._report(basename, "dtm", "start", res=res)
t2 = time.time()
# Classification IGN → mode pur : DTM = rasterisation brute des
# classes choisies, sans plancher ni comblement (sauf --bare-earth)
# classes choisies, sans plancher ni comblement
pure_ign = "_ground_ign" in Path(las_file).name
# Classes des voisines pour la bande de raccord : mêmes classes IGN
# que le MNT (les voisines sont lues dans leur pré-classification
# fournisseur, quelle que soit la méthode de la tuile centrale).
from .dtm import parse_ign_classes
neighbor_codes = parse_ign_classes(self.ign_classes)
dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, res,
force=self.force or self.force_classify,
output_suffix=res_suffix,
source_laz=laz_file,
bare_earth=self.bare_earth,
pure=pure_ign,
strip_align=self.strip_align)
strip_align=self.strip_align,
edge_buffer=self.edge_buffer,
neighbor_classes=neighbor_codes)
t_dtm = time.time() - t2
if not dtm_file:
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
@ -600,6 +684,10 @@ class LidarArchaeoPipeline:
if self.gpu_ids is not None:
restrict_gpus(self.gpu_ids)
# Raccord des bords : pré-télécharger les voisines manquantes avant
# les workers (no-op si le raccord est désactivé).
self._fetch_edge_neighbors(files)
if self.workers > 1 and len(files) > 1:
n_gpus = num_gpus() or 1
if n_gpus > 1:
@ -613,7 +701,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, self.openness_downsample): 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.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, self.edge_buffer): laz_file
for file_idx, laz_file in enumerate(files)
}
done = 0
@ -715,7 +803,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, openness_downsample=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, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None, edge_buffer=0.0):
"""Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts.
@ -742,7 +830,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, openness_downsample=openness_downsample)
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, quality=quality, only_viz=only_viz, skip_viz=skip_viz, output_format=output_format, openness_downsample=openness_downsample, edge_buffer=edge_buffer)
basename = _file_basename(laz_file_str)
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
pipeline.temp_dir.mkdir(exist_ok=True)