Rasterisation GPU du DTM (bin_mean_2d) + GPU fallback renforcé et workers mono-thread
Rastérisation des points sol via gpu.bin_mean_2d (bincount 2D CuPy, sémantique identique au repli scipy binned_statistic_2d) : ~x7 plus rapide sur cette étape, logs de durée par phase dans create_dtm_fast. safe_gpu_call retente en CPU sur toute erreur GPU (pas seulement les messages CUDA) : un transfert échoué en cours de run mélangeait types numpy/cupy et faisait échouer la visualisation entière. Workers en mono-thread BLAS/OpenMP sur la machine de traitement (plus de saturation des cœurs pendant les runs).
This commit is contained in:
@ -90,4 +90,113 @@ def test_to_gpu_roundtrip():
|
||||
assert isinstance(gpu_arr, cp.ndarray)
|
||||
result = to_cpu(gpu_arr)
|
||||
assert isinstance(result, np.ndarray)
|
||||
np.testing.assert_array_almost_equal(result, [1.0, 2.0, 3.0])
|
||||
np.testing.assert_array_almost_equal(result, [1.0, 2.0, 3.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bin_mean_2d — rasterisation MNT (moyenne z par cellule)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scipy_reference(xs, ys, zs, width, height, x_range, y_range):
|
||||
from scipy.stats import binned_statistic_2d
|
||||
stat = binned_statistic_2d(
|
||||
xs, ys, zs, statistic='mean', bins=[width, height],
|
||||
range=[list(x_range), list(y_range)]
|
||||
)
|
||||
return stat.statistic.T # (height, width)
|
||||
|
||||
|
||||
def test_bin_mean_core_parity_scipy():
|
||||
"""Le cœur numpy de bin_mean_2d reproduit 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)
|
||||
w, h = 40, 32
|
||||
n = 5000
|
||||
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
|
||||
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]])
|
||||
got = _bin_mean_core(np, xs, ys, zs, w, h, x_range, y_range)
|
||||
ref = _scipy_reference(xs, ys, zs, w, h, x_range, y_range)
|
||||
assert got.shape == (h, w)
|
||||
np.testing.assert_allclose(got, ref, equal_nan=True)
|
||||
|
||||
|
||||
def test_bin_mean_core_empty_and_single():
|
||||
"""Cellules vides → NaN ; un seul point → sa valeur partout où présent."""
|
||||
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
|
||||
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)
|
||||
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
|
||||
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."""
|
||||
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)
|
||||
w, h = 100, 100
|
||||
n = 20000
|
||||
xs = rng.uniform(*x_range, n)
|
||||
ys = rng.uniform(*y_range, n)
|
||||
zs = rng.uniform(50.0, 150.0, n)
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
from lidar_pipeline import gpu
|
||||
|
||||
class _FakeCP: # GPU « actif » sans CuPy
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(gpu, "_cp", _FakeCP())
|
||||
monkeypatch.setattr(gpu, "HAS_GPU", True)
|
||||
calls = []
|
||||
|
||||
def f(x):
|
||||
calls.append(x)
|
||||
if len(calls) == 1:
|
||||
raise TypeError("Unsupported type <class 'numpy.ndarray'>")
|
||||
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._cp is None
|
||||
# Mode CPU : l'erreur se relance telle quelle (rien à retrancher)
|
||||
def g(x):
|
||||
raise ValueError("boom")
|
||||
try:
|
||||
gpu.safe_gpu_call(g, 1)
|
||||
raise AssertionError("devait relancer l'erreur")
|
||||
except ValueError:
|
||||
pass
|
||||
Reference in New Issue
Block a user