Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts, compose files and AGENTS.md are now English. Data keys stay unchanged (relief_oriente, densite_sol, visualisations/, API JSON keys, link params). Wrong comments and help defaults found along the way are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -94,7 +94,7 @@ class TestCSFPipeline:
|
||||
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["resolution"] == 1.0 # 1 m cloth: ~4× faster, DTM unchanged
|
||||
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
|
||||
@ -141,11 +141,11 @@ class TestInterpolateHoles:
|
||||
|
||||
|
||||
class TestFillSmallGaps:
|
||||
"""Comblement borné à l'enveloppe des points (plus de pastilles ni de liseré)."""
|
||||
"""Gap fill bounded to the point envelope (no more patches or fringes)."""
|
||||
|
||||
@staticmethod
|
||||
def _grid(n=200, step=2, value=10.0):
|
||||
"""Semis régulier de points (1 pixel sur `step`) sur n × n pixels."""
|
||||
"""Regular point pattern (1 pixel in `step`) over n × n pixels."""
|
||||
dtm = np.full((n, n), np.nan)
|
||||
dtm[::step, ::step] = value
|
||||
return dtm
|
||||
@ -155,28 +155,28 @@ class TestFillSmallGaps:
|
||||
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 np.isnan(out).all() # neither a patch nor a lone point
|
||||
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
|
||||
dtm[:, 100:] = np.nan # large hole to the east
|
||||
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
|
||||
assert not np.isnan(out[10:190, 10:99]).any() # gaps between points filled
|
||||
assert np.isnan(out[:, 99:]).all() # nothing extrapolated into the hole
|
||||
|
||||
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é."""
|
||||
"""Sparse pattern (1 point / 1.8 m, 2.5 m diagonal gaps) filled; 3 m
|
||||
hole in a dense area kept; 1.6 m hole filled."""
|
||||
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)
|
||||
dense[90:105, 90:105] = np.nan # 3 m hole in a full pattern
|
||||
dense[40:48, 40:48] = np.nan # 1.6 m hole (car)
|
||||
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()
|
||||
@ -211,7 +211,7 @@ class TestFillSmallGaps:
|
||||
force=True, strip_align=False)
|
||||
assert read_dtm_gap_fill(out) == GAP_FILL_VERSION
|
||||
with rasterio.open(str(out)) as src:
|
||||
src.tags() # lisible
|
||||
src.tags() # readable
|
||||
legacy = tmp_output_dir / "legacy.tif"
|
||||
with rasterio.open(str(legacy), "w", driver="GTiff", width=2, height=2,
|
||||
count=1, dtype="float32") as dst:
|
||||
@ -221,11 +221,11 @@ class TestFillSmallGaps:
|
||||
|
||||
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."""
|
||||
"""4 points per m² over 10 × 10 m: density 4 in the core, 1 m grid."""
|
||||
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
|
||||
g = (np.arange(20) + 0.25) / 2.0 # 0.5 m step
|
||||
xx, yy = np.meshgrid(g, g)
|
||||
hdr = laspy.LasHeader(version='1.2', point_format=0)
|
||||
las = laspy.LasData(hdr)
|
||||
@ -303,7 +303,7 @@ class TestDetectGroundMethod:
|
||||
|
||||
class TestIGNPipeline:
|
||||
def test_pipeline_keeps_supplier_classification(self):
|
||||
"""create_ign_pipeline réutilise la pré-classification (classe 2) sans refiltrer."""
|
||||
"""create_ign_pipeline reuses the pre-classification (class 2) without refiltering."""
|
||||
from lidar_pipeline.dtm import create_ign_pipeline
|
||||
result = create_ign_pipeline("/input/a.laz", "/output/a_ground.las")
|
||||
pipeline = json.loads(result)
|
||||
@ -311,14 +311,14 @@ class TestIGNPipeline:
|
||||
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
|
||||
# No classification algorithm, no reset, no noise filters
|
||||
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
|
||||
# ReturnNumber filter kept + extraction of class 2 points
|
||||
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)
|
||||
@ -340,13 +340,13 @@ class TestDetectIGN:
|
||||
@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."""
|
||||
"""Pre-classified file (mostly class 2) → IGN method."""
|
||||
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
|
||||
cls[int(n * 0.15):] = 2 # 85 % class 2 points
|
||||
z_values = np.random.normal(100, 5, n)
|
||||
|
||||
mock_read.return_value = self._make_mock_las(cls, num_returns, z_values)
|
||||
@ -355,13 +355,13 @@ class TestDetectIGN:
|
||||
@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."""
|
||||
"""No usable classification → classic SMRF/CSF detection."""
|
||||
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
|
||||
num_returns[:int(n * 0.6)] = 2 # 60 % multi-return (forest) → not urban
|
||||
cls = np.zeros(n, dtype=np.uint8) # no class 2 point
|
||||
z_values = np.random.normal(100, 5, n)
|
||||
|
||||
mock_read.return_value = self._make_mock_las(cls, num_returns, z_values)
|
||||
@ -421,12 +421,12 @@ class TestClassifyGroundMethod:
|
||||
|
||||
class TestParseIgnClasses:
|
||||
def test_default_sol(self):
|
||||
"""'sol' → code 2 seul."""
|
||||
"""'sol' → code 2 only."""
|
||||
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."""
|
||||
"""Names accepted (EN/FR), sorted and de-duplicated."""
|
||||
from lidar_pipeline.dtm import parse_ign_classes
|
||||
assert parse_ign_classes("sol,unclassified") == [1, 2]
|
||||
assert parse_ign_classes("unclassified,sol") == [1, 2]
|
||||
@ -434,13 +434,13 @@ class TestParseIgnClasses:
|
||||
assert parse_ign_classes("sol,2") == [2]
|
||||
|
||||
def test_numeric_codes(self):
|
||||
"""Codes LAS directs, triés."""
|
||||
"""Raw LAS codes, sorted."""
|
||||
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."""
|
||||
"""Unknown name, out-of-range code or empty list → ValueError."""
|
||||
from lidar_pipeline.dtm import parse_ign_classes
|
||||
with pytest.raises(ValueError):
|
||||
parse_ign_classes("foo")
|
||||
@ -450,7 +450,7 @@ class TestParseIgnClasses:
|
||||
parse_ign_classes("")
|
||||
|
||||
def test_method_label(self):
|
||||
"""'ign' seul pour le sol, combinaison encodée sinon (invalidation cache)."""
|
||||
"""'ign' alone for ground, combination encoded otherwise (cache invalidation)."""
|
||||
from lidar_pipeline.dtm import ign_method_label
|
||||
assert ign_method_label([2]) == "ign"
|
||||
assert ign_method_label([1, 2]) == "ign_1_2"
|
||||
@ -459,7 +459,7 @@ class TestParseIgnClasses:
|
||||
|
||||
class TestIGNPipelineMultiClasses:
|
||||
def test_multi_class_limits(self):
|
||||
"""Plusieurs classes → plages OU logiques sur Classification."""
|
||||
"""Several classes → ORed ranges on 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])
|
||||
@ -471,7 +471,7 @@ class TestIGNPipelineMultiClasses:
|
||||
for l in limits)
|
||||
|
||||
def test_default_sol_only(self):
|
||||
"""Sans ign_codes, la voie IGN reste sol seul (2) — rétrocompatible."""
|
||||
"""Without ign_codes, the IGN path stays ground only (2), backward compatible."""
|
||||
from lidar_pipeline.dtm import _create_ground_pipeline
|
||||
result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las", 'ign')
|
||||
pipeline = json.loads(result)
|
||||
@ -485,7 +485,7 @@ class TestIGNPipelineMultiClasses:
|
||||
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."""
|
||||
"""--ign-classes sol,unclassified → ign_1_2 files + multi-class filter."""
|
||||
import tempfile
|
||||
from lidar_pipeline.dtm import classify_ground
|
||||
|
||||
@ -506,7 +506,7 @@ class TestClassifyGroundIgnClasses:
|
||||
|
||||
@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é)."""
|
||||
"""--ign-classes sol (default) → 'ign' names unchanged (cache preserved)."""
|
||||
import tempfile
|
||||
from lidar_pipeline.dtm import classify_ground
|
||||
|
||||
@ -521,7 +521,8 @@ class TestClassifyGroundIgnClasses:
|
||||
|
||||
|
||||
class TestPureDtm:
|
||||
"""Mode pur (classification IGN) : aucune retouche, trous en nodata."""
|
||||
"""The `pure` flag (IGN classification) no longer changes the DTM: both
|
||||
modes fill small gaps and never use a lowest-return floor."""
|
||||
|
||||
def _write_las(self, path, points):
|
||||
import laspy
|
||||
@ -534,8 +535,8 @@ class TestPureDtm:
|
||||
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."""
|
||||
"""2x2 grid (res=1.0). Ground on 3 cells (z=10), hole at (1,1).
|
||||
The full cloud has a lower return (z=7) in the hole."""
|
||||
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)]
|
||||
@ -554,11 +555,10 @@ class TestPureDtm:
|
||||
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.
|
||||
"""pure=True: the hole is filled from its neighbours, no floor at 7.
|
||||
|
||||
Le comblement est actif dans tous les modes (comportement
|
||||
historique) ; « pur » ne désactive que l'abaissement au retour
|
||||
le plus bas.
|
||||
Gap filling is active in every mode and there is no lowest-return
|
||||
floor at all, so `pure` has no effect.
|
||||
"""
|
||||
arr = self._dtm_array(tmp_output_dir, pure=True)
|
||||
assert int(np.isnan(arr).sum()) == 0
|
||||
@ -566,7 +566,7 @@ class TestPureDtm:
|
||||
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é)."""
|
||||
"""pure=False: the hole is filled as well (same behaviour)."""
|
||||
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())
|
||||
@ -612,7 +612,7 @@ class TestStripVerticalOffsets:
|
||||
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
|
||||
assert 0 not in offs # bias ~0 < threshold: no correction
|
||||
|
||||
def test_small_bias_below_threshold_ignored(self):
|
||||
"""Biases under the 0.5 cm threshold trigger no correction."""
|
||||
@ -640,10 +640,10 @@ class TestStripVerticalOffsets:
|
||||
|
||||
|
||||
class TestStripJitterOffsets:
|
||||
"""Gigue verticale intra-faisceau par fenêtres de temps GPS."""
|
||||
"""Intra-strip vertical jitter over GPS time windows."""
|
||||
|
||||
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."""
|
||||
"""Two interleaved strips; strip 1 carries a time-dependent bias."""
|
||||
rng = np.random.default_rng(seed)
|
||||
x = rng.uniform(0, extent, n)
|
||||
y = rng.uniform(0, extent, n)
|
||||
@ -653,11 +653,11 @@ class TestStripJitterOffsets:
|
||||
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.
|
||||
"""A slow ±4 cm oscillation of strip 1 is removed from the true terrain.
|
||||
|
||||
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.
|
||||
With a two-strip overlap, the out-of-strip reference assigns a series
|
||||
to each strip (each absorbs its share): the residual is checked
|
||||
against the clean synthetic terrain, window by window.
|
||||
"""
|
||||
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
|
||||
w = 2 * np.pi / 8.0
|
||||
@ -669,22 +669,22 @@ class TestStripJitterOffsets:
|
||||
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"
|
||||
assert abs(resid[m].mean()) < 0.012, f"window {lo:.0f}-{lo + 2:.0f} s"
|
||||
|
||||
def test_tracks_step_offset(self):
|
||||
"""Un échelon −3 cm sur la seconde moitié du vol est suivi."""
|
||||
"""A −3 cm step over the second half of the flight is tracked."""
|
||||
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
|
||||
for lo in (3.0, 6.0, 13.0, 16.0): # away from the smoothed transition
|
||||
m = (t >= lo) & (t < lo + 2.0)
|
||||
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
|
||||
assert abs(resid[m].mean()) < 0.012, f"window {lo:.0f}-{lo + 2:.0f} s"
|
||||
|
||||
def test_apply_interpolates_linearly(self):
|
||||
"""Interpolation entre centres de fenêtres ; 0 hors faisceau connu."""
|
||||
"""Interpolation between window centres; 0 outside known strips."""
|
||||
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)
|
||||
@ -693,7 +693,7 @@ class TestStripJitterOffsets:
|
||||
_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."""
|
||||
"""Single strip or non-finite time: nothing to correct."""
|
||||
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) == {}
|
||||
@ -713,11 +713,11 @@ class TestStripAlignSidecar:
|
||||
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["offsets"] == {"1049": 0.026, "1147": -0.026} # JSON keys as strings
|
||||
assert data["jitter"] == {}
|
||||
|
||||
def test_sidecar_records_jitter_series(self, tmp_path):
|
||||
"""Le sidecar consigne les séries de gigue et leurs paramètres."""
|
||||
"""The sidecar records the jitter series and their parameters."""
|
||||
from lidar_pipeline.dtm import (
|
||||
_write_strip_align_sidecar, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
|
||||
import json
|
||||
@ -745,22 +745,22 @@ class TestStripAlignSidecar:
|
||||
self.strip_align = strip_align
|
||||
|
||||
p = P(tmp_path, strip_align=True)
|
||||
# DTM hérité sans sidecar : à régénérer
|
||||
# Legacy DTM without a sidecar: regenerate
|
||||
assert not p._strip_align_matches("TILE", "_r0p2")
|
||||
# Sidecar conforme : valide
|
||||
# Matching sidecar: valid
|
||||
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
|
||||
assert p._strip_align_matches("TILE", "_r0p2")
|
||||
# Seuil différent : à régénérer
|
||||
# Different threshold: regenerate
|
||||
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
|
||||
# Different jitter parameters: regenerate
|
||||
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
|
||||
# Alignment disabled + aligned DTM: regenerate
|
||||
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")
|
||||
# Paramètres ligne à ligne différents : à régénérer
|
||||
# Different line-by-line parameters: regenerate
|
||||
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
|
||||
data = json.loads(bad.read_text())
|
||||
data["line_window"] = 99
|
||||
@ -770,12 +770,12 @@ class TestStripAlignSidecar:
|
||||
|
||||
|
||||
class TestEdgeBuffer:
|
||||
"""Raccord des bords : MNT étendu par les points sol des tuiles voisines."""
|
||||
"""Edge matching: DTM extended with the ground points of neighbouring tiles."""
|
||||
|
||||
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
|
||||
# LHD grid: (col, row) = north-west corner → 0638_6628 covers
|
||||
# X ∈ [638000, 639000], Y ∈ [6627000, 6628000] (north edge = 6628 km).
|
||||
NOMINAL = (638000.0, 6627000.0, 639000.0, 6628000.0) # 1 km tile
|
||||
|
||||
def _write_las(self, path, points, classification=None):
|
||||
import laspy
|
||||
@ -790,7 +790,7 @@ class TestEdgeBuffer:
|
||||
return path
|
||||
|
||||
def _write_clouds(self, root, res=50.0):
|
||||
"""Tuile centrale à z=10 + voisine EST à z=20 (un point par maille res)."""
|
||||
"""Central tile at z=10 + EAST neighbour at z=20 (one point per res cell)."""
|
||||
import numpy as np
|
||||
input_dir = root / "input"
|
||||
input_dir.mkdir(exist_ok=True)
|
||||
@ -814,7 +814,7 @@ class TestEdgeBuffer:
|
||||
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
|
||||
# Non-neighbour noise: never picked
|
||||
(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} == {
|
||||
@ -827,16 +827,16 @@ class TestEdgeBuffer:
|
||||
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/."""
|
||||
"""A neighbour kept in edge_neighbors/ is found; a tile directly in
|
||||
input/ still takes priority."""
|
||||
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
|
||||
# Neighbour only in the edge-matching subfolder
|
||||
(edge / "LHD_FXX_0638_6628_PTS_LAMB93_IGN69.copc.laz").touch()
|
||||
# Voisine présente aux deux endroits : la version input/ gagne
|
||||
# Neighbour present in both places: the input/ copy wins
|
||||
(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")
|
||||
@ -845,7 +845,7 @@ class TestEdgeBuffer:
|
||||
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."""
|
||||
"""24x24 DTM (20x20 tile + 100 m band), EAST band filled at z=20 by the neighbour."""
|
||||
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)
|
||||
@ -859,9 +859,9 @@ class TestEdgeBuffer:
|
||||
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 arr[12, 12] == 10.0 # core: central tile
|
||||
assert arr[12, 23] == 20.0 # EAST band: neighbour points
|
||||
assert np.isnan(arr[0, 0]) # WEST band without neighbour: empty
|
||||
assert read_dtm_edge_buffer(dtm) == 100.0
|
||||
|
||||
def test_unbuffered_dtm_has_no_tag(self, tmp_output_dir):
|
||||
@ -876,7 +876,7 @@ class TestEdgeBuffer:
|
||||
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."""
|
||||
"""Non-LHD name: no nominal tile, header bounds kept."""
|
||||
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer
|
||||
import rasterio
|
||||
ground = self._write_las(tmp_output_dir / "ground.las",
|
||||
@ -888,20 +888,20 @@ class TestEdgeBuffer:
|
||||
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 abs(src.bounds.left - 0.05) < 1e-6 # header bounds
|
||||
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."""
|
||||
"""Synthetic strip: scan lines (scan_angle sawtooth) over sloping terrain
|
||||
crossed by a ditch; vertical offsets per line."""
|
||||
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
|
||||
z = z - 0.5 * (np.abs(x_line - 41) < 1.0) # ditch perpendicular to the lines
|
||||
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))
|
||||
@ -912,16 +912,16 @@ def _synthetic_beam(n_lines=240, spacing=0.4, seed=0, offsets=None, tilts=None):
|
||||
|
||||
|
||||
class TestScanLineAlignment:
|
||||
"""3ᵉ passe du calage : décalage vertical entre lignes de balayage successives."""
|
||||
"""3rd alignment pass: vertical offset between successive scan lines."""
|
||||
|
||||
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
|
||||
keep = np.ones(50, bool); keep[13:17] = False # vegetation hole in line 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
|
||||
t2 = t.copy(); t2[30:] += 1.0 # end of pass: new line
|
||||
assert _scan_line_ids(t2, np.zeros(50)).max() == 1
|
||||
|
||||
def test_group_median_matches_numpy(self):
|
||||
@ -951,7 +951,7 @@ class TestScanLineAlignment:
|
||||
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)."""
|
||||
"""Roll: lines tilted alternately (one end high, the other low)."""
|
||||
from scipy.ndimage import gaussian_filter1d
|
||||
from lidar_pipeline.dtm import _scan_line_corrections_beam
|
||||
n = 240
|
||||
@ -964,15 +964,15 @@ class TestScanLineAlignment:
|
||||
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."""
|
||||
"""Two overlapping strips: the faulty strip (slow drift, roll, isolated
|
||||
line at −8 cm) is re-aligned on the other at every scale, with no
|
||||
drift of the overall elevation."""
|
||||
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
|
||||
bad_off = 0.03 * np.sin(2 * np.pi * k / 120) # slow drift (not visible on its own surface)
|
||||
bad_off[100] -= 0.08 # isolated, strongly shifted line
|
||||
bad_tilt = 0.02 * np.cos(2 * np.pi * k / 60) # slow roll
|
||||
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
|
||||
@ -981,22 +981,22 @@ class TestScanLineAlignment:
|
||||
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.
|
||||
# Without ground truth, the error is shared between the strips: it is
|
||||
# the offset BETWEEN strips (homologous points, same geometry) that
|
||||
# must vanish.
|
||||
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 abs(np.mean(corr)) < 0.002 # no overall drift
|
||||
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."""
|
||||
"""Arc-shaped calibration error by angle (same for every line):
|
||||
non-linear, invisible to offset + tilt, removed by the per-strip,
|
||||
per-angle-bin profile."""
|
||||
from lidar_pipeline.dtm import _joint_line_corrections
|
||||
n = 240
|
||||
xa, ya, za, ta, aa, _ = _synthetic_beam(n, seed=5, offsets=np.zeros(n))
|
||||
@ -1020,7 +1020,7 @@ class TestScanLineAlignment:
|
||||
|
||||
|
||||
class TestIgnDirectExtraction:
|
||||
"""Classification IGN : extraction directe par laspy (PDAL en secours)."""
|
||||
"""IGN classification: direct extraction with laspy (PDAL as fallback)."""
|
||||
|
||||
def test_keeps_requested_classes_and_valid_returns(self, tmp_path):
|
||||
import laspy
|
||||
|
||||
Reference in New Issue
Block a user