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:
Antoine Jacquin
2026-08-31 18:07:14 +02:00
parent 35bd827790
commit 8ca65155db
19 changed files with 3676 additions and 771 deletions

View File

@ -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

View File

@ -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

View 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 == []

View File

@ -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]

View File

@ -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"

View File

@ -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)

View File

@ -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"

View 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