Écrire le sidecar qualité aussi quand le DTM primaire vient du cache

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 15:25:55 +02:00
parent 7d5b5ecd42
commit 473097257e
2 changed files with 85 additions and 1 deletions

View File

@ -589,6 +589,17 @@ class LidarArchaeoPipeline:
logger.info(f"[1/5] Classification du sol — sautée (DTM existant)") logger.info(f"[1/5] Classification du sol — sautée (DTM existant)")
logger.info(f"[2/5] Génération DTM {res}m/px — sautée (DTM existant)") logger.info(f"[2/5] Génération DTM {res}m/px — sautée (DTM existant)")
self._report(basename, "classif", "skip", "DTM existant") self._report(basename, "classif", "skip", "DTM existant")
# Sidecar qualité (densité sol, dates de vol) pour
# l'encart de l'export PDF : DTM réutilisé depuis le
# cache — la classification sol a été sautée, donc
# mesure directement sur le LAZ d'entrée avec les
# classes IGN du run (ensure_quality ne recalcule
# rien si le sidecar existe déjà : coût en régime
# établi = une lecture JSON).
from .dtm import parse_ign_classes
from .quality import ensure_quality
ensure_quality(laz_file, basename, self.output_dir,
codes=tuple(parse_ign_classes(self.ign_classes)))
else: else:
logger.info(f" DTM {res}m/px déjà existant — ignoré") logger.info(f" DTM {res}m/px déjà existant — ignoré")
self._report(basename, "dtm", "skip", res=res) self._report(basename, "dtm", "skip", res=res)
@ -651,7 +662,9 @@ class LidarArchaeoPipeline:
if i == 0: if i == 0:
# Sidecar qualité (densité sol, dates de vol) pour l'encart de # Sidecar qualité (densité sol, dates de vol) pour l'encart de
# l'export PDF : calculé une fois, même si le DTM vient du cache. # l'export PDF : DTM fraîchement généré — mesure sur le LAS sol
# déjà filtré (le cas DTM réutilisé depuis le cache est couvert
# plus haut, avant le `continue`).
from .quality import ensure_quality from .quality import ensure_quality
ensure_quality(las_file, basename, self.output_dir) ensure_quality(las_file, basename, self.output_dir)

View File

@ -287,6 +287,77 @@ class TestEffectiveGroundMethod:
assert p._effective_ground_method() == "smrf" assert p._effective_ground_method() == "smrf"
class TestQualityCacheHit:
"""Sidecar qualité écrit même quand le DTM primaire est réutilisé depuis
le cache (classification sol sautée, pas de LAS sol disponible)."""
@staticmethod
def _write_las(path, x, y, cls, t):
import laspy
import numpy as np
header = laspy.LasHeader(point_format=6, version="1.4")
header.scales = [0.01, 0.01, 0.01]
header.offsets = [652000.0, 6861000.0, 0.0]
header.global_encoding.gps_time_type = laspy.header.GpsTimeType.STANDARD
las = laspy.LasData(header)
las.x = np.asarray(x, float); las.y = np.asarray(y, float)
las.z = np.zeros(len(x))
las.classification = np.asarray(cls, np.uint8)
las.return_number = np.ones(len(x), np.uint8)
las.number_of_returns = np.ones(len(x), np.uint8)
las.gps_time = np.asarray(t, float)
las.write(str(path))
def test_cache_hit_writes_quality_sidecar(self, tmp_path, monkeypatch):
import numpy as np
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline.pipeline import LidarArchaeoPipeline
import lidar_pipeline.pipeline as pipeline_mod
from lidar_pipeline.dtm import GAP_FILL_TAG, GAP_FILL_VERSION
from lidar_pipeline.quality import read_quality
input_dir = tmp_path / "input"
input_dir.mkdir()
base = "LHD_FXX_0652_6862_PTS_LAMB93_IGN69"
laz = input_dir / f"{base}.laz"
import datetime
epoch = datetime.datetime(1980, 1, 6, tzinfo=datetime.timezone.utc)
gps = (datetime.datetime(2022, 6, 1, 12, tzinfo=datetime.timezone.utc) - epoch).total_seconds() - 1e9
self._write_las(laz, [652100, 652200, 652300], [6861100, 6861200, 6861300],
[2, 2, 6], [gps, gps, gps])
pipeline = LidarArchaeoPipeline(
str(input_dir), str(tmp_path / "output"), ground_method='ign',
ign_classes="sol", strip_align=False, edge_buffer=0.0)
pipeline.viz_steps = [] # aucune visualisation à calculer (hors périmètre)
# DTM déjà en cache, compatible avec la config du run (pas de calage,
# pas de raccord, comblement à la version courante) : le run doit
# emprunter la branche « DTM existant » sans reclassifier ni régénérer.
dtm_path = pipeline.dtm_dir / f"{base}_dtm.tif"
with rasterio.open(
dtm_path, "w", driver="GTiff", height=10, width=10, count=1,
dtype="float32", crs="EPSG:2154",
transform=from_bounds(652000.0, 6861995.0, 652005.0, 6862000.0, 10, 10),
) as dst:
dst.write(np.zeros((10, 10), dtype="float32"), 1)
dst.update_tags(**{GAP_FILL_TAG: GAP_FILL_VERSION})
def boom(*a, **k):
raise AssertionError("cache hit attendu : ne doit pas reclassifier/régénérer le DTM")
monkeypatch.setattr(pipeline_mod, "classify_ground", boom)
monkeypatch.setattr(pipeline_mod, "create_dtm_fast", boom)
assert pipeline.process_file(laz) is True
data = read_quality(pipeline.output_dir, base)
assert data is not None
assert abs(data["ground_density"] - 2e-6) < 1e-9 # 2 points de classe 2 sur 1 km²
assert data["acq_start"] == "2022-06-01" and data["acq_source"] == "gps"
class TestResolveWorkers: class TestResolveWorkers:
def test_auto_scales_with_cpus(self): def test_auto_scales_with_cpus(self):
from lidar_pipeline.pipeline import resolve_workers from lidar_pipeline.pipeline import resolve_workers