Files
lidar_rendu/lidar_pipeline/tests/test_rendering.py
Antoine Jacquin 68272cabf0 Tuiles visibles en direct après régénération et déploiement webapp distante
- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et
  force le rechargement navigateur (cache heuristique contourné), y compris
  en plein run via /api/tiles ; veille permanente 15 s sur la carte
- simulation locale du mode deux machines (docker-compose.local-2m.yml) :
  worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/
- override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output,
  rsync vers le worker, labels Traefik (proxy/websecure/myresolver)
- purge 0,5 m : worker/process et politique générale passés à 0,2 m seul
- intègre le travail parallèle non commité : export mosaïque multi-dalles
  (export.py + /api/export), sous-tuilage intégral des couches, légendes
  VIZ_LEGENDS, docs et tests associés (213 tests verts)
2026-09-12 17:48:37 +02:00

177 lines
7.4 KiB
Python

"""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',
}
# IGN overlays (ortho, topo) are RGB images — no colormap needed
skip = {'ortho', 'topo'}
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)