Files
lidar_rendu/lidar_pipeline/tests/test_dtm.py
Antoine Jacquin 41206f65de Caler les faisceaux de vol, décimer l'openness et cibler les couches affichées
Trois chantiers liés à la qualité et au coût des rendus :

- Calage vertical des faisceaux : les passes d'une tuile peuvent être biaisées
  de quelques cm (±2,5 cm mesurés sur 1000_6882), créant des marches aux
  recouvrements. Les offsets par PointSourceId sont mesurés sur les points
  sol de la tuile (réf. médiane itérée) et retranchés ≥ 0,5 cm avant
  rastérisation, avec sidecar de cache et application au plancher bare-earth.
- Openness décimée ×2 : lancé de rayons sur grille par blocs (max/min) puis
  rééchantillonnage bilinéaire — 532 s → 40 s par tuile à 0,2 m sur CPU,
  signal archéologique préservé. Réglable --openness-downsample.
- Génération webapp concentrée sur les couches affichées : les défauts
  /api/generate, /api/preview et le sélecteur génèrent le panneau complet
  (slope, aspect, pos_open) au lieu d'aspect seul.
2026-09-19 00:53:22 +02:00

656 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
class TestStripLidarExt:
def test_copc_laz(self):
from lidar_pipeline.dtm import _strip_lidar_ext
assert _strip_lidar_ext("LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc.laz") == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
def test_laz(self):
from lidar_pipeline.dtm import _strip_lidar_ext
assert _strip_lidar_ext("file.laz") == "file"
def test_las(self):
from lidar_pipeline.dtm import _strip_lidar_ext
assert _strip_lidar_ext("file.las") == "file"
def test_path_object(self):
from lidar_pipeline.dtm import _strip_lidar_ext
from pathlib import Path
assert _strip_lidar_ext(Path("/data/input/file.copc.laz")) == "file"
class TestStripVerticalOffsets:
"""Vertical de-bias of flight-line point sources (strip alignment)."""
def _synthetic(self, offsets, n=1_500_000, extent=300.0, seed=0):
"""Interleaved sources over one tile; each carries a known Z bias."""
rng = np.random.default_rng(seed)
x = rng.uniform(0, extent, n)
y = rng.uniform(0, extent, n)
z = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n)
psid = rng.integers(0, len(offsets), n)
return x, y, z + np.asarray(offsets)[psid], psid.astype(np.uint16)
def test_recovers_known_offsets(self):
"""±5 cm biases are recovered; the aligned source stays untouched."""
from lidar_pipeline.dtm import _strip_vertical_offsets
x, y, z, psid = self._synthetic((0.0, 0.05, -0.05))
offs = _strip_vertical_offsets(x, y, z, psid)
assert abs(offs.get(1, 0.0) - 0.05) < 0.01
assert abs(offs.get(2, 0.0) + 0.05) < 0.01
assert 0 not in offs # biais ~0 < seuil : pas de correction
def test_small_bias_below_threshold_ignored(self):
"""Biases under the 0.5 cm threshold trigger no correction."""
from lidar_pipeline.dtm import _strip_vertical_offsets
x, y, z, psid = self._synthetic((0.0, 0.003, -0.003))
assert _strip_vertical_offsets(x, y, z, psid) == {}
def test_single_source_returns_empty(self):
"""A single point source cannot be compared: no offsets."""
from lidar_pipeline.dtm import _strip_vertical_offsets
x, y, z, psid = self._synthetic((0.05,))
assert _strip_vertical_offsets(x, y, z, psid) == {}
def test_no_shared_cells_returns_empty(self):
"""Sources covering disjoint areas (no overlap) are not corrected."""
from lidar_pipeline.dtm import _strip_vertical_offsets
rng = np.random.default_rng(1)
n = 200_000
x = np.concatenate([rng.uniform(0, 100, n), rng.uniform(200, 300, n)])
y = rng.uniform(0, 300, 2 * n)
z = 100 + rng.normal(0, 0.01, 2 * n)
psid = np.concatenate([np.zeros(n, np.uint16), np.ones(n, np.uint16)])
z[psid == 1] += 0.10
assert _strip_vertical_offsets(x, y, z, psid) == {}
class TestStripAlignSidecar:
def test_sidecar_roundtrip_and_threshold(self, tmp_path):
"""Sidecar records version/threshold/offsets and matches config."""
from lidar_pipeline.dtm import (
_write_strip_align_sidecar, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD)
import json
offsets = {1049: 0.026, 1147: -0.026}
_write_strip_align_sidecar(tmp_path, "TILE", "_r0p2", offsets)
data = json.loads((tmp_path / "TILE_dtm_r0p2_stripalign.json").read_text())
assert data["version"] == STRIP_ALIGN_VERSION
assert data["threshold"] == STRIP_ALIGN_THRESHOLD
assert data["offsets"] == {"1049": 0.026, "1147": -0.026} # clés JSON en chaînes
def test_pipeline_match_logic(self, tmp_path):
"""_strip_align_matches invalidates legacy DTMs and config changes."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
from lidar_pipeline.dtm import _write_strip_align_sidecar
import json
class P(LidarArchaeoPipeline):
def __init__(self, out, strip_align):
self.output_dir = out
self.dtm_dir = out / "DTM"
self.dtm_dir.mkdir(exist_ok=True)
self.strip_align = strip_align
p = P(tmp_path, strip_align=True)
# DTM hérité sans sidecar : à régénérer
assert not p._strip_align_matches("TILE", "_r0p2")
# Sidecar conforme : valide
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
assert p._strip_align_matches("TILE", "_r0p2")
# Seuil différent : à régénérer
bad = p.dtm_dir / "TILE_dtm_r0p2_stripalign.json"
bad.write_text(json.dumps({"version": 1, "threshold": 0.02, "offsets": {}}))
assert not p._strip_align_matches("TILE", "_r0p2")
# Calage désactivé + DTM calé : à régénérer
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")