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,24 +1,24 @@
|
||||
"""Tests pour la carte globale interactive (index.py)."""
|
||||
"""Tests for the tile catalog (index.py)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_parse_basename_coords_valid():
|
||||
"""Parse les coordonnées d'un basename LHD valide."""
|
||||
"""Parse the coordinates of a valid LHD basename."""
|
||||
from lidar_pipeline.index import parse_basename_coords
|
||||
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69") == (1000, 6881)
|
||||
assert parse_basename_coords("LHD_FXX_1049_6895_PTS_LAMB93_IGN69") == (1049, 6895)
|
||||
|
||||
|
||||
def test_parse_basename_coords_with_res_suffix():
|
||||
"""Les noms de dossier avec suffixe résolution sont aussi parsables."""
|
||||
"""Directory names with a resolution suffix can be parsed too."""
|
||||
from lidar_pipeline.index import parse_basename_coords
|
||||
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2") == (1000, 6881)
|
||||
|
||||
|
||||
def test_parse_basename_coords_invalid():
|
||||
"""Les noms non-LHD retournent None."""
|
||||
"""Non-LHD names return None."""
|
||||
from lidar_pipeline.index import parse_basename_coords
|
||||
assert parse_basename_coords("random_dir") is None
|
||||
assert parse_basename_coords("DTM") is None
|
||||
@ -26,7 +26,7 @@ def test_parse_basename_coords_invalid():
|
||||
|
||||
|
||||
def test_strip_res_suffix_primary():
|
||||
"""Dossier sans suffixe = résolution primaire (0.5)."""
|
||||
"""Directory without a suffix = primary resolution (0.5)."""
|
||||
from lidar_pipeline.index import _strip_res_suffix
|
||||
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69")
|
||||
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
@ -34,7 +34,7 @@ def test_strip_res_suffix_primary():
|
||||
|
||||
|
||||
def test_strip_res_suffix_multi():
|
||||
"""Dossier avec suffixe _r0p2 = résolution 0.2."""
|
||||
"""Directory with the _r0p2 suffix = resolution 0.2."""
|
||||
from lidar_pipeline.index import _strip_res_suffix
|
||||
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2")
|
||||
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
@ -42,7 +42,7 @@ def test_strip_res_suffix_multi():
|
||||
|
||||
|
||||
def test_compute_bbox():
|
||||
"""Calcule la bounding box d'un ensemble de tuiles."""
|
||||
"""Compute the bounding box of a set of tiles."""
|
||||
from lidar_pipeline.index import compute_bbox
|
||||
tiles = [
|
||||
{'col': 1000, 'row': 6881},
|
||||
@ -54,16 +54,16 @@ def test_compute_bbox():
|
||||
|
||||
|
||||
def test_compute_bbox_empty():
|
||||
"""Aucune tuile → None."""
|
||||
"""No tile → None."""
|
||||
from lidar_pipeline.index import compute_bbox
|
||||
assert compute_bbox([]) is None
|
||||
|
||||
|
||||
def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi', 'svf'), ext='webp', res_suffix=''):
|
||||
"""Crée un faux dossier de visualisations avec de petites images.
|
||||
"""Create a fake visualization directory with small images.
|
||||
|
||||
Le suffixe de résolution apparaît seulement dans le nom du dossier (miroir
|
||||
du pipeline : les fichiers restent préfixés par le basename nu).
|
||||
The resolution suffix only appears in the directory name (mirroring
|
||||
the pipeline: files stay prefixed with the bare basename).
|
||||
"""
|
||||
from PIL import Image as PILImage
|
||||
import numpy as np
|
||||
@ -80,7 +80,7 @@ def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi',
|
||||
|
||||
|
||||
def test_scan_tiles(tmp_path):
|
||||
"""scan_tiles détecte les dossiers de tuiles et leurs visualisations."""
|
||||
"""scan_tiles detects the tile directories and their visualizations."""
|
||||
from lidar_pipeline.index import scan_tiles
|
||||
|
||||
vis_dir = tmp_path / "visualisations"
|
||||
@ -93,14 +93,14 @@ def test_scan_tiles(tmp_path):
|
||||
names = sorted(t['dir_name'] for t in tiles)
|
||||
assert "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" in names
|
||||
assert "LHD_FXX_1001_6881_PTS_LAMB93_IGN69" in names
|
||||
# Vérifie que les viz sont détectées
|
||||
# Check that the visualizations are detected
|
||||
t0 = next(t for t in tiles if t['col'] == 1000)
|
||||
assert 'hillshade_multi' in t0['viz']
|
||||
assert 'svf' in t0['viz']
|
||||
|
||||
|
||||
def test_scan_tiles_ignores_non_lhd(tmp_path):
|
||||
"""Les dossiers non-LHD (ex: temp, DTM) sont ignorés."""
|
||||
"""Non-LHD directories (e.g. temp, DTM) are ignored."""
|
||||
from lidar_pipeline.index import scan_tiles
|
||||
|
||||
vis_dir = tmp_path / "visualisations"
|
||||
@ -114,7 +114,7 @@ def test_scan_tiles_ignores_non_lhd(tmp_path):
|
||||
|
||||
|
||||
def test_scan_tiles_multi_resolution(tmp_path):
|
||||
"""Les dossiers avec suffixe résolution sont correctement décodés."""
|
||||
"""Directories with a resolution suffix are decoded correctly."""
|
||||
from lidar_pipeline.index import scan_tiles
|
||||
|
||||
vis_dir = tmp_path / "visualisations"
|
||||
@ -129,14 +129,14 @@ def test_scan_tiles_multi_resolution(tmp_path):
|
||||
|
||||
|
||||
def test_res_suffix_str():
|
||||
"""Le suffixe de résolution reflète le nommage du pipeline (miroir)."""
|
||||
"""The resolution suffix mirrors the pipeline naming."""
|
||||
from lidar_pipeline.index import _res_suffix_str
|
||||
assert _res_suffix_str(0.5) == ''
|
||||
assert _res_suffix_str(0.2) == '_r0p2'
|
||||
|
||||
|
||||
def test_collect_tile_metadata(tmp_path):
|
||||
"""Les métadonnées lisent la méthode DTM et les dates/tailles des viz."""
|
||||
"""The metadata read the DTM method and the viz dates/sizes."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from lidar_pipeline.index import _collect_tile_metadata
|
||||
@ -151,7 +151,7 @@ def test_collect_tile_metadata(tmp_path):
|
||||
dtm_dir.mkdir()
|
||||
method_file = dtm_dir / f"{basename}_dtm_method.txt"
|
||||
method_file.write_text("ign", encoding="utf-8")
|
||||
# Dates déterministes : method.txt plus ancien que la viz
|
||||
# Deterministic dates: method.txt older than the viz
|
||||
os.utime(method_file, (1600000000, 1600000000))
|
||||
os.utime(viz_file, (1700000000, 1700000000))
|
||||
fmt = lambda ts: datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
|
||||
@ -169,7 +169,7 @@ def test_collect_tile_metadata(tmp_path):
|
||||
|
||||
|
||||
def test_collect_tile_metadata_resolution_suffix(tmp_path):
|
||||
"""Une tuile 0,2 m lit son sidecar _dtm_r0p2_method.txt dédié."""
|
||||
"""A 0.2 m tile reads its dedicated _dtm_r0p2_method.txt sidecar."""
|
||||
from lidar_pipeline.index import _collect_tile_metadata
|
||||
|
||||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
@ -184,13 +184,13 @@ def test_collect_tile_metadata_resolution_suffix(tmp_path):
|
||||
'viz': {}}
|
||||
meta = _collect_tile_metadata(tile, dtm_dir)
|
||||
assert meta['method'] == 'smrf'
|
||||
# La date vient du sidecar (écrit juste après la création du DTM)
|
||||
# The date comes from the sidecar (written right after the DTM is created)
|
||||
assert meta['generated'] is not None
|
||||
assert meta['viz'] == {}
|
||||
|
||||
|
||||
def test_collect_tile_metadata_fallback_date(tmp_path):
|
||||
"""Sans sidecar DTM, la date de génération remonte au plus ancien fichier viz."""
|
||||
"""Without a DTM sidecar, the generation date falls back to the oldest viz file."""
|
||||
from lidar_pipeline.index import _collect_tile_metadata
|
||||
|
||||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
@ -208,7 +208,7 @@ def test_collect_tile_metadata_fallback_date(tmp_path):
|
||||
|
||||
|
||||
def test_build_index_generates_inventory(tmp_path):
|
||||
"""build_index génère l'inventaire index_tiles.json et les vignettes."""
|
||||
"""build_index generates the index_tiles.json inventory and the thumbnails."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
@ -224,26 +224,26 @@ def test_build_index_generates_inventory(tmp_path):
|
||||
inv_path = Path(result)
|
||||
assert inv_path.exists()
|
||||
assert inv_path.name == "index_tiles.json"
|
||||
# L'ancienne interface HTML a été retirée : plus de coquille ni d'assets
|
||||
# The old HTML interface was removed: no more shell or assets
|
||||
assert not (output_dir / "index.html").exists()
|
||||
assert not (output_dir / "assets").exists()
|
||||
|
||||
# Inventaire servi par /api/tiles aux machines légères : tuiles avec coins,
|
||||
# libellés de couches, compteur.
|
||||
# Inventory served by /api/tiles to the lightweight machines: tiles with corners,
|
||||
# layer labels, counter.
|
||||
data = json.loads(inv_path.read_text(encoding='utf-8'))
|
||||
assert data['tiles'] and data['tiles'][0]['corners']
|
||||
assert 'aspect' in data['viz_meta']
|
||||
assert data['viz_meta']['aspect']['label']
|
||||
assert data['stats']['n_tiles'] == len(data['tiles'])
|
||||
# Vérifie les vignettes générées
|
||||
# Check the generated thumbnails
|
||||
thumb_dir = output_dir / "index_thumbs"
|
||||
assert thumb_dir.is_dir()
|
||||
thumbs = list(thumb_dir.glob("*.jpg"))
|
||||
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
|
||||
assert len(thumbs) >= 2 # at least hillshade for each tile
|
||||
|
||||
|
||||
def test_mid_thumbnails_generated(tmp_path):
|
||||
"""Vignette intermédiaire 640 px générée et référencée dans l'index."""
|
||||
"""640 px intermediate thumbnail generated and referenced in the index."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
@ -255,13 +255,13 @@ def test_mid_thumbnails_generated(tmp_path):
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||||
assert 'mid' in viz
|
||||
# L'URL porte un suffixe ?v= (invalidation cache) : chemin sans lui
|
||||
# The URL carries a ?v= suffix (cache busting): path without it
|
||||
assert (output_dir / viz['mid'].split('?')[0]).exists()
|
||||
assert viz['mid'].split('?')[0].endswith("_mid.jpg")
|
||||
|
||||
|
||||
def test_subtiles_cover_all_layers(tmp_path):
|
||||
"""Toutes les couches d'une dalle 0,2 m sont sous-tuilées (plus de liste courte)."""
|
||||
"""Every layer of a 0.2 m tile is cut into sub-tiles (no more short list)."""
|
||||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ, build_index
|
||||
|
||||
assert _CARTO_SUBTILED_VIZ == ()
|
||||
@ -277,7 +277,7 @@ def test_subtiles_cover_all_layers(tmp_path):
|
||||
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||||
avif = sub_dir / f"LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_{viz}_0_0.avif"
|
||||
assert avif.exists(), viz
|
||||
# L'index référence les sous-tuiles pour chaque couche, pas le repli dalle
|
||||
# The index references the sub-tiles for each layer, not the whole-tile fallback
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||||
assert len(subs) == 4
|
||||
@ -287,8 +287,8 @@ def test_subtiles_cover_all_layers(tmp_path):
|
||||
|
||||
|
||||
def test_panel_restricted_to_kept_layers():
|
||||
"""Le panneau est restreint aux couches conservées (PANEL_VIZ) : la couche
|
||||
principale par défaut et la précision y figurent."""
|
||||
"""The panel is restricted to the kept layers (PANEL_VIZ): the default
|
||||
main layer and the precision layer are in it."""
|
||||
from lidar_pipeline.index import (PANEL_VIZ, DEFAULT_VIZ, PRECISION_VIZ, VIEW_MODES,
|
||||
DEFAULT_VIEW_MODE, KEYWORD_TO_STEP, default_main_layer)
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
@ -296,19 +296,19 @@ def test_panel_restricted_to_kept_layers():
|
||||
assert DEFAULT_VIEW_MODE in VIEW_MODES
|
||||
assert default_main_layer(["densite_sol", "aspect"]) == "aspect"
|
||||
assert default_main_layer(["densite_sol"]) is None
|
||||
# Chaque couche conservée correspond à une étape --only valide
|
||||
# Each kept layer matches a valid --only step
|
||||
steps = {name for name, _ in VIZ_STEPS}
|
||||
for key in PANEL_VIZ:
|
||||
assert KEYWORD_TO_STEP.get(key, key) in steps
|
||||
|
||||
|
||||
def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||||
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
|
||||
"""A layer produced only at 0.5 m stays visible on the 0.2 m tile.
|
||||
|
||||
Le dernier run peut être interrompu entre les deux passes de résolution :
|
||||
la fusion inter-résolutions doit pointer vignettes et URLs vers le dossier
|
||||
d'origine de chaque fichier (dir_name), pas vers dir_path de la dalle
|
||||
affichée — sinon les vignettes échouent et la couche disparaît.
|
||||
The last run may have been interrupted between the two resolution passes:
|
||||
the cross-resolution merge must point thumbnails and URLs to the original
|
||||
directory of each file (dir_name), not to the dir_path of the displayed
|
||||
tile — otherwise the thumbnails fail and the layer disappears.
|
||||
"""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
@ -316,19 +316,19 @@ def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
base = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||||
# Passe 0,5 m : aspect + slope ; passe 0,2 m : aspect seulement
|
||||
# 0.5 m pass: aspect + slope; 0.2 m pass: aspect only
|
||||
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect', 'slope'))
|
||||
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect',), res_suffix='_r0p2')
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
# Une seule position affichée (résolution la plus fine), deux couches
|
||||
# A single displayed position (finest resolution), two layers
|
||||
tiles = [t for t in data['tiles'] if (t['col'], t['row']) == (1000, 6881)]
|
||||
keys = set()
|
||||
for t in tiles:
|
||||
keys.update(t['viz'].keys())
|
||||
assert {'aspect', 'slope'} <= keys
|
||||
# Vignette slope générée + chaque thumb/full existe réellement sur disque
|
||||
# Slope thumbnail generated + every thumb/full really exists on disk
|
||||
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
|
||||
for t in tiles:
|
||||
for v in t['viz'].values():
|
||||
@ -337,7 +337,7 @@ def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||||
|
||||
|
||||
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||
"""Une tuile recalculée (source plus récente) régénère sa vignette."""
|
||||
"""A recomputed tile (newer source) regenerates its thumbnail."""
|
||||
import os
|
||||
import time
|
||||
import numpy as np
|
||||
@ -354,7 +354,7 @@ def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||
assert thumb_path.exists()
|
||||
m1 = thumb_path.stat().st_mtime
|
||||
|
||||
# Recalcul de la tuile : source réécrite avec une mtime plus récente
|
||||
# Tile recomputed: source rewritten with a newer mtime
|
||||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||
arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
|
||||
PILImage.fromarray(arr).save(str(src), format='WEBP', quality=80)
|
||||
@ -362,16 +362,16 @@ def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
m2 = thumb_path.stat().st_mtime
|
||||
assert m2 > m1 # vignette régénérée
|
||||
assert m2 > m1 # thumbnail regenerated
|
||||
|
||||
# Source non modifiée depuis → pas de régénération inutile
|
||||
# Source not modified since → no needless regeneration
|
||||
os.utime(src, (time.time() - 10, time.time() - 10))
|
||||
assert build_index(output_dir) is not None
|
||||
assert thumb_path.stat().st_mtime == m2
|
||||
|
||||
|
||||
def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
||||
"""Une dalle 0,2 m recalculée régénère ses sous-tuiles (par visualisation)."""
|
||||
"""A recomputed 0.2 m tile regenerates its sub-tiles (per visualization)."""
|
||||
import os
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
@ -391,22 +391,22 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
||||
m_hill_1 = hill_avif.stat().st_mtime
|
||||
m_aspect_1 = aspect_avif.stat().st_mtime
|
||||
|
||||
# Recalcul : seule la source hillshade est plus récente
|
||||
# Recomputation: only the hillshade source is newer
|
||||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||
os.utime(src, (m_hill_1 + 5, m_hill_1 + 5))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
assert hill_avif.stat().st_mtime > m_hill_1 # sous-tuiles hillshade régénérées
|
||||
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect intact
|
||||
assert hill_avif.stat().st_mtime > m_hill_1 # hillshade sub-tiles regenerated
|
||||
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect untouched
|
||||
|
||||
|
||||
def test_urls_versioned_for_cache_busting(tmp_path):
|
||||
"""Les URLs images changent quand une tuile est régénérée.
|
||||
"""Image URLs change when a tile is regenerated.
|
||||
|
||||
Les URLs versionnées (?v=) sont servies avec un cache immutable : un
|
||||
recalcul DOIT changer l'URL pour forcer le rechargement, y compris en
|
||||
direct pendant un run (--incremental-index). Chaque URL porte la mtime
|
||||
de SON fichier (cf. test_urls_versioned_by_served_file_mtime).
|
||||
Versioned URLs (?v=) are served with an immutable cache: a
|
||||
recomputation MUST change the URL to force a reload, including
|
||||
live during a run (the inventory is rewritten after each tile). Each URL
|
||||
carries the mtime of ITS file (see test_urls_versioned_by_served_file_mtime).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
@ -426,25 +426,25 @@ def test_urls_versioned_for_cache_busting(tmp_path):
|
||||
|
||||
thumb1, mid1, full1 = read_urls()
|
||||
for url in (thumb1, mid1, full1):
|
||||
assert re.search(r"\?v=\d+$", url), url # versionnée (mtime ms)
|
||||
assert re.search(r"\?v=\d+$", url), url # versioned (ms mtime)
|
||||
assert (output_dir / url.split('?')[0]).exists(), url
|
||||
|
||||
# Même source, rebuild → URLs identiques (cache navigateur conservé)
|
||||
# Same source, rebuild → identical URLs (browser cache kept)
|
||||
assert build_index(output_dir) is not None
|
||||
assert read_urls() == (thumb1, mid1, full1)
|
||||
|
||||
# Régénération : mtime de la source plus récente → nouvelles URLs
|
||||
# Regeneration: newer source mtime → new URLs
|
||||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||
m = src.stat().st_mtime
|
||||
os.utime(src, (m + 10, m + 10))
|
||||
assert build_index(output_dir) is not None
|
||||
thumb2, mid2, full2 = read_urls()
|
||||
assert (thumb2, mid2, full2) != (thumb1, mid1, full1)
|
||||
assert thumb2.split('?')[0] == thumb1.split('?')[0] # même fichier, version neuve
|
||||
assert thumb2.split('?')[0] == thumb1.split('?')[0] # same file, new version
|
||||
|
||||
|
||||
def test_subtile_urls_versioned(tmp_path):
|
||||
"""Les URLs des sous-tuiles 0,2 m sont aussi versionnées (?v=)."""
|
||||
"""The URLs of the 0.2 m sub-tiles are versioned too (?v=)."""
|
||||
import re
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
@ -466,7 +466,7 @@ def test_subtile_urls_versioned(tmp_path):
|
||||
|
||||
|
||||
def test_build_index_empty_returns_none(tmp_path):
|
||||
"""Aucune tuile → build_index retourne None sans crash."""
|
||||
"""No tile → build_index returns None without crashing."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
@ -477,7 +477,7 @@ def test_build_index_empty_returns_none(tmp_path):
|
||||
|
||||
|
||||
def test_attach_gps_bounds():
|
||||
"""attach_gps_bounds ajoute des bounds GPS ordonnées (France métropolitaine)."""
|
||||
"""attach_gps_bounds adds ordered GPS bounds (metropolitan France)."""
|
||||
from lidar_pipeline.index import attach_gps_bounds
|
||||
tiles = [{'col': 1000, 'row': 6881}, {'col': 1042, 'row': 6900}]
|
||||
attach_gps_bounds(tiles)
|
||||
@ -486,16 +486,16 @@ def test_attach_gps_bounds():
|
||||
(lat_s, lon_w), (lat_n, lon_e) = t['bounds']
|
||||
assert lat_n > lat_s
|
||||
assert lon_e > lon_w
|
||||
# France métropolitaine
|
||||
# Metropolitan France
|
||||
assert 41 < lat_s < 51
|
||||
assert -5 < lon_w < 10
|
||||
|
||||
|
||||
def test_attach_gps_bounds_row_is_north_edge():
|
||||
"""Le numéro de ligne du fichier = bord NORD (convention LiDAR HD IGN).
|
||||
"""The file row number = NORTH edge (IGN LiDAR HD convention).
|
||||
|
||||
Vérifié sur les bounds des DTM : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
|
||||
La régression historique plaçait Y ∈ [row, row+1] (1 km trop au nord).
|
||||
Checked against the DTM bounds: X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
|
||||
The historical regression placed Y ∈ [row, row+1] (1 km too far north).
|
||||
"""
|
||||
from rasterio.warp import transform as warp_transform
|
||||
from lidar_pipeline.index import attach_gps_bounds
|
||||
@ -505,7 +505,7 @@ def test_attach_gps_bounds_row_is_north_edge():
|
||||
attach_gps_bounds(tiles)
|
||||
corners = tiles[0]['corners']
|
||||
|
||||
# Référence exacte de la vraie cellule : SW, SE, NE, NW
|
||||
# Exact reference of the real cell: SW, SE, NE, NW
|
||||
xs = [col * 1000, (col + 1) * 1000, (col + 1) * 1000, col * 1000]
|
||||
ys = [(row - 1) * 1000, (row - 1) * 1000, row * 1000, row * 1000]
|
||||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
|
||||
@ -513,14 +513,14 @@ def test_attach_gps_bounds_row_is_north_edge():
|
||||
assert abs(corners[k][0] - lats[k]) < 1e-9
|
||||
assert abs(corners[k][1] - lons[k]) < 1e-9
|
||||
|
||||
# L'ancienne convention (row = bord sud) serait décalée d'environ 1 km
|
||||
# The old convention (row = south edge) would be off by about 1 km
|
||||
lat_n = max(c[0] for c in corners)
|
||||
assert abs(lat_n - max(lats)) < 1e-9 # bord nord = Y = row×1000
|
||||
assert abs(lat_n - max(lats)) < 1e-9 # north edge = Y = row×1000
|
||||
|
||||
|
||||
|
||||
def test_pick_display_viz_prefers_hillshade():
|
||||
"""Le choix de viz par défaut privilégie hillshade_multi."""
|
||||
"""The default viz choice prefers hillshade_multi."""
|
||||
from lidar_pipeline.index import _pick_display_viz
|
||||
assert _pick_display_viz(['svf', 'hillshade_multi', 'slope']) == 'hillshade_multi'
|
||||
assert _pick_display_viz(['svf', 'slope']) == 'svf'
|
||||
@ -528,7 +528,7 @@ def test_pick_display_viz_prefers_hillshade():
|
||||
|
||||
|
||||
def test_subdivision_k():
|
||||
"""0,5 m/px (2000 px) reste entier ; 0,2 m/px (5000 px) est découpé en 2×2."""
|
||||
"""0.5 m/px (2000 px) stays whole; 0.2 m/px (5000 px) is split 2×2."""
|
||||
from lidar_pipeline.index import _subdivision_k
|
||||
assert _subdivision_k(0.5) == 1
|
||||
assert _subdivision_k(0.2) == 2
|
||||
@ -536,26 +536,26 @@ def test_subdivision_k():
|
||||
|
||||
|
||||
def test_subtile_corners_grid():
|
||||
"""Les sous-tuiles reconstruisent exactement la grille de la dalle."""
|
||||
"""The sub-tiles rebuild the tile grid exactly."""
|
||||
from lidar_pipeline.index import _subtile_corners
|
||||
corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]] # SW SE NE NW
|
||||
k = 2
|
||||
sw_quad = _subtile_corners(corners, 0, 0, k) # quadrant sud-ouest
|
||||
ne_quad = _subtile_corners(corners, 1, 1, k) # quadrant nord-est
|
||||
# Le quadrant SW partage le coin SW de la dalle
|
||||
sw_quad = _subtile_corners(corners, 0, 0, k) # south-west quadrant
|
||||
ne_quad = _subtile_corners(corners, 1, 1, k) # north-east quadrant
|
||||
# The SW quadrant shares the SW corner of the tile
|
||||
assert sw_quad[0] == corners[0]
|
||||
# Le quadrant NE partage le coin NE de la dalle
|
||||
# The NE quadrant shares the NE corner of the tile
|
||||
assert ne_quad[2] == corners[2]
|
||||
# Le quadrant SW a son coin NE au centre de la dalle
|
||||
# The SW quadrant has its NE corner at the center of the tile
|
||||
assert sw_quad[2] == [10.5, 2.5]
|
||||
# Adjacence : bord est du SW = bord ouest du SE (0,0)-(1,0)
|
||||
# Adjacency: east edge of SW = west edge of SE (0,0)-(1,0)
|
||||
se_quad = _subtile_corners(corners, 1, 0, k)
|
||||
assert sw_quad[1] == se_quad[0]
|
||||
assert sw_quad[2] == se_quad[3]
|
||||
|
||||
|
||||
def test_approx_wgs84_to_l93_roundtrip():
|
||||
"""L'approximation affine WGS84→L93 est l'inverse exacte de L93→WGS84."""
|
||||
"""The affine WGS84→L93 approximation is the exact inverse of L93→WGS84."""
|
||||
from lidar_pipeline.index import _approx_l93_to_wgs84, _approx_wgs84_to_l93
|
||||
for x, y in ((1054000.0, 6882000.0), (700000.0, 6600000.0), (950123.0, 6410456.0)):
|
||||
lon, lat = _approx_l93_to_wgs84(x, y)
|
||||
@ -566,32 +566,32 @@ def test_approx_wgs84_to_l93_roundtrip():
|
||||
|
||||
|
||||
def test_subtile_corners_shared_edges_exact():
|
||||
"""Bords partagés : deux sous-tuiles voisines reçoivent le MÊME point.
|
||||
"""Shared edges: two neighboring sub-tiles get the SAME point.
|
||||
|
||||
Sans partage, l'interpolation bilinéaire est évaluée deux fois sur des
|
||||
fractions différentes et les bords ne coïncident plus à moins d'un pixel
|
||||
— jointure cassée (ligne blanche) au zoom moyen.
|
||||
Without sharing, the bilinear interpolation is evaluated twice on
|
||||
different fractions and the edges miss by less than a pixel
|
||||
— broken seam (white line) at medium zoom.
|
||||
"""
|
||||
from lidar_pipeline.index import _subtile_corners
|
||||
corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]]
|
||||
k = 4
|
||||
a = _subtile_corners(corners, 0, 0, k)
|
||||
b = _subtile_corners(corners, 1, 0, k)
|
||||
assert a[1] == b[0] # bord est de (0,0) = bord ouest de (1,0)
|
||||
assert a[1] == b[0] # east edge of (0,0) = west edge of (1,0)
|
||||
assert a[2] == b[3]
|
||||
c = _subtile_corners(corners, 0, 1, k)
|
||||
assert a[3] == c[0] # bord nord de (0,0) = bord sud de (0,1)
|
||||
assert a[3] == c[0] # north edge of (0,0) = south edge of (0,1)
|
||||
d = _subtile_corners(corners, 3, 3, k)
|
||||
assert d[2] == corners[2] # coin NE de la dalle partagé tel quel
|
||||
assert d[2] == corners[2] # tile NE corner shared as is
|
||||
|
||||
|
||||
def test_overview_corners_shared_between_tiles(tmp_path):
|
||||
"""Le coin d'une dalle est aussi celui de sa voisine (même point).
|
||||
"""A tile's corner is also its neighbor's (same point).
|
||||
|
||||
Deux dalles adjacentes E-O partagent un bord vertical : le NE de la dalle
|
||||
ouest = le NW de la dalle est. Sans partage, les interpolations bilinéaires
|
||||
des sous-tuiles voisines ne coïncident plus à moins d'un pixel — jointure
|
||||
cassée au zoom moyen.
|
||||
Two E-W adjacent tiles share a vertical edge: the NE of the western
|
||||
tile = the NW of the eastern tile. Without sharing, the bilinear interpolations
|
||||
of neighboring sub-tiles miss by less than a pixel — broken seam
|
||||
at medium zoom.
|
||||
"""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
@ -604,16 +604,16 @@ def test_overview_corners_shared_between_tiles(tmp_path):
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
tiles = {t['col']: t for t in data['tiles']}
|
||||
a_ne = tiles[1000]['corners'][2] # NE de la dalle ouest
|
||||
b_nw = tiles[1001]['corners'][3] # NW de la dalle est
|
||||
assert a_ne == b_nw, f"coins partagés divergents : {a_ne} != {b_nw}"
|
||||
a_ne = tiles[1000]['corners'][2] # NE of the western tile
|
||||
b_nw = tiles[1001]['corners'][3] # NW of the eastern tile
|
||||
assert a_ne == b_nw, f"shared corners diverge: {a_ne} != {b_nw}"
|
||||
|
||||
|
||||
def test_urls_versioned_by_served_file_mtime(tmp_path):
|
||||
"""?v= suit la mtime du fichier SERVI — prérequis du cache immutable.
|
||||
"""?v= follows the mtime of the SERVED file — prerequisite of the immutable cache.
|
||||
|
||||
Une vignette recalculée après coup (source inchangée) change de contenu :
|
||||
son URL doit changer aussi, sans toucher celles des fichiers intacts.
|
||||
A thumbnail recomputed later (unchanged source) changes content:
|
||||
its URL must change too, without touching those of the intact files.
|
||||
"""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
@ -621,7 +621,7 @@ def test_urls_versioned_by_served_file_mtime(tmp_path):
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
# 0,2 m : la dalle passe par le sous-tuilage (vignettes/mid/AVIF propres)
|
||||
# 0.2 m: the tile goes through sub-tiling (own thumbnails/mid/AVIF)
|
||||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',),
|
||||
res_suffix='_r0p2')
|
||||
|
||||
@ -636,11 +636,11 @@ def test_urls_versioned_by_served_file_mtime(tmp_path):
|
||||
continue
|
||||
path, v = url.split('?v=')
|
||||
assert v.isdigit()
|
||||
# La version est la mtime ms du fichier servi lui-même
|
||||
# (tolérance 1 s : granularité du système de fichiers).
|
||||
# The version is the ms mtime of the served file itself
|
||||
# (1 s tolerance: file system granularity).
|
||||
assert abs(int(v) - int((output_dir / path).stat().st_mtime * 1000)) < 1000
|
||||
|
||||
# Vignette 0,5 m recalculée après coup : seule SON URL change.
|
||||
# 0.5 m thumbnail recomputed later: only ITS URL changes.
|
||||
tiles05 = [t for t in data['tiles'] if t.get('resolution') == 0.5]
|
||||
url_before = tiles05[0]['viz']['hillshade_multi']['thumb']
|
||||
thumb_path = output_dir / url_before.split('?')[0]
|
||||
|
||||
Reference in New Issue
Block a user