Ajouter la couche relief orienté et une échelle d'openness fixe
Nouvelle visualisation relief_oriente : une image RGB unique qui fusionne l'openness positive locale (MNT détendancé σ 10 m, rayons 5/10/20 m, 16 directions) portée par la clarté CIELAB et l'orientation des pentes portée par la teinte. Échelle log fixe et support de 40 m sous la bande de raccord de 100 m : dalles jointives. Calcul sur grille décimée à 0,8 m, noyau dédié (CuPy RawKernel, numba parallèle, repli numpy) et colorisation par table L* × teinte : ~8 s par dalle sur CPU au lieu de ~50 s. L'openness positive et négative est normalisée par des références figées mesurées sur 15 dalles au lieu d'un z-score par dalle, qui rendait l'échelle de couleur non jointive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -126,6 +126,146 @@ class TestOpenness:
|
||||
corr = np.corrcoef(a[m], b[m])[0, 1]
|
||||
assert corr > 0.97, f"corrélation openness décimée/native trop faible : {corr:.3f}"
|
||||
|
||||
@pytest.mark.parametrize("positive", [True, False])
|
||||
def test_scale_independent_of_rest_of_tile(self, tmp_path, tmp_output_dir, positive):
|
||||
"""Même relief local = même valeur, quel que soit le reste de la dalle.
|
||||
|
||||
Deux MNT identiques sur leur moitié ouest ; l'un porte en plus une
|
||||
colline à l'est, à plus de 100 m (rayon max) de la zone comparée. Une
|
||||
normalisation par dalle (z-score) décalait toute l'échelle et rendait
|
||||
les mosaïques non jointives ; avec les références figées, la moitié
|
||||
ouest doit sortir identique.
|
||||
"""
|
||||
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))
|
||||
# Tolérance : résidu d'interpolation de la grille décimée (≈0,01) ;
|
||||
# un z-score par dalle décalerait toute la zone de plusieurs dixièmes.
|
||||
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, "image uniforme : ni relief ni orientation rendus"
|
||||
|
||||
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 et numpy (et le noyau CUDA, même code) donnent le même 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):
|
||||
"""Même géométrie de rayons que _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):
|
||||
"""Même relief local = mêmes couleurs, quel que soit le reste de la dalle
|
||||
(support : rayon 20 m + détendance 4σ = 40 m, loin sous la bande de 100 m)."""
|
||||
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"écart de couleur loin de la colline : {diff.max()}"
|
||||
|
||||
def test_numba_colorize_matches_vectorized(self):
|
||||
"""Noyau CPU fusionné = chemin vectorisé (CuPy/numpy) à l'arrondi près."""
|
||||
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 divergent"
|
||||
|
||||
def test_lut_lightness_is_monotonic_and_hue_neutral(self):
|
||||
"""Clarté croissante avec L* ; à L* fixé, toutes les teintes ont la même
|
||||
luminance perçue (pas de faux relief dû à la couleur)."""
|
||||
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
|
||||
# Écart résiduel : écrêtage de gamme sRGB de quelques teintes à 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):
|
||||
|
||||
Reference in New Issue
Block a user