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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user