Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts, compose files and AGENTS.md are now English. Data keys stay unchanged (relief_oriente, densite_sol, visualisations/, API JSON keys, link params). Wrong comments and help defaults found along the way are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -1,11 +1,11 @@
|
||||
"""Tests de la pyramide de tuiles XYZ (schéma OpenStreetMap)."""
|
||||
"""Tests for the XYZ tile pyramid (OpenStreetMap scheme)."""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures : dalles factices aux conventions du pipeline
|
||||
# Fixtures: fake tiles following the pipeline conventions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _basename(col, row):
|
||||
@ -14,7 +14,7 @@ def _basename(col, row):
|
||||
|
||||
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."""
|
||||
"""Create a tile (image + thumbnails) as the pipeline would."""
|
||||
from PIL import Image
|
||||
base = _basename(col, row)
|
||||
vis = Path(output_dir) / "visualisations" / base
|
||||
@ -33,7 +33,7 @@ def _make_dalle(output_dir, col, row, viz_keys, color=(200, 30, 30), px=64,
|
||||
|
||||
|
||||
def _tile_of_cell(col, row, z):
|
||||
"""Indices (x, y) de la tuile du niveau z contenant le centre d'une dalle."""
|
||||
"""Indices (x, y) of the level-z tile containing the centre of a source tile."""
|
||||
from lidar_pipeline.tiles import _transformer
|
||||
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(
|
||||
col * 1000 + 500, (row - 1) * 1000 + 500)
|
||||
@ -45,11 +45,11 @@ def _tile_of_cell(col, row, z):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Géométrie de la grille
|
||||
# Grid geometry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tile_bounds_3857_known_values():
|
||||
"""z0 = le monde entier ; z1/x1/y0 = quadrant nord-est."""
|
||||
"""z0 = the whole world; z1/x1/y0 = north-east quadrant."""
|
||||
from lidar_pipeline.tiles import ORIGIN, tile_bounds_3857
|
||||
w, s, e, n = tile_bounds_3857(0, 0, 0)
|
||||
assert (round(w), round(s), round(e), round(n)) == (
|
||||
@ -60,23 +60,23 @@ def test_tile_bounds_3857_known_values():
|
||||
|
||||
|
||||
def test_tile_latitude_and_resolution():
|
||||
"""Latitude du centre et résolution terrain (0,2 m/px ≈ z19 en France)."""
|
||||
"""Centre latitude and ground resolution (0.2 m/px ≈ z19 in France)."""
|
||||
from lidar_pipeline.tiles import (TILE_MAX_NATIVE_Z, target_resolution,
|
||||
tile_latitude)
|
||||
assert abs(tile_latitude(0, 0)) < 1e-9
|
||||
assert abs(tile_latitude(1, 0) - 66.51) < 0.05
|
||||
# Tuile du niveau natif à la latitude de la France métropolitaine
|
||||
# Native-level tile at the latitude of metropolitan France
|
||||
z = TILE_MAX_NATIVE_Z
|
||||
y = int((1.0 - math.log(math.tan(math.radians(47)) + 1 / math.cos(math.radians(47)))
|
||||
/ math.pi) / 2.0 * 2 ** z)
|
||||
res = target_resolution(z, y)
|
||||
assert 0.15 < res < 0.25, res
|
||||
# @2x : deux fois plus fin pour le même (z, x, y)
|
||||
# @2x: twice as fine for the same (z, x, y)
|
||||
assert abs(target_resolution(z, y, scale=2) - res / 2) < 1e-9
|
||||
|
||||
|
||||
def test_tile_bounds_l93_covers_cell():
|
||||
"""L'emprise L93 d'une tuile contient bien la dalle qu'elle recouvre."""
|
||||
"""The L93 extent of a tile does contain the source tile it covers."""
|
||||
from lidar_pipeline.tiles import tile_bounds_l93
|
||||
col, row, z = 1054, 6882, 14
|
||||
x, y = _tile_of_cell(col, row, z)
|
||||
@ -86,25 +86,25 @@ def test_tile_bounds_l93_covers_cell():
|
||||
|
||||
|
||||
def test_perspective_coeffs_identity_and_scale():
|
||||
"""Identité → coefficients neutres ; homothétie → facteur exact."""
|
||||
"""Identity → neutral coefficients; scaling → exact factor."""
|
||||
from lidar_pipeline.tiles import perspective_coeffs
|
||||
quad = [(0, 0), (256, 0), (256, 256), (0, 256)]
|
||||
c = perspective_coeffs(quad, quad)
|
||||
assert [round(v, 9) for v in c] == [1, 0, 0, 0, 1, 0, 0, 0]
|
||||
# Sortie deux fois plus grande que la source : Pillow échantillonne à x/2
|
||||
# Output twice as large as the source: Pillow samples at x/2
|
||||
c = perspective_coeffs([(0, 0), (512, 0), (512, 512), (0, 512)], quad)
|
||||
assert abs(c[0] - 0.5) < 1e-9 and abs(c[4] - 0.5) < 1e-9
|
||||
|
||||
|
||||
def test_perspective_coeffs_degenerate():
|
||||
"""Quadrilatère dégénéré (dalle réduite à un point) → None, pas d'exception."""
|
||||
"""Degenerate quadrilateral (tile shrunk to a point) → None, no exception."""
|
||||
from lidar_pipeline.tiles import perspective_coeffs
|
||||
flat = [(0, 0), (0, 0), (0, 0), (0, 0)]
|
||||
assert perspective_coeffs(flat, [(0, 0), (1, 0), (1, 1), (0, 1)]) is None
|
||||
|
||||
|
||||
def test_zoom_supported_bounds():
|
||||
"""Plage de zooms servie ; @2x s'arrête un cran plus tôt (512 px)."""
|
||||
"""Served zoom range; @2x stops one level earlier (512 px)."""
|
||||
from lidar_pipeline.tiles import (TILE_MAX_NATIVE_Z, TILE_MIN_Z,
|
||||
zoom_supported)
|
||||
assert not zoom_supported(TILE_MIN_Z - 1)
|
||||
@ -116,25 +116,25 @@ def test_zoom_supported_bounds():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index des sources
|
||||
# Source index
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_source_index_and_layers(tmp_path, monkeypatch):
|
||||
"""L'index liste les couches présentes et leurs paliers (grossier → fin)."""
|
||||
"""The index lists the layers present and their tiers (coarse → fine)."""
|
||||
from lidar_pipeline import index, tiles
|
||||
monkeypatch.setattr(index, "PANEL_VIZ", None)
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect", "slope"])
|
||||
layers = tiles.source_index(tmp_path, force=True)
|
||||
assert set(layers) == {"aspect", "slope"}
|
||||
tiers = layers["aspect"][(1054, 6882)]
|
||||
# vignette (3,9 m/px) → intermédiaire (1,56) → dalle (0,5)
|
||||
# thumbnail (3.9 m/px) → intermediate (1.56) → tile (0.5)
|
||||
assert [round(t[0].res, 2) for t in tiers] == [3.91, 1.56, 0.5]
|
||||
assert tiles.available_layers(tmp_path) == ["slope", "aspect"] or \
|
||||
set(tiles.available_layers(tmp_path)) == {"slope", "aspect"}
|
||||
|
||||
|
||||
def test_available_layers_follow_panel(tmp_path, monkeypatch):
|
||||
"""Couches sur disque hors PANEL_VIZ : ni affichées ni servies."""
|
||||
"""Layers on disk outside PANEL_VIZ: neither displayed nor served."""
|
||||
from lidar_pipeline import index, tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect", "relief_oriente"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
@ -143,7 +143,7 @@ def test_available_layers_follow_panel(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_grid_bounds(tmp_path):
|
||||
"""Emprise L93 et WGS84 de la grille disponible."""
|
||||
"""L93 and WGS84 extent of the available grid."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
_make_dalle(tmp_path, 1055, 6883, ["aspect"])
|
||||
@ -155,14 +155,14 @@ def test_grid_bounds(tmp_path):
|
||||
|
||||
|
||||
def test_pick_tier_by_resolution(tmp_path):
|
||||
"""Palier retenu : le plus grossier dont la résolution suffit à la tuile."""
|
||||
"""Chosen tier: the coarsest whose resolution is sufficient for the tile."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
|
||||
assert tiles._pick_tier(tiers, 10.0)[0].res == tiers[0][0].res # 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, 10.0)[0].res == tiers[0][0].res # thumbnail
|
||||
assert tiles._pick_tier(tiers, 2.0)[0].res == tiers[1][0].res # intermediate
|
||||
assert tiles._pick_tier(tiers, 0.2)[0].res == tiers[-1][0].res # tile
|
||||
# Target finer than anything available: the finest tier is kept
|
||||
assert tiles._pick_tier(tiers, 0.01)[0].res == tiers[-1][0].res
|
||||
|
||||
|
||||
@ -181,7 +181,7 @@ def test_sources_in_bbox_picks_tier(tmp_path):
|
||||
|
||||
|
||||
def test_subtiles_preferred_over_full_dalle(tmp_path):
|
||||
"""Les quadrants index_subtiles servent de palier fin (4× moins à décoder)."""
|
||||
"""index_subtiles quadrants serve as the fine tier (4× less to decode)."""
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
@ -194,14 +194,14 @@ def test_subtiles_preferred_over_full_dalle(tmp_path):
|
||||
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")
|
||||
pytest.skip("AVIF encoder unavailable")
|
||||
tiers = tiles.source_index(tmp_path, force=True)["aspect"][(1054, 6882)]
|
||||
quads = next(t for t in tiers if len(t) == 4)
|
||||
# À résolution égale, les quadrants passent AVANT la dalle entière
|
||||
# At equal resolution, quadrants come BEFORE the whole tile
|
||||
assert tiers.index(quads) < tiers.index(next(t for t in tiers if len(t) == 1
|
||||
and t[0].path.suffix == ".webp"))
|
||||
assert len(quads) == 4
|
||||
# Emprises des quadrants : quatre demi-kilomètres jointifs
|
||||
# Quadrant extents: four adjoining half-kilometre squares
|
||||
assert {q.bounds for q in quads} == {
|
||||
(1054000.0, 6881000.0, 1054500.0, 6881500.0),
|
||||
(1054500.0, 6881000.0, 1055000.0, 6881500.0),
|
||||
@ -210,11 +210,11 @@ def test_subtiles_preferred_over_full_dalle(tmp_path):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendu et cache
|
||||
# Rendering and cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_tile_paints_cell(tmp_path):
|
||||
"""Une tuile au-dessus de la dalle est peinte ; ailleurs elle est vide."""
|
||||
"""A tile over the source tile is painted; elsewhere it is empty."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"], color=(200, 30, 30))
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
@ -224,12 +224,12 @@ def test_render_tile_paints_cell(tmp_path):
|
||||
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
|
||||
# Distant tile (another continent): no source
|
||||
assert tiles.render_tile(tmp_path, "aspect", z, 1, 1) is None
|
||||
|
||||
|
||||
def test_render_tile_scale2(tmp_path):
|
||||
"""`scale=2` rend la même emprise en 512 px (convention @2x)."""
|
||||
"""`scale=2` renders the same extent at 512 px (@2x convention)."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
@ -240,11 +240,11 @@ def test_render_tile_scale2(tmp_path):
|
||||
|
||||
|
||||
def test_tile_edges_transparent_outside_data(tmp_path):
|
||||
"""Hors emprise des dalles, la tuile reste transparente (superposable)."""
|
||||
"""Outside the source tiles' extent, the tile stays transparent (overlayable)."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
# Zoom où une tuile est bien plus grande que la dalle : les bords sont vides
|
||||
# Zoom where a tile is much larger than the source tile: edges are empty
|
||||
z = 11
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
img = tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
@ -254,9 +254,9 @@ def test_tile_edges_transparent_outside_data(tmp_path):
|
||||
|
||||
|
||||
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."""
|
||||
"""The disk cache is reused, then invalidated by a regenerated source tile."""
|
||||
from lidar_pipeline import tiles as _t
|
||||
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
|
||||
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # historical rule: everything is stored
|
||||
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
|
||||
import os
|
||||
import time
|
||||
@ -271,12 +271,12 @@ def test_get_tile_cache_and_staleness(tmp_path, monkeypatch):
|
||||
assert cache.is_file()
|
||||
first = cache.stat().st_mtime
|
||||
|
||||
# Sans changement : la tuile en cache est resservie telle quelle
|
||||
# Unchanged: the cached tile is served again as-is
|
||||
time.sleep(0.02)
|
||||
assert tiles.get_tile(tmp_path, "aspect", z, x, y) == data
|
||||
assert cache.stat().st_mtime == first
|
||||
|
||||
# Dalle régénérée (mtime plus récente) : la tuile est recalculée
|
||||
# Regenerated source tile (newer mtime): the tile is recomputed
|
||||
src = tmp_path / "visualisations" / base / f"{base}_aspect.webp"
|
||||
newer = first + 10
|
||||
os.utime(src, (newer, newer))
|
||||
@ -288,9 +288,9 @@ def test_get_tile_cache_and_staleness(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_get_tile_empty_marker(tmp_path, monkeypatch):
|
||||
"""Tuile sans donnée : None + marqueur .empty mémorisé."""
|
||||
"""Tile without data: None + remembered .empty marker."""
|
||||
from lidar_pipeline import tiles as _t
|
||||
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # règle historique : tout est stocké
|
||||
monkeypatch.setattr(_t, "TILE_EVEN_LEVELS", False) # historical rule: everything is stored
|
||||
monkeypatch.setattr(_t, "TILE_CACHE_MAX_Z", 99)
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
@ -300,7 +300,7 @@ def test_get_tile_empty_marker(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_get_tile_webp_scale2(tmp_path):
|
||||
"""Palier @2x en WebP : 512 px, chemin de cache distinct du 256 px."""
|
||||
"""@2x tier in WebP: 512 px, cache path distinct from the 256 px one."""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
@ -316,7 +316,7 @@ def test_get_tile_webp_scale2(tmp_path):
|
||||
|
||||
|
||||
def test_transparent_tile_is_fully_transparent():
|
||||
"""La tuile de repli (zone vide) est entièrement transparente."""
|
||||
"""The fallback tile (empty area) is fully transparent."""
|
||||
import io
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
@ -326,7 +326,7 @@ def test_transparent_tile_is_fully_transparent():
|
||||
|
||||
|
||||
def test_tiles_in_bounds_and_warm(tmp_path):
|
||||
"""Pré-chauffage : les tuiles de l'emprise sont calculées et mises en cache."""
|
||||
"""Warm-up: the tiles of the extent are computed and cached."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
@ -338,7 +338,7 @@ def test_tiles_in_bounds_and_warm(tmp_path):
|
||||
|
||||
|
||||
def test_tiles_stamp_follows_sources(tmp_path):
|
||||
"""La version globale suit la mtime la plus récente des dalles."""
|
||||
"""The global version follows the most recent source-tile mtime."""
|
||||
import os
|
||||
from lidar_pipeline import tiles
|
||||
base = _make_dalle(tmp_path, 1054, 6882, ["aspect"])
|
||||
@ -351,15 +351,15 @@ def test_tiles_stamp_follows_sources(tmp_path):
|
||||
|
||||
|
||||
def test_source_cache_respects_memory_budget(tmp_path, monkeypatch):
|
||||
"""Le cache d'images sources évince selon un budget en octets, pas un compte.
|
||||
"""The source image cache evicts by a byte budget, not by a count.
|
||||
|
||||
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).
|
||||
A decoded 5000² tile weighs ~100 MB (4 bytes/px): an "N entries" cache
|
||||
would overflow the memory of a small machine (Raspberry Pi).
|
||||
"""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
tiles.clear_source_cache()
|
||||
# Budget volontairement minuscule : une seule image tient à la fois
|
||||
# Deliberately tiny budget: only one image fits at a time
|
||||
monkeypatch.setattr(tiles, "SOURCE_CACHE_BYTES", 40 * 40 * 3 * 2 - 1)
|
||||
paths = []
|
||||
for i in range(3):
|
||||
@ -369,19 +369,19 @@ def test_source_cache_respects_memory_budget(tmp_path, monkeypatch):
|
||||
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
|
||||
# The last source used is the one that remains
|
||||
assert str(paths[-1]) == list(tiles._source_cache)[0][0]
|
||||
tiles.clear_source_cache()
|
||||
assert tiles._source_cache == {}
|
||||
|
||||
|
||||
def test_source_cache_holds_plain_decoded_images(tmp_path):
|
||||
"""Le cache ne retient que les pixels : ni fichier ouvert ni décodeur.
|
||||
"""The cache holds pixels only: no open file, no decoder.
|
||||
|
||||
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.
|
||||
An open AVIF image keeps its decoder (libavif/dav1d buffers): ~43 MB
|
||||
retained per 2500² quadrant instead of 25 — the Pi's container (1 GB)
|
||||
was killed by the OOM killer when browsing at high zoom. The budget
|
||||
counts 4 bytes/pixel: PIL stores RGB on 32 bits.
|
||||
"""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
@ -397,7 +397,7 @@ def test_source_cache_holds_plain_decoded_images(tmp_path):
|
||||
|
||||
|
||||
def test_source_cache_keyed_by_mtime(tmp_path):
|
||||
"""Une source réécrite n'est pas resservie depuis le cache."""
|
||||
"""A rewritten source is not served again from the cache."""
|
||||
import os
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
@ -414,10 +414,11 @@ def test_source_cache_keyed_by_mtime(tmp_path):
|
||||
|
||||
|
||||
def test_png_palette_option_shrinks_tiles(tmp_path, monkeypatch):
|
||||
"""`LIDAR_TILE_PNG_PALETTE=1` allège le PNG canonique (palette + alpha).
|
||||
"""`LIDAR_TILE_PNG_PALETTE=1` shrinks the canonical PNG (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.
|
||||
Measured on a realistic rendering (noisy color ramp): a flat-colored tile
|
||||
already compresses better in RGBA than with a palette, it would prove
|
||||
nothing.
|
||||
"""
|
||||
import io
|
||||
import random
|
||||
@ -445,18 +446,18 @@ def test_png_palette_option_shrinks_tiles(tmp_path, monkeypatch):
|
||||
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
|
||||
# The palettized PNG remains a readable PNG, at the right size, with alpha
|
||||
out = Image.open(io.BytesIO(palette))
|
||||
assert out.size == (256, 256)
|
||||
assert out.convert("RGBA").getextrema()[3][1] == 255
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serveur de dalles amont (LIDAR_SOURCE_URL)
|
||||
# Upstream tile server (LIDAR_SOURCE_URL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _remote_payload():
|
||||
"""Charge utile /api/tiles telle que la sert mapserve (serveur de dalles)."""
|
||||
"""/api/tiles payload as served by mapserve (tile server)."""
|
||||
base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2"
|
||||
tiles = []
|
||||
for i in range(2):
|
||||
@ -474,27 +475,27 @@ def _remote_payload():
|
||||
|
||||
|
||||
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)."""
|
||||
"""Without any local data, the index comes from the upstream (quadrants placed)."""
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
|
||||
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: _remote_payload())
|
||||
layers = tiles.source_index(tmp_path, force=True)
|
||||
assert set(layers) == {"aspect"}
|
||||
tiers = layers["aspect"][(1054, 6882)]
|
||||
# Trois paliers (vignette, intermédiaire, quadrant) × 4 quadrants chacun
|
||||
# Three tiers (thumbnail, intermediate, quadrant) × 4 quadrants each
|
||||
assert [len(t) for t in tiers] == [4, 4, 4]
|
||||
assert [round(t[0].res, 2) for t in tiers] == [3.12, 0.78, 0.2]
|
||||
fine = tiers[-1][0]
|
||||
assert fine.url.startswith("http://amont:8973/index_subtiles/")
|
||||
assert fine.path == tmp_path / fine.url.split("8973/")[1].split("?")[0]
|
||||
# La date de référence vient du ?v= annoncé, sans rien télécharger
|
||||
# The reference date comes from the announced ?v=, without downloading anything
|
||||
assert fine.mtime() == 1700000000.0
|
||||
assert tiles.grid_bounds_l93(tmp_path) == (1054000.0, 6881000.0,
|
||||
1055000.0, 6882000.0)
|
||||
|
||||
|
||||
def test_remote_source_downloaded_on_demand(tmp_path, monkeypatch):
|
||||
"""Une source distante n'est rapatriée qu'au premier rendu qui en a besoin."""
|
||||
"""A remote source is only fetched by the first rendering that needs it."""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
|
||||
@ -518,12 +519,12 @@ def test_remote_source_downloaded_on_demand(tmp_path, monkeypatch):
|
||||
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"
|
||||
assert fetched, "no source fetched"
|
||||
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
|
||||
# Fetched files land in the local cache, at the same path
|
||||
assert list(tmp_path.rglob("*.webp")) or list(tmp_path.rglob("*.avif"))
|
||||
# Second rendu : plus aucun téléchargement (cache local)
|
||||
# Second rendering: no more downloads (local cache)
|
||||
before = len(fetched)
|
||||
tiles.render_tile(tmp_path, "aspect", z, x, y)
|
||||
assert len(fetched) == before
|
||||
@ -531,7 +532,7 @@ def test_remote_source_downloaded_on_demand(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_remote_index_failure_keeps_local(tmp_path, monkeypatch):
|
||||
"""Amont injoignable : l'index local reste servi, sans exception."""
|
||||
"""Upstream unreachable: the local index is still served, without exception."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["slope"])
|
||||
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
|
||||
@ -541,36 +542,36 @@ def test_remote_index_failure_keeps_local(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_cached_tile_states(tmp_path):
|
||||
"""`cached_tile` : lecture cache seule — fresh / pending / empty, sans rendu."""
|
||||
"""`cached_tile`: cache-only read — fresh / pending / empty, no rendering."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["slope"])
|
||||
x, y = _tile_of_cell(1054, 6882, 14)
|
||||
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
|
||||
assert state == "pending" and data is None
|
||||
assert not tiles.tile_cache_path(tmp_path, "slope", 14, x, y).exists()
|
||||
tiles.get_tile(tmp_path, "slope", 14, x, y) # rendu (maintenance, warm…)
|
||||
tiles.get_tile(tmp_path, "slope", 14, x, y) # rendering (maintenance, warm…)
|
||||
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
|
||||
assert state == "fresh" and data
|
||||
# Hors données : état empty + marqueur posé, toujours sans rendu
|
||||
# Outside the data: empty state + marker set, still without rendering
|
||||
data, state = tiles.cached_tile(tmp_path, "slope", 14, 0, 0)
|
||||
assert state == "empty" and data is None
|
||||
assert tiles.empty_marker_exists(tmp_path, "slope", 14, 0, 0)
|
||||
|
||||
|
||||
def test_storage_policy_even_levels_up_to_max(monkeypatch):
|
||||
"""Stockage réduit : niveaux standard pairs ≤ LIDAR_TILE_CACHE_MAX_Z (une
|
||||
tuile @2x de niveau z vaut une 256 px de z+1)."""
|
||||
"""Reduced storage: even standard levels ≤ LIDAR_TILE_CACHE_MAX_Z (an
|
||||
@2x tile at level z equals a 256 px tile at z+1)."""
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
|
||||
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
|
||||
assert tiles.zoom_cached(16, 1) and not tiles.zoom_cached(15, 1)
|
||||
assert tiles.zoom_cached(15, 2) and not tiles.zoom_cached(16, 2) # @2x z15 = z16 standard
|
||||
assert not tiles.zoom_cached(18, 1) and not tiles.zoom_cached(17, 2) # niveau fin : à la volée
|
||||
assert tiles.zoom_cached(15, 2) and not tiles.zoom_cached(16, 2) # @2x z15 = standard z16
|
||||
assert not tiles.zoom_cached(18, 1) and not tiles.zoom_cached(17, 2) # fine level: on the fly
|
||||
|
||||
|
||||
def test_unstored_level_rendered_on_the_fly_without_disk(tmp_path, monkeypatch):
|
||||
"""Niveau non stocké : tuile rendue, rien d'écrit sur disque, seconde
|
||||
demande servie par le cache mémoire."""
|
||||
"""Unstored level: tile rendered, nothing written to disk, second
|
||||
request served by the memory cache."""
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "TILE_EVEN_LEVELS", True)
|
||||
monkeypatch.setattr(tiles, "TILE_CACHE_MAX_Z", 16)
|
||||
@ -588,10 +589,10 @@ def test_unstored_level_rendered_on_the_fly_without_disk(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_remote_payload_failure_not_retried_each_call(monkeypatch):
|
||||
"""Amont injoignable sans inventaire connu : l'échec est mémorisé (TTL).
|
||||
"""Upstream unreachable with no known inventory: the failure is remembered (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.
|
||||
Otherwise every call (several per /api/map/meta) pays the connection
|
||||
timeout again: the interface no longer loaded when the worker was down.
|
||||
"""
|
||||
import urllib.request
|
||||
from lidar_pipeline import tiles
|
||||
@ -602,7 +603,7 @@ def test_remote_payload_failure_not_retried_each_call(monkeypatch):
|
||||
|
||||
def boom(*a, **k):
|
||||
calls.append(1)
|
||||
raise OSError("hôte injoignable")
|
||||
raise OSError("host unreachable")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", boom)
|
||||
for _ in range(3):
|
||||
@ -611,8 +612,9 @@ def test_remote_payload_failure_not_retried_each_call(monkeypatch):
|
||||
|
||||
|
||||
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)."""
|
||||
"""Failed upstream source: the following ones fail without waiting for the
|
||||
network timeout during the suspension (the maintenance falls back to local
|
||||
rendering)."""
|
||||
import urllib.request
|
||||
from lidar_pipeline import tiles
|
||||
monkeypatch.setattr(tiles, "_SOURCE_OFFLINE", {"until": 0.0})
|
||||
@ -620,7 +622,7 @@ def test_fetch_source_offline_breaker(tmp_path, monkeypatch):
|
||||
|
||||
def boom(*a, **k):
|
||||
calls.append(1)
|
||||
raise OSError("hôte injoignable")
|
||||
raise OSError("host unreachable")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", boom)
|
||||
assert tiles._fetch_source("http://amont/a", tmp_path / "a.avif") is False
|
||||
@ -629,9 +631,9 @@ def test_fetch_source_offline_breaker(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
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)."""
|
||||
"""A fetched source carries the upstream version date: when the upstream
|
||||
goes down, the local index finds the same dates and already-rendered tiles
|
||||
stay fresh (no whole pyramid to redo)."""
|
||||
from lidar_pipeline import tiles
|
||||
|
||||
def fake_fetch(url, dest):
|
||||
@ -646,10 +648,10 @@ def test_fetched_source_dated_to_upstream_version(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
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é)."""
|
||||
"""Source tile entering the inventory AFTER an XYZ tile covering it was
|
||||
rendered, but with an older version date (written before, inventoried
|
||||
after): the XYZ tile must become stale — otherwise a permanent hole at
|
||||
that level, on disk, in memory and in the browser (unchanged stamp)."""
|
||||
import io
|
||||
import os
|
||||
from PIL import Image
|
||||
@ -667,15 +669,15 @@ def test_tile_refreshed_when_older_dalle_appears(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
|
||||
os.utime(f, (1_000_000, 1_000_000)) # version older than the XYZ tile
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "pending"
|
||||
second = tiles.get_tile(tmp_path, "slope", z, x, y)
|
||||
img = Image.open(io.BytesIO(second)).convert("RGBA")
|
||||
assert any(g > 150 and r < 80 and a == 255
|
||||
for r, g, b, a in img.getdata()), "nouvelle dalle absente de la tuile"
|
||||
for r, g, b, a in img.getdata()), "new source tile missing from the XYZ tile"
|
||||
assert tiles.tiles_stamp(tmp_path) > stamp
|
||||
# Registre persistant : un redémarrage ne réinvalide rien
|
||||
# Persistent registry: a restart invalidates nothing again
|
||||
tiles._index_cache.clear()
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "fresh"
|
||||
@ -683,8 +685,8 @@ def test_tile_refreshed_when_older_dalle_appears(tmp_path):
|
||||
|
||||
|
||||
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."""
|
||||
"""Upgrade: tile cache present but no registry — the existing tiles
|
||||
(possibly with holes) are made stale exactly once."""
|
||||
from lidar_pipeline import tiles
|
||||
tiles._seen_cache.clear()
|
||||
tiles._mem_tiles.clear()
|
||||
@ -693,7 +695,7 @@ def test_existing_cache_without_registry_is_refreshed_once(tmp_path):
|
||||
_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
|
||||
(tmp_path / tiles.TILE_DIRNAME / tiles._SEEN_FILE).unlink() # previous version
|
||||
tiles._seen_cache.clear()
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "pending"
|
||||
@ -704,8 +706,8 @@ def test_existing_cache_without_registry_is_refreshed_once(tmp_path):
|
||||
|
||||
|
||||
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)."""
|
||||
"""Density layer: lossless .webp quadrants recognised as the fine tier,
|
||||
and rendered with nearest neighbour (16 exact greys even when upscaled)."""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
base = _make_dalle(tmp_path, 1054, 6882, ["densite_sol"])
|
||||
@ -714,22 +716,22 @@ def test_webp_subtiles_indexed_and_rendered_nearest(tmp_path):
|
||||
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.paste(255, (0, 0, 4, 8)) # half white, half black
|
||||
im.save(str(sub / f"{base}_densite_sol_{i}_{j}.webp"), format="WEBP", lossless=True)
|
||||
tiers = tiles.source_index(tmp_path, force=True)["densite_sol"][(1054, 6882)]
|
||||
quads = next(t for t in tiers if len(t) == 4)
|
||||
assert all(q.path.suffix == ".webp" for q in quads)
|
||||
assert "densite_sol" in tiles.NEAREST_LAYERS
|
||||
# z17 : résolution visée (~0,8 m) atteinte par les quadrants (0,5 m ici)
|
||||
# z17: target resolution (~0.8 m) reached by the quadrants (0.5 m here)
|
||||
x, y = _tile_of_cell(1054, 6882, 17)
|
||||
img = tiles.render_tile(tmp_path, "densite_sol", 17, x, y)
|
||||
assert img is not None
|
||||
grays = {p[0] for p in img.getdata() if p[3] == 255}
|
||||
assert grays <= {0, 255} # aucun gris intermédiaire inventé
|
||||
assert grays <= {0, 255} # no invented intermediate grey
|
||||
|
||||
|
||||
def test_remote_quality_persisted_locally(tmp_path, monkeypatch):
|
||||
"""La table qualité amont est recopiée en sidecars locaux (Pi autonome)."""
|
||||
"""The upstream quality table is copied into local sidecars (self-sufficient Pi)."""
|
||||
from lidar_pipeline import tiles
|
||||
from lidar_pipeline.quality import read_quality
|
||||
payload = _remote_payload()
|
||||
|
||||
Reference in New Issue
Block a user