From 473097257e045711b551188902f4a88ae6cf288c Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 15:25:55 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89crire=20le=20sidecar=20qualit=C3=A9=20aus?= =?UTF-8?q?si=20quand=20le=20DTM=20primaire=20vient=20du=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- lidar_pipeline/pipeline.py | 15 +++++- lidar_pipeline/tests/test_pipeline.py | 71 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index 43f9777..fc12322 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -589,6 +589,17 @@ class LidarArchaeoPipeline: 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)") 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: logger.info(f" DTM {res}m/px déjà existant — ignoré") self._report(basename, "dtm", "skip", res=res) @@ -651,7 +662,9 @@ class LidarArchaeoPipeline: if i == 0: # 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 ensure_quality(las_file, basename, self.output_dir) diff --git a/lidar_pipeline/tests/test_pipeline.py b/lidar_pipeline/tests/test_pipeline.py index 21e9d25..cd6966a 100644 --- a/lidar_pipeline/tests/test_pipeline.py +++ b/lidar_pipeline/tests/test_pipeline.py @@ -287,6 +287,77 @@ class TestEffectiveGroundMethod: 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: def test_auto_scales_with_cpus(self): from lidar_pipeline.pipeline import resolve_workers