Files
lidar_rendu/lidar_pipeline/tests/test_visualizations.py
Antoine fb892ea9f2 Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:16:45 +02:00

587 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()
def test_downsampled_matches_full_resolution(self, tmp_path, tmp_output_dir):
"""Factor 2: same output shape, highly correlated with factor 1.
Dedicated 1 m/px DTM (structures spanning tens of pixels, as in the
0.2 m/px production regime): the shared 5 m/px synthetic fixture has
a wall only 2 px wide, outside the regime needed to validate a ×2
decimation.
"""
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline import visualizations
from lidar_pipeline.visualizations import generate_openness
size = 240
x = np.linspace(0, size, size)
X, Y = np.meshgrid(x, x)
dem = 100.0 + 0.01 * X + 0.005 * Y
dem += 5.0 * np.exp(-((X - 120)**2 + (Y - 120)**2) / (2 * 40**2))
dist_wall = np.abs((X - 40) * 0.707 + (Y - 60) * 0.707) / np.sqrt(2)
dem += 1.5 * np.exp(-dist_wall**2 / (2 * 8**2))
dem -= 2.0 * np.exp(-np.abs(X - 200)**2 / (2 * 12**2))
# No white noise: at 1 m/px a σ=5 cm noise dominates the short-range
# horizon angle (max along the ray) and hides the geometry this test
# validates (decimation + zoom).
dem_file = tmp_path / "dem_1m.tif"
with rasterio.open(dem_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154',
transform=from_bounds(660000, 6700000, 660240, 6700240, size, size)) as dst:
dst.write(dem.astype('float32'), 1)
saved = visualizations.OPENNESS_DOWNSAMPLE
try:
visualizations.OPENNESS_DOWNSAMPLE = 1
r1 = generate_openness(dem_file, "full", tmp_output_dir, 1.0, positive=True)
visualizations.OPENNESS_DOWNSAMPLE = 2
r2 = generate_openness(dem_file, "dec", tmp_output_dir, 1.0, positive=True)
finally:
visualizations.OPENNESS_DOWNSAMPLE = saved
with rasterio.open(r1) as s1, rasterio.open(r2) as s2:
a, b = s1.read(1), s2.read(1)
assert a.shape == b.shape
m = ~np.isnan(a) & ~np.isnan(b)
assert m.sum() > 0
corr = np.corrcoef(a[m], b[m])[0, 1]
assert corr > 0.97, f"decimated/native openness correlation too low: {corr:.3f}"
@pytest.mark.parametrize("positive", [True, False])
def test_scale_independent_of_rest_of_tile(self, tmp_path, tmp_output_dir, positive):
"""Same local relief = same value, whatever the rest of the tile.
Two DTMs identical on their western half; one also carries a hill to
the east, more than 100 m (max radius) from the compared area. A
per-tile normalization (z-score) shifted the whole scale and made
mosaics non-seamless; with the frozen references, the western half
must come out identical.
"""
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline.visualizations import generate_openness
size = 300
x = np.arange(size, dtype=float)
X, Y = np.meshgrid(x, x)
flat = 100.0 + 2.0 * np.exp(-((X - 60)**2 + (Y - 150)**2) / (2 * 15**2))
hill = flat + 40.0 * np.exp(-((X - 260)**2 + (Y - 150)**2) / (2 * 20**2))
results = []
for name, dem in (("flat", flat), ("hill", hill)):
f = tmp_path / f"{name}.tif"
with rasterio.open(f, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154',
transform=from_bounds(660000, 6700000, 660300, 6700300,
size, size)) as dst:
dst.write(dem.astype('float32'), 1)
out = generate_openness(f, name, tmp_output_dir, 1.0, positive=positive)
with rasterio.open(out) as src:
results.append(src.read(1))
# Tolerance: interpolation residual of the decimated grid (≈0.01);
# a per-tile z-score would shift the whole area by several tenths.
west = np.s_[:, :100]
np.testing.assert_allclose(results[0][west], results[1][west], atol=0.05)
def _write_dem(path, dem, res=1.0):
import rasterio
from rasterio.transform import from_origin
with rasterio.open(path, 'w', driver='GTiff', height=dem.shape[0], width=dem.shape[1],
count=1, dtype='float32', crs='EPSG:2154',
transform=from_origin(660000, 6700300, res, res)) as dst:
dst.write(dem.astype('float32'), 1)
return path
class TestReliefOriente:
def test_generates_rgb_uint8(self, synthetic_dem, tmp_output_dir):
import rasterio
from lidar_pipeline.visualizations import generate_relief_oriente
result = generate_relief_oriente(synthetic_dem, "test", tmp_output_dir, 5.0)
assert result is not None and result.name == "test_relief_oriente.tif"
with rasterio.open(result) as src, rasterio.open(synthetic_dem) as dem:
assert src.count == 3 and src.dtypes[0] == 'uint8'
assert (src.height, src.width) == (dem.height, dem.width)
rgb = src.read()
assert rgb.std() > 5, "uniform image: neither relief nor orientation rendered"
def test_nodata_gets_fixed_color(self, tmp_path, tmp_output_dir):
import rasterio
from lidar_pipeline.visualizations import generate_relief_oriente, RELIEF_NODATA_RGB
x = np.arange(120, dtype=float)
dem = 100 + 0.05 * x[None, :] + 0.02 * x[:, None]
dem[10:20, 10:20] = np.nan
out = generate_relief_oriente(_write_dem(tmp_path / "d.tif", dem), "t", tmp_output_dir, 1.0)
with rasterio.open(out) as src:
rgb = src.read()
assert tuple(rgb[:, 15, 15]) == RELIEF_NODATA_RGB
def test_horizon_kernels_agree(self):
"""numba and numpy (and the CUDA kernel, same code) give the same angle."""
pytest.importorskip("numba")
from lidar_pipeline.visualizations import (
_horizon_rays, _mean_horizon_numba, _mean_horizon_numpy)
rng = np.random.default_rng(0)
dem = rng.normal(0, 0.3, (60, 70)).astype(np.float32)
dem[20:30, 30:40] += 2.0
offs, dist, cps = _horizon_rays(0.8, 16, (5, 10, 20))
a = _mean_horizon_numba(dem, offs, dist, cps)
b = _mean_horizon_numpy(dem, offs, dist, cps)
np.testing.assert_allclose(a, b, atol=1e-5)
def test_matches_legacy_ray_trace_geometry(self):
"""Same ray geometry as _ray_trace_horizons (8 directions)."""
from lidar_pipeline.visualizations import (
_horizon_rays, _mean_horizon_numpy, _ray_trace_horizons)
rng = np.random.default_rng(1)
dem = rng.normal(0, 0.5, (50, 50)).astype(np.float32)
radii = (5, 10, 20)
pos, _ = _ray_trace_horizons(dem, 50, 50, 1.0, 8, 20, list(radii))
legacy = np.mean(pos, axis=(0, 1))
offs, dist, cps = _horizon_rays(1.0, 8, radii)
np.testing.assert_allclose(_mean_horizon_numpy(dem, offs, dist, cps), legacy, atol=1e-5)
def test_seamless_independent_of_rest_of_tile(self, tmp_path, tmp_output_dir):
"""Same local relief = same colors, whatever the rest of the tile
(support: 20 m radius + 4σ = 40 m detrend window, ~60 m in all, well
under the 100 m edge buffer)."""
import rasterio
from lidar_pipeline.visualizations import generate_relief_oriente
size = 300
X, Y = np.meshgrid(np.arange(size, dtype=float), np.arange(size, dtype=float))
flat = 100.0 + 1.5 * np.exp(-((X - 60)**2 + (Y - 150)**2) / (2 * 6**2))
hill = flat + 40.0 * np.exp(-((X - 260)**2 + (Y - 150)**2) / (2 * 10**2))
imgs = []
for name, dem in (("flat", flat), ("hill", hill)):
out = generate_relief_oriente(_write_dem(tmp_path / f"{name}.tif", dem),
name, tmp_output_dir, 1.0)
with rasterio.open(out) as src:
imgs.append(src.read().astype(int))
diff = np.abs(imgs[0][:, :, :150] - imgs[1][:, :, :150])
assert diff.max() <= 1, f"color difference far from the hill: {diff.max()}"
def test_numba_colorize_matches_vectorized(self):
"""Fused CPU kernel = vectorized path (CuPy/numpy) up to rounding."""
pytest.importorskip("numba")
from lidar_pipeline.gpu import xp_zoom
from lidar_pipeline.visualizations import (
_relief_colorize_numba, _relief_colorize_xp, _relief_lut, _pad_to)
rng = np.random.default_rng(2)
open_c = rng.uniform(0.3, 15, (30, 25)).astype(np.float32)
dx = rng.normal(0, 0.3, (120, 100)).astype(np.float32)
dy = rng.normal(0, 0.3, (120, 100)).astype(np.float32)
lut = _relief_lut()
a = _relief_colorize_numba(open_c, 4, dx, dy, lut).astype(int)
b = _relief_colorize_xp(np, _pad_to(np, xp_zoom(open_c, 4), 120, 100), dx, dy, lut).astype(int)
diff = np.abs(a - b).max(axis=2)
assert (diff > 3).mean() < 0.01, f"{(diff > 3).mean():.3%} pixels diverge"
def test_lut_lightness_is_monotonic_and_hue_neutral(self):
"""Lightness increases with L*; at fixed L*, all hues have the same
perceived luminance (no false relief caused by color)."""
from lidar_pipeline.visualizations import _relief_lut
lut = _relief_lut().astype(float) / 255
lin = np.where(lut <= 0.04045, lut / 12.92, ((lut + 0.055) / 1.055) ** 2.4)
Y = lin @ np.array([0.2126, 0.7152, 0.0722]) # (256, 360)
assert np.all(np.diff(Y.mean(axis=1)) >= -1e-6)
Lstar = 116 * np.cbrt(Y[100:180]) - 16 # L* ≈ 40–70
# Residual spread: sRGB gamut clipping of a few hues at C* 60
assert (Lstar.max(axis=1) - Lstar.min(axis=1)).max() < 6
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()
def test_output_median_centered(self, synthetic_dem, tmp_output_dir):
"""Robust rescaling: median ≈ 1 whatever the terrain.
This is what lets a single fixed color stretch give homogeneous
colors across tiles (see knots in rendering.py).
"""
import rasterio
from lidar_pipeline.visualizations import generate_wavelet
result = generate_wavelet(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src:
data = src.read(1)
valid = data[np.isfinite(data)]
assert abs(np.median(valid) - 1.0) < 0.05
def test_ditch_on_hilltop_not_amplified(self, tmp_path, tmp_output_dir):
"""A ditch on a hilltop does not stand out more than on flat ground.
Detrending (Gaussian local mean ~35 m) must neutralize the
topographic position:
- the index peak on the hilltop ditch stays comparable to the same
ditch on flat ground;
- the hilltop background (no structure) stays comparable to the flat
background.
Noisy synthetic DTM (σ=8 cm): without detrending, the hilltop
background comes out ~1.7× the flat background (yellow hilltops on
the map).
"""
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline.visualizations import generate_wavelet
size = 600
res = 1.0
x = np.arange(size) * res
y = np.arange(size) * res
X, Y = np.meshgrid(x, y)
dem = 100.0 + 0.01 * X
# Realistic hill (sigma 80 m, 25 m high) on the left + sensor noise
dem += 25.0 * np.exp(-((X - 150)**2 + (Y - 300)**2) / (2 * 80**2))
rng = np.random.default_rng(42)
dem += rng.normal(0, 0.08, dem.shape)
# Two identical ditches: one on the hilltop, the other on flat ground
for xc in (150, 480):
dem -= 1.5 * np.exp(-((X - xc)**2) / (2 * 1.2**2))
dem_file = tmp_path / "ditch_dem.tif"
transform = from_bounds(660000, 6700000, 660600, 6700600, size, size)
with rasterio.open(
dem_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
) as dst:
dst.write(dem.astype('float32'), 1)
result = generate_wavelet(dem_file, "ditch", tmp_output_dir, res)
assert result is not None and result.exists()
with rasterio.open(result) as src:
data = src.read(1)
# Peaks over a vertical band around each ditch
peak_hilltop = np.nanmax(data[:, 145:156])
peak_flat = np.nanmax(data[:, 475:486])
assert peak_flat > 5.0 # the ditch clearly stands out above the background
# No amplification of the ditch by the topographic position
assert peak_hilltop < 1.5 * peak_flat
# Hilltop background (35 m east of the hilltop ditch) vs flat background
bg_hilltop = np.nanmedian(data[250:350, 185:226])
bg_flat = np.nanmedian(data[250:350, 500:561])
assert bg_hilltop < 1.4 * bg_flat # without detrending: ~1.7×
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)
# log1p(x) >= 0 for x >= 0
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 is preserved in the renderings (legacy behaviour).
DTM holes are filled upstream (create_dtm_fast, all modes); if some
nodata remains anyway, hillshade/slope/aspect keep it (rendered black on
the map) instead of inventing interpolated values.
"""
@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(), "the hole must stay nodata"
# The gradient at the hole's edge spreads NaN over a 1 px ring:
# check an area far from the hole
assert not np.isnan(data[0:40, 0:40]).any(), "NaN far from the hole"
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, "shared", 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(), "the hole must stay nodata"
assert not np.isnan(data[0:40, 0:40]).any(), "NaN far from the hole"
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}: hole disappeared"
def test_ray_trace_horizons_cpu_fallback_on_oom(monkeypatch):
"""On GPU OOM, ray-tracing disables the GPU then retries on CPU."""
import lidar_pipeline.visualizations as viz
import lidar_pipeline.gpu as gpu_mod
calls = {"n": 0}
disabled = []
def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("Out of memory allocating 600,000,000 bytes")
return ("pos", "neg")
monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
monkeypatch.setattr(gpu_mod, "disable_gpu", lambda: disabled.append(True))
result = viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
assert result == ("pos", "neg")
assert calls["n"] == 2
assert disabled == [True]
def test_ray_trace_horizons_reraises_non_oom(monkeypatch):
"""A non-OOM error is not masked by the CPU fallback."""
import lidar_pipeline.visualizations as viz
import lidar_pipeline.gpu as gpu_mod
def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
raise ValueError("other error")
monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
try:
viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
assert False, "ValueError expected"
except ValueError:
pass
class TestPriorityFlood:
def test_numba_matches_python(self):
"""numba and Python results are identical on a DEM with a pit."""
from lidar_pipeline.visualizations import _priority_flood_numba, _priority_flood_python
dem = np.zeros((20, 20), dtype=np.float64)
dem[10, 10] = -5.0
dem[9:12, 9:12] = -3.0
nodata = np.zeros((20, 20), dtype=bool)
result_numba = _priority_flood_numba(dem.copy(), nodata)
result_python = _priority_flood_python(dem.copy(), nodata)
if result_numba is not None:
assert np.allclose(result_numba, result_python)
def test_pit_is_filled(self):
"""An isolated pit is raised to the level of its rim."""
from lidar_pipeline.visualizations import _priority_flood
dem = np.full((10, 10), 5.0, dtype=np.float64)
dem[5, 5] = 1.0
nodata = np.zeros((10, 10), dtype=bool)
result = _priority_flood(dem, nodata)
assert result[5, 5] == 5.0
def test_nodata_cells_untouched(self):
"""Nodata cells are never modified."""
from lidar_pipeline.visualizations import _priority_flood
dem = np.full((10, 10), 5.0, dtype=np.float64)
dem[5, 5] = 1.0
nodata = np.zeros((10, 10), dtype=bool)
nodata[2, 2] = True
dem[2, 2] = 999.0
result = _priority_flood(dem, nodata)
assert result[2, 2] == 999.0
class TestDensiteSol:
def test_density_levels_log_scale(self):
from lidar_pipeline.visualizations import density_levels
d = np.array([0.0, np.nan, 0.25, 0.36, 0.5, 1.0, 45.0, 45.3, 1000.0])
assert density_levels(d).tolist() == [0, 0, 0, 1, 2, 4, 14, 15, 15]
def test_generate_reads_density_sidecar(self, tmp_path):
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline.dtm import density_path
from lidar_pipeline.visualizations import generate_densite_sol
dem = tmp_path / "T_dtm_r0p2.tif"
dem.touch()
with rasterio.open(density_path(dem), 'w', driver='GTiff', width=4, height=1,
count=1, dtype='float32', crs='EPSG:2154',
transform=from_bounds(0, 0, 4, 1, 4, 1)) as dst:
dst.write(np.array([[0.0, 1.0, 4.0, 64.0]], dtype='float32'), 1)
out = generate_densite_sol(dem, "T", tmp_path, 0.2)
with rasterio.open(out) as src:
assert src.read(1).tolist() == [[0, 4, 8, 15]]
assert src.width == 4 # 1 m grid kept
def test_missing_sidecar_returns_none(self, tmp_path):
from lidar_pipeline.visualizations import generate_densite_sol
assert generate_densite_sol(tmp_path / "X_dtm.tif", "X", tmp_path, 0.2) is None