715 lines
31 KiB
Python
715 lines
31 KiB
Python
"""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é
|