"""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)