Troisième passe du calage vertical : chaque ligne de balayage (décalage et inclinaison due au roulis) est recalée contre le consensus des autres faisceaux, à toutes les échelles, avec un profil d'étalonnage par faisceau et par degré d'angle qui retire les écarts non linéaires en travers de la fauchée. Les lignes sans recouvrement sont corrigées contre leur propre faisceau. Efface les lignes en creux et la marche au bord de fauchée mesurées sur LHD_FXX_0999_6882 (validé sur des blocs jamais vus). Calcul vectorisé, CuPy si GPU ; la gigue par fenêtres de temps devient inutile quand scan_angle existe. Rendu plus rapide : encodage AVIF speed 9 (0,6 s au lieu de 4 s par dalle), classification IGN par extraction directe laspy au lieu de PDAL (4,9 s au lieu de 13,5 s), comblement des trous et gradients sur GPU, cache numba persistant dans l'image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
950 lines
45 KiB
Python
950 lines
45 KiB
Python
"""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 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 TestStripJitterOffsets:
|
||
"""Gigue verticale intra-faisceau par fenêtres de temps GPS."""
|
||
|
||
def _synthetic(self, bias_fn, n=800_000, extent=300.0, duration=20.0, seed=0):
|
||
"""Deux faisceaux entrelacés ; le n° 1 porte un biais dépendant du temps."""
|
||
rng = np.random.default_rng(seed)
|
||
x = rng.uniform(0, extent, n)
|
||
y = rng.uniform(0, extent, n)
|
||
clean = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n)
|
||
t = rng.uniform(0, duration, n)
|
||
psid = rng.integers(0, 2, n).astype(np.uint16)
|
||
return x, y, clean + bias_fn(t, psid), psid, t, clean
|
||
|
||
def test_recovers_time_varying_offset(self):
|
||
"""Une oscillation lente ±4 cm du faisceau 1 est retirée du terrain vrai.
|
||
|
||
En recouvrement à deux, la référence hors-faisceau attribue une série
|
||
à chaque faisceau (chacun absorbe sa part) : on vérifie le résidu
|
||
contre le terrain synthétique propre, fenêtre par fenêtre.
|
||
"""
|
||
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
|
||
w = 2 * np.pi / 8.0
|
||
x, y, z, psid, t, clean = self._synthetic(
|
||
lambda tt, p: 0.04 * np.sin(w * tt) * (p == 1))
|
||
jitter = _strip_jitter_offsets(x, y, z, psid, t)
|
||
assert set(jitter) == {0, 1}
|
||
resid = z - _apply_strip_jitter(psid, t, jitter) - clean
|
||
assert np.sqrt(np.mean(resid ** 2)) < 0.012
|
||
for lo in np.arange(0, 20.0, 2.0):
|
||
m = (t >= lo) & (t < lo + 2.0)
|
||
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
|
||
|
||
def test_tracks_step_offset(self):
|
||
"""Un échelon −3 cm sur la seconde moitié du vol est suivi."""
|
||
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
|
||
x, y, z, psid, t, clean = self._synthetic(
|
||
lambda tt, p: np.where(tt >= 10.0, -0.03, 0.0) * (p == 1))
|
||
jitter = _strip_jitter_offsets(x, y, z, psid, t)
|
||
resid = z - _apply_strip_jitter(psid, t, jitter) - clean
|
||
assert np.sqrt(np.mean(resid ** 2)) < 0.012
|
||
for lo in (3.0, 6.0, 13.0, 16.0): # loin de la transition lissée
|
||
m = (t >= lo) & (t < lo + 2.0)
|
||
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
|
||
|
||
def test_apply_interpolates_linearly(self):
|
||
"""Interpolation entre centres de fenêtres ; 0 hors faisceau connu."""
|
||
from lidar_pipeline.dtm import _apply_strip_jitter
|
||
jitter = {7: (np.array([10.0, 11.0]), np.array([0.0, 0.1]))}
|
||
psid = np.array([7, 7, 7, 3], dtype=np.uint16)
|
||
t = np.array([10.0, 10.5, 15.0, 10.5])
|
||
np.testing.assert_allclose(
|
||
_apply_strip_jitter(psid, t, jitter), [0.0, 0.05, 0.1, 0.0])
|
||
|
||
def test_requires_two_sources_and_time(self):
|
||
"""Faisceau unique ou temps non fini : rien à corriger."""
|
||
from lidar_pipeline.dtm import _strip_jitter_offsets
|
||
x, y, z, psid, t, _clean = self._synthetic(lambda tt, p: 0.04 * np.sin(tt) * (p == 1))
|
||
assert _strip_jitter_offsets(x, y, z, np.zeros_like(psid), t) == {}
|
||
t_nan = t.copy()
|
||
t_nan[0] = np.nan
|
||
assert _strip_jitter_offsets(x, y, z, psid, t_nan) == {}
|
||
|
||
|
||
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
|
||
assert data["jitter"] == {}
|
||
|
||
def test_sidecar_records_jitter_series(self, tmp_path):
|
||
"""Le sidecar consigne les séries de gigue et leurs paramètres."""
|
||
from lidar_pipeline.dtm import (
|
||
_write_strip_align_sidecar, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
|
||
import json
|
||
jitter = {11: (np.array([0.05, 0.15]), np.array([0.012, -0.008]))}
|
||
_write_strip_align_sidecar(tmp_path, "T", "", {}, jitter)
|
||
data = json.loads((tmp_path / "T_dtm_stripalign.json").read_text())
|
||
assert data["jitter_bin"] == STRIP_JITTER_BIN
|
||
assert data["jitter_smooth"] == STRIP_JITTER_SMOOTH
|
||
entry = data["jitter"]["11"]
|
||
assert entry["bins"] == 2
|
||
assert entry["series_m"] == [0.012, -0.008]
|
||
assert entry["max_m"] == 0.012
|
||
|
||
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")
|
||
# Paramètres de gigue différents : à régénérer
|
||
bad.write_text(json.dumps({"version": 2, "threshold": 0.005, "offsets": {},
|
||
"jitter_bin": 0.5, "jitter_smooth": 5}))
|
||
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")
|
||
# Paramètres ligne à ligne différents : à régénérer
|
||
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
|
||
data = json.loads(bad.read_text())
|
||
data["line_window"] = 99
|
||
bad.write_text(json.dumps(data))
|
||
assert not p._strip_align_matches("TILE", "_r0p2")
|
||
|
||
|
||
|
||
class TestEdgeBuffer:
|
||
"""Raccord des bords : MNT étendu par les points sol des tuiles voisines."""
|
||
|
||
BASENAME = "LHD_FXX_0638_6628_PTS_LAMB93_IGN69"
|
||
# Grille LHD : (col, row) = coin nord-ouest → 0638_6628 couvre
|
||
# X ∈ [638000, 639000], Y ∈ [6627000, 6628000] (bord nord = 6628 km).
|
||
NOMINAL = (638000.0, 6627000.0, 639000.0, 6628000.0) # dalle 1 km
|
||
|
||
def _write_las(self, path, points, classification=None):
|
||
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]
|
||
if classification is not None:
|
||
las.classification = classification
|
||
las.write(str(path))
|
||
return path
|
||
|
||
def _write_clouds(self, root, res=50.0):
|
||
"""Tuile centrale à z=10 + voisine EST à z=20 (un point par maille res)."""
|
||
import numpy as np
|
||
input_dir = root / "input"
|
||
input_dir.mkdir(exist_ok=True)
|
||
min_x, min_y, max_x, max_y = self.NOMINAL
|
||
xs = np.arange(min_x + res / 2, max_x, res)
|
||
ys = np.arange(min_y + res / 2, max_y, res)
|
||
gx, gy = np.meshgrid(xs, ys)
|
||
main = list(zip(gx.ravel(), gy.ravel(), np.full(gx.size, 10.0)))
|
||
nxs = xs + 1000.0
|
||
ngx, ngy = np.meshgrid(nxs, ys)
|
||
east = list(zip(ngx.ravel(), ngy.ravel(), np.full(ngx.size, 20.0)))
|
||
ground = self._write_las(root / "ground.las", main)
|
||
source = self._write_las(input_dir / f"{self.BASENAME}.copc.laz", main)
|
||
self._write_las(input_dir / "LHD_FXX_0639_6628_PTS_LAMB93_IGN69.copc.laz",
|
||
east, classification=[2] * len(east))
|
||
return ground, source, input_dir
|
||
|
||
def test_neighbor_discovery(self, tmp_output_dir):
|
||
from lidar_pipeline.dtm import _neighbor_laz_files
|
||
(tmp_output_dir / f"{self.BASENAME}.copc.laz").touch()
|
||
present = [(637, 6627), (639, 6629), (638, 6629)]
|
||
for c, r in present:
|
||
(tmp_output_dir / f"LHD_FXX_{c}_{r}_PTS_LAMB93_IGN69.copc.laz").touch()
|
||
# Bruit non voisin : jamais retenu
|
||
(tmp_output_dir / "LHD_FXX_0650_6700_PTS_LAMB93_IGN69.copc.laz").touch()
|
||
found = _neighbor_laz_files(tmp_output_dir / f"{self.BASENAME}.copc.laz")
|
||
assert {f.name for f in found} == {
|
||
f"LHD_FXX_{c}_{r}_PTS_LAMB93_IGN69.copc.laz" for c, r in present}
|
||
|
||
def test_neighbor_discovery_non_lhd(self, tmp_output_dir):
|
||
from lidar_pipeline.dtm import _neighbor_laz_files
|
||
src = tmp_output_dir / "nuage_arbitraire.laz"
|
||
src.touch()
|
||
assert _neighbor_laz_files(src) == []
|
||
|
||
def test_neighbor_found_in_edge_subdir(self, tmp_output_dir):
|
||
"""Une voisine isolée dans edge_neighbors/ est trouvée ; la priorité
|
||
reste à une dalle à plat dans input/."""
|
||
from lidar_pipeline.dtm import _neighbor_laz_files, EDGE_NEIGHBORS_DIRNAME
|
||
base = "LHD_FXX_0637_6627_PTS_LAMB93_IGN69"
|
||
(tmp_output_dir / f"{base}.copc.laz").touch()
|
||
edge = tmp_output_dir / EDGE_NEIGHBORS_DIRNAME
|
||
edge.mkdir()
|
||
# Voisine uniquement dans le sous-dossier de raccord
|
||
(edge / "LHD_FXX_0638_6628_PTS_LAMB93_IGN69.copc.laz").touch()
|
||
# Voisine présente aux deux endroits : la version input/ gagne
|
||
(edge / "LHD_FXX_0636_6626_PTS_LAMB93_IGN69.copc.laz").touch()
|
||
(tmp_output_dir / "LHD_FXX_0636_6626_PTS_LAMB93_IGN69.copc.laz").touch()
|
||
found = _neighbor_laz_files(tmp_output_dir / f"{base}.copc.laz")
|
||
by_name = {f.name: f for f in found}
|
||
assert by_name["LHD_FXX_0638_6628_PTS_LAMB93_IGN69.copc.laz"].parent == edge
|
||
assert by_name["LHD_FXX_0636_6626_PTS_LAMB93_IGN69.copc.laz"].parent == tmp_output_dir
|
||
|
||
def test_buffered_dtm_extends_into_neighbor(self, tmp_output_dir):
|
||
"""MNT 24x24 (dalle 20x20 + bande 100 m), bande EST remplie à z=20 par la voisine."""
|
||
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer, EDGE_BUFFER_TAG
|
||
import rasterio
|
||
ground, source, _ = self._write_clouds(tmp_output_dir)
|
||
dtm = create_dtm_fast(ground, self.BASENAME, tmp_output_dir, 50.0,
|
||
force=True, source_laz=source, strip_align=False,
|
||
edge_buffer=100.0, neighbor_classes=[2])
|
||
assert dtm is not None
|
||
with rasterio.open(str(dtm)) as src:
|
||
assert (src.width, src.height) == (24, 24)
|
||
assert abs(src.bounds.left - 637900.0) < 1e-6
|
||
assert abs(src.bounds.top - 6628100.0) < 1e-6
|
||
assert src.tags().get(EDGE_BUFFER_TAG) == "100"
|
||
arr = src.read(1)
|
||
assert arr[12, 12] == 10.0 # cœur : tuile centrale
|
||
assert arr[12, 23] == 20.0 # bande EST : points de la voisine
|
||
assert np.isnan(arr[0, 0]) # bande OUEST sans voisine : vide
|
||
assert read_dtm_edge_buffer(dtm) == 100.0
|
||
|
||
def test_unbuffered_dtm_has_no_tag(self, tmp_output_dir):
|
||
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer, EDGE_BUFFER_TAG
|
||
import rasterio
|
||
ground, source, _ = self._write_clouds(tmp_output_dir)
|
||
dtm = create_dtm_fast(ground, self.BASENAME, tmp_output_dir, 50.0,
|
||
force=True, source_laz=source, strip_align=False)
|
||
assert dtm is not None
|
||
with rasterio.open(str(dtm)) as src:
|
||
assert EDGE_BUFFER_TAG not in src.tags()
|
||
assert read_dtm_edge_buffer(dtm) == 0.0
|
||
|
||
def test_buffered_dtm_non_lhd_falls_back(self, tmp_output_dir):
|
||
"""Nom hors pattern LHD : pas de tuile nominale, bornes d'en-tête conservées."""
|
||
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer
|
||
import rasterio
|
||
ground = self._write_las(tmp_output_dir / "ground.las",
|
||
[(0.5, 0.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)])
|
||
source = self._write_las(tmp_output_dir / "nuage.laz",
|
||
[(0.5, 0.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)])
|
||
dtm = create_dtm_fast(ground, "nuage", tmp_output_dir, 1.0,
|
||
force=True, source_laz=source, strip_align=False,
|
||
edge_buffer=100.0)
|
||
assert dtm is not None
|
||
with rasterio.open(str(dtm)) as src:
|
||
assert abs(src.bounds.left - 0.05) < 1e-6 # bornes de l'en-tête
|
||
assert read_dtm_edge_buffer(dtm) == 0.0
|
||
|
||
|
||
def _synthetic_beam(n_lines=240, spacing=0.4, seed=0, offsets=None, tilts=None):
|
||
"""Faisceau synthétique : lignes de balayage (dents de scie de scan_angle)
|
||
sur un terrain en pente traversé par un fossé ; offsets verticaux par ligne."""
|
||
rng = np.random.default_rng(seed)
|
||
xs, ys, zs, ts, angs, ids = [], [], [], [], [], []
|
||
x_line = np.arange(0, 100, 0.12)
|
||
for k in range(n_lines):
|
||
y = k * spacing + rng.normal(0, 0.02, x_line.size)
|
||
z = 100 + 0.03 * x_line + 0.05 * y
|
||
z = z - 0.5 * (np.abs(x_line - 41) < 1.0) # fossé perpendiculaire aux lignes
|
||
u = np.linspace(-1, 1, x_line.size)
|
||
z = z + offsets[k] + (0 if tilts is None else tilts[k]) * u + rng.normal(0, 0.01, x_line.size)
|
||
xs.append(x_line); ys.append(y); zs.append(z); ids.append(np.full(x_line.size, k))
|
||
ts.append(k * 0.0067 + np.linspace(0, 0.006, x_line.size))
|
||
angs.append(np.linspace(-3300, 3300, x_line.size))
|
||
return (np.concatenate(xs), np.concatenate(ys), np.concatenate(zs),
|
||
np.concatenate(ts), np.concatenate(angs), np.concatenate(ids))
|
||
|
||
|
||
class TestScanLineAlignment:
|
||
"""3ᵉ passe du calage : décalage vertical entre lignes de balayage successives."""
|
||
|
||
def test_line_ids_follow_sawtooth_not_vegetation_gaps(self):
|
||
from lidar_pipeline.dtm import _scan_line_ids
|
||
t = np.arange(50) * 0.0001
|
||
ang = np.tile(np.linspace(-3000, 3000, 10), 5)
|
||
keep = np.ones(50, bool); keep[13:17] = False # trou de végétation dans la ligne 2
|
||
ids = _scan_line_ids(t[keep], ang[keep])
|
||
assert ids.max() == 4
|
||
t2 = t.copy(); t2[30:] += 1.0 # fin de passe : nouvelle ligne
|
||
assert _scan_line_ids(t2, np.zeros(50)).max() == 1
|
||
|
||
def test_group_median_matches_numpy(self):
|
||
from lidar_pipeline.dtm import _group_median
|
||
rng = np.random.default_rng(3)
|
||
g = rng.integers(0, 20, 2000)
|
||
v = rng.normal(size=2000); v[::17] = np.nan
|
||
med = _group_median(v, g, 20, 1)
|
||
for k in range(20):
|
||
vals = v[(g == k) & np.isfinite(v)]
|
||
assert med[k] == pytest.approx(np.median(vals))
|
||
|
||
def test_removes_alternating_line_offsets_and_keeps_ditch(self):
|
||
from lidar_pipeline.dtm import _scan_line_corrections_beam
|
||
n = 240
|
||
rng = np.random.default_rng(1)
|
||
offsets = 0.015 * (-1.0) ** np.arange(n) + rng.normal(0, 0.006, n)
|
||
x, y, z, t, ang, ids = _synthetic_beam(n, offsets=offsets)
|
||
corr, per_line, _ = _scan_line_corrections_beam(x, y, z, t, ang)
|
||
assert len(per_line) == n
|
||
from scipy.ndimage import gaussian_filter1d
|
||
hp = lambda v: v - gaussian_filter1d(v, 3, mode="nearest")
|
||
before, after = hp(offsets)[10:-10].std(), hp(offsets - per_line)[10:-10].std()
|
||
assert after < 0.2 * before, f"{before*1000:.1f} → {after*1000:.1f} mm"
|
||
ditch = np.abs(x - 41) < 0.8
|
||
depth = lambda zz: np.median(zz[~ditch & (np.abs(x - 41) < 4)]) - np.median(zz[ditch])
|
||
assert depth(z - corr) == pytest.approx(depth(z), abs=0.01)
|
||
|
||
def test_removes_alternating_line_tilts(self):
|
||
"""Roulis : lignes basculées alternativement (un bout haut, l'autre bas)."""
|
||
from scipy.ndimage import gaussian_filter1d
|
||
from lidar_pipeline.dtm import _scan_line_corrections_beam
|
||
n = 240
|
||
rng = np.random.default_rng(4)
|
||
tilts = 0.02 * (-1.0) ** np.arange(n) + rng.normal(0, 0.008, n)
|
||
x, y, z, t, ang, ids = _synthetic_beam(n, offsets=np.zeros(n), tilts=tilts)
|
||
corr, _, per_tilt = _scan_line_corrections_beam(x, y, z, t, ang)
|
||
hp = lambda v: v - gaussian_filter1d(v, 3, mode="nearest")
|
||
before, after = hp(tilts)[10:-10].std(), hp(tilts - per_tilt)[10:-10].std()
|
||
assert after < 0.2 * before, f"{before*1000:.1f} → {after*1000:.1f} mm"
|
||
|
||
def test_joint_adjustment_fixes_all_scales_against_other_beam(self):
|
||
"""Deux faisceaux superposés : le faisceau fautif (dérive lente, roulis,
|
||
ligne isolée à −8 cm) est recalé sur l'autre à toutes les échelles,
|
||
sans dérive de l'altitude d'ensemble."""
|
||
from lidar_pipeline.dtm import _joint_line_corrections
|
||
n = 240
|
||
k = np.arange(n)
|
||
bad_off = 0.03 * np.sin(2 * np.pi * k / 120) # dérive lente (non vue sur sa propre surface)
|
||
bad_off[100] -= 0.08 # ligne isolée très décalée
|
||
bad_tilt = 0.02 * np.cos(2 * np.pi * k / 60) # roulis lent
|
||
xa, ya, za, ta, aa, _ = _synthetic_beam(n, seed=1, offsets=bad_off, tilts=bad_tilt)
|
||
xb, yb, zb, tb, ab, _ = _synthetic_beam(n, seed=2, offsets=np.zeros(n))
|
||
tb = tb + 1000.0
|
||
x = np.r_[xa, xb]; y = np.r_[ya, yb]; z = np.r_[za, zb]; t = np.r_[ta, tb]
|
||
ang = np.r_[aa, ab]; psid = np.r_[np.full(len(za), 1), np.full(len(zb), 2)]
|
||
corr, gl, touched, iters = _joint_line_corrections(x, y, z, psid, t, ang)
|
||
truth = np.r_[bad_off[np.repeat(k, len(za) // n)] + bad_tilt[np.repeat(k, len(za) // n)]
|
||
* np.tile(np.linspace(-1, 1, len(za) // n), n), np.zeros(len(zb))]
|
||
# Sans vérité terrain, l'écart est partagé entre les faisceaux : c'est
|
||
# l'écart ENTRE faisceaux (points homologues, même géométrie) qui doit
|
||
# disparaître.
|
||
na = len(za)
|
||
before = truth[:na] - truth[:na].mean()
|
||
rel = (truth[:na] - corr[:na]) - (0.0 - corr[na:])
|
||
after = rel - rel.mean()
|
||
assert np.std(after) < 0.25 * np.std(before), \
|
||
f"{np.std(before)*1000:.1f} → {np.std(after)*1000:.1f} mm"
|
||
assert abs(np.mean(corr)) < 0.002 # pas de dérive d'ensemble
|
||
assert touched.mean() > 0.9 and iters <= 8
|
||
|
||
def test_joint_adjustment_removes_static_angle_profile(self):
|
||
"""Étalonnage en arc selon l'angle (même pour toutes les lignes) :
|
||
non linéaire, invisible pour décalage + inclinaison, retiré par le
|
||
profil par faisceau et classe d'angle."""
|
||
from lidar_pipeline.dtm import _joint_line_corrections
|
||
n = 240
|
||
xa, ya, za, ta, aa, _ = _synthetic_beam(n, seed=5, offsets=np.zeros(n))
|
||
uu = aa / 3300.0
|
||
prof = 0.02 * (uu ** 2 - np.mean(uu ** 2))
|
||
za = za + prof
|
||
xb, yb, zb, tb, ab, _ = _synthetic_beam(n, seed=6, offsets=np.zeros(n))
|
||
x = np.r_[xa, xb]; y = np.r_[ya, yb]; z = np.r_[za, zb]; t = np.r_[ta, tb + 1000.0]
|
||
ang = np.r_[aa, ab]; psid = np.r_[np.full(len(za), 1), np.full(len(zb), 2)]
|
||
corr, _, _, _ = _joint_line_corrections(x, y, z, psid, t, ang)
|
||
na = len(za)
|
||
rel = (prof - corr[:na]) - (0.0 - corr[na:])
|
||
assert np.std(rel - rel.mean()) < 0.3 * np.std(prof), \
|
||
f"{np.std(prof)*1000:.1f} → {np.std(rel - rel.mean())*1000:.1f} mm"
|
||
|
||
def test_clean_beam_left_untouched(self):
|
||
from lidar_pipeline.dtm import _scan_line_corrections
|
||
x, y, z, t, ang, ids = _synthetic_beam(240, offsets=np.zeros(240))
|
||
corr, stats = _scan_line_corrections(x, y, z, np.full(len(z), 7), t, ang)
|
||
assert stats == {} and not corr.any()
|
||
|
||
|
||
class TestIgnDirectExtraction:
|
||
"""Classification IGN : extraction directe par laspy (PDAL en secours)."""
|
||
|
||
def test_keeps_requested_classes_and_valid_returns(self, tmp_path):
|
||
import laspy
|
||
from lidar_pipeline.dtm import _extract_ign_ground
|
||
n = 1000
|
||
rng = np.random.default_rng(0)
|
||
hdr = laspy.LasHeader(point_format=6, version="1.4")
|
||
hdr.scales = np.array([0.01, 0.01, 0.001]); hdr.offsets = np.array([1000.0, 6800000.0, 0.0])
|
||
las = laspy.LasData(hdr)
|
||
las.x = 1000 + rng.uniform(0, 100, n); las.y = 6800000 + rng.uniform(0, 100, n)
|
||
las.z = rng.uniform(100, 110, n)
|
||
cls = rng.choice([1, 2, 3, 6, 9], n); las.classification = cls
|
||
rn = np.ones(n, dtype=np.uint8); rn[:10] = 0; las.return_number = rn
|
||
las.number_of_returns = np.ones(n, dtype=np.uint8)
|
||
las.point_source_id = np.full(n, 42, dtype=np.uint16)
|
||
src = tmp_path / "t.las"; las.write(str(src))
|
||
out = tmp_path / "g.las"
|
||
assert _extract_ign_ground(src, out, [2])
|
||
g = laspy.read(str(out))
|
||
expected = (cls == 2) & (rn >= 1)
|
||
assert len(g.points) == int(expected.sum())
|
||
assert set(np.unique(np.asarray(g.classification))) == {2}
|
||
assert g.header.point_format.id == 6 and np.all(np.asarray(g.point_source_id) == 42)
|
||
assert _extract_ign_ground(src, tmp_path / "e.las", [1, 2])
|
||
assert len(laspy.read(str(tmp_path / "e.las")).points) == int(((np.isin(cls, [1, 2])) & (rn >= 1)).sum())
|