Add web map with zone generation API, side job queue, and restore historical DTM hole rendering
- webapp.py: FastAPI serving the continuous map (port 8973) with /api/preview, /api/generate and /api/status; tiles are downloaded from IGN and processed in a logged subprocess, tracked live in a side "File de génération" panel that survives page reloads - fetch_ign.py: download missing 1 km LiDAR HD tiles from the IGN geoplateforme before processing - index.py: tile thumbnails and 500 m subtiles are now invalidated by mtime so regenerating a tile refreshes its cached images; progress logging per tile - dtm.py: back to the historical gap handling (small gaps filled by fillnodata only, larger holes left as nodata rendered black); lowest-return floor only via --bare-earth, IGN class selection via --ign-classes - cli.py: positional input now optional (--rebuild-index works alone) - docker-compose.yml: serve (GPU, port 8973) and process services; launch via docker compose only (documented in AGENTS.md/AGENTS.md) - tests: 131 passing, incl. regressions for thumbnail staleness, --rebuild-index without input, and nodata rendering
This commit is contained in:
@ -85,4 +85,21 @@ class TestSetupLogging:
|
||||
assert logger.level == logging.DEBUG
|
||||
fmt = logger.handlers[0].formatter._fmt
|
||||
assert "%(filename)s" in fmt
|
||||
assert "%(lineno)d" in fmt
|
||||
assert "%(lineno)d" in fmt
|
||||
|
||||
|
||||
def test_rebuild_index_without_input_arg(tmp_path):
|
||||
"""--rebuild-index fonctionne sans l'argument positionnel input.
|
||||
|
||||
Régression : input était obligatoire alors que --rebuild-index ne
|
||||
l'utilise pas (erreur argparse « the following arguments are required »).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "lidar_pipeline", "--rebuild-index",
|
||||
"-o", str(tmp_path)],
|
||||
capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "the following arguments are required" not in r.stderr
|
||||
@ -94,12 +94,127 @@ 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"] == 0.5
|
||||
assert csf_stage["resolution"] == 1.0 # cloth 1 m : ~4× plus rapide, MNT inchangé
|
||||
assert csf_stage["rigidness"] == 3
|
||||
assert csf_stage["smooth"] is True
|
||||
assert "hdiff" not in csf_stage # hdiff is not a valid PDAL CSF parameter
|
||||
|
||||
|
||||
class TestInterpolateHoles:
|
||||
def test_fills_interior_hole_with_surface(self):
|
||||
"""Large interior NaN hole is filled (no NaN left, value is plausible)."""
|
||||
from lidar_pipeline.dtm import _interpolate_holes
|
||||
# Linear-in-column surface z = 0.02 * x, with a large square hole in the middle.
|
||||
x = np.arange(40, dtype=float) * 0.02
|
||||
dtm = np.tile(x, (40, 1))
|
||||
dtm[16:24, 16:24] = np.nan
|
||||
filled, count = _interpolate_holes(dtm)
|
||||
assert count == 64
|
||||
assert not np.isnan(filled).any()
|
||||
# Filled values stay within the surrounding z range (no wild extrapolation).
|
||||
zmin, zmax = np.nanmin(dtm), np.nanmax(dtm)
|
||||
hole_vals = filled[16:24, 16:24]
|
||||
assert np.all(hole_vals >= zmin - 1e-6)
|
||||
assert np.all(hole_vals <= zmax + 1e-6)
|
||||
# A linear surface is interpolated near-exactly in the interior.
|
||||
expected = np.tile(x[16:24], (8, 1))
|
||||
assert np.allclose(hole_vals, expected, atol=0.02)
|
||||
# Original valid cells are untouched.
|
||||
valid = ~np.isnan(dtm)
|
||||
assert np.allclose(filled[valid], dtm[valid])
|
||||
|
||||
def test_no_holes_returns_unchanged(self):
|
||||
"""No NaN → returns same array and zero count."""
|
||||
from lidar_pipeline.dtm import _interpolate_holes
|
||||
dtm = np.arange(64, dtype=float).reshape(8, 8)
|
||||
filled, count = _interpolate_holes(dtm)
|
||||
assert count == 0
|
||||
assert np.shares_memory(filled, dtm)
|
||||
|
||||
def test_all_nan_returns_unchanged(self):
|
||||
"""No valid data → cannot interpolate, returns zeros-free NaN array."""
|
||||
from lidar_pipeline.dtm import _interpolate_holes
|
||||
dtm = np.full((8, 8), np.nan)
|
||||
filled, count = _interpolate_holes(dtm)
|
||||
assert count == 0
|
||||
assert np.isnan(filled).all()
|
||||
|
||||
|
||||
class TestMinReturnGrid:
|
||||
def test_takes_lowest_return_per_cell(self, tmp_output_dir):
|
||||
"""_min_return_grid rasterise le point le plus bas par cellule (pas la moyenne)."""
|
||||
import laspy
|
||||
from lidar_pipeline.dtm import _min_return_grid
|
||||
out = tmp_output_dir / "pts.las"
|
||||
hdr = laspy.LasHeader(version='1.2', point_format=0)
|
||||
las = laspy.LasData(hdr)
|
||||
# Grille 2x2 sur [0,2]x[0,2]. La cellule (0,0) porte deux points z=5 et
|
||||
# z=2 (min=2, moyenne=3.5) ; (1,0) z=3 ; (0,1) z=4 ; (1,1) vide.
|
||||
las.x = [0.2, 0.5, 1.2, 0.3]
|
||||
las.y = [0.2, 0.3, 0.4, 1.5]
|
||||
las.z = [5.0, 2.0, 3.0, 4.0]
|
||||
las.write(str(out))
|
||||
|
||||
grid = _min_return_grid(out, 2, 2, (0.0, 0.0, 2.0, 2.0))
|
||||
assert grid.shape == (2, 2)
|
||||
assert int(np.isnan(grid).sum()) == 1
|
||||
# Une seule valeur par cellule, et la cellule (0,0) vaut le MIN (2.0).
|
||||
vals = sorted(float(v) for v in grid[~np.isnan(grid)])
|
||||
assert vals == [2.0, 3.0, 4.0]
|
||||
assert 3.5 not in vals
|
||||
|
||||
|
||||
class TestBareEarth:
|
||||
"""Le plancher « sol nu » ramène le DTM au retour le plus bas de chaque cellule."""
|
||||
|
||||
def _write_las(self, path, points):
|
||||
"""points: list of (x, y, z). Écrit un LAS 1.2 format 0 aux bornes [0,2]x[0,2]."""
|
||||
import laspy
|
||||
hdr = laspy.LasHeader(version='1.2', point_format=0)
|
||||
las = laspy.LasData(hdr)
|
||||
las.x = [p[0] for p in points]
|
||||
las.y = [p[1] for p in points]
|
||||
las.z = [p[2] for p in points]
|
||||
las.write(str(path))
|
||||
return path
|
||||
|
||||
def _make_clouds(self, tmp_output_dir):
|
||||
"""Grille 2x2 (res=1.0). Les points « coin » à 0.05/1.95 imposent l'étendue
|
||||
[0.05,1.95] (laspy re-déduit les bornes de l'en-tête depuis les points).
|
||||
Le sol (las_file) vaut z=10 partout. Le nuage complet (source_laz) a un
|
||||
retour plus bas dans les cellules (0,0) -> 2 et (1,0) -> 5 ; les deux autres
|
||||
cellules n'ont que z=10."""
|
||||
ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0), (1.5, 1.5, 10.0),
|
||||
(0.05, 0.05, 10.0), (1.95, 1.95, 10.0)]
|
||||
source = list(ground) + [(0.3, 0.3, 2.0), (1.3, 0.3, 5.0)]
|
||||
las_file = self._write_las(tmp_output_dir / "ground.las", ground)
|
||||
source_laz = self._write_las(tmp_output_dir / "source.las", source)
|
||||
return las_file, source_laz
|
||||
|
||||
def _dtm_values(self, tmp_output_dir, bare_earth):
|
||||
from lidar_pipeline.dtm import create_dtm_fast
|
||||
import rasterio
|
||||
las_file, source_laz = self._make_clouds(tmp_output_dir)
|
||||
out = create_dtm_fast(las_file, "tile", tmp_output_dir, 1.0,
|
||||
force=True, source_laz=source_laz, bare_earth=bare_earth)
|
||||
assert out is not None
|
||||
with rasterio.open(str(out)) as src:
|
||||
arr = src.read(1).astype("float64")
|
||||
return arr[~np.isnan(arr)]
|
||||
|
||||
def test_bare_earth_pulls_dtm_to_lowest_return(self, tmp_output_dir):
|
||||
"""Avec bare_earth, le DTM descend aux retours les plus bas (2 et 5)."""
|
||||
vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=True))
|
||||
# Les cellules sans retour plus bas restent à 10 ; les deux autres descendent.
|
||||
assert vals == [2.0, 5.0, 10.0, 10.0]
|
||||
assert vals[0] == 2.0 and vals[1] == 5.0
|
||||
|
||||
def test_no_bare_earth_keeps_mean(self, tmp_output_dir):
|
||||
"""Sans bare_earth, le DTM garde la moyenne des points sol (10 partout)."""
|
||||
vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=False))
|
||||
assert all(v == 10.0 for v in vals)
|
||||
|
||||
|
||||
class TestDetectGroundMethod:
|
||||
def _make_mock_las(self, num_returns, z_values):
|
||||
"""Create a mock laspy object with specified NumberOfReturns and z."""
|
||||
@ -162,6 +277,73 @@ class TestDetectGroundMethod:
|
||||
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):
|
||||
@ -211,4 +393,158 @@ class TestClassifyGroundMethod:
|
||||
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
|
||||
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
|
||||
|
||||
98
lidar_pipeline/tests/test_fetch_ign.py
Normal file
98
lidar_pipeline/tests/test_fetch_ign.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Tests du téléchargement des dalles LiDAR HD de l'IGN (fetch_ign)."""
|
||||
|
||||
|
||||
def test_parse_tile_specs():
|
||||
"""Accepte 'col,row', 'col:row' et ignore les espaces."""
|
||||
from lidar_pipeline.fetch_ign import parse_tile_specs
|
||||
assert parse_tile_specs(["1055,6882"]) == [(1055, 6882)]
|
||||
assert parse_tile_specs(["1055:6883", " 651 , 6630 "]) == [(1055, 6883), (651, 6630)]
|
||||
|
||||
|
||||
def test_parse_tile_specs_rejects_invalid():
|
||||
"""Les spécifications mal formées lèvent une erreur explicite."""
|
||||
import pytest
|
||||
from lidar_pipeline.fetch_ign import parse_tile_specs
|
||||
with pytest.raises(ValueError):
|
||||
parse_tile_specs(["1055"])
|
||||
with pytest.raises(ValueError):
|
||||
parse_tile_specs(["abc,def"])
|
||||
with pytest.raises(ValueError):
|
||||
parse_tile_specs(["1055,6882,9999"])
|
||||
|
||||
|
||||
def test_tile_filename_pads_coordinates():
|
||||
"""Le nom de fichier DALLE utilise des coordonnées à 4 chiffres."""
|
||||
from lidar_pipeline.fetch_ign import tile_filename
|
||||
assert tile_filename(1055, 6882) == "LHD_FXX_1055_6882_PTS_LAMB93_IGN69.copc.laz"
|
||||
assert tile_filename(651, 6630) == "LHD_FXX_0651_6630_PTS_LAMB93_IGN69.copc.laz"
|
||||
|
||||
|
||||
def test_match_feature_uses_coordonnees_nw():
|
||||
"""La correspondance se fait sur lidarhd:coordonnees_NW (format 0816-6847)."""
|
||||
from lidar_pipeline.fetch_ign import match_feature
|
||||
features = [
|
||||
{"id": "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_NE",
|
||||
"properties": {"lidarhd:coordonnees_NW": "1054-6882"},
|
||||
"assets": {"data": {"href": "https://example.org/a.copc.laz"}}},
|
||||
{"id": "LHD_FXX_1055_6882_PTS_LAMB93_IGN69_NE",
|
||||
"properties": {"lidarhd:coordonnees_NW": "1055-6882"},
|
||||
"assets": {"data": {"href": "https://example.org/b.copc.laz"}}},
|
||||
]
|
||||
assert match_feature(features, 1055, 6882) is features[1]
|
||||
assert match_feature(features, 9999, 9999) is None
|
||||
|
||||
|
||||
def test_find_tile_url_matches_and_returns_href(monkeypatch):
|
||||
"""find_tile_url interroge le STAC et retourne l'href de l'asset data."""
|
||||
from lidar_pipeline import fetch_ign
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload.encode("utf-8")
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
payload = ('{"features": [{"properties": {"lidarhd:coordonnees_NW": "1055-6882"},'
|
||||
'"assets": {"data": {"href": "https://data.geopf.fr/x.copc.laz"}}}]}')
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
return FakeResponse(payload)
|
||||
|
||||
monkeypatch.setattr(fetch_ign.urllib.request, "urlopen", fake_urlopen)
|
||||
url = fetch_ign.find_tile_url(1055, 6882)
|
||||
assert url == "https://data.geopf.fr/x.copc.laz"
|
||||
assert "api.stac.teledetection.fr" in captured["url"]
|
||||
assert "bbox=" in captured["url"]
|
||||
|
||||
|
||||
def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
|
||||
"""Pas de téléchargement si le LAZ existe ou si les visualisations existent."""
|
||||
from lidar_pipeline import fetch_ign
|
||||
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
vis_dir.mkdir(parents=True)
|
||||
|
||||
# 1054,6882 : visualisations déjà générées
|
||||
# 1055,6882 : LAZ déjà présent
|
||||
existing = input_dir / fetch_ign.tile_filename(1055, 6882)
|
||||
existing.write_bytes(b"naze")
|
||||
|
||||
def fail_download(*args, **kwargs):
|
||||
raise AssertionError("ne doit pas être appelé")
|
||||
|
||||
monkeypatch.setattr(fetch_ign, "find_tile_url", fail_download)
|
||||
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
|
||||
output_dir=output_dir)
|
||||
assert result == []
|
||||
@ -60,7 +60,11 @@ def test_compute_bbox_empty():
|
||||
|
||||
|
||||
def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi', 'svf'), ext='webp', res_suffix=''):
|
||||
"""Crée un faux dossier de visualisations avec de petites images."""
|
||||
"""Crée un faux dossier de visualisations avec de petites images.
|
||||
|
||||
Le suffixe de résolution apparaît seulement dans le nom du dossier (miroir
|
||||
du pipeline : les fichiers restent préfixés par le basename nu).
|
||||
"""
|
||||
from PIL import Image as PILImage
|
||||
import numpy as np
|
||||
|
||||
@ -70,7 +74,7 @@ def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi',
|
||||
for v in viz_keys:
|
||||
arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
|
||||
img = PILImage.fromarray(arr)
|
||||
fname = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}_{v}.{ext}"
|
||||
fname = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69_{v}.{ext}"
|
||||
img.save(str(tile_dir / fname), format='WEBP', quality=80)
|
||||
return tile_dir
|
||||
|
||||
@ -124,6 +128,85 @@ def test_scan_tiles_multi_resolution(tmp_path):
|
||||
assert resolutions == [0.2, 0.5]
|
||||
|
||||
|
||||
def test_res_suffix_str():
|
||||
"""Le suffixe de résolution reflète le nommage du pipeline (miroir)."""
|
||||
from lidar_pipeline.index import _res_suffix_str
|
||||
assert _res_suffix_str(0.5) == ''
|
||||
assert _res_suffix_str(0.2) == '_r0p2'
|
||||
|
||||
|
||||
def test_collect_tile_metadata(tmp_path):
|
||||
"""Les métadonnées lisent la méthode DTM et les dates/tailles des viz."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from lidar_pipeline.index import _collect_tile_metadata
|
||||
|
||||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
tile_dir = tmp_path / "visualisations" / basename
|
||||
tile_dir.mkdir(parents=True)
|
||||
viz_file = tile_dir / f"{basename}_hillshade_multi.webp"
|
||||
viz_file.write_bytes(b"fake")
|
||||
|
||||
dtm_dir = tmp_path / "DTM"
|
||||
dtm_dir.mkdir()
|
||||
method_file = dtm_dir / f"{basename}_dtm_method.txt"
|
||||
method_file.write_text("ign", encoding="utf-8")
|
||||
# Dates déterministes : method.txt plus ancien que la viz
|
||||
os.utime(method_file, (1600000000, 1600000000))
|
||||
os.utime(viz_file, (1700000000, 1700000000))
|
||||
fmt = lambda ts: datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
tile = {
|
||||
'basename': basename, 'resolution': 0.5,
|
||||
'dir_path': str(tile_dir),
|
||||
'viz': {'hillshade_multi': {'filename': viz_file.name, 'ext': 'webp'}},
|
||||
}
|
||||
meta = _collect_tile_metadata(tile, dtm_dir)
|
||||
assert meta['method'] == 'ign'
|
||||
assert meta['generated'] == fmt(1600000000)
|
||||
assert meta['viz']['hillshade_multi']['size'] == 4
|
||||
assert meta['viz']['hillshade_multi']['date'] == fmt(1700000000)
|
||||
|
||||
|
||||
def test_collect_tile_metadata_resolution_suffix(tmp_path):
|
||||
"""Une tuile 0,2 m lit son sidecar _dtm_r0p2_method.txt dédié."""
|
||||
from lidar_pipeline.index import _collect_tile_metadata
|
||||
|
||||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
tile_dir = tmp_path / "visualisations" / (basename + "_r0p2")
|
||||
tile_dir.mkdir(parents=True)
|
||||
dtm_dir = tmp_path / "DTM"
|
||||
dtm_dir.mkdir()
|
||||
(dtm_dir / f"{basename}_dtm_r0p2_method.txt").write_text("smrf", encoding="utf-8")
|
||||
|
||||
tile = {'basename': basename, 'resolution': 0.2,
|
||||
'dir_path': str(tile_dir),
|
||||
'viz': {}}
|
||||
meta = _collect_tile_metadata(tile, dtm_dir)
|
||||
assert meta['method'] == 'smrf'
|
||||
# La date vient du sidecar (écrit juste après la création du DTM)
|
||||
assert meta['generated'] is not None
|
||||
assert meta['viz'] == {}
|
||||
|
||||
|
||||
def test_collect_tile_metadata_fallback_date(tmp_path):
|
||||
"""Sans sidecar DTM, la date de génération remonte au plus ancien fichier viz."""
|
||||
from lidar_pipeline.index import _collect_tile_metadata
|
||||
|
||||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
tile_dir = tmp_path / "visualisations" / basename
|
||||
tile_dir.mkdir(parents=True)
|
||||
f = tile_dir / f"{basename}_svf.webp"
|
||||
f.write_bytes(b"x")
|
||||
|
||||
tile = {'basename': basename, 'resolution': 0.5,
|
||||
'dir_path': str(tile_dir),
|
||||
'viz': {'svf': {'filename': f.name, 'ext': 'webp'}}}
|
||||
meta = _collect_tile_metadata(tile, tmp_path / "DTM")
|
||||
assert meta['method'] is None
|
||||
assert meta['generated'] is not None
|
||||
|
||||
|
||||
def test_build_index_generates_html(tmp_path):
|
||||
"""build_index génère index.html et les vignettes."""
|
||||
from lidar_pipeline.index import build_index
|
||||
@ -142,11 +225,18 @@ def test_build_index_generates_html(tmp_path):
|
||||
|
||||
content = html_path.read_text(encoding='utf-8')
|
||||
# Vérifie la présence des éléments clés
|
||||
assert "Carte continue LiDAR" in content
|
||||
assert "Carte LiDAR" in content
|
||||
assert "LHD_FXX_1000_6881" in content
|
||||
assert "LHD_FXX_1001_6881" in content
|
||||
# Vérifie que le JSON intégré est valide
|
||||
assert "const DATA" in content
|
||||
assert "const TILES" in content
|
||||
# Vérifie les assets de l'interface (CSS/JS séparés)
|
||||
assets = output_dir / "assets"
|
||||
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
||||
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
||||
assert "Couches" in app_js or "layers" in app_js
|
||||
assert 'assets/app.css' in content
|
||||
assert 'assets/app.js' in content
|
||||
# Vérifie les vignettes générées
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
assert thumb_dir.is_dir()
|
||||
@ -154,6 +244,68 @@ def test_build_index_generates_html(tmp_path):
|
||||
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
|
||||
|
||||
|
||||
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||
"""Une tuile recalculée (source plus récente) régénère sa vignette."""
|
||||
import os
|
||||
import time
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
thumb_path = output_dir / "index_thumbs" / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.jpg"
|
||||
assert thumb_path.exists()
|
||||
m1 = thumb_path.stat().st_mtime
|
||||
|
||||
# Recalcul de la tuile : source réécrite avec une mtime plus récente
|
||||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||
arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
|
||||
PILImage.fromarray(arr).save(str(src), format='WEBP', quality=80)
|
||||
os.utime(src, (m1 + 5, m1 + 5))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
m2 = thumb_path.stat().st_mtime
|
||||
assert m2 > m1 # vignette régénérée
|
||||
|
||||
# Source non modifiée depuis → pas de régénération inutile
|
||||
os.utime(src, (time.time() - 10, time.time() - 10))
|
||||
assert build_index(output_dir) is not None
|
||||
assert thumb_path.stat().st_mtime == m2
|
||||
|
||||
|
||||
def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
||||
"""Une dalle 0,2 m recalculée régénère ses sous-tuiles (par visualisation)."""
|
||||
import os
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881,
|
||||
('hillshade_multi', 'aspect'), res_suffix='_r0p2')
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
sub_dir = output_dir / "index_subtiles"
|
||||
hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif"
|
||||
aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif"
|
||||
assert hill_avif.exists() and aspect_avif.exists()
|
||||
m_hill_1 = hill_avif.stat().st_mtime
|
||||
m_aspect_1 = aspect_avif.stat().st_mtime
|
||||
|
||||
# Recalcul : seule la source hillshade est plus récente
|
||||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||
os.utime(src, (m_hill_1 + 5, m_hill_1 + 5))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
assert hill_avif.stat().st_mtime > m_hill_1 # sous-tuiles hillshade régénérées
|
||||
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect intact
|
||||
|
||||
|
||||
def test_build_index_empty_returns_none(tmp_path):
|
||||
"""Aucune tuile → build_index retourne None sans crash."""
|
||||
from lidar_pipeline.index import build_index
|
||||
@ -175,26 +327,66 @@ def test_build_index_embeds_valid_json(tmp_path):
|
||||
|
||||
build_index(output_dir)
|
||||
content = (output_dir / "index.html").read_text(encoding='utf-8')
|
||||
# Extrait le JSON entre "const DATA = " et ";"
|
||||
start = content.index("const DATA = ") + len("const DATA = ")
|
||||
# Extrait le JSON entre "const TILES = " et la fin de déclaration
|
||||
start = content.index("const TILES = ") + len("const TILES = ")
|
||||
# Trouve le ; de fin de déclaration
|
||||
depth = 0
|
||||
end = start
|
||||
for i, ch in enumerate(content[start:], start):
|
||||
if ch == '{':
|
||||
if ch in ('{', '['):
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
elif ch in ('}', ']'):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
data = json.loads(content[start:end])
|
||||
assert 'tiles' in data
|
||||
assert 'bbox' in data
|
||||
assert 'vizList' in data
|
||||
assert len(data['tiles']) == 1
|
||||
assert data['tiles'][0]['col'] == 1000
|
||||
assert data['tiles'][0]['row'] == 6881
|
||||
assert len(data) > 0
|
||||
assert data[0]['col'] == 1000
|
||||
assert data[0]['row'] == 6881
|
||||
|
||||
|
||||
def test_attach_gps_bounds():
|
||||
"""attach_gps_bounds ajoute des bounds GPS ordonnées (France métropolitaine)."""
|
||||
from lidar_pipeline.index import attach_gps_bounds
|
||||
tiles = [{'col': 1000, 'row': 6881}, {'col': 1042, 'row': 6900}]
|
||||
attach_gps_bounds(tiles)
|
||||
for t in tiles:
|
||||
assert 'bounds' in t
|
||||
(lat_s, lon_w), (lat_n, lon_e) = t['bounds']
|
||||
assert lat_n > lat_s
|
||||
assert lon_e > lon_w
|
||||
# France métropolitaine
|
||||
assert 41 < lat_s < 51
|
||||
assert -5 < lon_w < 10
|
||||
|
||||
|
||||
def test_attach_gps_bounds_row_is_north_edge():
|
||||
"""Le numéro de ligne du fichier = bord NORD (convention LiDAR HD IGN).
|
||||
|
||||
Vérifié sur les bounds des DTM : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
|
||||
La régression historique plaçait Y ∈ [row, row+1] (1 km trop au nord).
|
||||
"""
|
||||
from rasterio.warp import transform as warp_transform
|
||||
from lidar_pipeline.index import attach_gps_bounds
|
||||
|
||||
col, row = 1054, 6882
|
||||
tiles = [{'col': col, 'row': row}]
|
||||
attach_gps_bounds(tiles)
|
||||
corners = tiles[0]['corners']
|
||||
|
||||
# Référence exacte de la vraie cellule : SW, SE, NE, NW
|
||||
xs = [col * 1000, (col + 1) * 1000, (col + 1) * 1000, col * 1000]
|
||||
ys = [(row - 1) * 1000, (row - 1) * 1000, row * 1000, row * 1000]
|
||||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
|
||||
for k in range(4):
|
||||
assert abs(corners[k][0] - lats[k]) < 1e-9
|
||||
assert abs(corners[k][1] - lons[k]) < 1e-9
|
||||
|
||||
# L'ancienne convention (row = bord sud) serait décalée d'environ 1 km
|
||||
lat_n = max(c[0] for c in corners)
|
||||
assert abs(lat_n - max(lats)) < 1e-9 # bord nord = Y = row×1000
|
||||
|
||||
|
||||
|
||||
def test_pick_display_viz_prefers_hillshade():
|
||||
@ -203,3 +395,30 @@ def test_pick_display_viz_prefers_hillshade():
|
||||
assert _pick_display_viz(['svf', 'hillshade_multi', 'slope']) == 'hillshade_multi'
|
||||
assert _pick_display_viz(['svf', 'slope']) == 'svf'
|
||||
assert _pick_display_viz(['topo']) == 'topo'
|
||||
|
||||
|
||||
def test_subdivision_k():
|
||||
"""0,5 m/px (2000 px) reste entier ; 0,2 m/px (5000 px) est découpé en 2×2."""
|
||||
from lidar_pipeline.index import _subdivision_k
|
||||
assert _subdivision_k(0.5) == 1
|
||||
assert _subdivision_k(0.2) == 2
|
||||
assert _subdivision_k(1.0) == 1
|
||||
|
||||
|
||||
def test_subtile_corners_grid():
|
||||
"""Les sous-tuiles reconstruisent exactement la grille de la dalle."""
|
||||
from lidar_pipeline.index import _subtile_corners
|
||||
corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]] # SW SE NE NW
|
||||
k = 2
|
||||
sw_quad = _subtile_corners(corners, 0, 0, k) # quadrant sud-ouest
|
||||
ne_quad = _subtile_corners(corners, 1, 1, k) # quadrant nord-est
|
||||
# Le quadrant SW partage le coin SW de la dalle
|
||||
assert sw_quad[0] == corners[0]
|
||||
# Le quadrant NE partage le coin NE de la dalle
|
||||
assert ne_quad[2] == corners[2]
|
||||
# Le quadrant SW a son coin NE au centre de la dalle
|
||||
assert sw_quad[2] == [10.5, 2.5]
|
||||
# Adjacence : bord est du SW = bord ouest du SE (0,0)-(1,0)
|
||||
se_quad = _subtile_corners(corners, 1, 0, k)
|
||||
assert sw_quad[1] == se_quad[0]
|
||||
assert sw_quad[2] == se_quad[3]
|
||||
|
||||
@ -70,4 +70,99 @@ class TestLidarArchaeoPipeline:
|
||||
names = [f.name for f in files]
|
||||
assert "test.laz" in names
|
||||
assert "other.las" in names
|
||||
assert "readme.txt" not in names
|
||||
assert "readme.txt" not in names
|
||||
|
||||
|
||||
class TestDtmMethodSidecar:
|
||||
"""Méthode de classification enregistrée à côté du DTM (invalidation du cache)."""
|
||||
|
||||
def test_missing_sidecar_matches(self, tmp_path):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"), ground_method='csf')
|
||||
# Aucun sidecar écrit → cache conservé (considéré compatible).
|
||||
assert pipeline._dtm_method_matches("tileA", "") is True
|
||||
|
||||
def test_matching_method(self, tmp_path):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"), ground_method='csf')
|
||||
pipeline._write_dtm_method("tileA", "")
|
||||
assert pipeline._dtm_method_matches("tileA", "") is True
|
||||
|
||||
def test_different_method_invalidates_cache(self, tmp_path):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
out = str(tmp_path / "output")
|
||||
LidarArchaeoPipeline(str(input_dir), out, ground_method='ign')._write_dtm_method("tileA", "")
|
||||
csf = LidarArchaeoPipeline(str(input_dir), out, ground_method='csf')
|
||||
assert csf._dtm_method_matches("tileA", "") is False
|
||||
|
||||
def test_write_dtm_method(self, tmp_path):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"), ground_method='smrf')
|
||||
pipeline._write_dtm_method("tileA", "_r0p2")
|
||||
sidecar = tmp_path / "output" / "DTM" / "tileA_dtm_r0p2_method.txt"
|
||||
assert sidecar.exists()
|
||||
assert sidecar.read_text(encoding="utf-8").strip() == "smrf"
|
||||
assert pipeline._dtm_method_name("tileA", "_r0p2") == "smrf"
|
||||
# Le sidecar est un fichier .txt : il ne gêne pas la recherche des DTM .tif.
|
||||
dtm = tmp_path / "output" / "DTM" / "tileA_dtm_r0p2.tif"
|
||||
dtm.touch()
|
||||
assert [p.name for p in (tmp_path / "output" / "DTM").glob("*.tif")] == ["tileA_dtm_r0p2.tif"]
|
||||
|
||||
def test_force_images_regenerates_existing(self, tmp_path):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"), output_format='avif')
|
||||
calls = []
|
||||
|
||||
def fake_ortho(dem_file, basename, vis_dir, resolution):
|
||||
calls.append(basename)
|
||||
return vis_dir / f"{basename}_ortho.avif"
|
||||
|
||||
pipeline.viz_steps = [('ortho', fake_ortho)]
|
||||
vis_dir = tmp_path / "output" / "visualisations" / "tileA"
|
||||
vis_dir.mkdir(parents=True)
|
||||
(vis_dir / "tileA_ortho.avif").touch()
|
||||
dtm = tmp_path / "dtm.tif"
|
||||
|
||||
# Image existante, pas de force → ignorée (pas de régénération).
|
||||
pipeline.generate_all_visualizations(dtm, "tileA", resolution=0.5, vis_dir=vis_dir, force_images=False)
|
||||
assert calls == []
|
||||
|
||||
# Image existante, force_images=True → régénérée.
|
||||
calls.clear()
|
||||
pipeline.generate_all_visualizations(dtm, "tileA", resolution=0.5, vis_dir=vis_dir, force_images=True)
|
||||
assert calls == ["tileA"]
|
||||
|
||||
class TestEffectiveGroundMethod:
|
||||
def test_ign_label_encodes_classes(self):
|
||||
"""Les classes IGN sont encodées dans l'étiquette de cache (reclassification)."""
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
p = LidarArchaeoPipeline(tmpdir, tmpdir, ground_method='ign',
|
||||
ign_classes="sol,unclassified")
|
||||
assert p._effective_ground_method() == "ign_1_2"
|
||||
|
||||
def test_ign_default_label(self):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
p = LidarArchaeoPipeline(tmpdir, tmpdir, ground_method='ign')
|
||||
assert p._effective_ground_method() == "ign"
|
||||
|
||||
def test_other_methods_unchanged(self):
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
p = LidarArchaeoPipeline(tmpdir, tmpdir, ground_method='smrf',
|
||||
ign_classes="sol,unclassified")
|
||||
assert p._effective_ground_method() == "smrf"
|
||||
|
||||
@ -95,4 +95,58 @@ class TestApplyColormap:
|
||||
tif_file = _make_test_tif(tmp_path, data)
|
||||
result = tif_to_png(tif_file, tmp_path, 5.0)
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
assert result.exists()
|
||||
|
||||
|
||||
class TestTifToCrop:
|
||||
"""Conversion TIF → dalle cartographique (tif_to_crop)."""
|
||||
|
||||
@staticmethod
|
||||
def _write_named_tif(tmp_path, name, arr):
|
||||
transform = from_bounds(660000, 6700000, 661000, 6701000, arr.shape[1], arr.shape[0])
|
||||
tif_file = tmp_path / name
|
||||
with rasterio.open(
|
||||
tif_file, 'w', driver='GTiff', height=arr.shape[0], width=arr.shape[1],
|
||||
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
|
||||
nodata=float('nan'), compress='lzw'
|
||||
) as dst:
|
||||
dst.write(arr.astype('float32'), 1)
|
||||
return tif_file
|
||||
|
||||
def test_nodata_renders_black(self, tmp_path):
|
||||
"""Le nodata restant est rendu en noir (comportement historique).
|
||||
|
||||
Les trous du MNT sont comblés en amont (interpolation dans
|
||||
create_dtm_fast, tous modes) ; ce qui reste en nodata doit rester
|
||||
visible en noir sur la dalle plutôt qu'inventé au rendu.
|
||||
"""
|
||||
from PIL import Image as PILImage
|
||||
from lidar_pipeline.rendering import tif_to_crop
|
||||
|
||||
data = np.random.default_rng(7).normal(50, 10, (40, 40)).astype(np.float32)
|
||||
data[15:25, 15:25] = np.nan
|
||||
tif_file = self._write_named_tif(tmp_path, "LHD_test_slope.tif", data)
|
||||
|
||||
# WebP lossless : l'encodeur AVIF de l'image « saigne » légèrement les
|
||||
# bords du noir même en lossless — on teste la logique nodata→noir,
|
||||
# pas les artefacts du codec.
|
||||
out = tif_to_crop(tif_file, tmp_path, 5.0, keep_tif=True,
|
||||
quality=100, output_format='webp')
|
||||
assert out is not None and out.exists()
|
||||
|
||||
rgb = np.asarray(PILImage.open(str(out)).convert('RGB'))
|
||||
hole = rgb[15:25, 15:25, :]
|
||||
assert np.all(hole == 0), "le nodata doit être rendu en noir"
|
||||
|
||||
def test_without_nodata(self, tmp_path):
|
||||
"""Un TIF sans nodata est converti sans crash, taille préservée."""
|
||||
from PIL import Image as PILImage
|
||||
from lidar_pipeline.rendering import tif_to_crop
|
||||
|
||||
data = np.random.default_rng(7).normal(50, 10, (40, 40)).astype(np.float32)
|
||||
tif_file = self._write_named_tif(tmp_path, "LHD_test_slope.tif", data)
|
||||
|
||||
out = tif_to_crop(tif_file, tmp_path, 5.0)
|
||||
assert out is not None and out.exists()
|
||||
img = PILImage.open(str(out))
|
||||
assert img.size == (40, 40)
|
||||
@ -157,3 +157,61 @@ class TestRayTrace:
|
||||
)
|
||||
assert pos.shape == (4, 2, rows, cols)
|
||||
assert neg.shape == (4, 2, rows, cols)
|
||||
|
||||
|
||||
class TestNodataPreserved:
|
||||
"""Nodata préservé dans les rendus (comportement historique).
|
||||
|
||||
Les trous du MNT sont comblés en amont (create_dtm_fast, tous modes) ;
|
||||
si un nodata subsiste malgré tout, hillshade/slope/aspect le restituent
|
||||
(rendu noir en carte) au lieu d'inventer des valeurs interpolées.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _dem_with_hole(synthetic_dem, tmp_path):
|
||||
import rasterio
|
||||
with rasterio.open(synthetic_dem) as src:
|
||||
arr = src.read(1).copy()
|
||||
profile = src.profile.copy()
|
||||
arr[80:120, 80:120] = np.nan
|
||||
dem_hole = tmp_path / "dem_hole.tif"
|
||||
profile.update(dtype='float32', nodata=float('nan'))
|
||||
with rasterio.open(dem_hole, 'w', **profile) as dst:
|
||||
dst.write(arr.astype('float32'), 1)
|
||||
return dem_hole
|
||||
|
||||
def test_aspect_solo_preserves_nodata(self, synthetic_dem, tmp_path):
|
||||
from lidar_pipeline.visualizations import generate_aspect
|
||||
dem_hole = self._dem_with_hole(synthetic_dem, tmp_path)
|
||||
out = generate_aspect(dem_hole, "solo", tmp_path, 5.0)
|
||||
assert out is not None and out.exists()
|
||||
import rasterio
|
||||
with rasterio.open(out) as src:
|
||||
data = src.read(1)
|
||||
assert np.isnan(data[80:120, 80:120]).all(), "le trou doit rester en nodata"
|
||||
# Le gradient au bord du trou propage NaN sur un anneau de 1 px :
|
||||
# on vérifie une zone éloignée du trou
|
||||
assert not np.isnan(data[0:40, 0:40]).any(), "NaN loin du trou"
|
||||
|
||||
def test_aspect_shared_preserves_nodata(self, synthetic_dem, tmp_path):
|
||||
from lidar_pipeline.visualizations import SharedDEM, generate_aspect
|
||||
dem_hole = self._dem_with_hole(synthetic_dem, tmp_path)
|
||||
shared = SharedDEM(dem_hole, 5.0)
|
||||
out = generate_aspect(dem_hole, "partage", tmp_path, 5.0, shared=shared)
|
||||
assert out is not None and out.exists()
|
||||
import rasterio
|
||||
with rasterio.open(out) as src:
|
||||
data = src.read(1)
|
||||
assert np.isnan(data[80:120, 80:120]).all(), "le trou doit rester en nodata"
|
||||
assert not np.isnan(data[0:40, 0:40]).any(), "NaN loin du trou"
|
||||
|
||||
def test_slope_and_hillshade_preserve_nodata(self, synthetic_dem, tmp_path):
|
||||
from lidar_pipeline.visualizations import generate_slope, generate_hillshade
|
||||
dem_hole = self._dem_with_hole(synthetic_dem, tmp_path)
|
||||
import rasterio
|
||||
for gen, name in ((generate_slope, "p"), (generate_hillshade, "h")):
|
||||
out = gen(dem_hole, name, tmp_path, 5.0)
|
||||
assert out is not None and out.exists()
|
||||
with rasterio.open(out) as src:
|
||||
data = src.read(1)
|
||||
assert np.isnan(data[80:120, 80:120]).any(), f"{out.name} : trou disparu"
|
||||
|
||||
81
lidar_pipeline/tests/test_webapp.py
Normal file
81
lidar_pipeline/tests/test_webapp.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Tests du serveur web de génération de zones (webapp)."""
|
||||
|
||||
|
||||
def test_bbox_to_cells_single_km_cell():
|
||||
"""Une bbox couvrant ~1 km² retourne la cellule L93 correspondante."""
|
||||
from lidar_pipeline.webapp import bbox_to_cells
|
||||
# Cellule 1054,6882 : X∈[1054000,1055000], Y∈[6881000,6882000] (L93)
|
||||
from rasterio.warp import transform as warp_transform
|
||||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326',
|
||||
[1054100, 1054900], [6881100, 6881900])
|
||||
cells = bbox_to_cells(min(lons), min(lats), max(lons), max(lats))
|
||||
assert (1054, 6882) in cells
|
||||
# La sélection reste locale : pas de cellule lointaine
|
||||
for (c, r) in cells:
|
||||
assert abs(c - 1054) <= 1 and abs(r - 6882) <= 1
|
||||
|
||||
|
||||
def test_bbox_to_cells_empty_for_tiny_bbox():
|
||||
"""Une bbox quasi ponctuelle ne sélectionne rien (rétrécie sous 1 m)."""
|
||||
from lidar_pipeline.webapp import bbox_to_cells
|
||||
assert bbox_to_cells(7.850000, 48.930000, 7.850001, 48.930001) == []
|
||||
|
||||
|
||||
def test_processed_cells(tmp_path):
|
||||
"""processed_cells lit les dossiers de visualisations."""
|
||||
from lidar_pipeline.webapp import processed_cells
|
||||
vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2"
|
||||
vis.mkdir(parents=True)
|
||||
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2_aspect.avif").write_bytes(b"x")
|
||||
assert processed_cells(tmp_path) == {(1054, 6882)}
|
||||
|
||||
|
||||
def test_missing_cells_filters_processed(tmp_path):
|
||||
"""Les cellules déjà traitées sont exclues, les autres gardent leurs coins."""
|
||||
from lidar_pipeline.webapp import missing_cells_with_corners
|
||||
vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
vis.mkdir(parents=True)
|
||||
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x")
|
||||
todo = missing_cells_with_corners([(1054, 6882), (1055, 6882)], tmp_path)
|
||||
assert len(todo) == 1
|
||||
assert todo[0]['col'] == 1055 and todo[0]['row'] == 6882
|
||||
assert len(todo[0]['corners']) == 4 # SW, SE, NE, NW
|
||||
|
||||
|
||||
def test_missing_cells_include_done(tmp_path):
|
||||
"""include_done=True conserve les cellules déjà traitées (régénération)."""
|
||||
from lidar_pipeline.webapp import missing_cells_with_corners
|
||||
vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
||||
vis.mkdir(parents=True)
|
||||
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x")
|
||||
todo = missing_cells_with_corners([(1054, 6882), (1055, 6882)], tmp_path,
|
||||
include_done=True)
|
||||
assert {(t['col'], t['row']) for t in todo} == {(1054, 6882), (1055, 6882)}
|
||||
|
||||
|
||||
def test_build_command_regenerate():
|
||||
"""regenerate=True ajoute --force --force-classification à la commande."""
|
||||
from lidar_pipeline.webapp import _build_command
|
||||
cmd = " ".join(_build_command([(1054, 6882)], regenerate=True))
|
||||
assert "--force" in cmd
|
||||
assert "--force-classification" in cmd
|
||||
cmd = " ".join(_build_command([(1054, 6882)]))
|
||||
assert "--force" not in cmd
|
||||
assert "--force-classification" not in cmd
|
||||
|
||||
|
||||
def test_build_command_ground_classification():
|
||||
"""La commande utilise la méthode de classification demandée (défaut : ign)."""
|
||||
from lidar_pipeline.webapp import _build_command, GROUND_CLASS_METHODS
|
||||
# Défaut : ign (pré-classification)
|
||||
cmd = _build_command([(1054, 6882)])
|
||||
i = cmd.index("--ground-classification")
|
||||
assert cmd[i + 1] == "ign"
|
||||
# Chaque méthode valide est transmise telle quelle, avec ou sans régénération
|
||||
for method in GROUND_CLASS_METHODS:
|
||||
for regenerate in (False, True):
|
||||
cmd = _build_command([(1054, 6882)], regenerate=regenerate, ground_class=method)
|
||||
i = cmd.index("--ground-classification")
|
||||
assert cmd[i + 1] == method
|
||||
assert ("--force" in cmd) == regenerate
|
||||
assert ("--force-classification" in cmd) == regenerate
|
||||
Reference in New Issue
Block a user