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

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