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:
Antoine Jacquin
2026-09-19 00:53:22 +02:00
parent 796e68c870
commit 41206f65de
11 changed files with 455 additions and 25 deletions

View File

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