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:
Antoine
2026-09-27 23:16:45 +02:00
parent cc1c22d2b8
commit fb892ea9f2
52 changed files with 4356 additions and 4323 deletions

View File

@ -89,10 +89,10 @@ class TestSetupLogging:
def test_rebuild_index_without_input_arg(tmp_path):
"""--rebuild-index fonctionne sans l'argument positionnel input.
"""--rebuild-index works without the positional input argument.
Régression : input était obligatoire alors que --rebuild-index ne
l'utilise pas (erreur argparse « the following arguments are required »).
Regression: input used to be required although --rebuild-index does not
use it (argparse error "the following arguments are required").
"""
import subprocess

View File

@ -94,7 +94,7 @@ class TestCSFPipeline:
pipeline = json.loads(result)
csf_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.csf"][0]
assert csf_stage["resolution"] == 1.0 # cloth 1 m : ~4× plus rapide, MNT inchangé
assert csf_stage["resolution"] == 1.0 # 1 m cloth: ~4× faster, DTM unchanged
assert csf_stage["rigidness"] == 3
assert csf_stage["smooth"] is True
assert "hdiff" not in csf_stage # hdiff is not a valid PDAL CSF parameter
@ -141,11 +141,11 @@ class TestInterpolateHoles:
class TestFillSmallGaps:
"""Comblement borné à l'enveloppe des points (plus de pastilles ni de liseré)."""
"""Gap fill bounded to the point envelope (no more patches or fringes)."""
@staticmethod
def _grid(n=200, step=2, value=10.0):
"""Semis régulier de points (1 pixel sur `step`) sur n × n pixels."""
"""Regular point pattern (1 pixel in `step`) over n × n pixels."""
dtm = np.full((n, n), np.nan)
dtm[::step, ::step] = value
return dtm
@ -155,28 +155,28 @@ class TestFillSmallGaps:
dtm = np.full((100, 100), np.nan)
dtm[50, 50] = 5.0
out, filled, removed = _fill_small_gaps(dtm, 0.2)
assert np.isnan(out).all() # ni pastille, ni point seul
assert np.isnan(out).all() # neither a patch nor a lone point
assert (filled, removed) == (0, 1)
def test_gaps_between_points_filled_without_edge_band(self):
from lidar_pipeline.dtm import _fill_small_gaps
dtm = self._grid()
dtm[:, 100:] = np.nan # grand trou à l'est
dtm[:, 100:] = np.nan # large hole to the east
out, filled, _ = _fill_small_gaps(dtm, 0.2)
assert filled > 0
assert not np.isnan(out[10:190, 10:99]).any() # vides entre points comblés
assert np.isnan(out[:, 99:]).all() # rien d'extrapolé dans le trou
assert not np.isnan(out[10:190, 10:99]).any() # gaps between points filled
assert np.isnan(out[:, 99:]).all() # nothing extrapolated into the hole
def test_radius_follows_local_density(self):
"""Semis clairsemé (1 point / 1,8 m, vides de 2,5 m en diagonale)
comblé ; trou de 3 m en zone dense conservé ; trou de 1,6 m comblé."""
"""Sparse pattern (1 point / 1.8 m, 2.5 m diagonal gaps) filled; 3 m
hole in a dense area kept; 1.6 m hole filled."""
from lidar_pipeline.dtm import _fill_small_gaps
sparse = self._grid(step=9)
out, _, _ = _fill_small_gaps(sparse, 0.2)
assert not np.isnan(out[30:170, 30:170]).any()
dense = self._grid(step=1)
dense[90:105, 90:105] = np.nan # trou de 3 m dans un semis plein
dense[40:48, 40:48] = np.nan # trou de 1,6 m (voiture)
dense[90:105, 90:105] = np.nan # 3 m hole in a full pattern
dense[40:48, 40:48] = np.nan # 1.6 m hole (car)
out, _, _ = _fill_small_gaps(dense, 0.2)
assert np.isnan(out[95:100, 95:100]).all()
assert not np.isnan(out[40:48, 40:48]).any()
@ -211,7 +211,7 @@ class TestFillSmallGaps:
force=True, strip_align=False)
assert read_dtm_gap_fill(out) == GAP_FILL_VERSION
with rasterio.open(str(out)) as src:
src.tags() # lisible
src.tags() # readable
legacy = tmp_output_dir / "legacy.tif"
with rasterio.open(str(legacy), "w", driver="GTiff", width=2, height=2,
count=1, dtype="float32") as dst:
@ -221,11 +221,11 @@ class TestFillSmallGaps:
class TestDensitySidecar:
def test_dtm_writes_ground_density(self, tmp_output_dir):
"""4 points par m² sur 10 × 10 m : densité 4 au cœur, grille de 1 m."""
"""4 points per m² over 10 × 10 m: density 4 in the core, 1 m grid."""
import laspy
import rasterio
from lidar_pipeline.dtm import create_dtm_fast, density_path
g = (np.arange(20) + 0.25) / 2.0 # pas de 0,5 m
g = (np.arange(20) + 0.25) / 2.0 # 0.5 m step
xx, yy = np.meshgrid(g, g)
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
@ -303,7 +303,7 @@ class TestDetectGroundMethod:
class TestIGNPipeline:
def test_pipeline_keeps_supplier_classification(self):
"""create_ign_pipeline réutilise la pré-classification (classe 2) sans refiltrer."""
"""create_ign_pipeline reuses the pre-classification (class 2) without refiltering."""
from lidar_pipeline.dtm import create_ign_pipeline
result = create_ign_pipeline("/input/a.laz", "/output/a_ground.las")
pipeline = json.loads(result)
@ -311,14 +311,14 @@ class TestIGNPipeline:
stages = pipeline["pipeline"]
stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages]
# Aucun algorithme de classification, pas de remise à zéro, pas de filtres de bruit
# No classification algorithm, no reset, no noise filters
assert "filters.smrf" not in stage_types
assert "filters.csf" not in stage_types
assert "filters.assign" not in stage_types
assert "filters.elm" not in stage_types
assert "filters.outlier" not in stage_types
# Filtre ReturnNumber conservé + extraction des points classe 2
# ReturnNumber filter kept + extraction of class 2 points
range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"]
assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages)
assert any(s.get("limits") == "Classification[2:2]" for s in range_stages)
@ -340,13 +340,13 @@ class TestDetectIGN:
@patch('lidar_pipeline.dtm._read_with_pdal')
@patch('laspy.read')
def test_preclassified_returns_ign(self, mock_read, mock_pdal):
"""Fichier pré-classifié (majorité classe 2) → méthode IGN."""
"""Pre-classified file (mostly class 2) → IGN method."""
from lidar_pipeline.dtm import detect_ground_method
n = 10000
num_returns = np.ones(n, dtype=int)
cls = np.zeros(n, dtype=np.uint8)
cls[int(n * 0.15):] = 2 # 85 % de points classe 2
cls[int(n * 0.15):] = 2 # 85 % class 2 points
z_values = np.random.normal(100, 5, n)
mock_read.return_value = self._make_mock_las(cls, num_returns, z_values)
@ -355,13 +355,13 @@ class TestDetectIGN:
@patch('lidar_pipeline.dtm._read_with_pdal')
@patch('laspy.read')
def test_unclassified_falls_back_to_smrf_or_csf(self, mock_read, mock_pdal):
"""Sans classification exploitable → détection SMRF/CSF classique."""
"""No usable classification → classic SMRF/CSF detection."""
from lidar_pipeline.dtm import detect_ground_method
n = 10000
num_returns = np.ones(n, dtype=int)
num_returns[:int(n * 0.6)] = 2 # 60 % multi-retours (forêt) → non urbain
cls = np.zeros(n, dtype=np.uint8) # aucun point classe 2
num_returns[:int(n * 0.6)] = 2 # 60 % multi-return (forest) → not urban
cls = np.zeros(n, dtype=np.uint8) # no class 2 point
z_values = np.random.normal(100, 5, n)
mock_read.return_value = self._make_mock_las(cls, num_returns, z_values)
@ -421,12 +421,12 @@ class TestClassifyGroundMethod:
class TestParseIgnClasses:
def test_default_sol(self):
"""'sol' → code 2 seul."""
"""'sol' → code 2 only."""
from lidar_pipeline.dtm import parse_ign_classes
assert parse_ign_classes("sol") == [2]
def test_names_sorted_dedup(self):
"""Noms acceptés (EN/FR), triés et dédupliqués."""
"""Names accepted (EN/FR), sorted and de-duplicated."""
from lidar_pipeline.dtm import parse_ign_classes
assert parse_ign_classes("sol,unclassified") == [1, 2]
assert parse_ign_classes("unclassified,sol") == [1, 2]
@ -434,13 +434,13 @@ class TestParseIgnClasses:
assert parse_ign_classes("sol,2") == [2]
def test_numeric_codes(self):
"""Codes LAS directs, triés."""
"""Raw LAS codes, sorted."""
from lidar_pipeline.dtm import parse_ign_classes
assert parse_ign_classes("2,1") == [1, 2]
assert parse_ign_classes("66") == [66]
def test_invalid_raises(self):
"""Nom inconnu, code hors bornes ou liste vide → ValueError."""
"""Unknown name, out-of-range code or empty list → ValueError."""
from lidar_pipeline.dtm import parse_ign_classes
with pytest.raises(ValueError):
parse_ign_classes("foo")
@ -450,7 +450,7 @@ class TestParseIgnClasses:
parse_ign_classes("")
def test_method_label(self):
"""'ign' seul pour le sol, combinaison encodée sinon (invalidation cache)."""
"""'ign' alone for ground, combination encoded otherwise (cache invalidation)."""
from lidar_pipeline.dtm import ign_method_label
assert ign_method_label([2]) == "ign"
assert ign_method_label([1, 2]) == "ign_1_2"
@ -459,7 +459,7 @@ class TestParseIgnClasses:
class TestIGNPipelineMultiClasses:
def test_multi_class_limits(self):
"""Plusieurs classes → plages OU logiques sur Classification."""
"""Several classes → ORed ranges on Classification."""
from lidar_pipeline.dtm import _create_ground_pipeline
result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las",
'ign', ign_codes=[1, 2])
@ -471,7 +471,7 @@ class TestIGNPipelineMultiClasses:
for l in limits)
def test_default_sol_only(self):
"""Sans ign_codes, la voie IGN reste sol seul (2) — rétrocompatible."""
"""Without ign_codes, the IGN path stays ground only (2), backward compatible."""
from lidar_pipeline.dtm import _create_ground_pipeline
result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las", 'ign')
pipeline = json.loads(result)
@ -485,7 +485,7 @@ class TestIGNPipelineMultiClasses:
class TestClassifyGroundIgnClasses:
@patch('lidar_pipeline.dtm.subprocess')
def test_ign_classes_encoded_in_filenames(self, mock_subprocess):
"""--ign-classes sol,unclassified → fichiers ign_1_2 + filtre multi-classes."""
"""--ign-classes sol,unclassified → ign_1_2 files + multi-class filter."""
import tempfile
from lidar_pipeline.dtm import classify_ground
@ -506,7 +506,7 @@ class TestClassifyGroundIgnClasses:
@patch('lidar_pipeline.dtm.subprocess')
def test_ign_default_label_unchanged(self, mock_subprocess):
"""--ign-classes sol (défaut) → noms 'ign' inchangés (cache préservé)."""
"""--ign-classes sol (default) → 'ign' names unchanged (cache preserved)."""
import tempfile
from lidar_pipeline.dtm import classify_ground
@ -521,7 +521,8 @@ class TestClassifyGroundIgnClasses:
class TestPureDtm:
"""Mode pur (classification IGN) : aucune retouche, trous en nodata."""
"""The `pure` flag (IGN classification) no longer changes the DTM: both
modes fill small gaps and never use a lowest-return floor."""
def _write_las(self, path, points):
import laspy
@ -534,8 +535,8 @@ class TestPureDtm:
return path
def _make_clouds(self, tmp_output_dir):
"""Grille 2x2 (res=1.0). Sol sur 3 cellules (z=10), trou en (1,1).
Le nuage complet a un retour plus bas (z=7) dans le trou."""
"""2x2 grid (res=1.0). Ground on 3 cells (z=10), hole at (1,1).
The full cloud has a lower return (z=7) in the hole."""
corners = [(0.05, 0.05, 10.0), (1.95, 0.05, 10.0), (0.05, 1.95, 10.0)]
ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0)] + corners
source = list(ground) + [(1.5, 1.5, 7.0)]
@ -554,11 +555,10 @@ class TestPureDtm:
return src.read(1).astype("float64")
def test_pure_fills_holes_without_floor(self, tmp_output_dir):
"""pur=True : trous comblés par interpolation, sans plancher à 7.
"""pure=True: the hole is filled from its neighbours, no floor at 7.
Le comblement est actif dans tous les modes (comportement
historique) ; « pur » ne désactive que l'abaissement au retour
le plus bas.
Gap filling is active in every mode and there is no lowest-return
floor at all, so `pure` has no effect.
"""
arr = self._dtm_array(tmp_output_dir, pure=True)
assert int(np.isnan(arr).sum()) == 0
@ -566,7 +566,7 @@ class TestPureDtm:
assert vals == [10.0, 10.0, 10.0, 10.0]
def test_not_pure_fills_holes(self, tmp_output_dir):
"""pur=False : le trou est comblé (comportement historique conservé)."""
"""pure=False: the hole is filled as well (same behaviour)."""
arr = self._dtm_array(tmp_output_dir, pure=False)
assert int(np.isnan(arr).sum()) == 0
vals = sorted(float(v) for v in arr.flatten())
@ -612,7 +612,7 @@ class TestStripVerticalOffsets:
offs = _strip_vertical_offsets(x, y, z, psid)
assert abs(offs.get(1, 0.0) - 0.05) < 0.01
assert abs(offs.get(2, 0.0) + 0.05) < 0.01
assert 0 not in offs # biais ~0 < seuil : pas de correction
assert 0 not in offs # bias ~0 < threshold: no correction
def test_small_bias_below_threshold_ignored(self):
"""Biases under the 0.5 cm threshold trigger no correction."""
@ -640,10 +640,10 @@ class TestStripVerticalOffsets:
class TestStripJitterOffsets:
"""Gigue verticale intra-faisceau par fenêtres de temps GPS."""
"""Intra-strip vertical jitter over GPS time windows."""
def _synthetic(self, bias_fn, n=800_000, extent=300.0, duration=20.0, seed=0):
"""Deux faisceaux entrelacés ; le n° 1 porte un biais dépendant du temps."""
"""Two interleaved strips; strip 1 carries a time-dependent bias."""
rng = np.random.default_rng(seed)
x = rng.uniform(0, extent, n)
y = rng.uniform(0, extent, n)
@ -653,11 +653,11 @@ class TestStripJitterOffsets:
return x, y, clean + bias_fn(t, psid), psid, t, clean
def test_recovers_time_varying_offset(self):
"""Une oscillation lente ±4 cm du faisceau 1 est retirée du terrain vrai.
"""A slow ±4 cm oscillation of strip 1 is removed from the true terrain.
En recouvrement à deux, la référence hors-faisceau attribue une série
à chaque faisceau (chacun absorbe sa part) : on vérifie le résidu
contre le terrain synthétique propre, fenêtre par fenêtre.
With a two-strip overlap, the out-of-strip reference assigns a series
to each strip (each absorbs its share): the residual is checked
against the clean synthetic terrain, window by window.
"""
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
w = 2 * np.pi / 8.0
@ -669,22 +669,22 @@ class TestStripJitterOffsets:
assert np.sqrt(np.mean(resid ** 2)) < 0.012
for lo in np.arange(0, 20.0, 2.0):
m = (t >= lo) & (t < lo + 2.0)
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
assert abs(resid[m].mean()) < 0.012, f"window {lo:.0f}-{lo + 2:.0f} s"
def test_tracks_step_offset(self):
"""Un échelon −3 cm sur la seconde moitié du vol est suivi."""
"""A −3 cm step over the second half of the flight is tracked."""
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
x, y, z, psid, t, clean = self._synthetic(
lambda tt, p: np.where(tt >= 10.0, -0.03, 0.0) * (p == 1))
jitter = _strip_jitter_offsets(x, y, z, psid, t)
resid = z - _apply_strip_jitter(psid, t, jitter) - clean
assert np.sqrt(np.mean(resid ** 2)) < 0.012
for lo in (3.0, 6.0, 13.0, 16.0): # loin de la transition lissée
for lo in (3.0, 6.0, 13.0, 16.0): # away from the smoothed transition
m = (t >= lo) & (t < lo + 2.0)
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
assert abs(resid[m].mean()) < 0.012, f"window {lo:.0f}-{lo + 2:.0f} s"
def test_apply_interpolates_linearly(self):
"""Interpolation entre centres de fenêtres ; 0 hors faisceau connu."""
"""Interpolation between window centres; 0 outside known strips."""
from lidar_pipeline.dtm import _apply_strip_jitter
jitter = {7: (np.array([10.0, 11.0]), np.array([0.0, 0.1]))}
psid = np.array([7, 7, 7, 3], dtype=np.uint16)
@ -693,7 +693,7 @@ class TestStripJitterOffsets:
_apply_strip_jitter(psid, t, jitter), [0.0, 0.05, 0.1, 0.0])
def test_requires_two_sources_and_time(self):
"""Faisceau unique ou temps non fini : rien à corriger."""
"""Single strip or non-finite time: nothing to correct."""
from lidar_pipeline.dtm import _strip_jitter_offsets
x, y, z, psid, t, _clean = self._synthetic(lambda tt, p: 0.04 * np.sin(tt) * (p == 1))
assert _strip_jitter_offsets(x, y, z, np.zeros_like(psid), t) == {}
@ -713,11 +713,11 @@ class TestStripAlignSidecar:
data = json.loads((tmp_path / "TILE_dtm_r0p2_stripalign.json").read_text())
assert data["version"] == STRIP_ALIGN_VERSION
assert data["threshold"] == STRIP_ALIGN_THRESHOLD
assert data["offsets"] == {"1049": 0.026, "1147": -0.026} # clés JSON en chaînes
assert data["offsets"] == {"1049": 0.026, "1147": -0.026} # JSON keys as strings
assert data["jitter"] == {}
def test_sidecar_records_jitter_series(self, tmp_path):
"""Le sidecar consigne les séries de gigue et leurs paramètres."""
"""The sidecar records the jitter series and their parameters."""
from lidar_pipeline.dtm import (
_write_strip_align_sidecar, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
import json
@ -745,22 +745,22 @@ class TestStripAlignSidecar:
self.strip_align = strip_align
p = P(tmp_path, strip_align=True)
# DTM hérité sans sidecar : à régénérer
# Legacy DTM without a sidecar: regenerate
assert not p._strip_align_matches("TILE", "_r0p2")
# Sidecar conforme : valide
# Matching sidecar: valid
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
assert p._strip_align_matches("TILE", "_r0p2")
# Seuil différent : à régénérer
# Different threshold: regenerate
bad = p.dtm_dir / "TILE_dtm_r0p2_stripalign.json"
bad.write_text(json.dumps({"version": 1, "threshold": 0.02, "offsets": {}}))
assert not p._strip_align_matches("TILE", "_r0p2")
# Paramètres de gigue différents : à régénérer
# Different jitter parameters: regenerate
bad.write_text(json.dumps({"version": 2, "threshold": 0.005, "offsets": {},
"jitter_bin": 0.5, "jitter_smooth": 5}))
assert not p._strip_align_matches("TILE", "_r0p2")
# Calage désactivé + DTM calé : à régénérer
# Alignment disabled + aligned DTM: regenerate
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")
# Paramètres ligne à ligne différents : à régénérer
# Different line-by-line parameters: regenerate
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
data = json.loads(bad.read_text())
data["line_window"] = 99
@ -770,12 +770,12 @@ class TestStripAlignSidecar:
class TestEdgeBuffer:
"""Raccord des bords : MNT étendu par les points sol des tuiles voisines."""
"""Edge matching: DTM extended with the ground points of neighbouring tiles."""
BASENAME = "LHD_FXX_0638_6628_PTS_LAMB93_IGN69"
# Grille LHD : (col, row) = coin nord-ouest → 0638_6628 couvre
# X ∈ [638000, 639000], Y ∈ [6627000, 6628000] (bord nord = 6628 km).
NOMINAL = (638000.0, 6627000.0, 639000.0, 6628000.0) # dalle 1 km
# LHD grid: (col, row) = north-west corner → 0638_6628 covers
# X ∈ [638000, 639000], Y ∈ [6627000, 6628000] (north edge = 6628 km).
NOMINAL = (638000.0, 6627000.0, 639000.0, 6628000.0) # 1 km tile
def _write_las(self, path, points, classification=None):
import laspy
@ -790,7 +790,7 @@ class TestEdgeBuffer:
return path
def _write_clouds(self, root, res=50.0):
"""Tuile centrale à z=10 + voisine EST à z=20 (un point par maille res)."""
"""Central tile at z=10 + EAST neighbour at z=20 (one point per res cell)."""
import numpy as np
input_dir = root / "input"
input_dir.mkdir(exist_ok=True)
@ -814,7 +814,7 @@ class TestEdgeBuffer:
present = [(637, 6627), (639, 6629), (638, 6629)]
for c, r in present:
(tmp_output_dir / f"LHD_FXX_{c}_{r}_PTS_LAMB93_IGN69.copc.laz").touch()
# Bruit non voisin : jamais retenu
# Non-neighbour noise: never picked
(tmp_output_dir / "LHD_FXX_0650_6700_PTS_LAMB93_IGN69.copc.laz").touch()
found = _neighbor_laz_files(tmp_output_dir / f"{self.BASENAME}.copc.laz")
assert {f.name for f in found} == {
@ -827,16 +827,16 @@ class TestEdgeBuffer:
assert _neighbor_laz_files(src) == []
def test_neighbor_found_in_edge_subdir(self, tmp_output_dir):
"""Une voisine isolée dans edge_neighbors/ est trouvée ; la priorité
reste à une dalle à plat dans input/."""
"""A neighbour kept in edge_neighbors/ is found; a tile directly in
input/ still takes priority."""
from lidar_pipeline.dtm import _neighbor_laz_files, EDGE_NEIGHBORS_DIRNAME
base = "LHD_FXX_0637_6627_PTS_LAMB93_IGN69"
(tmp_output_dir / f"{base}.copc.laz").touch()
edge = tmp_output_dir / EDGE_NEIGHBORS_DIRNAME
edge.mkdir()
# Voisine uniquement dans le sous-dossier de raccord
# Neighbour only in the edge-matching subfolder
(edge / "LHD_FXX_0638_6628_PTS_LAMB93_IGN69.copc.laz").touch()
# Voisine présente aux deux endroits : la version input/ gagne
# Neighbour present in both places: the input/ copy wins
(edge / "LHD_FXX_0636_6626_PTS_LAMB93_IGN69.copc.laz").touch()
(tmp_output_dir / "LHD_FXX_0636_6626_PTS_LAMB93_IGN69.copc.laz").touch()
found = _neighbor_laz_files(tmp_output_dir / f"{base}.copc.laz")
@ -845,7 +845,7 @@ class TestEdgeBuffer:
assert by_name["LHD_FXX_0636_6626_PTS_LAMB93_IGN69.copc.laz"].parent == tmp_output_dir
def test_buffered_dtm_extends_into_neighbor(self, tmp_output_dir):
"""MNT 24x24 (dalle 20x20 + bande 100 m), bande EST remplie à z=20 par la voisine."""
"""24x24 DTM (20x20 tile + 100 m band), EAST band filled at z=20 by the neighbour."""
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer, EDGE_BUFFER_TAG
import rasterio
ground, source, _ = self._write_clouds(tmp_output_dir)
@ -859,9 +859,9 @@ class TestEdgeBuffer:
assert abs(src.bounds.top - 6628100.0) < 1e-6
assert src.tags().get(EDGE_BUFFER_TAG) == "100"
arr = src.read(1)
assert arr[12, 12] == 10.0 # cœur : tuile centrale
assert arr[12, 23] == 20.0 # bande EST : points de la voisine
assert np.isnan(arr[0, 0]) # bande OUEST sans voisine : vide
assert arr[12, 12] == 10.0 # core: central tile
assert arr[12, 23] == 20.0 # EAST band: neighbour points
assert np.isnan(arr[0, 0]) # WEST band without neighbour: empty
assert read_dtm_edge_buffer(dtm) == 100.0
def test_unbuffered_dtm_has_no_tag(self, tmp_output_dir):
@ -876,7 +876,7 @@ class TestEdgeBuffer:
assert read_dtm_edge_buffer(dtm) == 0.0
def test_buffered_dtm_non_lhd_falls_back(self, tmp_output_dir):
"""Nom hors pattern LHD : pas de tuile nominale, bornes d'en-tête conservées."""
"""Non-LHD name: no nominal tile, header bounds kept."""
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer
import rasterio
ground = self._write_las(tmp_output_dir / "ground.las",
@ -888,20 +888,20 @@ class TestEdgeBuffer:
edge_buffer=100.0)
assert dtm is not None
with rasterio.open(str(dtm)) as src:
assert abs(src.bounds.left - 0.05) < 1e-6 # bornes de l'en-tête
assert abs(src.bounds.left - 0.05) < 1e-6 # header bounds
assert read_dtm_edge_buffer(dtm) == 0.0
def _synthetic_beam(n_lines=240, spacing=0.4, seed=0, offsets=None, tilts=None):
"""Faisceau synthétique : lignes de balayage (dents de scie de scan_angle)
sur un terrain en pente traversé par un fossé ; offsets verticaux par ligne."""
"""Synthetic strip: scan lines (scan_angle sawtooth) over sloping terrain
crossed by a ditch; vertical offsets per line."""
rng = np.random.default_rng(seed)
xs, ys, zs, ts, angs, ids = [], [], [], [], [], []
x_line = np.arange(0, 100, 0.12)
for k in range(n_lines):
y = k * spacing + rng.normal(0, 0.02, x_line.size)
z = 100 + 0.03 * x_line + 0.05 * y
z = z - 0.5 * (np.abs(x_line - 41) < 1.0) # fossé perpendiculaire aux lignes
z = z - 0.5 * (np.abs(x_line - 41) < 1.0) # ditch perpendicular to the lines
u = np.linspace(-1, 1, x_line.size)
z = z + offsets[k] + (0 if tilts is None else tilts[k]) * u + rng.normal(0, 0.01, x_line.size)
xs.append(x_line); ys.append(y); zs.append(z); ids.append(np.full(x_line.size, k))
@ -912,16 +912,16 @@ def _synthetic_beam(n_lines=240, spacing=0.4, seed=0, offsets=None, tilts=None):
class TestScanLineAlignment:
"""3ᵉ passe du calage : décalage vertical entre lignes de balayage successives."""
"""3rd alignment pass: vertical offset between successive scan lines."""
def test_line_ids_follow_sawtooth_not_vegetation_gaps(self):
from lidar_pipeline.dtm import _scan_line_ids
t = np.arange(50) * 0.0001
ang = np.tile(np.linspace(-3000, 3000, 10), 5)
keep = np.ones(50, bool); keep[13:17] = False # trou de végétation dans la ligne 2
keep = np.ones(50, bool); keep[13:17] = False # vegetation hole in line 2
ids = _scan_line_ids(t[keep], ang[keep])
assert ids.max() == 4
t2 = t.copy(); t2[30:] += 1.0 # fin de passe : nouvelle ligne
t2 = t.copy(); t2[30:] += 1.0 # end of pass: new line
assert _scan_line_ids(t2, np.zeros(50)).max() == 1
def test_group_median_matches_numpy(self):
@ -951,7 +951,7 @@ class TestScanLineAlignment:
assert depth(z - corr) == pytest.approx(depth(z), abs=0.01)
def test_removes_alternating_line_tilts(self):
"""Roulis : lignes basculées alternativement (un bout haut, l'autre bas)."""
"""Roll: lines tilted alternately (one end high, the other low)."""
from scipy.ndimage import gaussian_filter1d
from lidar_pipeline.dtm import _scan_line_corrections_beam
n = 240
@ -964,15 +964,15 @@ class TestScanLineAlignment:
assert after < 0.2 * before, f"{before*1000:.1f} → {after*1000:.1f} mm"
def test_joint_adjustment_fixes_all_scales_against_other_beam(self):
"""Deux faisceaux superposés : le faisceau fautif (dérive lente, roulis,
ligne isolée à −8 cm) est recalé sur l'autre à toutes les échelles,
sans dérive de l'altitude d'ensemble."""
"""Two overlapping strips: the faulty strip (slow drift, roll, isolated
line at −8 cm) is re-aligned on the other at every scale, with no
drift of the overall elevation."""
from lidar_pipeline.dtm import _joint_line_corrections
n = 240
k = np.arange(n)
bad_off = 0.03 * np.sin(2 * np.pi * k / 120) # dérive lente (non vue sur sa propre surface)
bad_off[100] -= 0.08 # ligne isolée très décalée
bad_tilt = 0.02 * np.cos(2 * np.pi * k / 60) # roulis lent
bad_off = 0.03 * np.sin(2 * np.pi * k / 120) # slow drift (not visible on its own surface)
bad_off[100] -= 0.08 # isolated, strongly shifted line
bad_tilt = 0.02 * np.cos(2 * np.pi * k / 60) # slow roll
xa, ya, za, ta, aa, _ = _synthetic_beam(n, seed=1, offsets=bad_off, tilts=bad_tilt)
xb, yb, zb, tb, ab, _ = _synthetic_beam(n, seed=2, offsets=np.zeros(n))
tb = tb + 1000.0
@ -981,22 +981,22 @@ class TestScanLineAlignment:
corr, gl, touched, iters = _joint_line_corrections(x, y, z, psid, t, ang)
truth = np.r_[bad_off[np.repeat(k, len(za) // n)] + bad_tilt[np.repeat(k, len(za) // n)]
* np.tile(np.linspace(-1, 1, len(za) // n), n), np.zeros(len(zb))]
# Sans vérité terrain, l'écart est partagé entre les faisceaux : c'est
# l'écart ENTRE faisceaux (points homologues, même géométrie) qui doit
# disparaître.
# Without ground truth, the error is shared between the strips: it is
# the offset BETWEEN strips (homologous points, same geometry) that
# must vanish.
na = len(za)
before = truth[:na] - truth[:na].mean()
rel = (truth[:na] - corr[:na]) - (0.0 - corr[na:])
after = rel - rel.mean()
assert np.std(after) < 0.25 * np.std(before), \
f"{np.std(before)*1000:.1f} → {np.std(after)*1000:.1f} mm"
assert abs(np.mean(corr)) < 0.002 # pas de dérive d'ensemble
assert abs(np.mean(corr)) < 0.002 # no overall drift
assert touched.mean() > 0.9 and iters <= 8
def test_joint_adjustment_removes_static_angle_profile(self):
"""Étalonnage en arc selon l'angle (même pour toutes les lignes) :
non linéaire, invisible pour décalage + inclinaison, retiré par le
profil par faisceau et classe d'angle."""
"""Arc-shaped calibration error by angle (same for every line):
non-linear, invisible to offset + tilt, removed by the per-strip,
per-angle-bin profile."""
from lidar_pipeline.dtm import _joint_line_corrections
n = 240
xa, ya, za, ta, aa, _ = _synthetic_beam(n, seed=5, offsets=np.zeros(n))
@ -1020,7 +1020,7 @@ class TestScanLineAlignment:
class TestIgnDirectExtraction:
"""Classification IGN : extraction directe par laspy (PDAL en secours)."""
"""IGN classification: direct extraction with laspy (PDAL as fallback)."""
def test_keeps_requested_classes_and_valid_returns(self, tmp_path):
import laspy

View File

@ -1,4 +1,4 @@
"""Tests de l'export PDF (planche d'impression terrain)."""
"""Tests for the PDF export (printable field sheet)."""
import pytest
@ -8,7 +8,7 @@ def test_layout_landscape_a4_dimensions():
lay = layout("A4", "paysage", 2000)
assert (lay.page_w, lay.page_h) == (297.0, 210.0) and lay.dpi == 300
assert lay.map_w > 150 and lay.map_h > 150
assert lay.panel_x >= lay.map_x + lay.map_w # bandeau à droite
assert lay.panel_x >= lay.map_x + lay.map_w # panel on the right
assert lay.map_x + lay.map_w <= lay.page_w and lay.map_y + lay.map_h <= lay.page_h
@ -16,7 +16,7 @@ def test_layout_portrait_a3_panel_below():
from lidar_pipeline.export_pdf import layout
lay = layout("A3", "portrait", 5000)
assert (lay.page_w, lay.page_h) == (297.0, 420.0) and lay.dpi == 250
assert lay.panel_y + lay.panel_h <= lay.map_y # bandeau en bas
assert lay.panel_y + lay.panel_h <= lay.map_y # panel at the bottom
@pytest.mark.parametrize("args", [("A5", "paysage", 2000), ("A4", "biais", 2000),
@ -38,7 +38,7 @@ def test_map_bbox_matches_paper_times_scale():
def test_pixel_size_and_grid_step():
from lidar_pipeline.export_pdf import grid_step, layout, pixel_size
assert pixel_size(layout("A4", "paysage", 1000)) == 0.2 # plafonné au natif
assert pixel_size(layout("A4", "paysage", 1000)) == 0.2 # capped at the native resolution
assert abs(pixel_size(layout("A3", "paysage", 10000)) - 10000 * 0.0254 / 250) < 1e-9
assert [grid_step(s) for s in (1000, 2000, 5000, 10000)] == [100, 100, 500, 1000]
@ -57,7 +57,7 @@ def test_frame_roundtrip():
assert abs(f["cx"] - cx) < 1e-6 and abs(f["cy"] - cy) < 1e-6
assert len(f["corners"]) == 4
nw, ne, se, sw = f["corners"]
assert nw[0] > sw[0] and ne[1] > nw[1] # [lat, lon] : nord en haut, est à droite
assert nw[0] > sw[0] and ne[1] > nw[1] # [lat, lon]: north at the top, east on the right
assert f["width_m"] > f["height_m"] > 0
@ -99,11 +99,11 @@ def test_compose_l93_half_outside_is_white_hatched(tmp_path, monkeypatch):
from lidar_pipeline.export_pdf import compose_l93
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882)
# moitié ouest dans la dalle, moitié est hors données
# western half inside the tile, eastern half outside the data
img, mask, cells = compose_l93(tmp_path, (1054900.0, 6881400.0, 1055100.0, 6881500.0), 1.0)
assert mask.getpixel((50, 50)) == 255 and mask.getpixel((150, 50)) == 0
east = img.crop((110, 0, 200, 100)).convert("L").getextrema()
assert east[1] == 255 and east[0] < 255 # blanc + hachures
assert east[1] == 255 and east[0] < 255 # white + hatching
def test_compose_l93_nodata_pixels_masked(tmp_path, monkeypatch):
@ -145,12 +145,13 @@ def test_density_color_classes():
def test_pdf_text_replaces_unencodable():
from lidar_pipeline.export_pdf import _pdf_text
assert _pdf_text("Relief orienté — 1:2 000 ≥ 🚀") == "Relief orienté — 1:2 000 ? ?"
# é and — exist in cp1252; ≥ and the emoji do not
assert _pdf_text("Café — 1:2,000 ≥ 🚀") == "Café — 1:2,000 ? ?"
def test_pdf_legend_uses_reading_text(monkeypatch):
"""La légende de la planche PDF affiche le texte « Comment lire » de
VIZ_LEGENDS, habillé à la largeur de la boîte, plutôt que « legend »."""
"""The PDF sheet legend shows the "How to read" text of VIZ_LEGENDS,
wrapped to the box width, rather than "legend"."""
from lidar_pipeline import export_pdf
from lidar_pipeline.index import VIZ_LEGENDS
drawn = []
@ -177,13 +178,13 @@ def test_zone_quality_aggregates_two_cells():
from lidar_pipeline.export_pdf import zone_quality
table = {"LHD_FXX_1054_6882_PTS_LAMB93_IGN69": _q(4.0, "2023-03-15", "2023-03-15"),
"LHD_FXX_1055_6882_PTS_LAMB93_IGN69": _q(8.0, "2023-04-02", "2023-04-03", 0.3)}
bbox = (1054500.0, 6881500.0, 1055500.0, 6881600.0) # moitié de chaque dalle
bbox = (1054500.0, 6881500.0, 1055500.0, 6881600.0) # half of each tile
z = zone_quality(bbox, table, [(1054, 6882), (1055, 6882)])
assert abs(z["density_mean"] - 6.0) < 1e-6 and z["density_min"] == 4.0
assert abs(z["empty_fraction"] - 0.2) < 1e-6
assert (z["acq_start"], z["acq_end"]) == ("2023-03-15", "2023-04-03")
assert z["missing_relief"] == [] and z["missing_quality"] == []
assert len(z["grid_cells"]) == 2 * 10 * 2 # 10 mailles en x × 2 en y par dalle
assert len(z["grid_cells"]) == 2 * 10 * 2 # 10 cells in x × 2 in y per tile
def test_zone_quality_missing_cells():
@ -216,12 +217,13 @@ def test_build_pdf_a4_landscape(tmp_path, monkeypatch):
write_quality(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", _q(6.5))
lat, lon = _cell_center_wgs84(1054, 6882)
pdf, name = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000,
title="Prospection 🚀 bois", compress=False)
title="Survey 🚀 woods", compress=False)
assert pdf.startswith(b"%PDF")
w, h = _mediabox(pdf)
assert abs(w - 841.89) < 0.5 and abs(h - 595.28) < 0.5
assert name.startswith("relief_1054.") and name.endswith("_1-2000.pdf")
for s in (b"Prospection ? bois", b"1:2 000", b"Densit", b"2023-03-15", b"LiDAR HD", b"6,5 pts"):
for s in (b"Survey ? woods", b"1:2,000", b"ground density", b"2023-03-15", b"LiDAR HD",
b"6.5 pts", b"landscape"):
assert s in pdf, s
@ -237,7 +239,7 @@ def test_build_pdf_a3_portrait_size(tmp_path, monkeypatch):
def test_build_pdf_partial_zone_hatched(tmp_path, monkeypatch):
"""Zone à cheval sur le bord des données : planche produite, dalle absente listée."""
"""Area straddling the data edge: sheet produced, missing tile listed."""
from lidar_pipeline import index
from lidar_pipeline.export_pdf import build_pdf
from lidar_pipeline.tiles import _transformer
@ -246,7 +248,7 @@ def test_build_pdf_partial_zone_hatched(tmp_path, monkeypatch):
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1055000.0, 6881500.0)
pdf, _ = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000, compress=False)
assert pdf.startswith(b"%PDF") and b"1055_6882" in pdf
assert b"Donn" in pdf and b"manquante" in pdf
assert b"Missing data" in pdf and b"no relief" in pdf # "(" is escaped in PDF strings
def test_build_pdf_no_data_raises(tmp_path):
@ -258,13 +260,13 @@ def test_build_pdf_no_data_raises(tmp_path):
def test_fmt_int():
from lidar_pipeline.export_pdf import _fmt_int
assert _fmt_int(2000) == "2 000" and _fmt_int(500) == "500"
assert _fmt_int(2000) == "2,000" and _fmt_int(500) == "500"
def test_north_arrow_angle_matches_pyproj_reference():
"""L'angle de rotation reportlab doit amener la flèche (dessinée vers le
nord du quadrillage) sur le nord géographique, dans le bon sens, à l'est
et à l'ouest du méridien central (3°E)."""
"""The reportlab rotation angle must bring the arrow (drawn towards grid
north) onto true north, in the right direction, east and west of the
central meridian (3°E)."""
import math
from lidar_pipeline.export_pdf import _north_arrow_angle, _to_wgs84
from lidar_pipeline.tiles import _transformer, wgs84_to_l93
@ -275,12 +277,12 @@ def test_north_arrow_angle_matches_pyproj_reference():
lat0, lon0 = _to_wgs84(cx, cy)
x0, y0 = to_l93.transform(lon0, lat0)
x1, y1 = to_l93.transform(lon0, lat0 + 0.001)
# azimut (sens horaire depuis le nord du quadrillage) du nord géographique
# azimuth of true north (clockwise from grid north)
psi = math.degrees(math.atan2(x1 - x0, y1 - y0))
return -psi # rotation reportlab (antihoraire) amenant "haut" sur ce nord
return -psi # reportlab rotation (counter-clockwise) bringing "up" onto that north
east = wgs84_to_l93(6.0, 46.0) # à l'est du méridien central
west = wgs84_to_l93(0.0, 46.0) # à l'ouest du méridien central
east = wgs84_to_l93(6.0, 46.0) # east of the central meridian
west = wgs84_to_l93(0.0, 46.0) # west of the central meridian
for cx, cy in (east, west):
assert abs(_north_arrow_angle(cx, cy) - _reference_angle(cx, cy)) < 0.05
assert _north_arrow_angle(*east) > 0
@ -293,7 +295,7 @@ def test_wrap_text_lines_fit_width():
from io import BytesIO
from lidar_pipeline.export_pdf import _wrap_text
c = rl_canvas.Canvas(BytesIO())
text = "Donnée manquante (sans relief) : " + ", ".join(
text = "Missing data (no relief): " + ", ".join(
f"{1050 + i}_6882" for i in range(12))
max_width = 60 * mm
lines = _wrap_text(c, text, "Helvetica", 5.8, max_width, sep=", ")
@ -308,22 +310,22 @@ def test_fit_title_shrinks_then_elides():
from io import BytesIO
from lidar_pipeline.export_pdf import _fit_title
c = rl_canvas.Canvas(BytesIO())
long_title = "Relief orienté - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
long_title = "Oriented relief - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
text, size = _fit_title(c, long_title, "Helvetica-Bold", 10, 60 * mm, min_size=7.0)
assert size >= 7.0
assert c.stringWidth(text, "Helvetica-Bold", size) <= 60 * mm + 1e-6
def test_cartouche_title_avoids_north_arrow_column():
"""Le titre (et le sous-titre) de la cartouche ne doit jamais empiéter sur
la colonne réservée à la flèche du nord, en paysage comme en portrait."""
"""The title block's title (and subtitle) must never encroach on the
column reserved for the north arrow, in landscape as in portrait."""
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.units import mm
from io import BytesIO
from lidar_pipeline.export_pdf import (
_cartouche_text_max_width, _fit_title, _panel_boxes, layout)
c = rl_canvas.Canvas(BytesIO())
long_title = "Relief orienté - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
long_title = "Oriented relief - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
for paper, orient, scale in (("A4", "paysage", 2000), ("A4", "portrait", 10000),
("A3", "portrait", 2000)):
lay = layout(paper, orient, scale)
@ -332,5 +334,5 @@ def test_cartouche_title_avoids_north_arrow_column():
max_w = _cartouche_text_max_width(w, mm)
txt, size = _fit_title(c, long_title, "Helvetica-Bold", 10, max_w)
title_right_mm = x + c.stringWidth(txt, "Helvetica-Bold", size) / mm
arrow_left_mm = (x + w - 8) - 1.8 # bord gauche du triangle de la flèche
arrow_left_mm = (x + w - 8) - 1.8 # left edge of the arrow triangle
assert title_right_mm <= arrow_left_mm, (paper, orient, scale)

View File

@ -1,15 +1,15 @@
"""Tests du téléchargement des dalles LiDAR HD de l'IGN (fetch_ign)."""
"""Tests for IGN LiDAR HD tile downloads (fetch_ign)."""
def test_parse_tile_specs():
"""Accepte 'col,row', 'col:row' et ignore les espaces."""
"""Accepts 'col,row', 'col:row' and ignores whitespace."""
from lidar_pipeline.fetch_ign import parse_tile_specs
assert parse_tile_specs(["1055,6882"]) == [(1055, 6882)]
assert parse_tile_specs(["1055:6883", " 651 , 6630 "]) == [(1055, 6883), (651, 6630)]
def test_parse_tile_specs_rejects_invalid():
"""Les spécifications mal formées lèvent une erreur explicite."""
"""Malformed specifications raise an explicit error."""
import pytest
from lidar_pipeline.fetch_ign import parse_tile_specs
with pytest.raises(ValueError):
@ -21,14 +21,14 @@ def test_parse_tile_specs_rejects_invalid():
def test_tile_filename_pads_coordinates():
"""Le nom de fichier DALLE utilise des coordonnées à 4 chiffres."""
"""The tile file name uses 4-digit coordinates."""
from lidar_pipeline.fetch_ign import tile_filename
assert tile_filename(1055, 6882) == "LHD_FXX_1055_6882_PTS_LAMB93_IGN69.copc.laz"
assert tile_filename(651, 6630) == "LHD_FXX_0651_6630_PTS_LAMB93_IGN69.copc.laz"
def test_match_feature_uses_coordonnees_nw():
"""La correspondance se fait sur lidarhd:coordonnees_NW (format 0816-6847)."""
"""Matching uses lidarhd:coordonnees_NW (0816-6847 format)."""
from lidar_pipeline.fetch_ign import match_feature
features = [
{"id": "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_NE",
@ -43,7 +43,7 @@ def test_match_feature_uses_coordonnees_nw():
def test_find_tile_url_matches_and_returns_href(monkeypatch):
"""find_tile_url interroge le STAC et retourne l'href de l'asset data."""
"""find_tile_url queries the STAC API and returns the data asset href."""
from lidar_pipeline import fetch_ign
class FakeResponse:
@ -75,7 +75,7 @@ def test_find_tile_url_matches_and_returns_href(monkeypatch):
def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
"""Pas de téléchargement si le LAZ existe ou si les visualisations existent."""
"""No download if the LAZ exists or if visualisations exist."""
from lidar_pipeline import fetch_ign
input_dir = tmp_path / "input"
@ -84,13 +84,13 @@ def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
vis_dir = output_dir / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
vis_dir.mkdir(parents=True)
# 1054,6882 : visualisations déjà générées
# 1055,6882 : LAZ déjà présent
# 1054,6882: visualisations already generated
# 1055,6882: LAZ already present
existing = input_dir / fetch_ign.tile_filename(1055, 6882)
existing.write_bytes(b"naze")
def fail_download(*args, **kwargs):
raise AssertionError("ne doit pas être appelé")
raise AssertionError("must not be called")
monkeypatch.setattr(fetch_ign, "find_tile_url", fail_download)
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
@ -99,10 +99,10 @@ def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
def _make_viz_dirs(output_dir, col, row, viz_keys, resolutions=(0.5, 0.2)):
"""Crée les dossiers de visualisations d'une tuile (une entrée par résolution).
"""Create a tile's visualisation directories (one per resolution).
Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent
le basename nu (cf. _expected_output_path dans le pipeline).
The resolution suffix is only on the DIRECTORY: files keep the bare
basename (see _expected_output_path in the pipeline).
"""
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
for res in resolutions:
@ -114,21 +114,21 @@ def _make_viz_dirs(output_dir, col, row, viz_keys, resolutions=(0.5, 0.2)):
def test_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch):
"""Une tuile incomplète (visualisation manquante) est téléchargée, une complète non."""
"""An incomplete tile (missing visualisation) is downloaded, a complete one is not."""
from pathlib import Path
from lidar_pipeline import fetch_ign
input_dir = tmp_path / "input"
input_dir.mkdir()
output_dir = tmp_path / "output"
_make_viz_dirs(output_dir, 1054, 6882, ["aspect"]) # slope manquant
_make_viz_dirs(output_dir, 1055, 6882, ["aspect", "slope"]) # complète
_make_viz_dirs(output_dir, 1054, 6882, ["aspect"]) # slope missing
_make_viz_dirs(output_dir, 1055, 6882, ["aspect", "slope"]) # complete
calls = []
def fake_find_tile_url(col, row, timeout=20):
calls.append((col, row))
return "https://example.org/dalle.copc.laz"
return "https://example.org/tile.copc.laz"
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
Path(dest).write_bytes(b"laz")
@ -141,13 +141,13 @@ def test_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch):
output_dir=output_dir,
only_viz=["aspect", "slope"],
resolutions=(0.5, 0.2))
# Seule la tuile incomplète est retéléchargée
# Only the incomplete tile is downloaded again
assert calls == [(1054, 6882)]
assert [p.name for p in result] == [fetch_ign.tile_filename(1054, 6882)]
def test_fetch_tiles_promotes_edge_neighbor_copy(tmp_path, monkeypatch):
"""Une dalle déjà téléchargée comme voisine est déplacée dans input/ sans réseau."""
"""A tile already downloaded as a neighbour is moved to input/ without network access."""
from lidar_pipeline import fetch_ign
input_dir = tmp_path / "input"
@ -159,7 +159,7 @@ def test_fetch_tiles_promotes_edge_neighbor_copy(tmp_path, monkeypatch):
neighbor_copy.write_bytes(b"laz-voisine")
def fail(*args, **kwargs):
raise AssertionError("ne doit pas être appelé (pas de réseau attendu)")
raise AssertionError("must not be called (no network expected)")
monkeypatch.setattr(fetch_ign, "find_tile_url", fail)
monkeypatch.setattr(fetch_ign, "download_file", fail)
@ -168,27 +168,27 @@ def test_fetch_tiles_promotes_edge_neighbor_copy(tmp_path, monkeypatch):
assert not neighbor_copy.exists()
assert (input_dir / name).read_bytes() == b"laz-voisine"
assert result == [] # promotion = "skip", comme le cas déjà-présent
assert result == [] # promotion = "skip", like the already-present case
def test_fetch_tiles_does_not_promote_when_called_on_edge_dir(tmp_path, monkeypatch):
"""Appel avec input_dir=edge_dir (cas _fetch_edge_neighbors) : jamais de promotion."""
"""Call with input_dir=edge_dir (_fetch_edge_neighbors case): never promotes."""
from lidar_pipeline import fetch_ign
edge_dir = tmp_path / "input" / "edge_neighbors"
edge_dir.mkdir(parents=True)
inner_edge = edge_dir / "edge_neighbors"
name = fetch_ign.tile_filename(1055, 6882)
# Même si un dossier edge_neighbors/edge_neighbors/<name> existait par
# accident, il ne doit jamais être déplacé vers edge_dir/<name>.
# Even if an edge_neighbors/edge_neighbors/<name> directory existed by
# accident, it must never be moved to edge_dir/<name>.
inner_edge.mkdir()
(inner_edge / name).write_bytes(b"ne-doit-pas-bouger")
(inner_edge / name).write_bytes(b"must-not-move")
calls = []
def fake_find_tile_url(col, row, timeout=20):
calls.append((col, row))
return "https://example.org/dalle.copc.laz"
return "https://example.org/tile.copc.laz"
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
from pathlib import Path
@ -200,14 +200,14 @@ def test_fetch_tiles_does_not_promote_when_called_on_edge_dir(tmp_path, monkeypa
result = fetch_ign.fetch_tiles(edge_dir, [(1055, 6882)])
assert calls == [(1055, 6882)] # téléchargée normalement, pas promue
assert (inner_edge / name).read_bytes() == b"ne-doit-pas-bouger"
assert calls == [(1055, 6882)] # downloaded normally, not promoted
assert (inner_edge / name).read_bytes() == b"must-not-move"
assert (edge_dir / name).read_bytes() == b"laz"
assert len(result) == 1
def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch):
"""force=True télécharge même une tuile complète (régénération avec --force)."""
"""force=True downloads even a complete tile (regeneration with --force)."""
from pathlib import Path
from lidar_pipeline import fetch_ign
@ -220,7 +220,7 @@ def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch):
def fake_find_tile_url(col, row, timeout=20):
calls.append((col, row))
return "https://example.org/dalle.copc.laz"
return "https://example.org/tile.copc.laz"
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
Path(dest).write_bytes(b"laz")

View File

@ -94,7 +94,7 @@ def test_to_gpu_roundtrip():
# ---------------------------------------------------------------------------
# bin_mean_2d — rasterisation MNT (moyenne z par cellule)
# bin_mean_2d — DTM rasterization (mean z per cell)
# ---------------------------------------------------------------------------
def _scipy_reference(xs, ys, zs, width, height, x_range, y_range):
@ -107,7 +107,7 @@ def _scipy_reference(xs, ys, zs, width, height, x_range, y_range):
def test_bin_mean_core_parity_scipy():
"""Le cœur numpy de bin_mean_2d reproduit binned_statistic_2d (mean)."""
"""The numpy core of bin_mean_2d reproduces binned_statistic_2d (mean)."""
from lidar_pipeline.gpu import _bin_mean_core
rng = np.random.default_rng(42)
x_range, y_range = (1000.0, 1010.0), (6800.0, 6808.0)
@ -116,9 +116,9 @@ def test_bin_mean_core_parity_scipy():
xs = rng.uniform(*x_range, n)
ys = rng.uniform(*y_range, n)
zs = rng.uniform(50.0, 150.0, n)
# Cas limites : bord gauche/bas (inclus), bord droit/haut (inclus dans la
# dernière cellule), hors emprise (ignorés), points exactement sur une
# arête intérieure
# Edge cases: left/bottom edge (included), right/top edge (included in the
# last cell), outside the extent (ignored), points exactly on an interior
# cell boundary
xs = np.concatenate([xs, [1000.0, 1010.0, 999.9, 1010.1, 1002.5]])
ys = np.concatenate([ys, [6800.0, 6808.0, 6808.1, 6807.9, 6804.0]])
zs = np.concatenate([zs, [99.0, 101.0, 777.0, 777.0, 103.0]])
@ -129,25 +129,25 @@ def test_bin_mean_core_parity_scipy():
def test_bin_mean_core_empty_and_single():
"""Cellules vides → NaN ; un seul point → sa valeur partout où présent."""
"""Empty cells → NaN; a single point → its value in its own cell."""
from lidar_pipeline.gpu import _bin_mean_core
x_range, y_range = (0.0, 10.0), (0.0, 10.0)
# Aucun point dans l'emprise
# No point inside the extent
out = _bin_mean_core(np, [-5.0], [-5.0], [1.0], 5, 5, x_range, y_range)
assert out.shape == (5, 5)
assert np.isnan(out).all()
# Un point au centre exact : cellule (2, 2)
# One point exactly at the center: cell (2, 2)
out = _bin_mean_core(np, [5.0], [5.0], [7.5], 5, 5, x_range, y_range)
assert out[2, 2] == 7.5
assert np.isnan(out).sum() == 24
# Cellule avec plusieurs points : moyenne exacte
# Cell with several points: exact mean
out = _bin_mean_core(np, [5.0, 5.1, 5.2], [5.0, 5.0, 5.0],
[10.0, 20.0, 30.0], 5, 5, x_range, y_range)
assert out[2, 2] == 20.0
def test_bin_mean_2d_gpu_or_none():
"""bin_mean_2d : None sans GPU, sinon sortie identique au cœur numpy."""
"""bin_mean_2d: None without a GPU, otherwise output identical to the numpy core."""
from lidar_pipeline.gpu import bin_mean_2d, _bin_mean_core
rng = np.random.default_rng(7)
x_range, y_range = (1000.0, 1200.0), (6800.0, 7000.0)
@ -159,24 +159,24 @@ def test_bin_mean_2d_gpu_or_none():
got = bin_mean_2d(xs, ys, zs, w, h, x_range, y_range)
ref = _bin_mean_core(np, xs, ys, zs, w, h, x_range, y_range)
if got is None:
assert isinstance(ref, np.ndarray) # repli scipy assuré par l'appelant
assert isinstance(ref, np.ndarray) # the caller provides the scipy fallback
else:
assert isinstance(got, np.ndarray)
np.testing.assert_allclose(got, ref, equal_nan=True, rtol=1e-9)
def test_safe_gpu_call_retries_on_cpu_for_any_error(monkeypatch):
"""GPU actif : TOUTE erreur (pas seulement les messages CUDA) désactive
le GPU et retranche le calcul en CPU.
"""GPU active: ANY error (not only CUDA messages) disables the GPU and
retries the computation on CPU.
Cas réel : après un échec de transfert GPU, des types numpy/cupy mêlés
(« Unsupported type <class 'numpy.ndarray'> ») ne contenaient aucun
mot-clé CUDA et propageaient l'erreur — la visualisation entière
échouait alors pour rien.
Real case: after a GPU transfer failure, mixed numpy/cupy types
("Unsupported type <class 'numpy.ndarray'>") contained no CUDA keyword
and propagated the error — the whole visualization then failed for
nothing.
"""
from lidar_pipeline import gpu
class _FakeCP: # GPU « actif » sans CuPy
class _FakeCP: # "active" GPU without CuPy
pass
monkeypatch.setattr(gpu, "_cp", _FakeCP())
@ -190,23 +190,23 @@ def test_safe_gpu_call_retries_on_cpu_for_any_error(monkeypatch):
return x + 1
assert gpu.safe_gpu_call(f, 21) == 22
assert gpu.HAS_GPU is False # GPU désactivé après l'erreur
assert gpu.HAS_GPU is False # GPU disabled after the error
assert gpu._cp is None
# Mode CPU : l'erreur se relance telle quelle (rien à retrancher)
# CPU mode: the error is re-raised as is (nothing to retry)
def g(x):
raise ValueError("boom")
try:
gpu.safe_gpu_call(g, 1)
raise AssertionError("devait relancer l'erreur")
raise AssertionError("should have re-raised the error")
except ValueError:
pass
def test_gpu_worker_slots_bounded_by_free_vram(monkeypatch):
"""Places GPU par la VRAM libre : l'excédent de workers passe en CPU.
"""GPU slots bounded by free VRAM: surplus workers run on CPU.
Cas réel : LIDAR_WORKERS=auto = 12 workers sur 2 RTX 5060 (8 Go) —
6 workers par GPU, soit bien plus que la VRAM libre ne tient au pic
(calage des lignes + comblement GPU) : OOM.
Real case: LIDAR_WORKERS=auto = 12 workers on 2 RTX 5060 (8 GB) —
6 workers per GPU, far more than free VRAM holds at peak (scan-line
alignment + GPU gap filling): OOM.
"""
from lidar_pipeline import gpu
monkeypatch.setattr(gpu, "GPU_WORKER_MIB", 2000)
@ -216,24 +216,24 @@ def test_gpu_worker_slots_bounded_by_free_vram(monkeypatch):
assert len(slots) == 12
assert slots.count(0) == 3 # (7500 - 500) // 2000
assert slots.count(1) == 2 # (4600 - 500) // 2000
assert slots.count(-1) == 7 # reste en CPU
# GPU d'abord, entrelacés : les premiers workers créés se répartissent
assert slots.count(-1) == 7 # the rest on CPU
# GPUs first, interleaved: the first workers created are spread out
assert slots[:4] == [0, 1, 0, 1]
# Moins de workers que de places : aucun CPU forcé
# Fewer workers than slots: no forced CPU
assert gpu.gpu_worker_slots([0, 1], 3, free_mib=free) == [0, 1, 0]
def test_gpu_worker_slots_without_gpu_or_vram_info():
"""Sans GPU : tout en CPU implicite (None). VRAM inconnue : round-robin
historique (pas de bornage sans mesure)."""
"""No GPU: implicit CPU everywhere (None). Unknown VRAM: legacy
round-robin (no bound without a measurement)."""
from lidar_pipeline import gpu
assert gpu.gpu_worker_slots([], 4, free_mib={}) == [None] * 4
assert gpu.gpu_worker_slots([0, 1], 4, free_mib={}) == [0, 1, 0, 1]
def test_gpu_worker_slots_keeps_one_gpu_worker_when_tight(monkeypatch):
"""GPU presque plein : au moins une place pour que le GPU serve encore
(le repli CPU de safe_gpu_call couvre l'OOM éventuel)."""
"""Nearly full GPU: at least one slot so the GPU is still used
(safe_gpu_call's CPU fallback covers a possible OOM)."""
from lidar_pipeline import gpu
monkeypatch.setattr(gpu, "GPU_WORKER_MIB", 2000)
monkeypatch.setattr(gpu, "GPU_RESERVE_MIB", 500)
@ -241,7 +241,7 @@ def test_gpu_worker_slots_keeps_one_gpu_worker_when_tight(monkeypatch):
def test_force_cpu_disables_gpu_selection(monkeypatch):
"""force_cpu() : aucun candidat GPU, CuPy jamais initialisé."""
"""force_cpu(): no GPU candidate, CuPy never initialized."""
import os
from lidar_pipeline import gpu
monkeypatch.setattr(gpu, "_restricted_gpu_ids", None)

View File

@ -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]

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
"""Chargeur de l'interface : fichiers web/ du paquet, versions, syntaxe JS."""
"""Interface loader: the package's web/ files, versions, JS syntax."""
import shutil
import subprocess
@ -13,8 +13,8 @@ def test_constants_are_read_from_web_files():
def test_web_files_ship_with_installed_package():
# Image complète : paquet installé par pip (package_data) — les trois
# fichiers doivent y être, sinon l'interface est vide en production.
# Full image: package installed by pip (package_data) — all three files
# must be there, otherwise the interface is empty in production.
import lidar_pipeline
from pathlib import Path
web = Path(lidar_pipeline.__file__).resolve().parent / "web"
@ -29,7 +29,7 @@ def test_markers_kept():
assert "__ASSETS_V__" not in mapui.render_html()
@pytest.mark.skipif(shutil.which("node") is None, reason="node absent")
@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
def test_js_syntax():
from lidar_pipeline import mapui
res = subprocess.run(["node", "--check", str(mapui.WEB_DIR / "map.js")],

View File

@ -20,13 +20,13 @@ class TestVizSteps:
assert len(names) == len(set(names)), "VIZ_STEPS has duplicate names"
def test_expected_visualization_count(self):
"""17 visualisations : 14 terrain + densité de points + ortho + topo."""
"""17 visualizations: 14 terrain products + point density + ortho + topo."""
from lidar_pipeline.pipeline import VIZ_STEPS
assert len(VIZ_STEPS) == 17
def test_default_run_produces_only_panel_layers(self, tmp_path):
"""Sans --only : seules les couches affichées (relief orienté, densité
de points) sont produites ; --only reste libre pour les autres."""
"""Without --only: only the displayed layers (oriented relief, point
density) are produced; --only still allows the others."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
p = LidarArchaeoPipeline(tmp_path, tmp_path / "out")
assert [n for n, _ in p.viz_steps] == ["relief_oriente", "densite_sol"]
@ -34,24 +34,24 @@ class TestVizSteps:
assert [n for n, _ in p.viz_steps] == ["slope"]
def test_incremental_index_on_by_default(self, tmp_path):
"""La carte suit le rendu en cours quel que soit le lanceur."""
"""The map follows the ongoing render whatever the launcher."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
assert LidarArchaeoPipeline(tmp_path, tmp_path / "o").incremental_index
assert not LidarArchaeoPipeline(tmp_path, tmp_path / "o2", no_index=True).incremental_index
def test_debounced_tile_is_indexed_later(self, tmp_path, monkeypatch):
"""Une dalle terminée pendant l'anti-rebond est reprise par une passe
différée, sans attendre la dalle suivante."""
"""A tile finished during the debounce interval is picked up by a
deferred pass, without waiting for the next tile."""
import time
import lidar_pipeline.index as index
from lidar_pipeline.pipeline import LidarArchaeoPipeline
calls = []
monkeypatch.setattr(index, "build_index", lambda *a, **k: calls.append(time.time()))
p = LidarArchaeoPipeline(tmp_path, tmp_path / "o")
p._rebuild_index_incremental() # passe immédiate
p._last_index_rebuild = time.time() - 2.8 # anti-rebond presque écoulé
p._rebuild_index_incremental() # différée (~0,2 s)
p._rebuild_index_incremental() # déjà programmée : pas de doublon
p._rebuild_index_incremental() # immediate pass
p._last_index_rebuild = time.time() - 2.8 # debounce almost elapsed
p._rebuild_index_incremental() # deferred (~0.2 s)
p._rebuild_index_incremental() # already scheduled: no duplicate
time.sleep(0.6)
assert len(calls) == 2
@ -63,17 +63,17 @@ class TestVizSteps:
class TestFetchEdgeNeighbors:
"""Raccord des bords : pré-téléchargement des voisines manquantes."""
"""Edge stitching: pre-download of missing neighbors."""
def test_downloads_missing_ring_dedup(self, tmp_path, monkeypatch):
"""Les 8 voisines manquantes sont demandées une seule fois, présente exclue."""
"""Missing neighbors are requested once each, the present one excluded."""
import lidar_pipeline.fetch_ign as fetch_ign
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
input_dir.mkdir()
lazh = input_dir / "LHD_FXX_0999_6778_PTS_LAMB93_IGN69.copc.laz"
lazh.touch()
# Une voisine déjà présente ne doit pas être retéléchargée.
# A neighbor already present must not be downloaded again.
(input_dir / "LHD_FXX_1000_6779_PTS_LAMB93_IGN69.copc.laz").touch()
calls = []
@ -85,12 +85,12 @@ class TestFetchEdgeNeighbors:
pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output",
edge_buffer=100.0)
pipeline._fetch_edge_neighbors([lazh])
assert len(calls) == 7 # 8 voisines - 1 déjà présente
assert len(calls) == 7 # 8 neighbors - 1 already present
assert (1000, 6779) not in calls
assert sorted(set(calls)) == sorted(calls) # dédupliqué
assert sorted(set(calls)) == sorted(calls) # deduplicated
def test_no_download_without_edge_buffer(self, tmp_path, monkeypatch):
"""Raccord désactivé : aucun téléchargement de voisines."""
"""Edge stitching disabled: no neighbor download."""
import lidar_pipeline.fetch_ign as fetch_ign
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
@ -99,7 +99,7 @@ class TestFetchEdgeNeighbors:
lazh.touch()
def boom(*args, **kwargs):
raise AssertionError("fetch_tiles ne doit pas être appelé")
raise AssertionError("fetch_tiles must not be called")
monkeypatch.setattr(fetch_ign, "fetch_tiles", boom)
pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output",
@ -108,7 +108,7 @@ class TestFetchEdgeNeighbors:
class TestCleanupEdgeNeighborDuplicates:
"""Nettoyage des doublons entre input/ et input/edge_neighbors/."""
"""Cleanup of duplicates between input/ and input/edge_neighbors/."""
def test_removes_true_duplicates_keeps_unique_and_part(self, tmp_path):
from lidar_pipeline.pipeline import LidarArchaeoPipeline
@ -125,8 +125,8 @@ class TestCleanupEdgeNeighborDuplicates:
(input_dir / dup_name).write_bytes(b"authoritative")
(edge_dir / dup_name).write_bytes(b"authoritative")
# input/ tronqué (taille différente) : la voisine peut être la seule
# copie saine, elle doit rester.
# Truncated input/ copy (different size): the neighbor may be the only
# sound copy, it must stay.
(input_dir / mismatch_name).write_bytes(b"")
(edge_dir / mismatch_name).write_bytes(b"complete")
(edge_dir / unique_name).write_bytes(b"voisine-unique")
@ -149,7 +149,7 @@ class TestCleanupEdgeNeighborDuplicates:
input_dir.mkdir()
pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output",
edge_buffer=100.0)
# Ne doit pas lever si edge_neighbors/ n'existe pas encore.
# Must not raise if edge_neighbors/ does not exist yet.
pipeline._cleanup_edge_neighbor_duplicates(input_dir / "edge_neighbors")
@ -168,11 +168,11 @@ class TestLidarArchaeoPipeline:
def test_init_raises_on_missing_input(self, tmp_path):
from lidar_pipeline.pipeline import LidarArchaeoPipeline
with pytest.raises(ValueError, match="introuvable"):
with pytest.raises(ValueError, match="not found"):
LidarArchaeoPipeline("/nonexistent/path", str(tmp_path / "output"))
def test_incremental_index_rebuild(self, tmp_path, monkeypatch):
"""Mode incrémental : l'index est régénéré après une tuile, avec anti-rebond."""
"""Incremental mode: the index is rebuilt after a tile, with debounce."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
import lidar_pipeline.index as index_mod
@ -185,10 +185,10 @@ class TestLidarArchaeoPipeline:
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"),
incremental_index=True)
pipeline._rebuild_index_incremental()
pipeline._rebuild_index_incremental() # < 3 s : anti-rebond, ignoré
pipeline._rebuild_index_incremental() # < 3 s: debounced, deferred (not run immediately)
assert len(calls) == 1
# --no-index : jamais de rebuild incrémental
# --no-index: never an incremental rebuild
pipeline._last_index_rebuild = 0.0
pipeline.no_index = True
pipeline._rebuild_index_incremental()
@ -218,35 +218,35 @@ class TestLidarArchaeoPipeline:
assert "readme.txt" not in names
def test_find_laz_files_sorted_north_to_south(self, tmp_path):
"""Lignes LHD triées du nord au sud (row décroissante, col croissante)."""
"""LHD rows sorted north to south (decreasing row, increasing col)."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
input_dir.mkdir()
for name in ("LHD_FXX_1054_6880_PTS_LAMB93_IGN69.copc.laz",
"LHD_FXX_1056_6882_PTS_LAMB93_IGN69.copc.laz",
"LHD_FXX_1053_6882_PTS_LAMB93_IGN69.copc.laz",
"zz_autre.laz"):
"zz_other.laz"):
(input_dir / name).touch()
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"))
names = [f.name for f in pipeline.find_laz_files()]
assert names == [
"LHD_FXX_1053_6882_PTS_LAMB93_IGN69.copc.laz", # ligne nord, col mini
"LHD_FXX_1056_6882_PTS_LAMB93_IGN69.copc.laz", # ligne nord, col maxi
"LHD_FXX_1054_6880_PTS_LAMB93_IGN69.copc.laz", # ligne sud
"zz_autre.laz", # hors pattern : en fin
"LHD_FXX_1053_6882_PTS_LAMB93_IGN69.copc.laz", # north row, lowest col
"LHD_FXX_1056_6882_PTS_LAMB93_IGN69.copc.laz", # north row, highest col
"LHD_FXX_1054_6880_PTS_LAMB93_IGN69.copc.laz", # south row
"zz_other.laz", # non-LHD pattern: last
]
class TestDtmMethodSidecar:
"""Méthode de classification enregistrée à côté du DTM (invalidation du cache)."""
"""Classification method recorded next to the DTM (cache invalidation)."""
def test_missing_sidecar_matches(self, tmp_path):
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
input_dir.mkdir()
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"), ground_method='csf')
# Aucun sidecar écrit → cache conservé (considéré compatible).
# No sidecar written → cache kept (considered compatible).
assert pipeline._dtm_method_matches("tileA", "") is True
def test_matching_method(self, tmp_path):
@ -276,7 +276,7 @@ class TestDtmMethodSidecar:
assert sidecar.exists()
assert sidecar.read_text(encoding="utf-8").strip() == "smrf"
assert pipeline._dtm_method_name("tileA", "_r0p2") == "smrf"
# Le sidecar est un fichier .txt : il ne gêne pas la recherche des DTM .tif.
# The sidecar is a .txt file: it does not interfere with the .tif DTM lookup.
dtm = tmp_path / "output" / "DTM" / "tileA_dtm_r0p2.tif"
dtm.touch()
assert [p.name for p in (tmp_path / "output" / "DTM").glob("*.tif")] == ["tileA_dtm_r0p2.tif"]
@ -298,18 +298,18 @@ class TestDtmMethodSidecar:
(vis_dir / "tileA_ortho.avif").touch()
dtm = tmp_path / "dtm.tif"
# Image existante, pas de force → ignorée (pas de régénération).
# Existing image, no force → skipped (no regeneration).
pipeline.generate_all_visualizations(dtm, "tileA", resolution=0.5, vis_dir=vis_dir, force_images=False)
assert calls == []
# Image existante, force_images=True → régénérée.
# Existing image, force_images=True → regenerated.
calls.clear()
pipeline.generate_all_visualizations(dtm, "tileA", resolution=0.5, vis_dir=vis_dir, force_images=True)
assert calls == ["tileA"]
class TestEffectiveGroundMethod:
def test_ign_label_encodes_classes(self):
"""Les classes IGN sont encodées dans l'étiquette de cache (reclassification)."""
"""IGN classes are encoded in the cache label (reclassification)."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
@ -334,8 +334,8 @@ class TestEffectiveGroundMethod:
class TestQualityCacheHit:
"""Sidecar qualité écrit même quand le DTM primaire est réutilisé depuis
le cache (classification sol sautée, pas de LAS sol disponible)."""
"""Quality sidecar written even when the primary DTM is reused from the
cache (ground classification skipped, no ground LAS available)."""
@staticmethod
def _write_las(path, x, y, cls, t):
@ -376,11 +376,11 @@ class TestQualityCacheHit:
pipeline = LidarArchaeoPipeline(
str(input_dir), str(tmp_path / "output"), ground_method='ign',
ign_classes="sol", strip_align=False, edge_buffer=0.0)
pipeline.viz_steps = [] # aucune visualisation à calculer (hors périmètre)
pipeline.viz_steps = [] # no visualization to compute (out of scope)
# DTM déjà en cache, compatible avec la config du run (pas de calage,
# pas de raccord, comblement à la version courante) : le run doit
# emprunter la branche « DTM existant » sans reclassifier ni régénérer.
# DTM already cached, compatible with the run config (no alignment,
# no edge buffer, gap filling at the current version): the run must
# take the "existing DTM" branch without reclassifying or regenerating.
dtm_path = pipeline.dtm_dir / f"{base}_dtm.tif"
with rasterio.open(
dtm_path, "w", driver="GTiff", height=10, width=10, count=1,
@ -391,7 +391,7 @@ class TestQualityCacheHit:
dst.update_tags(**{GAP_FILL_TAG: GAP_FILL_VERSION})
def boom(*a, **k):
raise AssertionError("cache hit attendu : ne doit pas reclassifier/régénérer le DTM")
raise AssertionError("cache hit expected: must not reclassify/regenerate the DTM")
monkeypatch.setattr(pipeline_mod, "classify_ground", boom)
monkeypatch.setattr(pipeline_mod, "create_dtm_fast", boom)
@ -400,7 +400,7 @@ class TestQualityCacheHit:
data = read_quality(pipeline.output_dir, base)
assert data is not None
assert abs(data["ground_density"] - 2e-6) < 1e-9 # 2 points de classe 2 sur 1 km²
assert abs(data["ground_density"] - 2e-6) < 1e-9 # 2 class-2 points over 1 km²
assert data["acq_start"] == "2022-06-01" and data["acq_source"] == "gps"
@ -413,7 +413,7 @@ class TestResolveWorkers:
def test_auto_bounded(self):
from lidar_pipeline.pipeline import resolve_workers
# Bornes : jamais sous 2, jamais au-dessus de 16
# Bounds: never below 2, never above 16
assert 2 <= resolve_workers('auto') <= 16
def test_explicit_int(self):
@ -427,8 +427,8 @@ class TestResolveWorkers:
assert resolve_workers(None) == 1
def test_worker_slot_initializer(self, monkeypatch):
"""Chaque processus du pool prend UNE place à sa création : GPU
(set_active_gpu) ou CPU forcé (-1) ; None ou file vide = libre."""
"""Each pool process takes ONE slot when it starts: GPU
(set_active_gpu) or forced CPU (-1); None or empty queue = free."""
import queue
from lidar_pipeline import gpu, pipeline
calls = []
@ -437,6 +437,6 @@ class TestResolveWorkers:
q = queue.Queue()
for slot in (1, -1, None):
q.put(slot)
for _ in range(4): # 4e appel : file vide
for _ in range(4): # 4th call: empty queue
pipeline._init_worker_slot(q)
assert calls == [("gpu", 1), ("cpu",)]

View File

@ -1,8 +1,8 @@
"""Tests du journal d'événements de progression (file de génération)."""
"""Tests for the progress event log (generation queue)."""
def test_report_and_read_events(tmp_path):
"""Les événements s'écrivent en JSONL et se relisent dans l'ordre."""
"""Events are written as JSONL and read back in order."""
from lidar_pipeline.progress import read_events, report_event, reset_events
reset_events(tmp_path)
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "download", "start")
@ -15,7 +15,7 @@ def test_report_and_read_events(tmp_path):
def test_reset_clears_previous_run(tmp_path):
"""reset_events tronque le journal : un nouveau run repart de zéro."""
"""reset_events truncates the log: a new run starts from scratch."""
from lidar_pipeline.progress import read_events, report_event, reset_events
reset_events(tmp_path)
report_event(tmp_path, "X", "tile", "ok")
@ -24,7 +24,7 @@ def test_reset_clears_previous_run(tmp_path):
def test_read_events_missing_or_partial(tmp_path):
"""Dossier sans journal → liste vide ; ligne partielle → ignorée."""
"""Directory without a log → empty list; partial line → skipped."""
from lidar_pipeline.progress import events_path, read_events
assert read_events(tmp_path) == []
events_path(tmp_path).write_text(
@ -35,37 +35,37 @@ def test_read_events_missing_or_partial(tmp_path):
def test_short_name():
"""Le nom court extrait col-row du nom de dalle IGN."""
"""The short name extracts col-row from the IGN tile name."""
from lidar_pipeline.progress import tile_short_name
assert tile_short_name("LHD_FXX_1054_6882_PTS_LAMB93_IGN69") == "1054-6882"
assert tile_short_name("LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz") == "1054-6882"
assert tile_short_name("autre.tif") == "autre.tif"
assert tile_short_name("other.tif") == "other.tif"
assert tile_short_name("") == ""
def test_aggregate_full_run(tmp_path):
"""Run complet : étapes ordonnées avec libellés français, tuile terminée."""
"""Full run: ordered steps with their labels, tile done."""
from lidar_pipeline.progress import aggregate_tiles, read_events, report_event, reset_events
reset_events(tmp_path)
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
report_event(tmp_path, b, "download", "skip", "déjà dans input/")
report_event(tmp_path, b, "download", "skip", "already in input/")
report_event(tmp_path, b, "classif", "start")
report_event(tmp_path, b, "classif", "ok")
report_event(tmp_path, b, "dtm", "ok", res=0.5)
report_event(tmp_path, b, "viz", "start", "aspect", res=0.5)
report_event(tmp_path, b, "viz", "ok", "aspect", res=0.5)
report_event(tmp_path, b, "tile", "ok", "123s")
tiles = aggregate_tiles(read_events(tmp_path), viz_labels={"aspect": "Exposition"})
tiles = aggregate_tiles(read_events(tmp_path), viz_labels={"aspect": "Aspect"})
assert len(tiles) == 1
t = tiles[0]
assert t["short"] == "1054-6882" and t["state"] == "done"
assert [s["label"] for s in t["steps"]] == [
"Téléchargement", "Classification", "DTM 0,5 m", "Exposition"]
"Download", "Classification", "DTM 0.5 m", "Aspect"]
assert [s["state"] for s in t["steps"]] == ["skip", "ok", "ok", "ok"]
def test_aggregate_running_step(tmp_path):
"""Une étape démarrée non terminée laisse la tuile « running »."""
"""A started but unfinished step leaves the tile "running"."""
from lidar_pipeline.progress import aggregate_tiles, read_events, report_event, reset_events
reset_events(tmp_path)
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
@ -79,7 +79,7 @@ def test_aggregate_running_step(tmp_path):
def test_aggregate_failure_marks_tile_failed():
"""Une viz en échec marque la tuile failed même sans événement tuile."""
"""A failed viz marks the tile failed even without a tile event."""
from lidar_pipeline.progress import aggregate_tiles
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
tiles = aggregate_tiles([
@ -94,32 +94,32 @@ def test_aggregate_failure_marks_tile_failed():
def test_aggregate_multi_resolution_and_bad_input():
"""Deux résolutions → deux étapes DTM/viz distinctes ; entrées invalides ignorées."""
"""Two resolutions → distinct DTM/viz steps; invalid entries skipped."""
from lidar_pipeline.progress import aggregate_tiles
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
tiles = aggregate_tiles([
"pas du tout du json",
"not json at all",
None,
{"tile": b, "phase": "inconnu", "state": "ok"},
{"tile": b, "phase": "unknown", "state": "ok"},
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.5},
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.2},
{"tile": b, "phase": "viz", "state": "ok", "detail": "aspect", "res": 0.2},
{"tile": b, "phase": "tile", "state": "ok"},
], viz_labels={"aspect": "Exposition"})
], viz_labels={"aspect": "Aspect"})
assert len(tiles) == 1
labels = [s["label"] for s in tiles[0]["steps"]]
assert labels == ["DTM 0,5 m", "DTM 0,2 m", "Exposition 0,2 m"]
# La résolution secondaire (0,2 m) est suffixée, la primaire (0,5 m) non
assert labels == ["DTM 0.5 m", "DTM 0.2 m", "Aspect 0.2 m"]
# The secondary resolution (0.2 m) gets a suffix, the primary one (0.5 m) does not
def test_aggregate_multiple_tiles_ordered():
"""Une entrée par tuile, dans l'ordre de première apparition."""
"""One entry per tile, in order of first appearance."""
from lidar_pipeline.progress import aggregate_tiles
a = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
b = "LHD_FXX_1055_6882_PTS_LAMB93_IGN69"
tiles = aggregate_tiles([
{"tile": a, "phase": "download", "state": "ok"},
{"tile": b, "phase": "download", "state": "start", "detail": "catalogue IGN"},
{"tile": b, "phase": "download", "state": "start", "detail": "IGN catalogue"},
{"tile": a, "phase": "tile", "state": "ok"},
])
assert [t["short"] for t in tiles] == ["1054-6882", "1055-6882"]

View File

@ -1,10 +1,10 @@
"""Tests de la mesure de qualité des dalles (densité sol, date d'acquisition)."""
"""Tests for tile quality measurement (ground density, acquisition date)."""
BOUNDS = (652000.0, 6861000.0, 653000.0, 6862000.0)
def _gps_seconds(year, month, day):
"""Temps GPS ajusté standard (secondes GPS − 1e9) d'un midi UTC."""
"""Standard adjusted GPS time (GPS seconds − 1e9) of a UTC noon."""
from datetime import datetime, timezone
epoch = datetime(1980, 1, 6, tzinfo=timezone.utc)
return (datetime(year, month, day, 12, tzinfo=timezone.utc) - epoch).total_seconds() - 1e9
@ -18,7 +18,7 @@ def test_gps_adjusted_to_date():
def test_compute_quality_density_grid_and_dates():
import numpy as np
from lidar_pipeline.quality import compute_quality
# 1 point par m² sur la moitié nord, rien au sud ; 2 dates de vol.
# 1 point per m² on the northern half, nothing in the south; 2 flight dates.
xs, ys = np.meshgrid(np.arange(652000.5, 653000.0, 1.0),
np.arange(6861500.5, 6862000.0, 1.0))
x, y = xs.ravel(), ys.ravel()
@ -28,11 +28,11 @@ def test_compute_quality_density_grid_and_dates():
assert abs(q["ground_density"] - 0.5) < 1e-6
grid = q["density_grid"]
assert len(grid) == 20 and all(len(r) == 20 for r in grid)
assert grid[0][0] == 1.0 # ligne 0 = nord : couverte
assert grid[19][0] == 0.0 # sud : vide
assert grid[0][0] == 1.0 # row 0 = north: covered
assert grid[19][0] == 0.0 # south: empty
assert q["acq_start"] == "2023-03-15" and q["acq_end"] == "2023-03-17"
assert q["acq_source"] == "gps"
# 1 point par m² : 1 pixel 0,2 m sur 25 occupé au nord, aucun au sud.
# 1 point per m²: 1 in 25 0.2 m pixels occupied in the north, none in the south.
assert abs(q["empty_fraction"] - (1 - 0.5 / 25)) < 1e-3
@ -69,7 +69,7 @@ def test_sidecar_roundtrip_and_table(tmp_path):
base = "LHD_FXX_0652_6862_PTS_LAMB93_IGN69"
data = {"version": 1, "ground_density": 4.2}
assert write_quality(tmp_path, base, data) is True
assert write_quality(tmp_path, base, data) is False # contenu identique : pas réécrit
assert write_quality(tmp_path, base, data) is False # identical content: not rewritten
assert quality_path(tmp_path, base).is_file()
assert read_quality(tmp_path, base) == data
assert load_quality_table(tmp_path) == {base: data}
@ -83,7 +83,7 @@ def test_read_quality_rejects_other_version(tmp_path):
def test_quality_module_imports_without_numpy():
"""L'image légère n'a pas numpy : le module ne doit pas l'importer au chargement."""
"""The lightweight image has no numpy: the module must not import it at load time."""
import subprocess, sys
code = ("import sys; sys.modules['numpy'] = None; "
"import lidar_pipeline.quality as q; print(q.QUALITY_VERSION)")
@ -92,7 +92,7 @@ def test_quality_module_imports_without_numpy():
def _write_las(path, x, y, cls, t, adjusted=True, creation=None):
"""LAS minimal (format 6 : gps_time) pour les tests."""
"""Minimal LAS (format 6: gps_time) for the tests."""
import laspy
import numpy as np
header = laspy.LasHeader(point_format=6, version="1.4")
@ -135,7 +135,7 @@ def test_quality_from_las_week_time_uses_header_date(tmp_path):
def test_quality_from_las_unreadable_returns_none(tmp_path):
from lidar_pipeline.quality import quality_from_las
p = tmp_path / "bad.laz"; p.write_bytes(b"pas un LAS")
p = tmp_path / "bad.laz"; p.write_bytes(b"not a LAS")
assert quality_from_las(p, BOUNDS) is None
@ -148,7 +148,7 @@ def test_ensure_quality_skips_existing_and_unparsable(tmp_path):
assert read_quality(tmp_path, base)["acq_start"] == "2022-06-01"
write_quality(tmp_path, base, dict(read_quality(tmp_path, base), ground_density=99.0))
assert ensure_quality(p, base, tmp_path) is True
assert read_quality(tmp_path, base)["ground_density"] == 99.0 # non recalculé
assert read_quality(tmp_path, base)["ground_density"] == 99.0 # not recomputed
assert ensure_quality(p, "nom_libre", tmp_path) is False
@ -161,7 +161,7 @@ def test_backfill_quality_reads_input_laz(tmp_path):
[2, 6], [_gps_seconds(2022, 6, 1)] * 2)
assert backfill_quality(inp, out) == 1
assert abs(read_quality(out, base)["ground_density"] - 1e-6) < 1e-9
assert backfill_quality(inp, out) == 0 # déjà à jour
assert backfill_quality(inp, out) == 0 # already up to date
def test_cli_quality_backfill(tmp_path):

View File

@ -1,4 +1,4 @@
"""Tests for rendering module (colormaps, tif_to_png)."""
"""Tests for the rendering module (colormaps, tif_to_png, tif_to_crop)."""
import numpy as np
import rasterio
@ -39,7 +39,7 @@ class TestColormaps:
'neg_open': 'negative_openness',
'hillshade': 'hillshade_multi',
}
# Images RGB (fonds IGN, relief orienté) — pas de colormap
# RGB images (IGN backgrounds, oriented relief) — no colormap
from lidar_pipeline.rendering import RGB_KEYWORDS
skip = set(RGB_KEYWORDS)
for name, _ in VIZ_STEPS:
@ -100,32 +100,32 @@ class TestApplyColormap:
assert result.exists()
def test_knots_mode_fixed_transfer(self):
"""L'étalonnage quantile figé (ondelette) : même valeur → même couleur.
"""Frozen quantile calibration (wavelet): same value → same color.
Les nœuds sont constants (pas de percentile local) : clamp aux
extrêmes, médiane (1.0) → 0.5, transfert monotone.
The knots are constant (no local percentile): clamped at the
extremes, median (1.0) → 0.5, monotonic transfer.
"""
from lidar_pipeline.rendering import COLORMAPS, _apply_colormap
info = COLORMAPS['wavelet']
kv, kt = info['knots'][0.5]
assert kv[6] == 1.0 and kt[6] == 0.5 # nœud médian
assert kv[6] == 1.0 and kt[6] == 0.5 # median knot
vals = np.array([0.05, kv[0], 1.0, kv[-1], 50.0], dtype=float)
out, cmap, *_ = _apply_colormap(vals, 'x_wavelet.tif', resolution=0.5)
assert cmap == 'inferno'
assert out[0] == 0.0 and out[1] == 0.01 # clamp bas
assert abs(out[2] - 0.5) < 1e-9 # médiane → 0.5
assert out[3] == 0.995 and out[4] == 1.0 # nœud haut, puis clamp au-delà
# Monotonie (pas d'inversion de teinte)
assert out[0] == 0.0 and out[1] == 0.01 # low clamp
assert abs(out[2] - 0.5) < 1e-9 # median → 0.5
assert out[3] == 0.995 and out[4] == 1.0 # top knot, then clamped beyond
# Monotonicity (no hue inversion)
s = np.sort(np.random.default_rng(1).uniform(0.2, 3.0, 500))
o, *_ = _apply_colormap(s, 'x_wavelet.tif', resolution=0.5)
assert np.all(np.diff(o) >= -1e-12)
# Les nœuds 0.2 m diffèrent de ceux 0.5 m (calibrations distinctes)
# The 0.2 m knots differ from the 0.5 m ones (separate calibrations)
kv02, _ = info['knots'][0.2]
assert kv02 != kv
class TestTifToCrop:
"""Conversion TIF → dalle cartographique (tif_to_crop)."""
"""TIF → map tile conversion (tif_to_crop)."""
@staticmethod
def _write_named_tif(tmp_path, name, arr):
@ -140,11 +140,11 @@ class TestTifToCrop:
return tif_file
def test_nodata_renders_black(self, tmp_path):
"""Le nodata restant est rendu en noir (comportement historique).
"""Remaining nodata is rendered black (historical behavior).
Les trous du MNT sont comblés en amont (interpolation dans
create_dtm_fast, tous modes) ; ce qui reste en nodata doit rester
visible en noir sur la dalle plutôt qu'inventé au rendu.
DTM holes are filled upstream (gap filling in create_dtm_fast);
whatever is still nodata must stay visible as black on the tile
rather than being invented at render time.
"""
from PIL import Image as PILImage
from lidar_pipeline.rendering import tif_to_crop
@ -153,19 +153,19 @@ class TestTifToCrop:
data[15:25, 15:25] = np.nan
tif_file = self._write_named_tif(tmp_path, "LHD_test_slope.tif", data)
# WebP lossless : l'encodeur AVIF de l'image « saigne » légèrement les
# bords du noir même en lossless — on teste la logique nodata→noir,
# pas les artefacts du codec.
# Lossless WebP: the image's AVIF encoder slightly "bleeds" the edges
# of the black area even in lossless mode — we test the nodata→black
# logic, not codec artifacts.
out = tif_to_crop(tif_file, tmp_path, 5.0, keep_tif=True,
quality=100, output_format='webp')
assert out is not None and out.exists()
rgb = np.asarray(PILImage.open(str(out)).convert('RGB'))
hole = rgb[15:25, 15:25, :]
assert np.all(hole < 10), "le nodata doit être rendu en noir"
assert np.all(hole < 10), "nodata must be rendered black"
def test_without_nodata(self, tmp_path):
"""Un TIF sans nodata est converti sans crash, taille préservée."""
"""A TIF without nodata converts without crashing, size preserved."""
from PIL import Image as PILImage
from lidar_pipeline.rendering import tif_to_crop
@ -178,7 +178,7 @@ class TestTifToCrop:
assert img.size == (40, 40)
class TestCoreTileWindow:
"""Recadrage des TIF à bande de raccord sur la dalle nominale 1 km."""
"""Cropping of edge-buffered TIFs to the nominal 1 km tile."""
def _write_tif(self, tmp_path, name, bounds, size):
transform = from_bounds(*bounds, size, size)
@ -204,7 +204,7 @@ class TestCoreTileWindow:
assert abs(wt.f - 6628000.0) < 1e-6
def test_no_window_without_overflow(self, tmp_path):
"""TIF historique sur les bornes d'en-tête (~999,99 m) : rien à recadrer."""
"""Historical TIF on the header bounds (~999.99 m): nothing to crop."""
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "LHD_FXX_0638_6628_PTS_LAMB93_IGN69_slope.tif",
(638000, 6627000, 638999.99, 6627999.99), 5000)
@ -220,8 +220,8 @@ class TestCoreTileWindow:
class TestDensiteSolCrop:
"""Densité de points : WebP sans perte en niveaux de gris, sous-tuiles
écrites depuis l'image d'origine (un seul encodage)."""
"""Point density: lossless grayscale WebP, sub-tiles written from the
original image (a single encoding)."""
def test_lossless_gray_levels_and_subtiles(self, tmp_path):
from PIL import Image as PILImage
@ -233,20 +233,20 @@ class TestDensiteSolCrop:
tif = TestTifToCrop._write_named_tif(vis, f"{base}_densite_sol.tif", levels)
out = tif_to_crop(tif, vis, 0.2, output_format='avif', subtiles_dir=tmp_path)
assert out is not None and out.suffix == ".webp"
# WebP n'a pas de mode gris : relu en RGB à canaux égaux
# WebP has no gray mode: read back as RGB with equal channels
rgb = np.asarray(PILImage.open(str(out)).convert("RGB")).astype(int)
assert (rgb[..., 0] == rgb[..., 1]).all() and (rgb[..., 1] == rgb[..., 2]).all()
img = PILImage.fromarray(rgb[..., 0].astype(np.uint8))
row = np.asarray(img)[0, ::4].astype(int)
assert len(set(row.tolist())) == 16 and np.all(np.diff(row) > 0)
# Sous-tuiles 2 × 2 (0,2 m/px) en WebP sans perte, identiques à la dalle
# 2 × 2 sub-tiles (0.2 m/px) in lossless WebP, identical to the tile
q = tmp_path / "index_subtiles" / f"{base}_r0p2_densite_sol_0_1.webp"
assert q.exists()
assert np.array_equal(np.asarray(PILImage.open(str(q)).convert("L")), np.asarray(img)[:32, :32])
assert (tmp_path / "index_subtiles" / f"{base}_r0p2_densite_sol_0_1_mid.webp").exists()
def test_relief_subtiles_encoded_from_source(self, tmp_path):
"""Relief : sous-tuiles AVIF écrites par tif_to_crop, plus récentes que la dalle."""
"""Relief: AVIF sub-tiles written by tif_to_crop, newer than the tile."""
import pytest
from lidar_pipeline.rendering import tif_to_crop
base = "LHD_FXX_0660_6701_PTS_LAMB93_IGN69"
@ -261,7 +261,7 @@ class TestDensiteSolCrop:
try:
out = tif_to_crop(tif, vis, 0.2, output_format='avif', subtiles_dir=tmp_path)
except Exception:
pytest.skip("encodeur AVIF indisponible")
pytest.skip("AVIF encoder unavailable")
sub = tmp_path / "index_subtiles"
quads = sorted(sub.glob(f"{base}_r0p2_relief_oriente_?_?.avif"))
assert len(quads) == 4

View File

@ -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()

View File

@ -83,9 +83,10 @@ class TestOpenness:
def test_downsampled_matches_full_resolution(self, tmp_path, tmp_output_dir):
"""Factor 2: same output shape, highly correlated with factor 1.
MNT dédié à 1 m/px (structures de plusieurs dizaines de pixels, régime
de production 0,2 m/px) : la fixture synthétique partagée à 5 m/px a un
mur large de 2 px, hors régime pour valider une décimation ×2.
Dedicated 1 m/px DTM (structures spanning tens of pixels, as in the
0.2 m/px production regime): the shared 5 m/px synthetic fixture has
a wall only 2 px wide, outside the regime needed to validate a ×2
decimation.
"""
import rasterio
from rasterio.transform import from_bounds
@ -100,9 +101,9 @@ class TestOpenness:
dist_wall = np.abs((X - 40) * 0.707 + (Y - 60) * 0.707) / np.sqrt(2)
dem += 1.5 * np.exp(-dist_wall**2 / (2 * 8**2))
dem -= 2.0 * np.exp(-np.abs(X - 200)**2 / (2 * 12**2))
# Sans bruit blanc : à 1 m/px un bruit σ=5 cm domine l'angle
# d'horizon à courte distance (max le long du rayon) et masque la
# géométrie que ce test cherche à valider (décimation + zoom).
# No white noise: at 1 m/px a σ=5 cm noise dominates the short-range
# horizon angle (max along the ray) and hides the geometry this test
# validates (decimation + zoom).
dem_file = tmp_path / "dem_1m.tif"
with rasterio.open(dem_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154',
@ -124,17 +125,17 @@ class TestOpenness:
m = ~np.isnan(a) & ~np.isnan(b)
assert m.sum() > 0
corr = np.corrcoef(a[m], b[m])[0, 1]
assert corr > 0.97, f"corrélation openness décimée/native trop faible : {corr:.3f}"
assert corr > 0.97, f"decimated/native openness correlation too low: {corr:.3f}"
@pytest.mark.parametrize("positive", [True, False])
def test_scale_independent_of_rest_of_tile(self, tmp_path, tmp_output_dir, positive):
"""Même relief local = même valeur, quel que soit le reste de la dalle.
"""Same local relief = same value, whatever the rest of the tile.
Deux MNT identiques sur leur moitié ouest ; l'un porte en plus une
colline à l'est, à plus de 100 m (rayon max) de la zone comparée. Une
normalisation par dalle (z-score) décalait toute l'échelle et rendait
les mosaïques non jointives ; avec les références figées, la moitié
ouest doit sortir identique.
Two DTMs identical on their western half; one also carries a hill to
the east, more than 100 m (max radius) from the compared area. A
per-tile normalization (z-score) shifted the whole scale and made
mosaics non-seamless; with the frozen references, the western half
must come out identical.
"""
import rasterio
from rasterio.transform import from_bounds
@ -156,8 +157,8 @@ class TestOpenness:
out = generate_openness(f, name, tmp_output_dir, 1.0, positive=positive)
with rasterio.open(out) as src:
results.append(src.read(1))
# Tolérance : résidu d'interpolation de la grille décimée (≈0,01) ;
# un z-score par dalle décalerait toute la zone de plusieurs dixièmes.
# Tolerance: interpolation residual of the decimated grid (≈0.01);
# a per-tile z-score would shift the whole area by several tenths.
west = np.s_[:, :100]
np.testing.assert_allclose(results[0][west], results[1][west], atol=0.05)
@ -182,7 +183,7 @@ class TestReliefOriente:
assert src.count == 3 and src.dtypes[0] == 'uint8'
assert (src.height, src.width) == (dem.height, dem.width)
rgb = src.read()
assert rgb.std() > 5, "image uniforme : ni relief ni orientation rendus"
assert rgb.std() > 5, "uniform image: neither relief nor orientation rendered"
def test_nodata_gets_fixed_color(self, tmp_path, tmp_output_dir):
import rasterio
@ -196,7 +197,7 @@ class TestReliefOriente:
assert tuple(rgb[:, 15, 15]) == RELIEF_NODATA_RGB
def test_horizon_kernels_agree(self):
"""numba et numpy (et le noyau CUDA, même code) donnent le même angle."""
"""numba and numpy (and the CUDA kernel, same code) give the same angle."""
pytest.importorskip("numba")
from lidar_pipeline.visualizations import (
_horizon_rays, _mean_horizon_numba, _mean_horizon_numpy)
@ -209,7 +210,7 @@ class TestReliefOriente:
np.testing.assert_allclose(a, b, atol=1e-5)
def test_matches_legacy_ray_trace_geometry(self):
"""Même géométrie de rayons que _ray_trace_horizons (8 directions)."""
"""Same ray geometry as _ray_trace_horizons (8 directions)."""
from lidar_pipeline.visualizations import (
_horizon_rays, _mean_horizon_numpy, _ray_trace_horizons)
rng = np.random.default_rng(1)
@ -221,8 +222,9 @@ class TestReliefOriente:
np.testing.assert_allclose(_mean_horizon_numpy(dem, offs, dist, cps), legacy, atol=1e-5)
def test_seamless_independent_of_rest_of_tile(self, tmp_path, tmp_output_dir):
"""Même relief local = mêmes couleurs, quel que soit le reste de la dalle
(support : rayon 20 m + détendance 4σ = 40 m, loin sous la bande de 100 m)."""
"""Same local relief = same colors, whatever the rest of the tile
(support: 20 m radius + 4σ = 40 m detrend window, ~60 m in all, well
under the 100 m edge buffer)."""
import rasterio
from lidar_pipeline.visualizations import generate_relief_oriente
size = 300
@ -236,10 +238,10 @@ class TestReliefOriente:
with rasterio.open(out) as src:
imgs.append(src.read().astype(int))
diff = np.abs(imgs[0][:, :, :150] - imgs[1][:, :, :150])
assert diff.max() <= 1, f"écart de couleur loin de la colline : {diff.max()}"
assert diff.max() <= 1, f"color difference far from the hill: {diff.max()}"
def test_numba_colorize_matches_vectorized(self):
"""Noyau CPU fusionné = chemin vectorisé (CuPy/numpy) à l'arrondi près."""
"""Fused CPU kernel = vectorized path (CuPy/numpy) up to rounding."""
pytest.importorskip("numba")
from lidar_pipeline.gpu import xp_zoom
from lidar_pipeline.visualizations import (
@ -252,18 +254,18 @@ class TestReliefOriente:
a = _relief_colorize_numba(open_c, 4, dx, dy, lut).astype(int)
b = _relief_colorize_xp(np, _pad_to(np, xp_zoom(open_c, 4), 120, 100), dx, dy, lut).astype(int)
diff = np.abs(a - b).max(axis=2)
assert (diff > 3).mean() < 0.01, f"{(diff > 3).mean():.3%} pixels divergent"
assert (diff > 3).mean() < 0.01, f"{(diff > 3).mean():.3%} pixels diverge"
def test_lut_lightness_is_monotonic_and_hue_neutral(self):
"""Clarté croissante avec L* ; à L* fixé, toutes les teintes ont la même
luminance perçue (pas de faux relief dû à la couleur)."""
"""Lightness increases with L*; at fixed L*, all hues have the same
perceived luminance (no false relief caused by color)."""
from lidar_pipeline.visualizations import _relief_lut
lut = _relief_lut().astype(float) / 255
lin = np.where(lut <= 0.04045, lut / 12.92, ((lut + 0.055) / 1.055) ** 2.4)
Y = lin @ np.array([0.2126, 0.7152, 0.0722]) # (256, 360)
assert np.all(np.diff(Y.mean(axis=1)) >= -1e-6)
Lstar = 116 * np.cbrt(Y[100:180]) - 16 # L* ≈ 40–70
# Écart résiduel : écrêtage de gamme sRGB de quelques teintes à C* 60
# Residual spread: sRGB gamut clipping of a few hues at C* 60
assert (Lstar.max(axis=1) - Lstar.min(axis=1)).max() < 6
@ -308,10 +310,10 @@ class TestWavelet:
assert result.exists()
def test_output_median_centered(self, synthetic_dem, tmp_output_dir):
"""Recentrage robuste : médiane ≈ 1 quel que soit le terrain.
"""Robust rescaling: median ≈ 1 whatever the terrain.
C'est la condition pour qu'un étirement couleur global fixe donne
des couleurs homogènes entre tuiles (cf. knots dans rendering.py).
This is what lets a single fixed color stretch give homogeneous
colors across tiles (see knots in rendering.py).
"""
import rasterio
from lidar_pipeline.visualizations import generate_wavelet
@ -322,16 +324,18 @@ class TestWavelet:
assert abs(np.median(valid) - 1.0) < 0.05
def test_ditch_on_hilltop_not_amplified(self, tmp_path, tmp_output_dir):
"""Un fossé en sommet de colline ne ressort pas plus qu'à plat.
"""A ditch on a hilltop does not stand out more than on flat ground.
Le détendage (moyenne locale gaussienne ~35 m) doit neutraliser la
position topographique :
- le pic d'indice sur le fossé en sommet reste comparable au même
fossé sur terrain plat ;
- le fond du sommet (sans structure) reste comparable au fond plat.
Detrending (Gaussian local mean ~35 m) must neutralize the
topographic position:
- the index peak on the hilltop ditch stays comparable to the same
ditch on flat ground;
- the hilltop background (no structure) stays comparable to the flat
background.
MNT synthétique bruité (σ=8 cm) : sans détendage, le fond sommet
ressort ~1,7× le fond plat (sommets jaunes sur la carte).
Noisy synthetic DTM (σ=8 cm): without detrending, the hilltop
background comes out ~1.7× the flat background (yellow hilltops on
the map).
"""
import rasterio
from rasterio.transform import from_bounds
@ -344,12 +348,12 @@ class TestWavelet:
X, Y = np.meshgrid(x, y)
dem = 100.0 + 0.01 * X
# Colline réaliste (sigma 80 m, 25 m de haut) à gauche + bruit capteur
# Realistic hill (sigma 80 m, 25 m high) on the left + sensor noise
dem += 25.0 * np.exp(-((X - 150)**2 + (Y - 300)**2) / (2 * 80**2))
rng = np.random.default_rng(42)
dem += rng.normal(0, 0.08, dem.shape)
# Deux fossés identiques : l'un au sommet de la colline, l'autre à plat
# Two identical ditches: one on the hilltop, the other on flat ground
for xc in (150, 480):
dem -= 1.5 * np.exp(-((X - xc)**2) / (2 * 1.2**2))
@ -366,17 +370,17 @@ class TestWavelet:
with rasterio.open(result) as src:
data = src.read(1)
# Pics sur une bande verticale autour de chaque fossé
# Peaks over a vertical band around each ditch
peak_hilltop = np.nanmax(data[:, 145:156])
peak_flat = np.nanmax(data[:, 475:486])
assert peak_flat > 5.0 # le fossé ressort nettement au-dessus du fond
# Pas d'amplification du fossé par la position topographique
assert peak_flat > 5.0 # the ditch clearly stands out above the background
# No amplification of the ditch by the topographic position
assert peak_hilltop < 1.5 * peak_flat
# Fond du sommet (35 m à l'est du fossé sommital) vs fond plat
# Hilltop background (35 m east of the hilltop ditch) vs flat background
bg_hilltop = np.nanmedian(data[250:350, 185:226])
bg_flat = np.nanmedian(data[250:350, 500:561])
assert bg_hilltop < 1.4 * bg_flat # sans détendage : ~1,7×
assert bg_hilltop < 1.4 * bg_flat # without detrending: ~1.7×
class TestFlowAccumulation:
@ -392,7 +396,7 @@ class TestFlowAccumulation:
result = generate_flow_accumulation(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src:
data = src.read(1)
# log10(x) >= 0 for x >= 1
# log1p(x) >= 0 for x >= 0
valid = data[~np.isnan(data)]
assert np.nanmin(valid) >= 0
@ -417,11 +421,11 @@ class TestRayTrace:
class TestNodataPreserved:
"""Nodata préservé dans les rendus (comportement historique).
"""Nodata is preserved in the renderings (legacy behaviour).
Les trous du MNT sont comblés en amont (create_dtm_fast, tous modes) ;
si un nodata subsiste malgré tout, hillshade/slope/aspect le restituent
(rendu noir en carte) au lieu d'inventer des valeurs interpolées.
DTM holes are filled upstream (create_dtm_fast, all modes); if some
nodata remains anyway, hillshade/slope/aspect keep it (rendered black on
the map) instead of inventing interpolated values.
"""
@staticmethod
@ -445,22 +449,22 @@ class TestNodataPreserved:
import rasterio
with rasterio.open(out) as src:
data = src.read(1)
assert np.isnan(data[80:120, 80:120]).all(), "le trou doit rester en nodata"
# Le gradient au bord du trou propage NaN sur un anneau de 1 px :
# on vérifie une zone éloignée du trou
assert not np.isnan(data[0:40, 0:40]).any(), "NaN loin du trou"
assert np.isnan(data[80:120, 80:120]).all(), "the hole must stay nodata"
# The gradient at the hole's edge spreads NaN over a 1 px ring:
# check an area far from the hole
assert not np.isnan(data[0:40, 0:40]).any(), "NaN far from the hole"
def test_aspect_shared_preserves_nodata(self, synthetic_dem, tmp_path):
from lidar_pipeline.visualizations import SharedDEM, generate_aspect
dem_hole = self._dem_with_hole(synthetic_dem, tmp_path)
shared = SharedDEM(dem_hole, 5.0)
out = generate_aspect(dem_hole, "partage", tmp_path, 5.0, shared=shared)
out = generate_aspect(dem_hole, "shared", tmp_path, 5.0, shared=shared)
assert out is not None and out.exists()
import rasterio
with rasterio.open(out) as src:
data = src.read(1)
assert np.isnan(data[80:120, 80:120]).all(), "le trou doit rester en nodata"
assert not np.isnan(data[0:40, 0:40]).any(), "NaN loin du trou"
assert np.isnan(data[80:120, 80:120]).all(), "the hole must stay nodata"
assert not np.isnan(data[0:40, 0:40]).any(), "NaN far from the hole"
def test_slope_and_hillshade_preserve_nodata(self, synthetic_dem, tmp_path):
from lidar_pipeline.visualizations import generate_slope, generate_hillshade
@ -471,11 +475,11 @@ class TestNodataPreserved:
assert out is not None and out.exists()
with rasterio.open(out) as src:
data = src.read(1)
assert np.isnan(data[80:120, 80:120]).any(), f"{out.name} : trou disparu"
assert np.isnan(data[80:120, 80:120]).any(), f"{out.name}: hole disappeared"
def test_ray_trace_horizons_cpu_fallback_on_oom(monkeypatch):
"""Sur OOM GPU, le ray-tracing désactive le GPU puis recommence sur CPU."""
"""On GPU OOM, ray-tracing disables the GPU then retries on CPU."""
import lidar_pipeline.visualizations as viz
import lidar_pipeline.gpu as gpu_mod
@ -498,25 +502,25 @@ def test_ray_trace_horizons_cpu_fallback_on_oom(monkeypatch):
def test_ray_trace_horizons_reraises_non_oom(monkeypatch):
"""Une erreur non-OOM n'est pas masquée par le repli CPU."""
"""A non-OOM error is not masked by the CPU fallback."""
import lidar_pipeline.visualizations as viz
import lidar_pipeline.gpu as gpu_mod
def fake_core(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
raise ValueError("autre erreur")
raise ValueError("other error")
monkeypatch.setattr(viz, "_ray_trace_horizons_core", fake_core)
monkeypatch.setattr(gpu_mod, "is_gpu_active", lambda: True)
try:
viz._ray_trace_horizons(None, 4, 4, 0.5, 8, 10)
assert False, "ValueError attendue"
assert False, "ValueError expected"
except ValueError:
pass
class TestPriorityFlood:
def test_numba_matches_python(self):
"""Le résultat numba et python sont identiques sur un DEM avec un puits."""
"""numba and Python results are identical on a DEM with a pit."""
from lidar_pipeline.visualizations import _priority_flood_numba, _priority_flood_python
dem = np.zeros((20, 20), dtype=np.float64)
@ -531,7 +535,7 @@ class TestPriorityFlood:
assert np.allclose(result_numba, result_python)
def test_pit_is_filled(self):
"""Un puits isolé est ramené au niveau de son bord."""
"""An isolated pit is raised to the level of its rim."""
from lidar_pipeline.visualizations import _priority_flood
dem = np.full((10, 10), 5.0, dtype=np.float64)
@ -542,7 +546,7 @@ class TestPriorityFlood:
assert result[5, 5] == 5.0
def test_nodata_cells_untouched(self):
"""Les cellules nodata ne sont jamais modifiées."""
"""Nodata cells are never modified."""
from lidar_pipeline.visualizations import _priority_flood
dem = np.full((10, 10), 5.0, dtype=np.float64)
@ -575,7 +579,7 @@ class TestDensiteSol:
out = generate_densite_sol(dem, "T", tmp_path, 0.2)
with rasterio.open(out) as src:
assert src.read(1).tolist() == [[0, 4, 8, 15]]
assert src.width == 4 # grille de 1 m conservée
assert src.width == 4 # 1 m grid kept
def test_missing_sidecar_returns_none(self, tmp_path):
from lidar_pipeline.visualizations import generate_densite_sol