Files
lidar_rendu/lidar_pipeline/tests/test_rendering.py

269 lines
12 KiB
Python
Raw 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 rendering module (colormaps, tif_to_png)."""
import numpy as np
import rasterio
from rasterio.transform import from_bounds
import pytest
from pathlib import Path
def _make_test_tif(tmp_path, data=None, size=50):
"""Create a small test GeoTIFF and return its path."""
if data is None:
rng = np.random.default_rng(42)
data = rng.normal(0, 1, (size, size)).astype(np.float32)
transform = from_bounds(660000, 6700000, 661000, 6701000, size, size)
tif_file = tmp_path / "test_vis.tif"
with rasterio.open(
tif_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
compress='lzw'
) as dst:
dst.write(data, 1)
return tif_file
class TestColormaps:
def test_colormaps_dict_exists(self):
from lidar_pipeline.rendering import COLORMAPS
assert isinstance(COLORMAPS, dict)
def test_all_viz_steps_have_colormaps(self):
"""Every VIZ_STEPS entry should have a corresponding COLORMAPS entry or render correctly."""
from lidar_pipeline.pipeline import VIZ_STEPS
from lidar_pipeline.rendering import COLORMAPS
# Some viz names differ from colormap keys
name_map = {
'pos_open': 'positive_openness',
'neg_open': 'negative_openness',
'hillshade': 'hillshade_multi',
}
# 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
cmap_key = name_map.get(name, name)
assert cmap_key in COLORMAPS, f"Missing colormap for: {name} (looked as {cmap_key})"
def test_colormap_has_required_keys(self):
"""Each colormap entry must have cmap, title, legend, description."""
from lidar_pipeline.rendering import COLORMAPS
required = {'cmap', 'title', 'legend', 'description'}
for name, entry in COLORMAPS.items():
missing = required - set(entry.keys())
assert not missing, f"Colormap '{name}' missing keys: {missing}"
class TestTifToPng:
def test_converts_tif_to_webp(self, tmp_path):
from lidar_pipeline.rendering import tif_to_png
tif_file = _make_test_tif(tmp_path)
result = tif_to_png(tif_file, tmp_path, 5.0)
assert result is not None
assert result.exists()
assert result.suffix == '.avif'
def test_removes_source_tif(self, tmp_path):
from lidar_pipeline.rendering import tif_to_png
tif_file = _make_test_tif(tmp_path)
assert tif_file.exists()
tif_to_png(tif_file, tmp_path, 5.0)
assert not tif_file.exists(), "Source TIF should be deleted after conversion"
def test_webp_has_content(self, tmp_path):
from lidar_pipeline.rendering import tif_to_png
tif_file = _make_test_tif(tmp_path)
result = tif_to_png(tif_file, tmp_path, 5.0)
assert result.stat().st_size > 1000 # Must be a real image
class TestApplyColormap:
def test_symmetric_mode(self, tmp_path):
from lidar_pipeline.rendering import COLORMAPS, tif_to_png
# LRM uses symmetric mode
data = np.random.default_rng(42).normal(0, 0.5, (50, 50)).astype(np.float32)
tif_file = _make_test_tif(tmp_path, data)
result = tif_to_png(tif_file, tmp_path, 5.0)
assert result is not None
assert result.exists()
def test_percentile_mode(self, tmp_path):
from lidar_pipeline.rendering import tif_to_png
# Most visualizations use percentile mode
data = np.random.default_rng(42).normal(50, 10, (50, 50)).astype(np.float32)
tif_file = _make_test_tif(tmp_path, data)
result = tif_to_png(tif_file, tmp_path, 5.0)
assert result is not None
assert result.exists()
def test_knots_mode_fixed_transfer(self):
"""L'étalonnage quantile figé (ondelette) : même valeur → même couleur.
Les nœuds sont constants (pas de percentile local) : clamp aux
extrêmes, médiane (1.0) → 0.5, transfert monotone.
"""
from lidar_pipeline.rendering import COLORMAPS, _apply_colormap
info = COLORMAPS['wavelet']
kv, kt = info['knots'][0.5]
assert kv[6] == 1.0 and kt[6] == 0.5 # nœud médian
vals = np.array([0.05, kv[0], 1.0, kv[-1], 50.0], dtype=float)
out, cmap, *_ = _apply_colormap(vals, 'x_wavelet.tif', resolution=0.5)
assert cmap == 'inferno'
assert out[0] == 0.0 and out[1] == 0.01 # clamp bas
assert abs(out[2] - 0.5) < 1e-9 # médiane → 0.5
assert out[3] == 0.995 and out[4] == 1.0 # nœud haut, puis clamp au-delà
# Monotonie (pas d'inversion de teinte)
s = np.sort(np.random.default_rng(1).uniform(0.2, 3.0, 500))
o, *_ = _apply_colormap(s, 'x_wavelet.tif', resolution=0.5)
assert np.all(np.diff(o) >= -1e-12)
# Les nœuds 0.2 m diffèrent de ceux 0.5 m (calibrations distinctes)
kv02, _ = info['knots'][0.2]
assert kv02 != kv
class TestTifToCrop:
"""Conversion TIF → dalle cartographique (tif_to_crop)."""
@staticmethod
def _write_named_tif(tmp_path, name, arr):
transform = from_bounds(660000, 6700000, 661000, 6701000, arr.shape[1], arr.shape[0])
tif_file = tmp_path / name
with rasterio.open(
tif_file, 'w', driver='GTiff', height=arr.shape[0], width=arr.shape[1],
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
nodata=float('nan'), compress='lzw'
) as dst:
dst.write(arr.astype('float32'), 1)
return tif_file
def test_nodata_renders_black(self, tmp_path):
"""Le nodata restant est rendu en noir (comportement historique).
Les trous du MNT sont comblés en amont (interpolation dans
create_dtm_fast, tous modes) ; ce qui reste en nodata doit rester
visible en noir sur la dalle plutôt qu'inventé au rendu.
"""
from PIL import Image as PILImage
from lidar_pipeline.rendering import tif_to_crop
data = np.random.default_rng(7).normal(50, 10, (40, 40)).astype(np.float32)
data[15:25, 15:25] = np.nan
tif_file = self._write_named_tif(tmp_path, "LHD_test_slope.tif", data)
# WebP lossless : l'encodeur AVIF de l'image « saigne » légèrement les
# bords du noir même en lossless — on teste la logique nodata→noir,
# pas les artefacts du codec.
out = tif_to_crop(tif_file, tmp_path, 5.0, keep_tif=True,
quality=100, output_format='webp')
assert out is not None and out.exists()
rgb = np.asarray(PILImage.open(str(out)).convert('RGB'))
hole = rgb[15:25, 15:25, :]
assert np.all(hole < 10), "le nodata doit être rendu en noir"
def test_without_nodata(self, tmp_path):
"""Un TIF sans nodata est converti sans crash, taille préservée."""
from PIL import Image as PILImage
from lidar_pipeline.rendering import tif_to_crop
data = np.random.default_rng(7).normal(50, 10, (40, 40)).astype(np.float32)
tif_file = self._write_named_tif(tmp_path, "LHD_test_slope.tif", data)
out = tif_to_crop(tif_file, tmp_path, 5.0)
assert out is not None and out.exists()
img = PILImage.open(str(out))
assert img.size == (40, 40)
class TestCoreTileWindow:
"""Recadrage des TIF à bande de raccord sur la dalle nominale 1 km."""
def _write_tif(self, tmp_path, name, bounds, size):
transform = from_bounds(*bounds, size, size)
tif_file = tmp_path / name
with rasterio.open(
tif_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
) as dst:
dst.write(np.zeros((size, size), dtype=np.float32), 1)
return tif_file
def test_window_on_buffered_tif(self, tmp_path):
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "LHD_FXX_0638_6628_PTS_LAMB93_IGN69_slope.tif",
(637900, 6626900, 639100, 6628100), 1200)
with rasterio.open(tif) as src:
win = _core_tile_window(tif, src)
assert win is not None
assert (win.width, win.height) == (1000, 1000)
assert (win.col_off, win.row_off) == (100, 100)
wt = src.window_transform(win)
assert abs(wt.c - 638000.0) < 1e-6
assert abs(wt.f - 6628000.0) < 1e-6
def test_no_window_without_overflow(self, tmp_path):
"""TIF historique sur les bornes d'en-tête (~999,99 m) : rien à recadrer."""
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "LHD_FXX_0638_6628_PTS_LAMB93_IGN69_slope.tif",
(638000, 6627000, 638999.99, 6627999.99), 5000)
with rasterio.open(tif) as src:
assert _core_tile_window(tif, src) is None
def test_no_window_for_non_lhd_name(self, tmp_path):
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "test_vis.tif",
(637900, 6626900, 639100, 6628100), 1200)
with rasterio.open(tif) as src:
assert _core_tile_window(tif, src) is None
class TestDensiteSolCrop:
"""Densité de points : WebP sans perte en niveaux de gris, sous-tuiles
écrites depuis l'image d'origine (un seul encodage)."""
def test_lossless_gray_levels_and_subtiles(self, tmp_path):
from PIL import Image as PILImage
from lidar_pipeline.rendering import tif_to_crop
base = "LHD_FXX_0660_6701_PTS_LAMB93_IGN69"
vis = tmp_path / "visualisations" / f"{base}_r0p2"
vis.mkdir(parents=True)
levels = np.tile(np.arange(16, dtype=np.float32).repeat(4), (64, 1)) # 64 × 64
tif = TestTifToCrop._write_named_tif(vis, f"{base}_densite_sol.tif", levels)
out = tif_to_crop(tif, vis, 0.2, output_format='avif', subtiles_dir=tmp_path)
assert out is not None and out.suffix == ".webp"
# WebP n'a pas de mode gris : relu en RGB à canaux égaux
rgb = np.asarray(PILImage.open(str(out)).convert("RGB")).astype(int)
assert (rgb[..., 0] == rgb[..., 1]).all() and (rgb[..., 1] == rgb[..., 2]).all()
img = PILImage.fromarray(rgb[..., 0].astype(np.uint8))
row = np.asarray(img)[0, ::4].astype(int)
assert len(set(row.tolist())) == 16 and np.all(np.diff(row) > 0)
# Sous-tuiles 2 × 2 (0,2 m/px) en WebP sans perte, identiques à la dalle
q = tmp_path / "index_subtiles" / f"{base}_r0p2_densite_sol_0_1.webp"
assert q.exists()
assert np.array_equal(np.asarray(PILImage.open(str(q)).convert("L")), np.asarray(img)[:32, :32])
assert (tmp_path / "index_subtiles" / f"{base}_r0p2_densite_sol_0_1_mid.webp").exists()
def test_relief_subtiles_encoded_from_source(self, tmp_path):
"""Relief : sous-tuiles AVIF écrites par tif_to_crop, plus récentes que la dalle."""
import pytest
from lidar_pipeline.rendering import tif_to_crop
base = "LHD_FXX_0660_6701_PTS_LAMB93_IGN69"
vis = tmp_path / "visualisations" / f"{base}_r0p2"
vis.mkdir(parents=True)
rgb = np.random.default_rng(1).integers(0, 255, (3, 64, 64)).astype('uint8')
tif = vis / f"{base}_relief_oriente.tif"
with rasterio.open(tif, 'w', driver='GTiff', height=64, width=64, count=3,
dtype='uint8', crs='EPSG:2154',
transform=from_bounds(660000, 6700000, 661000, 6701000, 64, 64)) as dst:
dst.write(rgb)
try:
out = tif_to_crop(tif, vis, 0.2, output_format='avif', subtiles_dir=tmp_path)
except Exception:
pytest.skip("encodeur AVIF indisponible")
sub = tmp_path / "index_subtiles"
quads = sorted(sub.glob(f"{base}_r0p2_relief_oriente_?_?.avif"))
assert len(quads) == 4
assert all(q.stat().st_mtime_ns >= out.stat().st_mtime_ns for q in quads)