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