Raccorder les bords de tuiles voisines et encadrer les rendus en cours

Le MNT de chaque dalle s'étend d'une bande de 100 m remplie avec les
points sol des 8 tuiles voisines (option « Raccord des bords ») : les
rendus à grand noyau (openness, SVF, LRM) deviennent continus d'une
dalle à l'autre, les images restent recadrées sur le kilomètre exact.

Pendant une génération, la carte encadre les dalles du run : orange
pulsant en cours de rendu, rouge en échec — les coins WGS84 sont portés
par /api/status pour toutes les dalles non terminées.

La file de génération trie les dalles du nord au sud et passe à 10
workers GPU.
This commit is contained in:
Antoine Jacquin
2026-09-19 20:44:44 +02:00
parent 204b2ad111
commit 5f018fe2b6
15 changed files with 1075 additions and 247 deletions

View File

@ -140,81 +140,6 @@ class TestInterpolateHoles:
assert np.isnan(filled).all()
class TestMinReturnGrid:
def test_takes_lowest_return_per_cell(self, tmp_output_dir):
"""_min_return_grid rasterise le point le plus bas par cellule (pas la moyenne)."""
import laspy
from lidar_pipeline.dtm import _min_return_grid
out = tmp_output_dir / "pts.las"
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
# Grille 2x2 sur [0,2]x[0,2]. La cellule (0,0) porte deux points z=5 et
# z=2 (min=2, moyenne=3.5) ; (1,0) z=3 ; (0,1) z=4 ; (1,1) vide.
las.x = [0.2, 0.5, 1.2, 0.3]
las.y = [0.2, 0.3, 0.4, 1.5]
las.z = [5.0, 2.0, 3.0, 4.0]
las.write(str(out))
grid = _min_return_grid(out, 2, 2, (0.0, 0.0, 2.0, 2.0))
assert grid.shape == (2, 2)
assert int(np.isnan(grid).sum()) == 1
# Une seule valeur par cellule, et la cellule (0,0) vaut le MIN (2.0).
vals = sorted(float(v) for v in grid[~np.isnan(grid)])
assert vals == [2.0, 3.0, 4.0]
assert 3.5 not in vals
class TestBareEarth:
"""Le plancher « sol nu » ramène le DTM au retour le plus bas de chaque cellule."""
def _write_las(self, path, points):
"""points: list of (x, y, z). Écrit un LAS 1.2 format 0 aux bornes [0,2]x[0,2]."""
import laspy
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
las.x = [p[0] for p in points]
las.y = [p[1] for p in points]
las.z = [p[2] for p in points]
las.write(str(path))
return path
def _make_clouds(self, tmp_output_dir):
"""Grille 2x2 (res=1.0). Les points « coin » à 0.05/1.95 imposent l'étendue
[0.05,1.95] (laspy re-déduit les bornes de l'en-tête depuis les points).
Le sol (las_file) vaut z=10 partout. Le nuage complet (source_laz) a un
retour plus bas dans les cellules (0,0) -> 2 et (1,0) -> 5 ; les deux autres
cellules n'ont que z=10."""
ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0), (1.5, 1.5, 10.0),
(0.05, 0.05, 10.0), (1.95, 1.95, 10.0)]
source = list(ground) + [(0.3, 0.3, 2.0), (1.3, 0.3, 5.0)]
las_file = self._write_las(tmp_output_dir / "ground.las", ground)
source_laz = self._write_las(tmp_output_dir / "source.las", source)
return las_file, source_laz
def _dtm_values(self, tmp_output_dir, bare_earth):
from lidar_pipeline.dtm import create_dtm_fast
import rasterio
las_file, source_laz = self._make_clouds(tmp_output_dir)
out = create_dtm_fast(las_file, "tile", tmp_output_dir, 1.0,
force=True, source_laz=source_laz, bare_earth=bare_earth)
assert out is not None
with rasterio.open(str(out)) as src:
arr = src.read(1).astype("float64")
return arr[~np.isnan(arr)]
def test_bare_earth_pulls_dtm_to_lowest_return(self, tmp_output_dir):
"""Avec bare_earth, le DTM descend aux retours les plus bas (2 et 5)."""
vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=True))
# Les cellules sans retour plus bas restent à 10 ; les deux autres descendent.
assert vals == [2.0, 5.0, 10.0, 10.0]
assert vals[0] == 2.0 and vals[1] == 5.0
def test_no_bare_earth_keeps_mean(self, tmp_output_dir):
"""Sans bare_earth, le DTM garde la moyenne des points sol (10 partout)."""
vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=False))
assert all(v == 10.0 for v in vals)
class TestDetectGroundMethod:
def _make_mock_las(self, num_returns, z_values):
"""Create a mock laspy object with specified NumberOfReturns and z."""
@ -615,6 +540,69 @@ class TestStripVerticalOffsets:
assert _strip_vertical_offsets(x, y, z, psid) == {}
class TestStripJitterOffsets:
"""Gigue verticale intra-faisceau par fenêtres de temps GPS."""
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."""
rng = np.random.default_rng(seed)
x = rng.uniform(0, extent, n)
y = rng.uniform(0, extent, n)
clean = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n)
t = rng.uniform(0, duration, n)
psid = rng.integers(0, 2, n).astype(np.uint16)
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.
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.
"""
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
w = 2 * np.pi / 8.0
x, y, z, psid, t, clean = self._synthetic(
lambda tt, p: 0.04 * np.sin(w * tt) * (p == 1))
jitter = _strip_jitter_offsets(x, y, z, psid, t)
assert set(jitter) == {0, 1}
resid = z - _apply_strip_jitter(psid, t, jitter) - clean
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"
def test_tracks_step_offset(self):
"""Un échelon −3 cm sur la seconde moitié du vol est suivi."""
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
m = (t >= lo) & (t < lo + 2.0)
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
def test_apply_interpolates_linearly(self):
"""Interpolation entre centres de fenêtres ; 0 hors faisceau connu."""
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)
t = np.array([10.0, 10.5, 15.0, 10.5])
np.testing.assert_allclose(
_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."""
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) == {}
t_nan = t.copy()
t_nan[0] = np.nan
assert _strip_jitter_offsets(x, y, z, psid, t_nan) == {}
class TestStripAlignSidecar:
def test_sidecar_roundtrip_and_threshold(self, tmp_path):
"""Sidecar records version/threshold/offsets and matches config."""
@ -627,6 +615,22 @@ class TestStripAlignSidecar:
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["jitter"] == {}
def test_sidecar_records_jitter_series(self, tmp_path):
"""Le sidecar consigne les séries de gigue et leurs paramètres."""
from lidar_pipeline.dtm import (
_write_strip_align_sidecar, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
import json
jitter = {11: (np.array([0.05, 0.15]), np.array([0.012, -0.008]))}
_write_strip_align_sidecar(tmp_path, "T", "", {}, jitter)
data = json.loads((tmp_path / "T_dtm_stripalign.json").read_text())
assert data["jitter_bin"] == STRIP_JITTER_BIN
assert data["jitter_smooth"] == STRIP_JITTER_SMOOTH
entry = data["jitter"]["11"]
assert entry["bins"] == 2
assert entry["series_m"] == [0.012, -0.008]
assert entry["max_m"] == 0.012
def test_pipeline_match_logic(self, tmp_path):
"""_strip_align_matches invalidates legacy DTMs and config changes."""
@ -651,5 +655,114 @@ class TestStripAlignSidecar:
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
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
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")
class TestEdgeBuffer:
"""Raccord des bords : MNT étendu par les points sol des tuiles voisines."""
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
def _write_las(self, path, points, classification=None):
import laspy
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
las.x = [p[0] for p in points]
las.y = [p[1] for p in points]
las.z = [p[2] for p in points]
if classification is not None:
las.classification = classification
las.write(str(path))
return path
def _write_clouds(self, root, res=50.0):
"""Tuile centrale à z=10 + voisine EST à z=20 (un point par maille res)."""
import numpy as np
input_dir = root / "input"
input_dir.mkdir(exist_ok=True)
min_x, min_y, max_x, max_y = self.NOMINAL
xs = np.arange(min_x + res / 2, max_x, res)
ys = np.arange(min_y + res / 2, max_y, res)
gx, gy = np.meshgrid(xs, ys)
main = list(zip(gx.ravel(), gy.ravel(), np.full(gx.size, 10.0)))
nxs = xs + 1000.0
ngx, ngy = np.meshgrid(nxs, ys)
east = list(zip(ngx.ravel(), ngy.ravel(), np.full(ngx.size, 20.0)))
ground = self._write_las(root / "ground.las", main)
source = self._write_las(input_dir / f"{self.BASENAME}.copc.laz", main)
self._write_las(input_dir / "LHD_FXX_0639_6628_PTS_LAMB93_IGN69.copc.laz",
east, classification=[2] * len(east))
return ground, source, input_dir
def test_neighbor_discovery(self, tmp_output_dir):
from lidar_pipeline.dtm import _neighbor_laz_files
(tmp_output_dir / f"{self.BASENAME}.copc.laz").touch()
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
(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} == {
f"LHD_FXX_{c}_{r}_PTS_LAMB93_IGN69.copc.laz" for c, r in present}
def test_neighbor_discovery_non_lhd(self, tmp_output_dir):
from lidar_pipeline.dtm import _neighbor_laz_files
src = tmp_output_dir / "nuage_arbitraire.laz"
src.touch()
assert _neighbor_laz_files(src) == []
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."""
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)
dtm = create_dtm_fast(ground, self.BASENAME, tmp_output_dir, 50.0,
force=True, source_laz=source, strip_align=False,
edge_buffer=100.0, neighbor_classes=[2])
assert dtm is not None
with rasterio.open(str(dtm)) as src:
assert (src.width, src.height) == (24, 24)
assert abs(src.bounds.left - 637900.0) < 1e-6
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 read_dtm_edge_buffer(dtm) == 100.0
def test_unbuffered_dtm_has_no_tag(self, tmp_output_dir):
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)
dtm = create_dtm_fast(ground, self.BASENAME, tmp_output_dir, 50.0,
force=True, source_laz=source, strip_align=False)
assert dtm is not None
with rasterio.open(str(dtm)) as src:
assert EDGE_BUFFER_TAG not in src.tags()
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."""
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer
import rasterio
ground = self._write_las(tmp_output_dir / "ground.las",
[(0.5, 0.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)])
source = self._write_las(tmp_output_dir / "nuage.laz",
[(0.5, 0.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)])
dtm = create_dtm_fast(ground, "nuage", tmp_output_dir, 1.0,
force=True, source_laz=source, strip_align=False,
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 read_dtm_edge_buffer(dtm) == 0.0