Files
lidar_rendu/lidar_pipeline/tests/test_visualizations.py
Antoine Jacquin 8ca65155db 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
2026-08-31 18:07:14 +02:00

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"