- 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
218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
"""Tests for visualization functions.
|
|
|
|
Each test creates a small synthetic DEM and runs a visualization function,
|
|
checking that it produces a valid output file.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
|
|
# --- Core terrain visualizations (no GPU required) ---
|
|
|
|
class TestHillshade:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_hillshade
|
|
result = generate_hillshade(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
assert result.suffix == ".tif"
|
|
|
|
def test_output_values_valid(self, synthetic_dem, tmp_output_dir):
|
|
import rasterio
|
|
from lidar_pipeline.visualizations import generate_hillshade
|
|
result = generate_hillshade(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
with rasterio.open(result) as src:
|
|
data = src.read(1)
|
|
assert data.shape[0] > 0
|
|
assert np.nanmin(data) >= 0
|
|
assert np.nanmax(data) <= 1
|
|
|
|
|
|
class TestSlope:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_slope
|
|
result = generate_slope(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
def test_slope_values_degrees(self, synthetic_dem, tmp_output_dir):
|
|
import rasterio
|
|
from lidar_pipeline.visualizations import generate_slope
|
|
result = generate_slope(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
with rasterio.open(result) as src:
|
|
data = src.read(1)
|
|
assert np.nanmin(data) >= 0
|
|
assert np.nanmax(data) <= 90
|
|
|
|
|
|
# --- GPU-accelerated visualizations ---
|
|
|
|
class TestSVF:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_svf
|
|
result = generate_svf(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
def test_svf_values_0_1(self, synthetic_dem, tmp_output_dir):
|
|
import rasterio
|
|
from lidar_pipeline.visualizations import generate_svf
|
|
result = generate_svf(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
with rasterio.open(result) as src:
|
|
data = src.read(1)
|
|
valid = data[~np.isnan(data)]
|
|
assert np.nanmin(valid) >= 0
|
|
assert np.nanmax(valid) <= 1
|
|
|
|
|
|
class TestOpenness:
|
|
def test_positive_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_openness
|
|
result = generate_openness(synthetic_dem, "test", tmp_output_dir, 5.0, positive=True)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
def test_negative_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_openness
|
|
result = generate_openness(synthetic_dem, "test", tmp_output_dir, 5.0, positive=False)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
|
|
class TestMSLRM:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_mslrm
|
|
result = generate_mslrm(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
|
|
class TestSAILORE:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_sailore
|
|
result = generate_sailore(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
|
|
class TestRoughness:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_roughness
|
|
result = generate_roughness(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
def test_roughness_non_negative(self, synthetic_dem, tmp_output_dir):
|
|
import rasterio
|
|
from lidar_pipeline.visualizations import generate_roughness
|
|
result = generate_roughness(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
with rasterio.open(result) as src:
|
|
data = src.read(1)
|
|
# Standard deviation is always >= 0
|
|
assert np.nanmin(data) >= 0
|
|
|
|
|
|
class TestWavelet:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_wavelet
|
|
result = generate_wavelet(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
|
|
class TestFlowAccumulation:
|
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
|
from lidar_pipeline.visualizations import generate_flow_accumulation
|
|
result = generate_flow_accumulation(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
assert result is not None
|
|
assert result.exists()
|
|
|
|
def test_flow_log_values(self, synthetic_dem, tmp_output_dir):
|
|
import rasterio
|
|
from lidar_pipeline.visualizations import generate_flow_accumulation
|
|
result = generate_flow_accumulation(synthetic_dem, "test", tmp_output_dir, 5.0)
|
|
with rasterio.open(result) as src:
|
|
data = src.read(1)
|
|
# log10(x) >= 0 for x >= 1
|
|
valid = data[~np.isnan(data)]
|
|
assert np.nanmin(valid) >= 0
|
|
|
|
|
|
class TestRayTrace:
|
|
def test_rays_are_traced(self, synthetic_dem, tmp_output_dir):
|
|
"""Verify _ray_trace_horizons returns expected shapes."""
|
|
from lidar_pipeline.visualizations import _ray_trace_horizons, _prepare_dem_for_raycast
|
|
import rasterio
|
|
with rasterio.open(synthetic_dem) as src:
|
|
dem_np = src.read(1)
|
|
rows, cols = dem_np.shape
|
|
# Create a simple filled DEM for testing
|
|
import numpy as np
|
|
filled = np.nan_to_num(dem_np, nan=0)
|
|
# Test with numpy (no GPU)
|
|
pos, neg = _ray_trace_horizons(
|
|
filled, rows, cols, 5.0, n_dirs=4, max_dist=10, radii_m=[25, 50]
|
|
)
|
|
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"
|