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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user