Ajouter une carte à tuiles XYZ (image lidar-maps) réutilisable dans OSM
L'ancienne carte n'est pas une carte à tuiles : une div Leaflet par dalle,
rotée en CSS pour coller la grille Lambert 93 sur le Web Mercator, trois
paliers d'images choisis à la main, un plafond d'images pleine résolution et
une mosaïque d'overview pour boucher les trous au dézoom. Des centaines de
nœuds DOM, des AVIF de 2500² à 5000² décodés dans le navigateur, un LOD
maison — et rien de réutilisable hors de cette page.
Nouvelle image légère (Pillow + pyproj, ni GDAL ni PDAL, port 8975) servant
une pyramide XYZ EPSG:3857 au schéma OpenStreetMap, rendue à la demande
depuis les dalles et mise en cache dans output/index_xyz/ :
- tiles.py : grille XYZ, reprojection par transformation projective dalle par
dalle (calage mesuré < 1 px), choix du palier source parmi ceux que le
pipeline produit déjà (vignette, intermédiaire, quadrant, dalle), cache
périmé dès qu'une dalle contributrice est plus récente, marqueur .empty
pour les zones sans donnée, cache d'images sources à budget mémoire ;
- mapserve.py : /tiles/{couche}/{z}/{x}/{y}.png (256 px canonique) et
@2x.webp (512 px, interface), TileJSON, WMTS, josm.imagery.xml, CORS —
les rendus deviennent un fond d'imagerie pour JOSM, iD, QGIS, uMap ;
- mapui.py : une L.tileLayer par couche dans un pane isolé — LOD, cache et
animation natifs de Leaflet ; pile réordonnable (glisser avec barre
d'insertion, ou boutons ▲▼ au doigt), modes de fusion CSS par couche,
configuration figeable comme défaut de tous les navigateurs.
Deux amonts pour les déploiements en deux machines : LIDAR_SOURCE_URL
(webapp du pipeline — inventaire complet, dalles rapatriées à la demande) et
LIDAR_MAPS_URL (autre instance carte). Charge bornée et réglable, taillée
par défaut pour une petite machine : 2 rendus et 2 téléchargements
simultanés, 192 Mo de cache de sources.
Mesuré sur 849 dalles réelles : première tuile d'une zone 0,9–3,2 s
(téléchargement compris), ~1 ms ensuite ; un chemin OSM se superpose
exactement à la trace du rendu de pente, et les coutures entre tuiles
restent sous le bruit naturel du terrain.
L'interface historique (port 8973) n'est pas touchée : génération et export
y restent, les deux cartes coexistent.
This commit is contained in:
532
lidar_pipeline/tests/test_mapserve.py
Normal file
532
lidar_pipeline/tests/test_mapserve.py
Normal file
@ -0,0 +1,532 @@
|
||||
"""Tests du serveur de tuiles XYZ et de sa découverte (TileJSON, WMTS, JOSM)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from lidar_pipeline.tests.test_tiles import _make_dalle, _tile_of_cell
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Requête minimale : seules l'origine et les en-têtes de proxy comptent."""
|
||||
|
||||
def __init__(self, host="carte.local:8975", proto=None, scheme="http"):
|
||||
self.headers = {"host": host}
|
||||
if proto:
|
||||
self.headers["x-forwarded-proto"] = proto
|
||||
self.url = type("U", (), {"scheme": scheme, "hostname": "carte.local",
|
||||
"port": 8975})()
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, layers=("aspect", "slope")):
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, list(layers))
|
||||
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
return mapserve
|
||||
|
||||
|
||||
def test_tile_png_is_256_rgba(tmp_path, monkeypatch):
|
||||
"""La tuile canonique est un PNG RGBA de 256 px, avec en-têtes CORS."""
|
||||
import io
|
||||
from PIL import Image
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png"))
|
||||
assert resp.media_type == "image/png"
|
||||
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
||||
assert resp.headers["X-Tile-Empty"] == "0"
|
||||
img = Image.open(io.BytesIO(resp.body))
|
||||
assert img.size == (256, 256) and img.mode in ("RGBA", "P")
|
||||
|
||||
|
||||
def test_tile_2x_is_512_webp(tmp_path, monkeypatch):
|
||||
"""Le palier @2x sert du WebP 512 px (interface interne)."""
|
||||
import io
|
||||
from PIL import Image
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.webp"))
|
||||
assert resp.media_type == "image/webp"
|
||||
assert Image.open(io.BytesIO(resp.body)).size == (512, 512)
|
||||
|
||||
|
||||
def test_tile_outside_data_is_transparent(tmp_path, monkeypatch):
|
||||
"""Hors données : tuile transparente (et pas une erreur) pour JOSM/iD."""
|
||||
import io
|
||||
from PIL import Image
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png"))
|
||||
assert resp.headers["X-Tile-Empty"] == "1"
|
||||
img = Image.open(io.BytesIO(resp.body)).convert("RGBA")
|
||||
assert img.getextrema()[3] == (0, 0)
|
||||
|
||||
|
||||
def test_tile_cache_headers(tmp_path, monkeypatch):
|
||||
"""URL versionnée → cache immuable ; sans version → revalidation courte."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
plain = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png"))
|
||||
assert "must-revalidate" in plain.headers["Cache-Control"]
|
||||
versioned = asyncio.run(mapserve.tile("aspect", z, x, f"{y}.png", v="123"))
|
||||
assert "immutable" in versioned.headers["Cache-Control"]
|
||||
|
||||
|
||||
def test_tile_rejects_bad_input(tmp_path, monkeypatch):
|
||||
"""Nom de tuile, couche, zoom et indices sont validés (404)."""
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
for layer, zz, xx, name in (("aspect", z, x, "abc.png"), # nom invalide
|
||||
("aspect", z, x, f"{y}.tif"), # format refusé
|
||||
("inconnue", z, x, f"{y}.png"), # couche absente
|
||||
("../etc", z, x, f"{y}.png"), # traversée
|
||||
("aspect", 2, x, f"{y}.png"), # zoom trop bas
|
||||
("aspect", 25, x, f"{y}.png"), # zoom trop haut
|
||||
("aspect", z, 10 ** 9, f"{y}.png")): # hors grille
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(mapserve.tile(layer, zz, xx, name))
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_tile_upstream_fallback(tmp_path, monkeypatch):
|
||||
"""Tuile absente localement : rapatriée depuis LIDAR_MAPS_URL et mise en cache."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
from lidar_pipeline import tiles
|
||||
_setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975")
|
||||
monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0})
|
||||
called = {}
|
||||
|
||||
def fake_fetch(rel_path):
|
||||
called["path"] = rel_path
|
||||
return b"\x89PNG\r\n\x1a\nFAKE"
|
||||
|
||||
monkeypatch.setattr(mapserve, "_fetch_upstream", fake_fetch)
|
||||
# Zone sans donnée locale → le rendu local échoue, l'amont prend le relais
|
||||
resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png"))
|
||||
assert called["path"] == "tiles/aspect/15/1/1.png"
|
||||
assert resp.body == b"\x89PNG\r\n\x1a\nFAKE"
|
||||
assert tiles.tile_cache_path(tmp_path, "aspect", 15, 1, 1).is_file()
|
||||
|
||||
|
||||
def test_upstream_breaker(monkeypatch):
|
||||
"""3 échecs consécutifs suspendent les tentatives amont ; un succès réarme."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0})
|
||||
for _ in range(2):
|
||||
mapserve._upstream_mark(False)
|
||||
assert mapserve._upstream_offline() is False
|
||||
mapserve._upstream_mark(False)
|
||||
assert mapserve._upstream_offline() is True
|
||||
mapserve._upstream_mark(True)
|
||||
assert mapserve._upstream_offline() is False
|
||||
|
||||
|
||||
def test_tilejson_is_valid(tmp_path, monkeypatch):
|
||||
"""TileJSON 3.0.0 : champs obligatoires, URL XYZ absolue, attribution."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
data = json.loads(mapserve.tilejson("aspect", _FakeRequest()).body)
|
||||
assert data["tilejson"] == "3.0.0"
|
||||
assert data["scheme"] == "xyz"
|
||||
assert data["tiles"] == ["http://carte.local:8975/tiles/aspect/{z}/{x}/{y}.png"]
|
||||
assert data["minzoom"] < data["maxzoom"] <= 19
|
||||
assert len(data["bounds"]) == 4 and data["bounds"][0] < data["bounds"][2]
|
||||
assert "IGN" in data["attribution"]
|
||||
|
||||
|
||||
def test_tilejson_unknown_layer(tmp_path, monkeypatch):
|
||||
"""Couche absente du disque → 404."""
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
with pytest.raises(HTTPException) as e:
|
||||
mapserve.tilejson("wavelet", _FakeRequest())
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_base_url_honours_proxy(tmp_path, monkeypatch):
|
||||
"""Derrière un proxy TLS, les URL publiées sont en https."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
data = json.loads(mapserve.tilejson(
|
||||
"aspect", _FakeRequest(host="lidar.example.fr", proto="https")).body)
|
||||
assert data["tiles"][0].startswith("https://lidar.example.fr/tiles/")
|
||||
|
||||
|
||||
def test_josm_imagery_xml(tmp_path, monkeypatch):
|
||||
"""Fichier d'imagerie JOSM : une entrée TMS par couche, URL en {zoom}/{x}/{y}."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
xml = mapserve.josm_imagery(_FakeRequest()).body.decode("utf-8")
|
||||
root = ET.fromstring(xml)
|
||||
ns = {"j": "http://josm.openstreetmap.de/maps-1.0"}
|
||||
entries = root.findall("j:entry", ns)
|
||||
assert {e.find("j:id", ns).text for e in entries} == {
|
||||
"lidar-hd-aspect", "lidar-hd-slope"}
|
||||
for entry in entries:
|
||||
assert entry.find("j:type", ns).text == "tms"
|
||||
assert "{zoom}/{x}/{y}.png" in entry.find("j:url", ns).text
|
||||
assert entry.find("j:max-zoom", ns).text == "19"
|
||||
assert entry.find("j:bounds", ns) is not None
|
||||
|
||||
|
||||
def test_wmts_capabilities(tmp_path, monkeypatch):
|
||||
"""Capacités WMTS : couches, grille GoogleMapsCompatible, gabarit REST."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
xml = mapserve.wmts_capabilities(_FakeRequest()).body.decode("utf-8")
|
||||
root = ET.fromstring(xml)
|
||||
ns = {"w": "http://www.opengis.net/wmts/1.0",
|
||||
"ows": "http://www.opengis.net/ows/1.1"}
|
||||
ids = [l.find("ows:Identifier", ns).text
|
||||
for l in root.findall(".//w:Layer", ns)]
|
||||
assert set(ids) == {"aspect", "slope"}
|
||||
# `.//w:TileMatrixSet` attraperait d'abord le renvoi de
|
||||
# TileMatrixSetLink (simple texte) : on vise la définition.
|
||||
tms = root.find("w:Contents/w:TileMatrixSet", ns)
|
||||
assert tms.find("ows:Identifier", ns).text == "GoogleMapsCompatible"
|
||||
levels = [m.find("ows:Identifier", ns).text
|
||||
for m in tms.findall("w:TileMatrix", ns)]
|
||||
assert levels[0] == "5" and levels[-1] == "19"
|
||||
template = root.find(".//w:ResourceURL", ns).get("template")
|
||||
assert template.endswith("/{TileMatrix}/{TileCol}/{TileRow}.png")
|
||||
|
||||
|
||||
def test_map_meta(tmp_path, monkeypatch):
|
||||
"""/api/map/meta décrit les couches et le contrat de tuilage de l'interface."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
meta = mapserve.map_meta()
|
||||
assert {l["key"] for l in meta["layers"]} == {"aspect", "slope"}
|
||||
assert all(l["label"] for l in meta["layers"])
|
||||
assert meta["tile_url"] == "tiles/{layer}/{z}/{x}/{y}@2x.webp"
|
||||
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
|
||||
assert meta["max_native_zoom"] == 18
|
||||
assert meta["bounds"] and meta["stamp"] > 0
|
||||
assert "aspect" in meta["default_layers"]
|
||||
|
||||
|
||||
def test_map_tile_info(tmp_path, monkeypatch):
|
||||
"""/api/map/tile identifie la dalle sous un point et lit ses métadonnées."""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
dtm = tmp_path / "DTM"
|
||||
dtm.mkdir(parents=True, exist_ok=True)
|
||||
(dtm / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_dtm_method.txt").write_text(
|
||||
"ign", encoding="utf-8")
|
||||
from lidar_pipeline.tiles import _transformer
|
||||
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1054500, 6881500)
|
||||
data = mapserve.map_tile_info(lat=lat, lng=lon)
|
||||
assert data["found"] is True
|
||||
assert (data["col"], data["row"]) == (1054, 6882)
|
||||
assert data["method"] == "ign"
|
||||
assert data["layers"] == ["aspect", "slope"]
|
||||
# Point hors zone : réponse explicite, sans erreur
|
||||
assert mapserve.map_tile_info(lat=45.0, lng=1.0)["found"] is False
|
||||
|
||||
|
||||
def test_healthz_and_assets(tmp_path, monkeypatch):
|
||||
"""Sonde de santé et assets de l'interface (revalidés, jamais immuables)."""
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
assert mapserve.healthz()["layers"] == 2
|
||||
resp = mapserve.assets("app.js")
|
||||
assert resp.headers["Cache-Control"] == "no-cache, must-revalidate"
|
||||
with pytest.raises(HTTPException):
|
||||
mapserve.assets("introuvable.js")
|
||||
|
||||
|
||||
def test_ui_offers_reorder_and_blend_modes():
|
||||
"""Pile de couches : réordonnancement au doigt ET modes de fusion.
|
||||
|
||||
La superposition ne se réduit pas à de la transparence : chaque couche
|
||||
porte un mix-blend-mode, cantonné à la pile LiDAR par un conteneur isolé
|
||||
(sinon « Produit » assombrirait aussi le fond de carte).
|
||||
"""
|
||||
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
|
||||
# Modes proposés
|
||||
for mode in ("multiply", "screen", "overlay", "soft-light", "difference",
|
||||
"luminosity"):
|
||||
assert f"'{mode}'" in _MAP_JS, mode
|
||||
# Réordonnancement : glisser-déposer ET boutons (seuls utilisables au doigt)
|
||||
assert "function moveLayer(" in _MAP_JS
|
||||
assert "moveLayer(key, 1)" in _MAP_JS and "moveLayer(key, -1)" in _MAP_JS
|
||||
assert "dragstart" in _MAP_JS and "layer-move" in _MAP_CSS
|
||||
# Pile isolée + fusion par couche et fusion de la pile sur le fond
|
||||
assert "createPane('lidarStack')" in _MAP_JS
|
||||
assert "isolation = 'isolate'" in _MAP_JS
|
||||
assert "map.createPane(pane, stack)" in _MAP_JS
|
||||
assert "pane.style.mixBlendMode" in _MAP_JS
|
||||
assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML
|
||||
# Le lien partagé transporte la fusion (4e champ, rétrocompatible)
|
||||
assert "(STATE.blend[k] || 'normal')" in _MAP_JS
|
||||
assert "const b = validBlend(f[3]);" in _MAP_JS
|
||||
|
||||
|
||||
def test_defaults_roundtrip(tmp_path, monkeypatch):
|
||||
"""L'état réglé depuis la carte devient la configuration servie à tous."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
|
||||
|
||||
# Sans enregistrement : réglages du registre (index.py)
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["defaults_saved"] is False
|
||||
assert meta["default_order"] is None
|
||||
assert meta["default_stack_blend"] == "normal"
|
||||
|
||||
req = mapserve.DefaultsRequest(
|
||||
order=["slope", "aspect"], on=["aspect"],
|
||||
opacity={"aspect": 0.6, "slope": 1.0},
|
||||
blend={"aspect": "multiply", "slope": "normal"},
|
||||
base={"on": True, "opacity": 0.4, "dark": False},
|
||||
stack_blend="soft-light")
|
||||
assert mapserve.set_defaults(req)["enregistré"] is True
|
||||
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["defaults_saved"] is True
|
||||
assert meta["default_order"] == ["slope", "aspect"]
|
||||
assert meta["default_layers"] == ["aspect"]
|
||||
assert meta["default_opacity"]["aspect"] == 0.6
|
||||
assert meta["default_blend"]["aspect"] == "multiply"
|
||||
assert meta["default_base"] == {"on": True, "opacity": 0.4, "dark": False}
|
||||
assert meta["default_stack_blend"] == "soft-light"
|
||||
# Persisté sur disque : survit au redémarrage du conteneur
|
||||
assert (tmp_path / ".map-defaults.json").is_file()
|
||||
assert mapserve.get_defaults()["defaults"]["on"] == ["aspect"]
|
||||
|
||||
# Retrait : retour aux réglages du registre
|
||||
assert mapserve.clear_defaults()["supprimé"] is True
|
||||
assert mapserve.map_meta()["defaults_saved"] is False
|
||||
|
||||
|
||||
def test_defaults_sanitised(tmp_path, monkeypatch):
|
||||
"""Couches inconnues écartées, opacités bornées, fusions validées."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
|
||||
data = mapserve.set_defaults(mapserve.DefaultsRequest(
|
||||
order=["aspect", "inexistante"], on=["aspect", "inexistante"],
|
||||
opacity={"aspect": 5, "slope": "x", "inexistante": 0.5},
|
||||
blend={"aspect": "vaudou", "slope": "screen"},
|
||||
base={"opacity": -3}, stack_blend="vaudou"))["defaults"]
|
||||
assert "inexistante" not in data["order"] and "inexistante" not in data["on"]
|
||||
# Les couches présentes mais non citées complètent l'ordre
|
||||
assert set(data["order"]) == {"aspect", "slope"}
|
||||
assert data["opacity"]["aspect"] == 1.0 # borné à 1
|
||||
assert data["opacity"]["slope"] == 1.0 # valeur illisible → opaque
|
||||
assert data["blend"] == {"slope": "screen"} # mode inconnu écarté
|
||||
assert data["base"]["opacity"] == 0.0 # borné à 0
|
||||
assert data["stack_blend"] == "normal" # repli
|
||||
|
||||
|
||||
def test_ui_applies_server_defaults():
|
||||
"""L'interface part des défauts du serveur et sait les (re)définir."""
|
||||
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
|
||||
assert 'id="btnDefault"' in _MAP_HTML and 'id="btnReset"' in _MAP_HTML
|
||||
assert "api/map/defaults" in _MAP_JS
|
||||
assert "meta.default_order" in _MAP_JS
|
||||
assert "meta.default_base" in _MAP_JS
|
||||
assert "validBlend(meta.default_stack_blend)" in _MAP_JS
|
||||
# Réinitialiser oublie l'état local avant de reprendre celui du serveur
|
||||
assert "localStorage.removeItem(LS_KEY)" in _MAP_JS
|
||||
|
||||
|
||||
def test_ui_reorder_shows_destination():
|
||||
"""Réorganiser doit être lisible : repère de chute, fantôme, arrivée signalée.
|
||||
|
||||
Sans repère, on lâche une couche sans savoir où elle atterrit — la pile
|
||||
n'est pas un détail, elle décide de ce qu'on voit.
|
||||
"""
|
||||
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
|
||||
# Barre d'insertion selon la moitié survolée, effacée entre deux survols
|
||||
assert ".layer-row.drop-before" in _MAP_CSS and ".layer-row.drop-after" in _MAP_CSS
|
||||
assert "clearDropMarks" in _MAP_JS
|
||||
assert "box.height / 2" in _MAP_JS
|
||||
assert "row.classList.add(before ? 'drop-before' : 'drop-after')" in _MAP_JS
|
||||
# Ligne déplacée estompée pendant le glisser
|
||||
assert ".layer-row.dragging" in _MAP_CSS and "classList.add('dragging')" in _MAP_JS
|
||||
# Insertion relative (avant/après la ligne visée), pas un simple échange
|
||||
assert "function reorderRelative(" in _MAP_JS
|
||||
# Arrivée mise en évidence puis ramenée dans le champ de vision
|
||||
assert "just-moved" in _MAP_CSS and "@keyframes moved" in _MAP_CSS
|
||||
assert "scrollIntoView" in _MAP_JS
|
||||
assert "renderPanel(key)" in _MAP_JS and "renderPanel(src)" in _MAP_JS
|
||||
# Extrémités de la pile nommées
|
||||
assert 'id="edgeTop"' in _MAP_HTML and 'id="edgeBottom"' in _MAP_HTML
|
||||
assert "Haut de pile" in _MAP_HTML and "Bas de pile" in _MAP_HTML
|
||||
|
||||
|
||||
def test_ui_uses_standard_tilelayer():
|
||||
"""L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison."""
|
||||
from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version
|
||||
assert "L.tileLayer(" in _MAP_JS
|
||||
assert "tileSize: META.tile_size" in _MAP_JS
|
||||
assert "zoomOffset: META.zoom_offset" in _MAP_JS
|
||||
assert "maxNativeZoom: META.max_native_zoom" in _MAP_JS
|
||||
# Plus aucune mécanique de l'ancienne carte
|
||||
for banned in ("quadMatrix3d", "FULL_CAP", "ovTick", "layoutTiles"):
|
||||
assert banned not in _MAP_JS
|
||||
html = render_html()
|
||||
assert ui_version() in html and "__UI_VERSION__" not in html
|
||||
|
||||
|
||||
def test_write_map_assets(tmp_path):
|
||||
"""Les assets écrits portent la version et embarquent Leaflet vendorisé."""
|
||||
from lidar_pipeline.mapui import _MAP_JS, ui_version, write_map_assets
|
||||
write_map_assets(tmp_path / "ui")
|
||||
js = (tmp_path / "ui" / "app.js").read_text(encoding="utf-8")
|
||||
assert js == _MAP_JS.replace("__UI_VERSION__", ui_version())
|
||||
assert (tmp_path / "ui" / "app.css").read_text(encoding="utf-8").startswith("/*")
|
||||
assert (tmp_path / "ui" / "vendor" / "leaflet" / "leaflet.js").is_file()
|
||||
|
||||
|
||||
def test_warm_endpoint(tmp_path, monkeypatch):
|
||||
"""/api/map/warm lance le pré-calcul en tâche de fond puis rend compte."""
|
||||
import time
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mapserve, "_warm", {"running": False, "done": None,
|
||||
"result": None})
|
||||
req = mapserve.WarmRequest(layers=["aspect"], z_min=12, z_max=12)
|
||||
assert mapserve.map_warm(req)["couches"] == ["aspect"]
|
||||
for _ in range(200):
|
||||
if not mapserve._warm["running"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert mapserve.map_warm_status()["result"]["rendues"] >= 1
|
||||
|
||||
|
||||
def test_tile_locks_are_refcounted(tmp_path, monkeypatch):
|
||||
"""Verrou par tuile : partagé pendant le rendu, retiré une fois libéré.
|
||||
|
||||
Sans compteur, le registre grossit sans fin (des millions de tuiles) ou un
|
||||
verrou disparaît alors qu'un autre fil l'attend — deux rendus concurrents
|
||||
de la même tuile.
|
||||
"""
|
||||
mapserve = _setup(tmp_path, monkeypatch)
|
||||
key = ("aspect", 15, 1, 1, 1, "png")
|
||||
a = mapserve._acquire_lock(key)
|
||||
b = mapserve._acquire_lock(key)
|
||||
assert a is b and a[1] == 2
|
||||
mapserve._release_lock(key, a)
|
||||
assert key in mapserve._locks
|
||||
mapserve._release_lock(key, b)
|
||||
assert key not in mapserve._locks
|
||||
|
||||
|
||||
def test_concurrent_requests_render_once(tmp_path, monkeypatch):
|
||||
"""Deux requêtes simultanées sur la même tuile ne la rendent qu'une fois."""
|
||||
import threading
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
calls = []
|
||||
real = mapserve.tiles_mod.get_tile
|
||||
|
||||
def slow_get_tile(*args, **kwargs):
|
||||
calls.append(args)
|
||||
threading.Event().wait(0.05)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(mapserve.tiles_mod, "get_tile", slow_get_tile)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
results = []
|
||||
threads = [threading.Thread(
|
||||
target=lambda: results.append(mapserve._tile_bytes("aspect", z, x, y, 1, "png")))
|
||||
for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert all(r == results[0] for r in results)
|
||||
# Les appels suivants lisent le cache disque : un seul rendu effectif
|
||||
assert len(calls) == 4 # chaque requête interroge get_tile…
|
||||
assert mapserve._locks == {} # …mais sérialisées, et les verrous libérés
|
||||
|
||||
|
||||
def test_tile_bytes_survives_render_error(tmp_path, monkeypatch):
|
||||
"""Un rendu qui échoue rend une tuile vide sans laisser de verrou fuité."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("source corrompue")
|
||||
|
||||
monkeypatch.setattr(mapserve.tiles_mod, "get_tile", boom)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
assert mapserve._tile_bytes("aspect", z, x, y, 1, "png") is None
|
||||
assert mapserve._locks == {}
|
||||
|
||||
|
||||
def test_render_concurrency_is_capped(tmp_path, monkeypatch):
|
||||
"""Le plafond de rendus simultanés est tenu (petit serveur protégé).
|
||||
|
||||
Sans lui, autant de rendus que de requêtes en vol : chacun décode une dalle
|
||||
de plusieurs dizaines de Mo — mémoire et CPU d'un Raspberry Pi saturés.
|
||||
"""
|
||||
import threading
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
|
||||
# Plafond volontairement bas pour le test
|
||||
monkeypatch.setattr(mapserve, "_render_sem", threading.Semaphore(2))
|
||||
state = {"now": 0, "peak": 0}
|
||||
guard = threading.Lock()
|
||||
real = mapserve.tiles_mod.get_tile
|
||||
|
||||
def slow(*args, **kwargs):
|
||||
with guard:
|
||||
state["now"] += 1
|
||||
state["peak"] = max(state["peak"], state["now"])
|
||||
try:
|
||||
threading.Event().wait(0.05)
|
||||
return real(*args, **kwargs)
|
||||
finally:
|
||||
with guard:
|
||||
state["now"] -= 1
|
||||
|
||||
monkeypatch.setattr(mapserve.tiles_mod, "get_tile", slow)
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
# Tuiles DIFFÉRENTES : le verrou par tuile ne masque pas le sémaphore
|
||||
threads = [threading.Thread(target=mapserve._tile_bytes,
|
||||
args=("aspect", z, x + i, y, 1, "png"))
|
||||
for i in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert state["peak"] <= 2, state["peak"]
|
||||
assert mapserve._locks == {}
|
||||
|
||||
|
||||
def test_worker_limits_are_configurable(monkeypatch):
|
||||
"""Les plafonds viennent de l'environnement, avec un défaut modeste."""
|
||||
import importlib
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
import lidar_pipeline.tiles as tiles
|
||||
|
||||
# Défauts : 2 rendus, 2 téléchargements — taillés pour un petit serveur
|
||||
assert mapserve.TILE_WORKERS == 2
|
||||
assert tiles.FETCH_WORKERS == 2
|
||||
|
||||
monkeypatch.setenv("LIDAR_TILE_WORKERS", "6")
|
||||
monkeypatch.setenv("LIDAR_TILE_FETCH_WORKERS", "3")
|
||||
try:
|
||||
importlib.reload(tiles)
|
||||
importlib.reload(mapserve)
|
||||
assert mapserve.TILE_WORKERS == 6
|
||||
assert tiles.FETCH_WORKERS == 3
|
||||
# Valeur absurde : jamais zéro fil (blocage complet du service)
|
||||
monkeypatch.setenv("LIDAR_TILE_WORKERS", "0")
|
||||
importlib.reload(mapserve)
|
||||
assert mapserve.TILE_WORKERS == 1
|
||||
finally:
|
||||
monkeypatch.delenv("LIDAR_TILE_WORKERS", raising=False)
|
||||
monkeypatch.delenv("LIDAR_TILE_FETCH_WORKERS", raising=False)
|
||||
importlib.reload(tiles)
|
||||
importlib.reload(mapserve)
|
||||
489
lidar_pipeline/tests/test_tiles.py
Normal file
489
lidar_pipeline/tests/test_tiles.py
Normal file
@ -0,0 +1,489 @@
|
||||
"""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):
|
||||
"""L'index liste les couches présentes et leurs paliers (grossier → fin)."""
|
||||
from lidar_pipeline import tiles
|
||||
_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_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):
|
||||
"""Le cache disque est réutilisé, puis invalidé par une dalle régénérée."""
|
||||
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):
|
||||
"""Tuile sans donnée : None + marqueur .empty mémorisé."""
|
||||
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_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 la webapp du pipeline."""
|
||||
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"}
|
||||
Reference in New Issue
Block a user