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:
Antoine Jacquin
2026-09-27 00:05:40 +02:00
parent 2a84b76d33
commit 0d3c3ba8bc
11 changed files with 587 additions and 25 deletions

View File

@ -198,15 +198,15 @@ def test_wmts_capabilities(tmp_path, monkeypatch):
def test_map_meta(tmp_path, monkeypatch):
"""/api/map/meta décrit les couches et le contrat de tuilage de l'interface."""
mapserve = _setup(tmp_path, monkeypatch)
mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente", "aspect", "slope"))
meta = mapserve.map_meta()
assert {l["key"] for l in meta["layers"]} == {"aspect", "slope"}
assert {l["key"] for l in meta["layers"]} == {"relief_oriente", "aspect", "slope"}
assert all(l["label"] for l in meta["layers"])
assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.webp"
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
assert meta["max_native_zoom"] == 18
assert meta["bounds"] and meta["stamp"] > 0
assert "aspect" in meta["default_layers"]
assert meta["default_layers"] == ["relief_oriente"]
def test_map_tile_info(tmp_path, monkeypatch):

View File

@ -20,9 +20,9 @@ class TestVizSteps:
assert len(names) == len(set(names)), "VIZ_STEPS has duplicate names"
def test_expected_visualization_count(self):
"""Should have 15 visualizations (13 terrain + ortho + topo)."""
"""Should have 16 visualizations (14 terrain + ortho + topo)."""
from lidar_pipeline.pipeline import VIZ_STEPS
assert len(VIZ_STEPS) == 15
assert len(VIZ_STEPS) == 16
def test_ortho_and_topo_present(self):
from lidar_pipeline.pipeline import VIZ_STEPS

View File

@ -39,8 +39,9 @@ class TestColormaps:
'neg_open': 'negative_openness',
'hillshade': 'hillshade_multi',
}
# IGN overlays (ortho, topo) are RGB images — no colormap needed
skip = {'ortho', 'topo'}
# Images RGB (fonds IGN, relief orienté) — pas de colormap
from lidar_pipeline.rendering import RGB_KEYWORDS
skip = set(RGB_KEYWORDS)
for name, _ in VIZ_STEPS:
if name in skip:
continue

View File

@ -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):