Caler les faisceaux de vol, décimer l'openness et cibler les couches affichées
Trois chantiers liés à la qualité et au coût des rendus : - Calage vertical des faisceaux : les passes d'une tuile peuvent être biaisées de quelques cm (±2,5 cm mesurés sur 1000_6882), créant des marches aux recouvrements. Les offsets par PointSourceId sont mesurés sur les points sol de la tuile (réf. médiane itérée) et retranchés ≥ 0,5 cm avant rastérisation, avec sidecar de cache et application au plancher bare-earth. - Openness décimée ×2 : lancé de rayons sur grille par blocs (max/min) puis rééchantillonnage bilinéaire — 532 s → 40 s par tuile à 0,2 m sur CPU, signal archéologique préservé. Réglable --openness-downsample. - Génération webapp concentrée sur les couches affichées : les défauts /api/generate, /api/preview et le sélecteur génèrent le panneau complet (slope, aspect, pos_open) au lieu d'aspect seul.
This commit is contained in:
@ -567,3 +567,89 @@ class TestStripLidarExt:
|
||||
from lidar_pipeline.dtm import _strip_lidar_ext
|
||||
from pathlib import Path
|
||||
assert _strip_lidar_ext(Path("/data/input/file.copc.laz")) == "file"
|
||||
|
||||
|
||||
class TestStripVerticalOffsets:
|
||||
"""Vertical de-bias of flight-line point sources (strip alignment)."""
|
||||
|
||||
def _synthetic(self, offsets, n=1_500_000, extent=300.0, seed=0):
|
||||
"""Interleaved sources over one tile; each carries a known Z bias."""
|
||||
rng = np.random.default_rng(seed)
|
||||
x = rng.uniform(0, extent, n)
|
||||
y = rng.uniform(0, extent, n)
|
||||
z = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n)
|
||||
psid = rng.integers(0, len(offsets), n)
|
||||
return x, y, z + np.asarray(offsets)[psid], psid.astype(np.uint16)
|
||||
|
||||
def test_recovers_known_offsets(self):
|
||||
"""±5 cm biases are recovered; the aligned source stays untouched."""
|
||||
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||
x, y, z, psid = self._synthetic((0.0, 0.05, -0.05))
|
||||
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
|
||||
|
||||
def test_small_bias_below_threshold_ignored(self):
|
||||
"""Biases under the 0.5 cm threshold trigger no correction."""
|
||||
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||
x, y, z, psid = self._synthetic((0.0, 0.003, -0.003))
|
||||
assert _strip_vertical_offsets(x, y, z, psid) == {}
|
||||
|
||||
def test_single_source_returns_empty(self):
|
||||
"""A single point source cannot be compared: no offsets."""
|
||||
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||
x, y, z, psid = self._synthetic((0.05,))
|
||||
assert _strip_vertical_offsets(x, y, z, psid) == {}
|
||||
|
||||
def test_no_shared_cells_returns_empty(self):
|
||||
"""Sources covering disjoint areas (no overlap) are not corrected."""
|
||||
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||
rng = np.random.default_rng(1)
|
||||
n = 200_000
|
||||
x = np.concatenate([rng.uniform(0, 100, n), rng.uniform(200, 300, n)])
|
||||
y = rng.uniform(0, 300, 2 * n)
|
||||
z = 100 + rng.normal(0, 0.01, 2 * n)
|
||||
psid = np.concatenate([np.zeros(n, np.uint16), np.ones(n, np.uint16)])
|
||||
z[psid == 1] += 0.10
|
||||
assert _strip_vertical_offsets(x, y, z, psid) == {}
|
||||
|
||||
|
||||
class TestStripAlignSidecar:
|
||||
def test_sidecar_roundtrip_and_threshold(self, tmp_path):
|
||||
"""Sidecar records version/threshold/offsets and matches config."""
|
||||
from lidar_pipeline.dtm import (
|
||||
_write_strip_align_sidecar, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD)
|
||||
import json
|
||||
offsets = {1049: 0.026, 1147: -0.026}
|
||||
_write_strip_align_sidecar(tmp_path, "TILE", "_r0p2", offsets)
|
||||
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
|
||||
|
||||
def test_pipeline_match_logic(self, tmp_path):
|
||||
"""_strip_align_matches invalidates legacy DTMs and config changes."""
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
from lidar_pipeline.dtm import _write_strip_align_sidecar
|
||||
import json
|
||||
|
||||
class P(LidarArchaeoPipeline):
|
||||
def __init__(self, out, strip_align):
|
||||
self.output_dir = out
|
||||
self.dtm_dir = out / "DTM"
|
||||
self.dtm_dir.mkdir(exist_ok=True)
|
||||
self.strip_align = strip_align
|
||||
|
||||
p = P(tmp_path, strip_align=True)
|
||||
# DTM hérité sans sidecar : à régénérer
|
||||
assert not p._strip_align_matches("TILE", "_r0p2")
|
||||
# Sidecar conforme : valide
|
||||
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
|
||||
assert p._strip_align_matches("TILE", "_r0p2")
|
||||
# Seuil différent : à régénérer
|
||||
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")
|
||||
# Calage désactivé + DTM calé : à régénérer
|
||||
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")
|
||||
|
||||
@ -80,6 +80,52 @@ class TestOpenness:
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
|
||||
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.
|
||||
"""
|
||||
import rasterio
|
||||
from rasterio.transform import from_bounds
|
||||
from lidar_pipeline import visualizations
|
||||
from lidar_pipeline.visualizations import generate_openness
|
||||
|
||||
size = 240
|
||||
x = np.linspace(0, size, size)
|
||||
X, Y = np.meshgrid(x, x)
|
||||
dem = 100.0 + 0.01 * X + 0.005 * Y
|
||||
dem += 5.0 * np.exp(-((X - 120)**2 + (Y - 120)**2) / (2 * 40**2))
|
||||
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).
|
||||
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',
|
||||
transform=from_bounds(660000, 6700000, 660240, 6700240, size, size)) as dst:
|
||||
dst.write(dem.astype('float32'), 1)
|
||||
|
||||
saved = visualizations.OPENNESS_DOWNSAMPLE
|
||||
try:
|
||||
visualizations.OPENNESS_DOWNSAMPLE = 1
|
||||
r1 = generate_openness(dem_file, "full", tmp_output_dir, 1.0, positive=True)
|
||||
visualizations.OPENNESS_DOWNSAMPLE = 2
|
||||
r2 = generate_openness(dem_file, "dec", tmp_output_dir, 1.0, positive=True)
|
||||
finally:
|
||||
visualizations.OPENNESS_DOWNSAMPLE = saved
|
||||
|
||||
with rasterio.open(r1) as s1, rasterio.open(r2) as s2:
|
||||
a, b = s1.read(1), s2.read(1)
|
||||
assert a.shape == b.shape
|
||||
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}"
|
||||
|
||||
|
||||
class TestMSLRM:
|
||||
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
||||
|
||||
@ -558,12 +558,15 @@ def test_start_next_queued_launches_after_run(tmp_path, monkeypatch):
|
||||
webapp._queue[:] = saved_queue
|
||||
|
||||
|
||||
def test_build_command_default_viz_aspect():
|
||||
"""Sans choix de visualisation, la commande génère uniquement aspect."""
|
||||
from lidar_pipeline.webapp import _build_command
|
||||
def test_build_command_default_viz_panel():
|
||||
"""Sans choix de visualisation, la commande génère les couches affichées."""
|
||||
from lidar_pipeline.webapp import _build_command, _panel_viz_steps
|
||||
cmd = _build_command([(1054, 6882)])
|
||||
i = cmd.index("--only")
|
||||
assert cmd[i + 1] == "aspect"
|
||||
expected = _panel_viz_steps()
|
||||
n = len(expected)
|
||||
assert cmd[i + 1:i + 1 + n] == expected
|
||||
assert "pos_open" in expected # panneau : slope, aspect, positive_openness
|
||||
|
||||
|
||||
def test_build_command_custom_viz():
|
||||
|
||||
Reference in New Issue
Block a user