Files
lidar_rendu/lidar_pipeline/tests/test_dtm.py

1049 lines
50 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 TestFillSmallGaps:
"""Comblement borné à l'enveloppe des points (plus de pastilles ni de liseré)."""
@staticmethod
def _grid(n=200, step=2, value=10.0):
"""Semis régulier de points (1 pixel sur `step`) sur n × n pixels."""
dtm = np.full((n, n), np.nan)
dtm[::step, ::step] = value
return dtm
def test_isolated_point_is_removed_not_grown(self):
from lidar_pipeline.dtm import _fill_small_gaps
dtm = np.full((100, 100), np.nan)
dtm[50, 50] = 5.0
out, filled, removed = _fill_small_gaps(dtm, 0.2)
assert np.isnan(out).all() # ni pastille, ni point seul
assert (filled, removed) == (0, 1)
def test_gaps_between_points_filled_without_edge_band(self):
from lidar_pipeline.dtm import _fill_small_gaps
dtm = self._grid()
dtm[:, 100:] = np.nan # grand trou à l'est
out, filled, _ = _fill_small_gaps(dtm, 0.2)
assert filled > 0
assert not np.isnan(out[10:190, 10:99]).any() # vides entre points comblés
assert np.isnan(out[:, 99:]).all() # rien d'extrapolé dans le trou
def test_radius_follows_local_density(self):
"""Semis clairsemé (1 point / 1,8 m, vides de 2,5 m en diagonale)
comblé ; trou de 3 m en zone dense conservé ; trou de 1,6 m comblé."""
from lidar_pipeline.dtm import _fill_small_gaps
sparse = self._grid(step=9)
out, _, _ = _fill_small_gaps(sparse, 0.2)
assert not np.isnan(out[30:170, 30:170]).any()
dense = self._grid(step=1)
dense[90:105, 90:105] = np.nan # trou de 3 m dans un semis plein
dense[40:48, 40:48] = np.nan # trou de 1,6 m (voiture)
out, _, _ = _fill_small_gaps(dense, 0.2)
assert np.isnan(out[95:100, 95:100]).all()
assert not np.isnan(out[40:48, 40:48]).any()
def test_morph_disk_matches_scipy(self):
from scipy import ndimage as nd
from lidar_pipeline.dtm import _morph_disk
rng = np.random.default_rng(0)
mask = rng.random((60, 70)) < 0.05
square = np.ones((3, 3), dtype=bool)
cross = nd.generate_binary_structure(2, 1)
ref = mask
for i in range(5):
ref = nd.binary_dilation(ref, structure=square if i % 2 == 0 else cross)
assert (_morph_disk(mask, 5) == ref).all()
ref_e = ref
for i in range(5):
ref_e = nd.binary_erosion(ref_e, structure=square if i % 2 == 0 else cross,
border_value=1)
assert (_morph_disk(ref, 5, erode=True) == ref_e).all()
def test_dtm_records_gap_fill_version(self, tmp_output_dir):
import laspy
import rasterio
from lidar_pipeline.dtm import (create_dtm_fast, read_dtm_gap_fill,
GAP_FILL_VERSION)
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
las.x, las.y, las.z = [0.5, 1.5, 0.5, 1.5], [0.5, 0.5, 1.5, 1.5], [10.0] * 4
las.write(str(tmp_output_dir / "g.las"))
out = create_dtm_fast(tmp_output_dir / "g.las", "g", tmp_output_dir, 1.0,
force=True, strip_align=False)
assert read_dtm_gap_fill(out) == GAP_FILL_VERSION
with rasterio.open(str(out)) as src:
src.tags() # lisible
legacy = tmp_output_dir / "legacy.tif"
with rasterio.open(str(legacy), "w", driver="GTiff", width=2, height=2,
count=1, dtype="float32") as dst:
dst.write(np.zeros((1, 2, 2), dtype="float32"))
assert read_dtm_gap_fill(legacy) == 1
class TestDensitySidecar:
def test_dtm_writes_ground_density(self, tmp_output_dir):
"""4 points par m² sur 10 × 10 m : densité 4 au cœur, grille de 1 m."""
import laspy
import rasterio
from lidar_pipeline.dtm import create_dtm_fast, density_path
g = (np.arange(20) + 0.25) / 2.0 # pas de 0,5 m
xx, yy = np.meshgrid(g, g)
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
las.x, las.y, las.z = xx.ravel(), yy.ravel(), np.full(xx.size, 10.0)
las.write(str(tmp_output_dir / "d.las"))
out = create_dtm_fast(tmp_output_dir / "d.las", "d", tmp_output_dir, 0.5,
force=True, strip_align=False)
with rasterio.open(density_path(out)) as src:
dens = src.read(1)
assert abs(src.transform.a - 1.0) < 1e-9
assert np.allclose(dens[2:-2, 2:-2], 4.0)
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())