Files
lidar_rendu/lidar_pipeline/tests/test_rendering.py
Antoine fb892ea9f2 Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:16:45 +02:00

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 the rendering module (colormaps, tif_to_png, tif_to_crop)."""
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',
}
# RGB images (IGN backgrounds, oriented relief) — no 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):
"""Frozen quantile calibration (wavelet): same value → same color.
The knots are constant (no local percentile): clamped at the
extremes, median (1.0) → 0.5, monotonic transfer.
"""
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 # median knot
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 # low clamp
assert abs(out[2] - 0.5) < 1e-9 # median → 0.5
assert out[3] == 0.995 and out[4] == 1.0 # top knot, then clamped beyond
# Monotonicity (no hue inversion)
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)
# The 0.2 m knots differ from the 0.5 m ones (separate calibrations)
kv02, _ = info['knots'][0.2]
assert kv02 != kv
class TestTifToCrop:
"""TIF → map tile conversion (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):
"""Remaining nodata is rendered black (historical behavior).
DTM holes are filled upstream (gap filling in create_dtm_fast);
whatever is still nodata must stay visible as black on the tile
rather than being invented at render time.
"""
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)
# Lossless WebP: the image's AVIF encoder slightly "bleeds" the edges
# of the black area even in lossless mode — we test the nodata→black
# logic, not codec artifacts.
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), "nodata must be rendered black"
def test_without_nodata(self, tmp_path):
"""A TIF without nodata converts without crashing, size preserved."""
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:
"""Cropping of edge-buffered TIFs to the nominal 1 km tile."""
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):
"""Historical TIF on the header bounds (~999.99 m): nothing to crop."""
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:
"""Point density: lossless grayscale WebP, sub-tiles written from the
original image (a single encoding)."""
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 has no gray mode: read back as RGB with equal channels
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)
# 2 × 2 sub-tiles (0.2 m/px) in lossless WebP, identical to the tile
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: AVIF sub-tiles written by tif_to_crop, newer than the tile."""
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("AVIF encoder unavailable")
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)