"""Tests for DTM module.""" import json import numpy as np import pytest from pathlib import Path from unittest.mock import patch, MagicMock class TestSMRFPipeline: def test_pipeline_json_valid(self): """create_smrf_pipeline produces valid JSON with expected stages.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/data/input/test.laz", "/data/output/test_ground.las") pipeline = json.loads(result) assert "pipeline" in pipeline stages = pipeline["pipeline"] # Should have: reader, range filter (ReturnNumber), assign, ELM, outlier, SMRF, range filter (Classification), writer stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] # First stage is the filename string (reader) assert isinstance(stages[0], str) assert "test.laz" in stages[0] # Must contain preprocessing steps assert "filters.assign" in stage_types assert "filters.elm" in stage_types assert "filters.outlier" in stage_types # Must contain SMRF filter assert "filters.smrf" in stage_types # Must contain ReturnNumber filter range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] assert len(range_stages) >= 1 # At least one should filter ReturnNumber assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) def test_pipeline_elm_parameters(self): """ELM filter has terrain-adapted parameters.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) elm_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.elm"][0] assert elm_stage["cell"] == 5.0 assert elm_stage["threshold"] == 2.0 def test_pipeline_outlier_parameters(self): """Outlier filter uses statistical method.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) outlier_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.outlier"][0] assert outlier_stage["method"] == "statistical" assert outlier_stage["mean_k"] == 8 assert outlier_stage["multiplier"] == 3.0 def test_pipeline_output_path(self): """Pipeline output path is set correctly.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) # Last stage should be writer with correct output path writer = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "writers.las"][0] assert writer["filename"] == "/output/a_ground.las" class TestCSFPipeline: def test_pipeline_json_valid(self): """create_csf_pipeline produces valid JSON with CSF filter.""" from lidar_pipeline.dtm import create_csf_pipeline result = create_csf_pipeline("/data/input/test.laz", "/data/output/test_ground.las") pipeline = json.loads(result) assert "pipeline" in pipeline stages = pipeline["pipeline"] stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] # Must contain CSF filter assert "filters.csf" in stage_types # Must contain ReturnNumber filter range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) def test_csf_parameters(self): """CSF pipeline has expected parameters.""" from lidar_pipeline.dtm import create_csf_pipeline result = create_csf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) csf_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.csf"][0] assert csf_stage["resolution"] == 1.0 # cloth 1 m : ~4× plus rapide, MNT inchangé assert csf_stage["rigidness"] == 3 assert csf_stage["smooth"] is True assert "hdiff" not in csf_stage # hdiff is not a valid PDAL CSF parameter class TestInterpolateHoles: def test_fills_interior_hole_with_surface(self): """Large interior NaN hole is filled (no NaN left, value is plausible).""" from lidar_pipeline.dtm import _interpolate_holes # Linear-in-column surface z = 0.02 * x, with a large square hole in the middle. x = np.arange(40, dtype=float) * 0.02 dtm = np.tile(x, (40, 1)) dtm[16:24, 16:24] = np.nan filled, count = _interpolate_holes(dtm) assert count == 64 assert not np.isnan(filled).any() # Filled values stay within the surrounding z range (no wild extrapolation). zmin, zmax = np.nanmin(dtm), np.nanmax(dtm) hole_vals = filled[16:24, 16:24] assert np.all(hole_vals >= zmin - 1e-6) assert np.all(hole_vals <= zmax + 1e-6) # A linear surface is interpolated near-exactly in the interior. expected = np.tile(x[16:24], (8, 1)) assert np.allclose(hole_vals, expected, atol=0.02) # Original valid cells are untouched. valid = ~np.isnan(dtm) assert np.allclose(filled[valid], dtm[valid]) def test_no_holes_returns_unchanged(self): """No NaN → returns same array and zero count.""" from lidar_pipeline.dtm import _interpolate_holes dtm = np.arange(64, dtype=float).reshape(8, 8) filled, count = _interpolate_holes(dtm) assert count == 0 assert np.shares_memory(filled, dtm) def test_all_nan_returns_unchanged(self): """No valid data → cannot interpolate, returns zeros-free NaN array.""" from lidar_pipeline.dtm import _interpolate_holes dtm = np.full((8, 8), np.nan) filled, count = _interpolate_holes(dtm) assert count == 0 assert np.isnan(filled).all() class TestMinReturnGrid: def test_takes_lowest_return_per_cell(self, tmp_output_dir): """_min_return_grid rasterise le point le plus bas par cellule (pas la moyenne).""" import laspy from lidar_pipeline.dtm import _min_return_grid out = tmp_output_dir / "pts.las" hdr = laspy.LasHeader(version='1.2', point_format=0) las = laspy.LasData(hdr) # Grille 2x2 sur [0,2]x[0,2]. La cellule (0,0) porte deux points z=5 et # z=2 (min=2, moyenne=3.5) ; (1,0) z=3 ; (0,1) z=4 ; (1,1) vide. las.x = [0.2, 0.5, 1.2, 0.3] las.y = [0.2, 0.3, 0.4, 1.5] las.z = [5.0, 2.0, 3.0, 4.0] las.write(str(out)) grid = _min_return_grid(out, 2, 2, (0.0, 0.0, 2.0, 2.0)) assert grid.shape == (2, 2) assert int(np.isnan(grid).sum()) == 1 # Une seule valeur par cellule, et la cellule (0,0) vaut le MIN (2.0). vals = sorted(float(v) for v in grid[~np.isnan(grid)]) assert vals == [2.0, 3.0, 4.0] assert 3.5 not in vals class TestBareEarth: """Le plancher « sol nu » ramène le DTM au retour le plus bas de chaque cellule.""" def _write_las(self, path, points): """points: list of (x, y, z). Écrit un LAS 1.2 format 0 aux bornes [0,2]x[0,2].""" import laspy hdr = laspy.LasHeader(version='1.2', point_format=0) las = laspy.LasData(hdr) las.x = [p[0] for p in points] las.y = [p[1] for p in points] las.z = [p[2] for p in points] las.write(str(path)) return path def _make_clouds(self, tmp_output_dir): """Grille 2x2 (res=1.0). Les points « coin » à 0.05/1.95 imposent l'étendue [0.05,1.95] (laspy re-déduit les bornes de l'en-tête depuis les points). Le sol (las_file) vaut z=10 partout. Le nuage complet (source_laz) a un retour plus bas dans les cellules (0,0) -> 2 et (1,0) -> 5 ; les deux autres cellules n'ont que z=10.""" ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0), (1.5, 1.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)] source = list(ground) + [(0.3, 0.3, 2.0), (1.3, 0.3, 5.0)] las_file = self._write_las(tmp_output_dir / "ground.las", ground) source_laz = self._write_las(tmp_output_dir / "source.las", source) return las_file, source_laz def _dtm_values(self, tmp_output_dir, bare_earth): from lidar_pipeline.dtm import create_dtm_fast import rasterio las_file, source_laz = self._make_clouds(tmp_output_dir) out = create_dtm_fast(las_file, "tile", tmp_output_dir, 1.0, force=True, source_laz=source_laz, bare_earth=bare_earth) assert out is not None with rasterio.open(str(out)) as src: arr = src.read(1).astype("float64") return arr[~np.isnan(arr)] def test_bare_earth_pulls_dtm_to_lowest_return(self, tmp_output_dir): """Avec bare_earth, le DTM descend aux retours les plus bas (2 et 5).""" vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=True)) # Les cellules sans retour plus bas restent à 10 ; les deux autres descendent. assert vals == [2.0, 5.0, 10.0, 10.0] assert vals[0] == 2.0 and vals[1] == 5.0 def test_no_bare_earth_keeps_mean(self, tmp_output_dir): """Sans bare_earth, le DTM garde la moyenne des points sol (10 partout).""" vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=False)) assert all(v == 10.0 for v in vals) class TestDetectGroundMethod: def _make_mock_las(self, num_returns, z_values): """Create a mock laspy object with specified NumberOfReturns and z.""" mock_las = MagicMock() mock_las.NumberOfReturns = np.array(num_returns) mock_las.z = np.array(z_values) mock_las.points = MagicMock() mock_las.points.__len__ = lambda self: len(num_returns) return mock_las @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_urban_terrain_returns_csf(self, mock_read, mock_pdal): """High single-return ratio (>0.6) should select CSF.""" from lidar_pipeline.dtm import detect_ground_method # 70% single returns = urban n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.3)] = 2 # 30% multi-return z_values = np.random.normal(100, 5, n) # Low variance = flat terrain mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'csf' @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_natural_terrain_returns_smrf(self, mock_read, mock_pdal): """Low single-return ratio and moderate variance should select SMRF.""" from lidar_pipeline.dtm import detect_ground_method # 40% single returns, moderate variance n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.6)] = 2 # 60% multi-return (forest) z_values = np.random.normal(100, 15, n) # Moderate variance mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'smrf' @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_mountainous_terrain_returns_csf(self, mock_read, mock_pdal): """High variance terrain (>30m std) selects CSF for complex terrain.""" from lidar_pipeline.dtm import detect_ground_method # Moderate single-return ratio but very high height variance n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.5)] = 2 z_values = np.random.normal(100, 50, n) # Very high variance = mountainous mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'csf' class TestIGNPipeline: def test_pipeline_keeps_supplier_classification(self): """create_ign_pipeline réutilise la pré-classification (classe 2) sans refiltrer.""" from lidar_pipeline.dtm import create_ign_pipeline result = create_ign_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) stages = pipeline["pipeline"] stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] # Aucun algorithme de classification, pas de remise à zéro, pas de filtres de bruit assert "filters.smrf" not in stage_types assert "filters.csf" not in stage_types assert "filters.assign" not in stage_types assert "filters.elm" not in stage_types assert "filters.outlier" not in stage_types # Filtre ReturnNumber conservé + extraction des points classe 2 range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) assert any(s.get("limits") == "Classification[2:2]" for s in range_stages) writer = [s for s in stages if isinstance(s, dict) and s.get("type") == "writers.las"][0] assert writer["filename"] == "/output/a_ground.las" class TestDetectIGN: def _make_mock_las(self, classification, num_returns, z_values): mock_las = MagicMock() mock_las.classification = classification mock_las.NumberOfReturns = np.array(num_returns) mock_las.z = np.array(z_values) mock_las.points = MagicMock() mock_las.points.__len__ = lambda self: len(num_returns) return mock_las @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_preclassified_returns_ign(self, mock_read, mock_pdal): """Fichier pré-classifié (majorité classe 2) → méthode IGN.""" from lidar_pipeline.dtm import detect_ground_method n = 10000 num_returns = np.ones(n, dtype=int) cls = np.zeros(n, dtype=np.uint8) cls[int(n * 0.15):] = 2 # 85 % de points classe 2 z_values = np.random.normal(100, 5, n) mock_read.return_value = self._make_mock_las(cls, num_returns, z_values) assert detect_ground_method(Path("/data/input/test.laz")) == 'ign' @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_unclassified_falls_back_to_smrf_or_csf(self, mock_read, mock_pdal): """Sans classification exploitable → détection SMRF/CSF classique.""" from lidar_pipeline.dtm import detect_ground_method n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.6)] = 2 # 60 % multi-retours (forêt) → non urbain cls = np.zeros(n, dtype=np.uint8) # aucun point classe 2 z_values = np.random.normal(100, 5, n) mock_read.return_value = self._make_mock_las(cls, num_returns, z_values) assert detect_ground_method(Path("/data/input/test.laz")) == 'smrf' class TestClassifyGroundMethod: @patch('lidar_pipeline.dtm.subprocess') def test_classify_ground_auto_calls_detect(self, mock_subprocess): """classify_ground with method='auto' should call detect_ground_method.""" from lidar_pipeline.dtm import classify_ground # Mock detect_ground_method to return 'csf' with patch('lidar_pipeline.dtm.detect_ground_method', return_value='csf') as mock_detect: mock_subprocess.run.return_value = MagicMock(returncode=0) result = classify_ground(Path("/data/input/test.laz"), Path("/tmp"), method='auto') mock_detect.assert_called_once() @patch('lidar_pipeline.dtm.subprocess') def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_subprocess): """classify_ground with method='smrf' should create SMRF pipeline.""" from lidar_pipeline.dtm import classify_ground, _create_ground_pipeline mock_subprocess.run.return_value = MagicMock(returncode=0) with patch('lidar_pipeline.dtm.detect_ground_method'): # Create temp dir import tempfile with tempfile.TemporaryDirectory() as tmpdir: result = classify_ground(Path("/data/input/test.laz"), Path(tmpdir), method='smrf') # Check the pipeline JSON was written with SMRF pipeline_file = Path(tmpdir) / "pipeline_smrf.json" if pipeline_file.exists(): pipeline = json.loads(pipeline_file.read_text()) stage_types = [s.get("type") if isinstance(s, dict) else None for s in pipeline["pipeline"]] assert "filters.smrf" in stage_types @patch('lidar_pipeline.dtm.subprocess') def test_classify_ground_csf_uses_csf_pipeline(self, mock_subprocess): """classify_ground with method='csf' should create CSF pipeline.""" from lidar_pipeline.dtm import classify_ground mock_subprocess.run.return_value = MagicMock(returncode=0) with patch('lidar_pipeline.dtm.detect_ground_method'): import tempfile with tempfile.TemporaryDirectory() as tmpdir: result = classify_ground(Path("/data/input/test.laz"), Path(tmpdir), method='csf') pipeline_file = Path(tmpdir) / "pipeline_csf.json" if pipeline_file.exists(): pipeline = json.loads(pipeline_file.read_text()) stage_types = [s.get("type") if isinstance(s, dict) else None for s in pipeline["pipeline"]] assert "filters.csf" in stage_types class TestParseIgnClasses: def test_default_sol(self): """'sol' → code 2 seul.""" from lidar_pipeline.dtm import parse_ign_classes assert parse_ign_classes("sol") == [2] def test_names_sorted_dedup(self): """Noms acceptés (EN/FR), triés et dédupliqués.""" from lidar_pipeline.dtm import parse_ign_classes assert parse_ign_classes("sol,unclassified") == [1, 2] assert parse_ign_classes("unclassified,sol") == [1, 2] assert parse_ign_classes("non-classe") == [1] assert parse_ign_classes("sol,2") == [2] def test_numeric_codes(self): """Codes LAS directs, triés.""" from lidar_pipeline.dtm import parse_ign_classes assert parse_ign_classes("2,1") == [1, 2] assert parse_ign_classes("66") == [66] def test_invalid_raises(self): """Nom inconnu, code hors bornes ou liste vide → ValueError.""" from lidar_pipeline.dtm import parse_ign_classes with pytest.raises(ValueError): parse_ign_classes("foo") with pytest.raises(ValueError): parse_ign_classes("300") with pytest.raises(ValueError): parse_ign_classes("") def test_method_label(self): """'ign' seul pour le sol, combinaison encodée sinon (invalidation cache).""" from lidar_pipeline.dtm import ign_method_label assert ign_method_label([2]) == "ign" assert ign_method_label([1, 2]) == "ign_1_2" assert ign_method_label([2, 1]) == "ign_1_2" class TestIGNPipelineMultiClasses: def test_multi_class_limits(self): """Plusieurs classes → plages OU logiques sur Classification.""" from lidar_pipeline.dtm import _create_ground_pipeline result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las", 'ign', ign_codes=[1, 2]) pipeline = json.loads(result) range_stages = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.range"] limits = [str(s.get("limits", "")) for s in range_stages] assert any("Classification[1:1]" in l and "Classification[2:2]" in l for l in limits) def test_default_sol_only(self): """Sans ign_codes, la voie IGN reste sol seul (2) — rétrocompatible.""" from lidar_pipeline.dtm import _create_ground_pipeline result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las", 'ign') pipeline = json.loads(result) range_stages = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.range"] limits = [str(s.get("limits", "")) for s in range_stages] assert any("Classification[2:2]" in l and "Classification[1:1]" not in l for l in limits) class TestClassifyGroundIgnClasses: @patch('lidar_pipeline.dtm.subprocess') def test_ign_classes_encoded_in_filenames(self, mock_subprocess): """--ign-classes sol,unclassified → fichiers ign_1_2 + filtre multi-classes.""" import tempfile from lidar_pipeline.dtm import classify_ground mock_subprocess.run.return_value = MagicMock(returncode=0) with tempfile.TemporaryDirectory() as tmpdir: tmpdir = Path(tmpdir) classify_ground(Path("/data/input/test.laz"), tmpdir, method='ign', ign_classes="sol,unclassified") pipeline_file = tmpdir / "pipeline_ign_1_2.json" assert pipeline_file.exists() pipeline = json.loads(pipeline_file.read_text()) limits = [str(s.get("limits", "")) for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.range"] assert any("Classification[1:1]" in l and "Classification[2:2]" in l for l in limits) @patch('lidar_pipeline.dtm.subprocess') def test_ign_default_label_unchanged(self, mock_subprocess): """--ign-classes sol (défaut) → noms 'ign' inchangés (cache préservé).""" import tempfile from lidar_pipeline.dtm import classify_ground mock_subprocess.run.return_value = MagicMock(returncode=0) with tempfile.TemporaryDirectory() as tmpdir: tmpdir = Path(tmpdir) classify_ground(Path("/data/input/test.laz"), tmpdir, method='ign') assert (tmpdir / "pipeline_ign.json").exists() assert not (tmpdir / "pipeline_ign_1_2.json").exists() class TestPureDtm: """Mode pur (classification IGN) : aucune retouche, trous en nodata.""" def _write_las(self, path, points): import laspy hdr = laspy.LasHeader(version='1.2', point_format=0) las = laspy.LasData(hdr) las.x = [p[0] for p in points] las.y = [p[1] for p in points] las.z = [p[2] for p in points] las.write(str(path)) return path def _make_clouds(self, tmp_output_dir): """Grille 2x2 (res=1.0). Sol sur 3 cellules (z=10), trou en (1,1). Le nuage complet a un retour plus bas (z=7) dans le trou.""" corners = [(0.05, 0.05, 10.0), (1.95, 0.05, 10.0), (0.05, 1.95, 10.0)] ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0)] + corners source = list(ground) + [(1.5, 1.5, 7.0)] las_file = self._write_las(tmp_output_dir / "ground_pure.las", ground) source_laz = self._write_las(tmp_output_dir / "source_pure.las", source) return las_file, source_laz def _dtm_array(self, tmp_output_dir, pure): from lidar_pipeline.dtm import create_dtm_fast import rasterio las_file, source_laz = self._make_clouds(tmp_output_dir) out = create_dtm_fast(las_file, "tile_pure", tmp_output_dir, 1.0, force=True, source_laz=source_laz, pure=pure) assert out is not None with rasterio.open(str(out)) as src: return src.read(1).astype("float64") def test_pure_fills_holes_without_floor(self, tmp_output_dir): """pur=True : trous comblés par interpolation, sans plancher à 7. Le comblement est actif dans tous les modes (comportement historique) ; « pur » ne désactive que l'abaissement au retour le plus bas. """ arr = self._dtm_array(tmp_output_dir, pure=True) assert int(np.isnan(arr).sum()) == 0 vals = sorted(float(v) for v in arr.flatten()) assert vals == [10.0, 10.0, 10.0, 10.0] def test_not_pure_fills_holes(self, tmp_output_dir): """pur=False : le trou est comblé (comportement historique conservé).""" arr = self._dtm_array(tmp_output_dir, pure=False) assert int(np.isnan(arr).sum()) == 0 vals = sorted(float(v) for v in arr.flatten()) assert len(vals) == 4 assert vals[-1] == 10.0