Files
lidar_rendu/lidar_pipeline/tests/test_tiles.py
2026-09-27 15:29:06 +02:00

732 lines
32 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 de la pyramide de tuiles XYZ (schéma OpenStreetMap)."""
import math
from pathlib import Path
# ---------------------------------------------------------------------------
# Fixtures : dalles factices aux conventions du pipeline
# ---------------------------------------------------------------------------
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):
"""Crée une dalle (image + vignettes) comme le ferait le pipeline."""
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) de la tuile du niveau z contenant le centre d'une dalle."""
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
# ---------------------------------------------------------------------------
# Géométrie de la grille
# ---------------------------------------------------------------------------
def test_tile_bounds_3857_known_values():
"""z0 = le monde entier ; z1/x1/y0 = quadrant nord-est."""
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():
"""Latitude du centre et résolution terrain (0,2 m/px ≈ z19 en 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
# Tuile du niveau natif à la latitude de la France métropolitaine
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 : deux fois plus fin pour le même (z, x, y)
assert abs(target_resolution(z, y, scale=2) - res / 2) < 1e-9
def test_tile_bounds_l93_covers_cell():
"""L'emprise L93 d'une tuile contient bien la dalle qu'elle recouvre."""
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():
"""Identité → coefficients neutres ; homothétie → facteur exact."""
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]
# Sortie deux fois plus grande que la source : Pillow échantillonne à 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():
"""Quadrilatère dégénéré (dalle réduite à un point) → None, pas d'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():
"""Plage de zooms servie ; @2x s'arrête un cran plus tôt (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)
# ---------------------------------------------------------------------------
# Index des sources
# ---------------------------------------------------------------------------
def test_source_index_and_layers(tmp_path, monkeypatch):
"""L'index liste les couches présentes et leurs paliers (grossier → fin)."""
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)]
# vignette (3,9 m/px) → intermédiaire (1,56) → dalle (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):
"""Couches sur disque hors PANEL_VIZ : ni affichées ni servies."""
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):
"""Emprise L93 et WGS84 de la grille disponible."""
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):
"""Palier retenu : le plus grossier dont la résolution suffit à la tuile."""
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 # vignette
assert tiles._pick_tier(tiers, 2.0)[0].res == tiers[1][0].res # intermédiaire
assert tiles._pick_tier(tiers, 0.2)[0].res == tiers[-1][0].res # dalle
# Cible plus fine que tout ce qui existe : on garde le palier le plus fin
assert tiles._pick_tier(tiers, 0.01)[0].res == tiers[-1][0].res
def test_subtiles_preferred_over_full_dalle(tmp_path):
"""Les quadrants index_subtiles servent de palier fin (4× moins à décoder)."""
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("encodeur AVIF indisponible")
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
quads = next(t for t in tiers if len(t) == 4)
# À résolution égale, les quadrants passent AVANT la dalle entière
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
# Emprises des quadrants : quatre demi-kilomètres jointifs
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)}
# ---------------------------------------------------------------------------
# Rendu et cache
# ---------------------------------------------------------------------------
def test_render_tile_paints_cell(tmp_path):
"""Une tuile au-dessus de la dalle est peinte ; ailleurs elle est vide."""
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
# Tuile lointaine (autre continent) : aucune source
assert tiles.render_tile(tmp_path, "aspect", z, 1, 1) is None
def test_render_tile_scale2(tmp_path):
"""`scale=2` rend la même emprise en 512 px (convention @2x)."""
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):
"""Hors emprise des dalles, la tuile reste transparente (superposable)."""
from lidar_pipeline import tiles
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
tiles.source_index(tmp_path, force=True)
# Zoom où une tuile est bien plus grande que la dalle : les bords sont vides
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):
"""Le cache disque est réutilisé, puis invalidé par une dalle régénérée."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
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
# Sans changement : la tuile en cache est resservie telle quelle
time.sleep(0.02)
assert tiles.get_tile(tmp_path, "aspect", z, x, y) == data
assert cache.stat().st_mtime == first
# Dalle régénérée (mtime plus récente) : la tuile est recalculée
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):
"""Tuile sans donnée : None + marqueur .empty mémorisé."""
from lidar_pipeline import tiles as _t
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
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):
"""Palier @2x en WebP : 512 px, chemin de cache distinct du 256 px."""
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():
"""La tuile de repli (zone vide) est entièrement transparente."""
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):
"""Pré-chauffage : les tuiles de l'emprise sont calculées et mises en cache."""
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):
"""La version globale suit la mtime la plus récente des dalles."""
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):
"""Le cache d'images sources évince selon un budget en octets, pas un compte.
Une dalle 5000² pèse ~75 Mo décodée : un cache « N entrées » ferait
déborder la mémoire d'une petite machine (Raspberry Pi).
"""
from PIL import Image
from lidar_pipeline import tiles
tiles.clear_source_cache()
# Budget volontairement minuscule : une seule image tient à la fois
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
# La dernière source utilisée est celle qui reste
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):
"""Le cache ne retient que les pixels : ni fichier ouvert ni décodeur.
Une image AVIF ouverte garde son décodeur (tampons libavif/dav1d) :
~43 Mo retenus par quadrant 2500² au lieu de 25 — le conteneur du Pi
(1 Go) était tué par l'OOM killer en navigation à fort zoom. Le budget
compte 4 octets/pixel : PIL stocke le RGB sur 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):
"""Une source réécrite n'est pas resservie depuis le 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` allège le PNG canonique (palette + alpha).
Mesuré sur un rendu réaliste (rampe de couleur bruitée) : une dalle unie
compresse déjà mieux en RGBA qu'en palette, elle ne prouverait rien.
"""
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)
# Le PNG palettisé reste un PNG lisible, à la bonne taille, avec alpha
out = Image.open(io.BytesIO(palette))
assert out.size == (256, 256)
assert out.convert("RGBA").getextrema()[3][1] == 255
# ---------------------------------------------------------------------------
# Serveur de dalles amont (LIDAR_SOURCE_URL)
# ---------------------------------------------------------------------------
def _remote_payload():
"""Charge utile /api/tiles telle que la sert mapserve (serveur de dalles)."""
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):
"""Sans aucune donnée locale, l'index vient de l'amont (quadrants placés)."""
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)]
# Trois paliers (vignette, intermédiaire, quadrant) × 4 quadrants chacun
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]
# La date de référence vient du ?v= annoncé, sans rien télécharger
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):
"""Une source distante n'est rapatriée qu'au premier rendu qui en a besoin."""
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, "aucune source rapatriée"
r, g, b, a = img.getpixel((128, 128))
assert a == 255 and g > 150 and r < 80
# Les fichiers rapatriés atterrissent dans le cache local, au même chemin
assert list(tmp_path.rglob("*.webp")) or list(tmp_path.rglob("*.avif"))
# Second rendu : plus aucun téléchargement (cache local)
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):
"""Amont injoignable : l'index local reste servi, sans 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` : lecture cache seule — fresh / pending / empty, sans rendu."""
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) # rendu (maintenance, warm…)
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
assert state == "fresh" and data
# Hors données : état empty + marqueur posé, toujours sans rendu
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):
"""Stockage réduit : niveaux standard pairs ≤ LIDAR_TILE_CACHE_MAX_Z (une
tuile @2x de niveau z vaut une 256 px de 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 = z16 standard
assert not tiles.zoom_cached(18, 1) and not tiles.zoom_cached(17, 2) # niveau fin : à la volée
def test_unstored_level_rendered_on_the_fly_without_disk(tmp_path, monkeypatch):
"""Niveau non stocké : tuile rendue, rien d'écrit sur disque, seconde
demande servie par le cache mémoire."""
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):
"""Amont injoignable sans inventaire connu : l'échec est mémorisé (TTL).
Sinon chaque appel (plusieurs par /api/map/meta) repaie le délai de
connexion : l'interface ne se chargeait plus quand le worker était éteint.
"""
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("hôte injoignable")
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):
"""Source amont en échec : les suivantes échouent sans attendre le délai
réseau pendant la suspension (la maintenance passe au rendu local)."""
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("hôte injoignable")
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):
"""Une source rapatriée porte la date de version amont : quand l'amont
s'éteint, l'index local retrouve les mêmes dates et les tuiles déjà
faites restent fraîches (pas de pyramide entière à refaire)."""
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):
"""Dalle entrée dans l'inventaire APRÈS le rendu d'une tuile qui la couvre,
mais avec une date de version plus ancienne (écrite avant, inventoriée
après) : la tuile doit être périmée — sinon trou permanent à ce niveau,
sur le disque, en mémoire et dans le navigateur (stamp inchangé)."""
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 antérieure à la tuile
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()), "nouvelle dalle absente de la tuile"
assert tiles.tiles_stamp(tmp_path) > stamp
# Registre persistant : un redémarrage ne réinvalide rien
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):
"""Mise à jour : cache de tuiles présent mais pas de registre — les
tuiles existantes (peut-être trouées) sont périmées une seule fois."""
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() # version précédente
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):
"""Couche densité : quadrants .webp sans perte reconnus comme palier fin,
et rendus au plus proche voisin (16 gris exacts même agrandis)."""
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)) # moitié blanche, moitié noire
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 : résolution visée (~0,8 m) atteinte par les quadrants (0,5 m ici)
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} # aucun gris intermédiaire inventé
def test_remote_quality_persisted_locally(tmp_path, monkeypatch):
"""La table qualité amont est recopiée en sidecars locaux (Pi autonome)."""
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"]