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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user