Files
lidar_rendu/lidar_pipeline/tests/test_tiles.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

748 lines
32 KiB
Python
Raw Permalink 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 XYZ tile pyramid (OpenStreetMap scheme)."""
import math
from pathlib import Path
# ---------------------------------------------------------------------------
# Fixtures: fake tiles following the pipeline conventions
# ---------------------------------------------------------------------------
def _basename(col, row):
return f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
def _make_dalle(output_dir, col, row, viz_keys, color=(200, 30, 30), px=64,
thumbs=True):
"""Create a tile (image + thumbnails) as the pipeline would."""
from PIL import Image
base = _basename(col, row)
vis = Path(output_dir) / "visualisations" / base
vis.mkdir(parents=True, exist_ok=True)
thumb_dir = Path(output_dir) / "index_thumbs"
thumb_dir.mkdir(parents=True, exist_ok=True)
for key in viz_keys:
Image.new("RGB", (px, px), color).save(
str(vis / f"{base}_{key}.webp"), format="WEBP", lossless=True)
if thumbs:
Image.new("RGB", (64, 64), color).save(
str(thumb_dir / f"{base}_{key}.jpg"), format="JPEG", quality=90)
Image.new("RGB", (64, 64), color).save(
str(thumb_dir / f"{base}_{key}_mid.jpg"), format="JPEG", quality=90)
return base
def _tile_of_cell(col, row, z):
"""Indices (x, y) of the level-z tile containing the centre of a source tile."""
from lidar_pipeline.tiles import _transformer
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(
col * 1000 + 500, (row - 1) * 1000 + 500)
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
rad = math.radians(lat)
y = int((1.0 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2.0 * n)
return x, y
# ---------------------------------------------------------------------------
# Grid geometry
# ---------------------------------------------------------------------------
def test_tile_bounds_3857_known_values():
"""z0 = the whole world; z1/x1/y0 = north-east quadrant."""
from lidar_pipeline.tiles import ORIGIN, tile_bounds_3857
w, s, e, n = tile_bounds_3857(0, 0, 0)
assert (round(w), round(s), round(e), round(n)) == (
round(-ORIGIN), round(-ORIGIN), round(ORIGIN), round(ORIGIN))
w, s, e, n = tile_bounds_3857(1, 1, 0)
assert abs(w) < 1e-6 and abs(s) < 1e-6
assert abs(e - ORIGIN) < 1e-6 and abs(n - ORIGIN) < 1e-6
def test_tile_latitude_and_resolution():
"""Centre latitude and ground resolution (0.2 m/px ≈ z19 in France)."""
from lidar_pipeline.tiles import (TILE_MAX_NATIVE_Z, target_resolution,
tile_latitude)
assert abs(tile_latitude(0, 0)) < 1e-9
assert abs(tile_latitude(1, 0) - 66.51) < 0.05
# Native-level tile at the latitude of metropolitan France
z = TILE_MAX_NATIVE_Z
y = int((1.0 - math.log(math.tan(math.radians(47)) + 1 / math.cos(math.radians(47)))
/ math.pi) / 2.0 * 2 ** z)
res = target_resolution(z, y)
assert 0.15 < res < 0.25, res
# @2x: twice as fine for the same (z, x, y)
assert abs(target_resolution(z, y, scale=2) - res / 2) < 1e-9
def test_tile_bounds_l93_covers_cell():
"""The L93 extent of a tile does contain the source tile it covers."""
from lidar_pipeline.tiles import tile_bounds_l93
col, row, z = 1054, 6882, 14
x, y = _tile_of_cell(col, row, z)
min_x, min_y, max_x, max_y = tile_bounds_l93(z, x, y)
assert min_x < col * 1000 + 500 < max_x
assert min_y < (row - 1) * 1000 + 500 < max_y
def test_perspective_coeffs_identity_and_scale():
"""Identity → neutral coefficients; scaling → exact factor."""
from lidar_pipeline.tiles import perspective_coeffs
quad = [(0, 0), (256, 0), (256, 256), (0, 256)]
c = perspective_coeffs(quad, quad)
assert [round(v, 9) for v in c] == [1, 0, 0, 0, 1, 0, 0, 0]
# Output twice as large as the source: Pillow samples at x/2
c = perspective_coeffs([(0, 0), (512, 0), (512, 512), (0, 512)], quad)
assert abs(c[0] - 0.5) < 1e-9 and abs(c[4] - 0.5) < 1e-9
def test_perspective_coeffs_degenerate():
"""Degenerate quadrilateral (tile shrunk to a point) → None, no exception."""
from lidar_pipeline.tiles import perspective_coeffs
flat = [(0, 0), (0, 0), (0, 0), (0, 0)]
assert perspective_coeffs(flat, [(0, 0), (1, 0), (1, 1), (0, 1)]) is None
def test_zoom_supported_bounds():
"""Served zoom range; @2x stops one level earlier (512 px)."""
from lidar_pipeline.tiles import (TILE_MAX_NATIVE_Z, TILE_MIN_Z,
zoom_supported)
assert not zoom_supported(TILE_MIN_Z - 1)
assert zoom_supported(TILE_MIN_Z)
assert zoom_supported(TILE_MAX_NATIVE_Z)
assert not zoom_supported(TILE_MAX_NATIVE_Z + 1)
assert not zoom_supported(TILE_MAX_NATIVE_Z, scale=2)
assert zoom_supported(TILE_MAX_NATIVE_Z - 1, scale=2)
# ---------------------------------------------------------------------------
# Source index
# ---------------------------------------------------------------------------
def test_source_index_and_layers(tmp_path, monkeypatch):
"""The index lists the layers present and their tiers (coarse → fine)."""
from lidar_pipeline import index, tiles
monkeypatch.setattr(index, "PANEL_VIZ", None)
_make_dalle(tmp_path, 1054, 6882, ["aspect", "slope"])
layers = tiles.source_index(tmp_path, force=True)
assert set(layers) == {"aspect", "slope"}
tiers = layers["aspect"][(1054, 6882)]
# thumbnail (3.9 m/px) → intermediate (1.56) → tile (0.5)
assert [round(t[0].res, 2) for t in tiers] == [3.91, 1.56, 0.5]
assert tiles.available_layers(tmp_path) == ["slope", "aspect"] or \
set(tiles.available_layers(tmp_path)) == {"slope", "aspect"}
def test_available_layers_follow_panel(tmp_path, monkeypatch):
"""Layers on disk outside PANEL_VIZ: neither displayed nor served."""
from lidar_pipeline import index, tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect", "relief_oriente"])
tiles.source_index(tmp_path, force=True)
monkeypatch.setattr(index, "PANEL_VIZ", ("relief_oriente",))
assert tiles.available_layers(tmp_path) == ["relief_oriente"]
def test_grid_bounds(tmp_path):
"""L93 and WGS84 extent of the available grid."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
_make_dalle(tmp_path, 1055, 6883, ["aspect"])
tiles.source_index(tmp_path, force=True)
assert tiles.grid_bounds_l93(tmp_path) == (1054000.0, 6881000.0,
1056000.0, 6883000.0)
w, s, e, n = tiles.grid_bounds_wgs84(tmp_path)
assert 7.0 < w < 8.5 and 47.5 < s < 49.5 and e > w and n > s
def test_pick_tier_by_resolution(tmp_path):
"""Chosen tier: the coarsest whose resolution is sufficient for the tile."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
assert tiles._pick_tier(tiers, 10.0)[0].res == tiers[0][0].res # thumbnail
assert tiles._pick_tier(tiers, 2.0)[0].res == tiers[1][0].res # intermediate
assert tiles._pick_tier(tiers, 0.2)[0].res == tiers[-1][0].res # tile
# Target finer than anything available: the finest tier is kept
assert tiles._pick_tier(tiers, 0.01)[0].res == tiers[-1][0].res
def test_sources_in_bbox_picks_tier(tmp_path):
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"], px=64)
tiles.source_index(tmp_path, force=True)
bbox = (1054000.0, 6881000.0, 1055000.0, 6882000.0)
fine = tiles.sources_in_bbox(tmp_path, "aspect", bbox, 0.2)
coarse = tiles.sources_in_bbox(tmp_path, "aspect", bbox, 50.0)
assert fine and coarse and fine[0][0] == (1054, 6882)
assert min(s.res for _c, s in fine) <= min(s.res for _c, s in coarse)
assert tiles.sources_in_bbox(tmp_path, "aspect", (0, 0, 10, 10), 0.2) == []
img = tiles.load_source(fine[0][1])
assert img is not None and img.mode in ("RGB", "RGBA")
def test_subtiles_preferred_over_full_dalle(tmp_path):
"""index_subtiles quadrants serve as the fine tier (4× less to decode)."""
import pytest
from PIL import Image
from lidar_pipeline import tiles
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
sub = tmp_path / "index_subtiles"
sub.mkdir(parents=True, exist_ok=True)
try:
for i in range(2):
for j in range(2):
Image.new("RGB", (32, 32), (10, 10, 10)).save(
str(sub / f"{base}_aspect_{i}_{j}.avif"), format="AVIF")
except Exception:
pytest.skip("AVIF encoder unavailable")
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
quads = next(t for t in tiers if len(t) == 4)
# At equal resolution, quadrants come BEFORE the whole tile
assert tiers.index(quads) < tiers.index(next(t for t in tiers if len(t) == 1
and t[0].path.suffix == ".webp"))
assert len(quads) == 4
# Quadrant extents: four adjoining half-kilometre squares
assert {q.bounds for q in quads} == {
(1054000.0, 6881000.0, 1054500.0, 6881500.0),
(1054500.0, 6881000.0, 1055000.0, 6881500.0),
(1054000.0, 6881500.0, 1054500.0, 6882000.0),
(1054500.0, 6881500.0, 1055000.0, 6882000.0)}
# ---------------------------------------------------------------------------
# Rendering and cache
# ---------------------------------------------------------------------------
def test_render_tile_paints_cell(tmp_path):
"""A tile over the source tile is painted; elsewhere it is empty."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"], color=(200, 30, 30))
tiles.source_index(tmp_path, force=True)
z = 15
x, y = _tile_of_cell(1054, 6882, z)
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
assert img is not None and img.size == (256, 256)
r, g, b, a = img.getpixel((128, 128))
assert a == 255 and r > 150 and g < 90 and b < 90
# Distant tile (another continent): no source
assert tiles.render_tile(tmp_path, "aspect", z, 1, 1) is None
def test_render_tile_scale2(tmp_path):
"""`scale=2` renders the same extent at 512 px (@2x convention)."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
z = 15
x, y = _tile_of_cell(1054, 6882, z)
img = tiles.render_tile(tmp_path, "aspect", z, x, y, scale=2)
assert img is not None and img.size == (512, 512)
def test_tile_edges_transparent_outside_data(tmp_path):
"""Outside the source tiles' extent, the tile stays transparent (overlayable)."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
# Zoom where a tile is much larger than the source tile: edges are empty
z = 11
x, y = _tile_of_cell(1054, 6882, z)
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
assert img is not None
assert img.getpixel((0, 0))[3] == 0
assert img.getpixel((255, 255))[3] == 0
def test_get_tile_cache_and_staleness(tmp_path, monkeypatch):
"""The disk cache is reused, then invalidated by a regenerated source tile."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # historical rule: everything is stored
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
import os
import time
from lidar_pipeline import tiles
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
z = 15
x, y = _tile_of_cell(1054, 6882, z)
data = tiles.get_tile(tmp_path, "aspect", z, x, y)
assert data and data[:8] == b"\x89PNG\r\n\x1a\n"
cache = tiles.tile_cache_path(tmp_path, "aspect", z, x, y)
assert cache.is_file()
first = cache.stat().st_mtime
# Unchanged: the cached tile is served again as-is
time.sleep(0.02)
assert tiles.get_tile(tmp_path, "aspect", z, x, y) == data
assert cache.stat().st_mtime == first
# Regenerated source tile (newer mtime): the tile is recomputed
src = tmp_path / "visualisations" / base / f"{base}_aspect.webp"
newer = first + 10
os.utime(src, (newer, newer))
for f in (tmp_path / "index_thumbs").iterdir():
os.utime(f, (newer, newer))
tiles.source_index(tmp_path, force=True)
tiles.get_tile(tmp_path, "aspect", z, x, y)
assert cache.stat().st_mtime > first
def test_get_tile_empty_marker(tmp_path, monkeypatch):
"""Tile without data: None + remembered .empty marker."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # historical rule: everything is stored
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
assert tiles.get_tile(tmp_path, "aspect", 15, 1, 1) is None
assert tiles.empty_marker_exists(tmp_path, "aspect", 15, 1, 1)
def test_get_tile_webp_scale2(tmp_path):
"""@2x tier in WebP: 512 px, cache path distinct from the 256 px one."""
from PIL import Image
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
z = 15
x, y = _tile_of_cell(1054, 6882, z)
data = tiles.get_tile(tmp_path, "aspect", z, x, y, scale=2, fmt="webp")
assert data and data[:4] == b"RIFF"
path = tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "webp")
assert path.name.endswith("@2x.webp") and path.is_file()
import io
assert Image.open(io.BytesIO(data)).size == (512, 512)
def test_transparent_tile_is_fully_transparent():
"""The fallback tile (empty area) is fully transparent."""
import io
from PIL import Image
from lidar_pipeline import tiles
img = Image.open(io.BytesIO(tiles.transparent_tile()))
assert img.size == (256, 256)
assert img.convert("RGBA").getextrema()[3] == (0, 0)
def test_tiles_in_bounds_and_warm(tmp_path):
"""Warm-up: the tiles of the extent are computed and cached."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
bounds = tiles.grid_bounds_wgs84(tmp_path)
assert len(tiles.tiles_in_bounds(bounds, 14)) >= 1
report = tiles.warm(tmp_path, ["aspect"], 12, 13)
assert report["rendues"] >= 1 and report["limite"] is False
assert any((tmp_path / tiles.TILE_DIRNAME / "aspect").rglob("*.png"))
def test_tiles_stamp_follows_sources(tmp_path):
"""The global version follows the most recent source-tile mtime."""
import os
from lidar_pipeline import tiles
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
first = tiles.tiles_stamp(tmp_path)
src = tmp_path / "visualisations" / base / f"{base}_aspect.webp"
os.utime(src, (first / 1000 + 60, first / 1000 + 60))
tiles.source_index(tmp_path, force=True)
assert tiles.tiles_stamp(tmp_path) > first
def test_source_cache_respects_memory_budget(tmp_path, monkeypatch):
"""The source image cache evicts by a byte budget, not by a count.
A decoded 5000² tile weighs ~100 MB (4 bytes/px): an "N entries" cache
would overflow the memory of a small machine (Raspberry Pi).
"""
from PIL import Image
from lidar_pipeline import tiles
tiles.clear_source_cache()
# Deliberately tiny budget: only one image fits at a time
monkeypatch.setattr(tiles, "SOURCE_CACHE_BYTES", 40 * 40 * 3 * 2 - 1)
paths = []
for i in range(3):
f = tmp_path / f"src{i}.png"
Image.new("RGB", (40, 40), (i * 40, 0, 0)).save(str(f))
paths.append(f)
for f in paths:
tiles._open_source(str(f), f.stat().st_mtime)
assert len(tiles._source_cache) == 1
# The last source used is the one that remains
assert str(paths[-1]) == list(tiles._source_cache)[0][0]
tiles.clear_source_cache()
assert tiles._source_cache == {}
def test_source_cache_holds_plain_decoded_images(tmp_path):
"""The cache holds pixels only: no open file, no decoder.
An open AVIF image keeps its decoder (libavif/dav1d buffers): ~43 MB
retained per 2500² quadrant instead of 25 — the Pi's container (1 GB)
was killed by the OOM killer when browsing at high zoom. The budget
counts 4 bytes/pixel: PIL stores RGB on 32 bits.
"""
from PIL import Image
from lidar_pipeline import tiles
tiles.clear_source_cache()
f = tmp_path / "src.png"
Image.new("RGB", (40, 30), (10, 20, 30)).save(str(f))
img = tiles._open_source(str(f), f.stat().st_mtime)
assert type(img) is Image.Image
assert getattr(img, "fp", None) is None
assert img.info["_bytes"] == 40 * 30 * 4
assert img.getpixel((0, 0))[:3] == (10, 20, 30)
tiles.clear_source_cache()
def test_source_cache_keyed_by_mtime(tmp_path):
"""A rewritten source is not served again from the cache."""
import os
from PIL import Image
from lidar_pipeline import tiles
tiles.clear_source_cache()
f = tmp_path / "src.png"
Image.new("RGB", (8, 8), (10, 10, 10)).save(str(f))
first = tiles._open_source(str(f), f.stat().st_mtime)
assert first.getpixel((0, 0))[:3] == (10, 10, 10)
Image.new("RGB", (8, 8), (200, 200, 200)).save(str(f))
os.utime(f, (f.stat().st_mtime + 5, f.stat().st_mtime + 5))
second = tiles._open_source(str(f), f.stat().st_mtime)
assert second.getpixel((0, 0))[:3] == (200, 200, 200)
tiles.clear_source_cache()
def test_png_palette_option_shrinks_tiles(tmp_path, monkeypatch):
"""`LIDAR_TILE_PNG_PALETTE=1` shrinks the canonical PNG (palette + alpha).
Measured on a realistic rendering (noisy color ramp): a flat-colored tile
already compresses better in RGBA than with a palette, it would prove
nothing.
"""
import io
import random
from PIL import Image
from lidar_pipeline import tiles
base = _basename(1054, 6882)
vis = tmp_path / "visualisations" / base
vis.mkdir(parents=True, exist_ok=True)
rng = random.Random(7)
img = Image.new("RGB", (256, 256))
px = img.load()
for j in range(256):
for i in range(256):
px[i, j] = (min(255, i + rng.randint(0, 12)),
min(255, j + rng.randint(0, 12)),
rng.randint(40, 90))
img.save(str(vis / f"{base}_aspect.webp"), format="WEBP", lossless=True)
tiles.source_index(tmp_path, force=True)
z = 15
x, y = _tile_of_cell(1054, 6882, z)
rendered = tiles.render_tile(tmp_path, "aspect", z, x, y)
lossless = tiles._encode(rendered, "png")
monkeypatch.setattr(tiles, "PNG_PALETTE", True)
palette = tiles._encode(rendered, "png")
assert len(palette) < len(lossless)
# The palettized PNG remains a readable PNG, at the right size, with alpha
out = Image.open(io.BytesIO(palette))
assert out.size == (256, 256)
assert out.convert("RGBA").getextrema()[3][1] == 255
# ---------------------------------------------------------------------------
# Upstream tile server (LIDAR_SOURCE_URL)
# ---------------------------------------------------------------------------
def _remote_payload():
"""/api/tiles payload as served by mapserve (tile server)."""
base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2"
tiles = []
for i in range(2):
for j in range(2):
tiles.append({
"col": 1054, "row": 6882, "resolution": 0.2,
"dir_name": base, "sub_i": i, "sub_j": j, "sub_k": 2,
"viz": {"aspect": {
"thumb": f"index_subtiles/{base}_aspect_{i}_{j}_thumb160.webp?v=1700000000000",
"mid": f"index_subtiles/{base}_aspect_{i}_{j}_mid.webp?v=1700000000000",
"full": f"index_subtiles/{base}_aspect_{i}_{j}.avif?v=1700000000000",
}},
})
return {"tiles": tiles, "viz_meta": {"aspect": {"label": "Aspect"}}}
def test_remote_index_lists_tiles_without_local_data(tmp_path, monkeypatch):
"""Without any local data, the index comes from the upstream (quadrants placed)."""
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: _remote_payload())
layers = tiles.source_index(tmp_path, force=True)
assert set(layers) == {"aspect"}
tiers = layers["aspect"][(1054, 6882)]
# Three tiers (thumbnail, intermediate, quadrant) × 4 quadrants each
assert [len(t) for t in tiers] == [4, 4, 4]
assert [round(t[0].res, 2) for t in tiers] == [3.12, 0.78, 0.2]
fine = tiers[-1][0]
assert fine.url.startswith("http://amont:8973/index_subtiles/")
assert fine.path == tmp_path / fine.url.split("8973/")[1].split("?")[0]
# The reference date comes from the announced ?v=, without downloading anything
assert fine.mtime() == 1700000000.0
assert tiles.grid_bounds_l93(tmp_path) == (1054000.0, 6881000.0,
1055000.0, 6882000.0)
def test_remote_source_downloaded_on_demand(tmp_path, monkeypatch):
"""A remote source is only fetched by the first rendering that needs it."""
from PIL import Image
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: _remote_payload())
tiles.clear_source_cache()
import io
buf = io.BytesIO()
Image.new("RGB", (64, 64), (12, 200, 90)).save(buf, format="WEBP", lossless=True)
body = buf.getvalue()
fetched = []
def fake_fetch(url, dest):
fetched.append(url)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(body)
return True
monkeypatch.setattr(tiles, "_fetch_source", fake_fetch)
z = 15
x, y = _tile_of_cell(1054, 6882, z)
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
assert img is not None
assert fetched, "no source fetched"
r, g, b, a = img.getpixel((128, 128))
assert a == 255 and g > 150 and r < 80
# Fetched files land in the local cache, at the same path
assert list(tmp_path.rglob("*.webp")) or list(tmp_path.rglob("*.avif"))
# Second rendering: no more downloads (local cache)
before = len(fetched)
tiles.render_tile(tmp_path, "aspect", z, x, y)
assert len(fetched) == before
tiles.clear_source_cache()
def test_remote_index_failure_keeps_local(tmp_path, monkeypatch):
"""Upstream unreachable: the local index is still served, without exception."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["slope"])
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: None)
layers = tiles.source_index(tmp_path, force=True)
assert set(layers) == {"slope"}
def test_cached_tile_states(tmp_path):
"""`cached_tile`: cache-only read — fresh / pending / empty, no rendering."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["slope"])
x, y = _tile_of_cell(1054, 6882, 14)
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
assert state == "pending" and data is None
assert not tiles.tile_cache_path(tmp_path, "slope", 14, x, y).exists()
tiles.get_tile(tmp_path, "slope", 14, x, y) # rendering (maintenance, warm…)
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
assert state == "fresh" and data
# Outside the data: empty state + marker set, still without rendering
data, state = tiles.cached_tile(tmp_path, "slope", 14, 0, 0)
assert state == "empty" and data is None
assert tiles.empty_marker_exists(tmp_path, "slope", 14, 0, 0)
def test_storage_policy_even_levels_up_to_max(monkeypatch):
"""Reduced storage: even standard levels ≤ LIDAR_TILE_CACHE_MAX_Z (an
@2x tile at level z equals a 256 px tile at z+1)."""
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
assert tiles.zoom_cached(16, 1) and not tiles.zoom_cached(15, 1)
assert tiles.zoom_cached(15, 2) and not tiles.zoom_cached(16, 2) # @2x z15 = standard z16
assert not tiles.zoom_cached(18, 1) and not tiles.zoom_cached(17, 2) # fine level: on the fly
def test_unstored_level_rendered_on_the_fly_without_disk(tmp_path, monkeypatch):
"""Unstored level: tile rendered, nothing written to disk, second
request served by the memory cache."""
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
z = 17
x, y = _tile_of_cell(1054, 6882, z)
calls = []
real = tiles.render_tile
monkeypatch.setattr(tiles, "render_tile", lambda *a, **k: calls.append(1) or real(*a, **k))
data = tiles.get_tile(tmp_path, "aspect", z, x, y, 1, "png")
assert data and data[:4] == b"\x89PNG"
assert not tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 1, "png").exists()
assert tiles.get_tile(tmp_path, "aspect", z, x, y, 1, "png") == data and len(calls) == 1
def test_remote_payload_failure_not_retried_each_call(monkeypatch):
"""Upstream unreachable with no known inventory: the failure is remembered (TTL).
Otherwise every call (several per /api/map/meta) pays the connection
timeout again: the interface no longer loaded when the worker was down.
"""
import urllib.request
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
monkeypatch.setattr(tiles, "_remote_cache",
{"payload": None, "at": 0.0, "index": None, "root": None})
calls = []
def boom(*a, **k):
calls.append(1)
raise OSError("host unreachable")
monkeypatch.setattr(urllib.request, "urlopen", boom)
for _ in range(3):
assert tiles._remote_payload() is None
assert len(calls) == 1
def test_fetch_source_offline_breaker(tmp_path, monkeypatch):
"""Failed upstream source: the following ones fail without waiting for the
network timeout during the suspension (the maintenance falls back to local
rendering)."""
import urllib.request
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "_SOURCE_OFFLINE", {"until": 0.0})
calls = []
def boom(*a, **k):
calls.append(1)
raise OSError("host unreachable")
monkeypatch.setattr(urllib.request, "urlopen", boom)
assert tiles._fetch_source("http://amont/a", tmp_path / "a.avif") is False
assert tiles._fetch_source("http://amont/b", tmp_path / "b.avif") is False
assert len(calls) == 1
def test_fetched_source_dated_to_upstream_version(tmp_path, monkeypatch):
"""A fetched source carries the upstream version date: when the upstream
goes down, the local index finds the same dates and already-rendered tiles
stay fresh (no whole pyramid to redo)."""
from lidar_pipeline import tiles
def fake_fetch(url, dest):
dest.write_bytes(b"x")
return True
monkeypatch.setattr(tiles, "_fetch_source", fake_fetch)
src = tiles._Source(tmp_path / "q.avif", (0, 0, 1, 1), 0.2,
url="http://amont/q.avif", version=1700000000000)
assert src.ensure() is True
assert (tmp_path / "q.avif").stat().st_mtime == 1700000000.0
def test_tile_refreshed_when_older_dalle_appears(tmp_path):
"""Source tile entering the inventory AFTER an XYZ tile covering it was
rendered, but with an older version date (written before, inventoried
after): the XYZ tile must become stale — otherwise a permanent hole at
that level, on disk, in memory and in the browser (unchanged stamp)."""
import io
import os
from PIL import Image
from lidar_pipeline import tiles
tiles.clear_source_cache()
tiles._mem_tiles.clear()
z = 10
assert _tile_of_cell(1054, 6882, z) == _tile_of_cell(1055, 6882, z)
x, y = _tile_of_cell(1054, 6882, z)
_make_dalle(tmp_path, 1054, 6882, ["slope"], color=(200, 30, 30))
tiles.source_index(tmp_path, force=True)
first = tiles.get_tile(tmp_path, "slope", z, x, y)
assert first is not None
stamp = tiles.tiles_stamp(tmp_path)
_make_dalle(tmp_path, 1055, 6882, ["slope"], color=(30, 200, 30))
for f in tmp_path.rglob("*1055_6882*"):
os.utime(f, (1_000_000, 1_000_000)) # version older than the XYZ tile
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "pending"
second = tiles.get_tile(tmp_path, "slope", z, x, y)
img = Image.open(io.BytesIO(second)).convert("RGBA")
assert any(g > 150 and r < 80 and a == 255
for r, g, b, a in img.getdata()), "new source tile missing from the XYZ tile"
assert tiles.tiles_stamp(tmp_path) > stamp
# Persistent registry: a restart invalidates nothing again
tiles._index_cache.clear()
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "fresh"
tiles.clear_source_cache()
def test_existing_cache_without_registry_is_refreshed_once(tmp_path):
"""Upgrade: tile cache present but no registry — the existing tiles
(possibly with holes) are made stale exactly once."""
from lidar_pipeline import tiles
tiles._seen_cache.clear()
tiles._mem_tiles.clear()
z = 10
x, y = _tile_of_cell(1054, 6882, z)
_make_dalle(tmp_path, 1054, 6882, ["slope"])
tiles.source_index(tmp_path, force=True)
assert tiles.get_tile(tmp_path, "slope", z, x, y) is not None
(tmp_path / tiles.TILE_DIRNAME / tiles._SEEN_FILE).unlink() # previous version
tiles._seen_cache.clear()
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "pending"
tiles.get_tile(tmp_path, "slope", z, x, y)
tiles._seen_cache.clear()
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "fresh"
def test_webp_subtiles_indexed_and_rendered_nearest(tmp_path):
"""Density layer: lossless .webp quadrants recognised as the fine tier,
and rendered with nearest neighbour (16 exact greys even when upscaled)."""
from PIL import Image
from lidar_pipeline import tiles
base = _make_dalle(tmp_path, 1054, 6882, ["densite_sol"])
sub = tmp_path / "index_subtiles"
sub.mkdir(parents=True, exist_ok=True)
for i in range(2):
for j in range(2):
im = Image.new("L", (8, 8), 0)
im.paste(255, (0, 0, 4, 8)) # half white, half black
im.save(str(sub / f"{base}_densite_sol_{i}_{j}.webp"), format="WEBP", lossless=True)
tiers = tiles.source_index(tmp_path, force=True)["densite_sol"][(1054, 6882)]
quads = next(t for t in tiers if len(t) == 4)
assert all(q.path.suffix == ".webp" for q in quads)
assert "densite_sol" in tiles.NEAREST_LAYERS
# z17: target resolution (~0.8 m) reached by the quadrants (0.5 m here)
x, y = _tile_of_cell(1054, 6882, 17)
img = tiles.render_tile(tmp_path, "densite_sol", 17, x, y)
assert img is not None
grays = {p[0] for p in img.getdata() if p[3] == 255}
assert grays <= {0, 255} # no invented intermediate grey
def test_remote_quality_persisted_locally(tmp_path, monkeypatch):
"""The upstream quality table is copied into local sidecars (self-sufficient Pi)."""
from lidar_pipeline import tiles
from lidar_pipeline.quality import read_quality
payload = _remote_payload()
base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
payload["quality"] = {base: {"version": 1, "ground_density": 3.0},
"../evasion": {"version": 1}}
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont")
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: payload)
monkeypatch.setattr(tiles, "_remote_cache", dict(tiles._remote_cache, index=None, built=None))
tiles._remote_index(tmp_path, force=True)
assert read_quality(tmp_path, base) == {"version": 1, "ground_density": 3.0}
assert not (tmp_path / "evasion.json").exists()
assert list((tmp_path / "quality").iterdir()) == [tmp_path / "quality" / f"{base}.json"]