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>
269 lines
12 KiB
Python
269 lines
12 KiB
Python
"""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)
|