prepare: add index module, update pipeline, tests, Dockerfile, and run.sh
This commit is contained in:
@ -110,8 +110,9 @@ class TestDetectGroundMethod:
|
||||
mock_las.points.__len__ = lambda self: len(num_returns)
|
||||
return mock_las
|
||||
|
||||
@patch('lidar_pipeline.dtm.laspy')
|
||||
def test_urban_terrain_returns_csf(self, mock_laspy):
|
||||
@patch('lidar_pipeline.dtm._read_with_pdal')
|
||||
@patch('laspy.read')
|
||||
def test_urban_terrain_returns_csf(self, mock_read, mock_pdal):
|
||||
"""High single-return ratio (>0.6) should select CSF."""
|
||||
from lidar_pipeline.dtm import detect_ground_method
|
||||
|
||||
@ -121,13 +122,14 @@ class TestDetectGroundMethod:
|
||||
num_returns[:int(n * 0.3)] = 2 # 30% multi-return
|
||||
z_values = np.random.normal(100, 5, n) # Low variance = flat terrain
|
||||
|
||||
mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values)
|
||||
mock_read.return_value = self._make_mock_las(num_returns, z_values)
|
||||
|
||||
result = detect_ground_method(Path("/data/input/test.laz"))
|
||||
assert result == 'csf'
|
||||
|
||||
@patch('lidar_pipeline.dtm.laspy')
|
||||
def test_natural_terrain_returns_smrf(self, mock_laspy):
|
||||
@patch('lidar_pipeline.dtm._read_with_pdal')
|
||||
@patch('laspy.read')
|
||||
def test_natural_terrain_returns_smrf(self, mock_read, mock_pdal):
|
||||
"""Low single-return ratio and moderate variance should select SMRF."""
|
||||
from lidar_pipeline.dtm import detect_ground_method
|
||||
|
||||
@ -137,13 +139,14 @@ class TestDetectGroundMethod:
|
||||
num_returns[:int(n * 0.6)] = 2 # 60% multi-return (forest)
|
||||
z_values = np.random.normal(100, 15, n) # Moderate variance
|
||||
|
||||
mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values)
|
||||
mock_read.return_value = self._make_mock_las(num_returns, z_values)
|
||||
|
||||
result = detect_ground_method(Path("/data/input/test.laz"))
|
||||
assert result == 'smrf'
|
||||
|
||||
@patch('lidar_pipeline.dtm.laspy')
|
||||
def test_mountainous_terrain_returns_csf(self, mock_laspy):
|
||||
@patch('lidar_pipeline.dtm._read_with_pdal')
|
||||
@patch('laspy.read')
|
||||
def test_mountainous_terrain_returns_csf(self, mock_read, mock_pdal):
|
||||
"""High variance terrain (>30m std) selects CSF for complex terrain."""
|
||||
from lidar_pipeline.dtm import detect_ground_method
|
||||
|
||||
@ -153,7 +156,7 @@ class TestDetectGroundMethod:
|
||||
num_returns[:int(n * 0.5)] = 2
|
||||
z_values = np.random.normal(100, 50, n) # Very high variance = mountainous
|
||||
|
||||
mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values)
|
||||
mock_read.return_value = self._make_mock_las(num_returns, z_values)
|
||||
|
||||
result = detect_ground_method(Path("/data/input/test.laz"))
|
||||
assert result == 'csf'
|
||||
@ -161,8 +164,7 @@ class TestDetectGroundMethod:
|
||||
|
||||
class TestClassifyGroundMethod:
|
||||
@patch('lidar_pipeline.dtm.subprocess')
|
||||
@patch('lidar_pipeline.dtm.laspy')
|
||||
def test_classify_ground_auto_calls_detect(self, mock_laspy, mock_subprocess):
|
||||
def test_classify_ground_auto_calls_detect(self, mock_subprocess):
|
||||
"""classify_ground with method='auto' should call detect_ground_method."""
|
||||
from lidar_pipeline.dtm import classify_ground
|
||||
|
||||
@ -174,8 +176,7 @@ class TestClassifyGroundMethod:
|
||||
mock_detect.assert_called_once()
|
||||
|
||||
@patch('lidar_pipeline.dtm.subprocess')
|
||||
@patch('lidar_pipeline.dtm.laspy')
|
||||
def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_laspy, mock_subprocess):
|
||||
def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_subprocess):
|
||||
"""classify_ground with method='smrf' should create SMRF pipeline."""
|
||||
from lidar_pipeline.dtm import classify_ground, _create_ground_pipeline
|
||||
|
||||
@ -195,8 +196,7 @@ class TestClassifyGroundMethod:
|
||||
assert "filters.smrf" in stage_types
|
||||
|
||||
@patch('lidar_pipeline.dtm.subprocess')
|
||||
@patch('lidar_pipeline.dtm.laspy')
|
||||
def test_classify_ground_csf_uses_csf_pipeline(self, mock_laspy, mock_subprocess):
|
||||
def test_classify_ground_csf_uses_csf_pipeline(self, mock_subprocess):
|
||||
"""classify_ground with method='csf' should create CSF pipeline."""
|
||||
from lidar_pipeline.dtm import classify_ground
|
||||
|
||||
|
||||
205
lidar_pipeline/tests/test_index.py
Normal file
205
lidar_pipeline/tests/test_index.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""Tests pour la carte globale interactive (index.py)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_parse_basename_coords_valid():
|
||||
"""Parse les coordonnées d'un basename LHD valide."""
|
||||
from lidar_pipeline.index import parse_basename_coords
|
||||
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69") == (1000, 6881)
|
||||
assert parse_basename_coords("LHD_FXX_1049_6895_PTS_LAMB93_IGN69") == (1049, 6895)
|
||||
|
||||
|
||||
def test_parse_basename_coords_with_res_suffix():
|
||||
"""Les noms de dossier avec suffixe résolution sont aussi parsables."""
|
||||
from lidar_pipeline.index import parse_basename_coords
|
||||
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2") == (1000, 6881)
|
||||
|
||||
|
||||
def test_parse_basename_coords_invalid():
|
||||
"""Les noms non-LHD retournent None."""
|
||||
from lidar_pipeline.index import parse_basename_coords
|
||||
assert parse_basename_coords("random_dir") is None
|
||||
assert parse_basename_coords("DTM") is None
|
||||
assert parse_basename_coords("") is None
|
||||
|
||||
|
||||
def test_strip_res_suffix_primary():
|
||||
"""Dossier sans suffixe = résolution primaire (0.5)."""
|
||||
from lidar_pipeline.index import _strip_res_suffix
|
||||
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69")
|
||||
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
assert res == 0.5
|
||||
|
||||
|
||||
def test_strip_res_suffix_multi():
|
||||
"""Dossier avec suffixe _r0p2 = résolution 0.2."""
|
||||
from lidar_pipeline.index import _strip_res_suffix
|
||||
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2")
|
||||
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
assert res == 0.2
|
||||
|
||||
|
||||
def test_compute_bbox():
|
||||
"""Calcule la bounding box d'un ensemble de tuiles."""
|
||||
from lidar_pipeline.index import compute_bbox
|
||||
tiles = [
|
||||
{'col': 1000, 'row': 6881},
|
||||
{'col': 1001, 'row': 6882},
|
||||
{'col': 1002, 'row': 6880},
|
||||
]
|
||||
bbox = compute_bbox(tiles)
|
||||
assert bbox == {'min_col': 1000, 'max_col': 1002, 'min_row': 6880, 'max_row': 6882}
|
||||
|
||||
|
||||
def test_compute_bbox_empty():
|
||||
"""Aucune tuile → None."""
|
||||
from lidar_pipeline.index import compute_bbox
|
||||
assert compute_bbox([]) is None
|
||||
|
||||
|
||||
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."""
|
||||
from PIL import Image as PILImage
|
||||
import numpy as np
|
||||
|
||||
dir_name = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}"
|
||||
tile_dir = Path(vis_dir) / dir_name
|
||||
tile_dir.mkdir(parents=True, exist_ok=True)
|
||||
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}"
|
||||
img.save(str(tile_dir / fname), format='WEBP', quality=80)
|
||||
return tile_dir
|
||||
|
||||
|
||||
def test_scan_tiles(tmp_path):
|
||||
"""scan_tiles détecte les dossiers de tuiles et leurs visualisations."""
|
||||
from lidar_pipeline.index import scan_tiles
|
||||
|
||||
vis_dir = tmp_path / "visualisations"
|
||||
vis_dir.mkdir()
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
|
||||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
|
||||
|
||||
tiles = scan_tiles(vis_dir)
|
||||
assert len(tiles) == 2
|
||||
names = sorted(t['dir_name'] for t in tiles)
|
||||
assert "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" in names
|
||||
assert "LHD_FXX_1001_6881_PTS_LAMB93_IGN69" in names
|
||||
# Vérifie que les viz sont détectées
|
||||
t0 = next(t for t in tiles if t['col'] == 1000)
|
||||
assert 'hillshade_multi' in t0['viz']
|
||||
assert 'svf' in t0['viz']
|
||||
|
||||
|
||||
def test_scan_tiles_ignores_non_lhd(tmp_path):
|
||||
"""Les dossiers non-LHD (ex: temp, DTM) sont ignorés."""
|
||||
from lidar_pipeline.index import scan_tiles
|
||||
|
||||
vis_dir = tmp_path / "visualisations"
|
||||
vis_dir.mkdir()
|
||||
(vis_dir / "random_folder").mkdir()
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881)
|
||||
|
||||
tiles = scan_tiles(vis_dir)
|
||||
assert len(tiles) == 1
|
||||
assert tiles[0]['col'] == 1000
|
||||
|
||||
|
||||
def test_scan_tiles_multi_resolution(tmp_path):
|
||||
"""Les dossiers avec suffixe résolution sont correctement décodés."""
|
||||
from lidar_pipeline.index import scan_tiles
|
||||
|
||||
vis_dir = tmp_path / "visualisations"
|
||||
vis_dir.mkdir()
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='')
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='_r0p2')
|
||||
|
||||
tiles = scan_tiles(vis_dir)
|
||||
assert len(tiles) == 2
|
||||
resolutions = sorted(t['resolution'] for t in tiles)
|
||||
assert resolutions == [0.2, 0.5]
|
||||
|
||||
|
||||
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
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
|
||||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
|
||||
|
||||
result = build_index(output_dir)
|
||||
assert result is not None
|
||||
html_path = Path(result)
|
||||
assert html_path.exists()
|
||||
assert html_path.name == "index.html"
|
||||
|
||||
content = html_path.read_text(encoding='utf-8')
|
||||
# Vérifie la présence des éléments clés
|
||||
assert "Carte globale 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
|
||||
# Vérifie les vignettes générées
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
assert thumb_dir.is_dir()
|
||||
thumbs = list(thumb_dir.glob("*.jpg"))
|
||||
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
|
||||
|
||||
|
||||
def test_build_index_empty_returns_none(tmp_path):
|
||||
"""Aucune tuile → build_index retourne None sans crash."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
(output_dir / "visualisations").mkdir(parents=True)
|
||||
result = build_index(output_dir)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_build_index_embeds_valid_json(tmp_path):
|
||||
"""Le JSON embarqué dans le HTML est valide et contient les tuiles."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
|
||||
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 = ")
|
||||
# Trouve le ; de fin de déclaration
|
||||
depth = 0
|
||||
end = start
|
||||
for i, ch in enumerate(content[start:], start):
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
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
|
||||
|
||||
|
||||
def test_pick_display_viz_prefers_hillshade():
|
||||
"""Le choix de viz par défaut privilégie hillshade_multi."""
|
||||
from lidar_pipeline.index import _pick_display_viz
|
||||
assert _pick_display_viz(['svf', 'hillshade_multi', 'slope']) == 'hillshade_multi'
|
||||
assert _pick_display_viz(['svf', 'slope']) == 'svf'
|
||||
assert _pick_display_viz(['topo']) == 'topo'
|
||||
@ -62,7 +62,7 @@ class TestTifToPng:
|
||||
result = tif_to_png(tif_file, tmp_path, 5.0)
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
assert result.suffix == '.webp'
|
||||
assert result.suffix == '.avif'
|
||||
|
||||
def test_removes_source_tif(self, tmp_path):
|
||||
from lidar_pipeline.rendering import tif_to_png
|
||||
|
||||
Reference in New Issue
Block a user