Borner le comblement du MNT à l'enveloppe des points, ajouter la couche précision et un affichage relief/précision, encoder les sous-tuiles une seule fois en q75
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -140,6 +140,105 @@ class TestInterpolateHoles:
|
||||
assert np.isnan(filled).all()
|
||||
|
||||
|
||||
class TestFillSmallGaps:
|
||||
"""Comblement borné à l'enveloppe des points (plus de pastilles ni de liseré)."""
|
||||
|
||||
@staticmethod
|
||||
def _grid(n=200, step=2, value=10.0):
|
||||
"""Semis régulier de points (1 pixel sur `step`) sur n × n pixels."""
|
||||
dtm = np.full((n, n), np.nan)
|
||||
dtm[::step, ::step] = value
|
||||
return dtm
|
||||
|
||||
def test_isolated_point_is_removed_not_grown(self):
|
||||
from lidar_pipeline.dtm import _fill_small_gaps
|
||||
dtm = np.full((100, 100), np.nan)
|
||||
dtm[50, 50] = 5.0
|
||||
out, filled, removed = _fill_small_gaps(dtm, 0.2)
|
||||
assert np.isnan(out).all() # ni pastille, ni point seul
|
||||
assert (filled, removed) == (0, 1)
|
||||
|
||||
def test_gaps_between_points_filled_without_edge_band(self):
|
||||
from lidar_pipeline.dtm import _fill_small_gaps
|
||||
dtm = self._grid()
|
||||
dtm[:, 100:] = np.nan # grand trou à l'est
|
||||
out, filled, _ = _fill_small_gaps(dtm, 0.2)
|
||||
assert filled > 0
|
||||
assert not np.isnan(out[10:190, 10:99]).any() # vides entre points comblés
|
||||
assert np.isnan(out[:, 99:]).all() # rien d'extrapolé dans le trou
|
||||
|
||||
def test_radius_follows_local_density(self):
|
||||
"""Semis clairsemé (1 point / 1,8 m, vides de 2,5 m en diagonale)
|
||||
comblé ; trou de 3 m en zone dense conservé ; trou de 1,6 m comblé."""
|
||||
from lidar_pipeline.dtm import _fill_small_gaps
|
||||
sparse = self._grid(step=9)
|
||||
out, _, _ = _fill_small_gaps(sparse, 0.2)
|
||||
assert not np.isnan(out[30:170, 30:170]).any()
|
||||
dense = self._grid(step=1)
|
||||
dense[90:105, 90:105] = np.nan # trou de 3 m dans un semis plein
|
||||
dense[40:48, 40:48] = np.nan # trou de 1,6 m (voiture)
|
||||
out, _, _ = _fill_small_gaps(dense, 0.2)
|
||||
assert np.isnan(out[95:100, 95:100]).all()
|
||||
assert not np.isnan(out[40:48, 40:48]).any()
|
||||
|
||||
def test_morph_disk_matches_scipy(self):
|
||||
from scipy import ndimage as nd
|
||||
from lidar_pipeline.dtm import _morph_disk
|
||||
rng = np.random.default_rng(0)
|
||||
mask = rng.random((60, 70)) < 0.05
|
||||
square = np.ones((3, 3), dtype=bool)
|
||||
cross = nd.generate_binary_structure(2, 1)
|
||||
ref = mask
|
||||
for i in range(5):
|
||||
ref = nd.binary_dilation(ref, structure=square if i % 2 == 0 else cross)
|
||||
assert (_morph_disk(mask, 5) == ref).all()
|
||||
ref_e = ref
|
||||
for i in range(5):
|
||||
ref_e = nd.binary_erosion(ref_e, structure=square if i % 2 == 0 else cross,
|
||||
border_value=1)
|
||||
assert (_morph_disk(ref, 5, erode=True) == ref_e).all()
|
||||
|
||||
def test_dtm_records_gap_fill_version(self, tmp_output_dir):
|
||||
import laspy
|
||||
import rasterio
|
||||
from lidar_pipeline.dtm import (create_dtm_fast, read_dtm_gap_fill,
|
||||
GAP_FILL_VERSION)
|
||||
hdr = laspy.LasHeader(version='1.2', point_format=0)
|
||||
las = laspy.LasData(hdr)
|
||||
las.x, las.y, las.z = [0.5, 1.5, 0.5, 1.5], [0.5, 0.5, 1.5, 1.5], [10.0] * 4
|
||||
las.write(str(tmp_output_dir / "g.las"))
|
||||
out = create_dtm_fast(tmp_output_dir / "g.las", "g", tmp_output_dir, 1.0,
|
||||
force=True, strip_align=False)
|
||||
assert read_dtm_gap_fill(out) == GAP_FILL_VERSION
|
||||
with rasterio.open(str(out)) as src:
|
||||
src.tags() # lisible
|
||||
legacy = tmp_output_dir / "legacy.tif"
|
||||
with rasterio.open(str(legacy), "w", driver="GTiff", width=2, height=2,
|
||||
count=1, dtype="float32") as dst:
|
||||
dst.write(np.zeros((1, 2, 2), dtype="float32"))
|
||||
assert read_dtm_gap_fill(legacy) == 1
|
||||
|
||||
|
||||
class TestDensitySidecar:
|
||||
def test_dtm_writes_ground_density(self, tmp_output_dir):
|
||||
"""4 points par m² sur 10 × 10 m : densité 4 au cœur, grille de 1 m."""
|
||||
import laspy
|
||||
import rasterio
|
||||
from lidar_pipeline.dtm import create_dtm_fast, density_path
|
||||
g = (np.arange(20) + 0.25) / 2.0 # pas de 0,5 m
|
||||
xx, yy = np.meshgrid(g, g)
|
||||
hdr = laspy.LasHeader(version='1.2', point_format=0)
|
||||
las = laspy.LasData(hdr)
|
||||
las.x, las.y, las.z = xx.ravel(), yy.ravel(), np.full(xx.size, 10.0)
|
||||
las.write(str(tmp_output_dir / "d.las"))
|
||||
out = create_dtm_fast(tmp_output_dir / "d.las", "d", tmp_output_dir, 0.5,
|
||||
force=True, strip_align=False)
|
||||
with rasterio.open(density_path(out)) as src:
|
||||
dens = src.read(1)
|
||||
assert abs(src.transform.a - 1.0) < 1e-9
|
||||
assert np.allclose(dens[2:-2, 2:-2], 4.0)
|
||||
|
||||
|
||||
class TestDetectGroundMethod:
|
||||
def _make_mock_las(self, num_returns, z_values):
|
||||
"""Create a mock laspy object with specified NumberOfReturns and z."""
|
||||
|
||||
@ -287,17 +287,15 @@ def test_subtiles_cover_all_layers(tmp_path):
|
||||
|
||||
|
||||
def test_panel_restricted_to_kept_layers():
|
||||
"""Le panneau est restreint aux couches conservées (PANEL_VIZ).
|
||||
|
||||
Les couches allumées par défaut (DEFAULT_LAYERS) y figurent toutes — le
|
||||
panneau peut en proposer davantage (ex. pente éteinte par défaut), et
|
||||
chaque opacité par défaut vise une couche allumée.
|
||||
"""
|
||||
from lidar_pipeline.index import (PANEL_VIZ, DEFAULT_LAYERS, DEFAULT_OPACITY,
|
||||
KEYWORD_TO_STEP)
|
||||
"""Le panneau est restreint aux couches conservées (PANEL_VIZ) : la couche
|
||||
principale par défaut et la précision y figurent."""
|
||||
from lidar_pipeline.index import (PANEL_VIZ, DEFAULT_VIZ, PRECISION_VIZ, VIEW_MODES,
|
||||
DEFAULT_VIEW_MODE, KEYWORD_TO_STEP, default_main_layer)
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
assert set(DEFAULT_LAYERS) <= set(PANEL_VIZ)
|
||||
assert set(DEFAULT_OPACITY) <= set(DEFAULT_LAYERS)
|
||||
assert DEFAULT_VIZ in PANEL_VIZ and PRECISION_VIZ in PANEL_VIZ
|
||||
assert DEFAULT_VIEW_MODE in VIEW_MODES
|
||||
assert default_main_layer(["densite_sol", "aspect"]) == "aspect"
|
||||
assert default_main_layer(["densite_sol"]) is None
|
||||
# Chaque couche conservée correspond à une étape --only valide
|
||||
steps = {name for name, _ in VIZ_STEPS}
|
||||
for key in PANEL_VIZ:
|
||||
|
||||
@ -210,7 +210,9 @@ def test_map_meta(tmp_path, monkeypatch):
|
||||
assert meta["tile_size"] == 512 and meta["zoom_offset"] == -1
|
||||
assert meta["max_native_zoom"] == 19
|
||||
assert meta["bounds"] and meta["stamp"] > 0
|
||||
assert meta["default_layers"] == ["relief_oriente"]
|
||||
assert meta["default_main"] == "relief_oriente"
|
||||
assert meta["precision_layer"] is None # densité absente de la fixture
|
||||
assert meta["default_mode"] == "relief"
|
||||
|
||||
|
||||
def test_only_panel_layers_are_served(tmp_path, monkeypatch):
|
||||
@ -256,31 +258,31 @@ def test_healthz_and_assets(tmp_path, monkeypatch):
|
||||
mapserve.assets("introuvable.js")
|
||||
|
||||
|
||||
def test_ui_offers_reorder_and_blend_modes():
|
||||
"""Pile de couches : réordonnancement au doigt ET modes de fusion.
|
||||
def test_ui_main_layer_and_precision_modes():
|
||||
"""Une couche d'affichage principal et la précision : relief seul,
|
||||
précision seule, ou précision en « produit » sur le relief.
|
||||
|
||||
La superposition ne se réduit pas à de la transparence : chaque couche
|
||||
porte un mix-blend-mode, cantonné à la pile LiDAR par un conteneur isolé
|
||||
(sinon « Produit » assombrirait aussi le fond de carte).
|
||||
Plus de pile réordonnable ni de fusion par couche. Le produit est
|
||||
cantonné à la pile LiDAR par un conteneur isolé (sinon il assombrirait
|
||||
aussi le fond de carte). Bascule rapide au clavier (touche P).
|
||||
"""
|
||||
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
|
||||
# Modes proposés
|
||||
for mode in ("multiply", "screen", "overlay", "soft-light", "difference",
|
||||
"luminosity"):
|
||||
assert f"'{mode}'" in _MAP_JS, mode
|
||||
# Réordonnancement : glisser-déposer ET boutons (seuls utilisables au doigt)
|
||||
assert "function moveLayer(" in _MAP_JS
|
||||
assert "moveLayer(key, 1)" in _MAP_JS and "moveLayer(key, -1)" in _MAP_JS
|
||||
assert "dragstart" in _MAP_JS and "layer-move" in _MAP_CSS
|
||||
# Pile isolée + fusion par couche et fusion de la pile sur le fond
|
||||
assert "createPane('lidarStack')" in _MAP_JS
|
||||
assert "isolation = 'isolate'" in _MAP_JS
|
||||
assert "map.createPane(paneName, stack)" in _MAP_JS
|
||||
assert "pane.style.mixBlendMode" in _MAP_JS
|
||||
assert "STATE.stackBlend" in _MAP_JS and 'id="stackBlend"' in _MAP_HTML
|
||||
# Le lien partagé transporte la fusion (4e champ, rétrocompatible)
|
||||
assert "(STATE.blend[k] || 'normal')" in _MAP_JS
|
||||
assert "const b = validBlend(f[3]);" in _MAP_JS
|
||||
for mode in ('data-mode="relief"', 'data-mode="precision"', 'data-mode="both"'):
|
||||
assert mode in _MAP_HTML
|
||||
assert 'id="precOpacity"' in _MAP_HTML and 'id="precLegend"' in _MAP_HTML
|
||||
assert 'id="mainSel"' in _MAP_HTML
|
||||
assert "blend = 'multiply'" in _MAP_JS
|
||||
assert "createPane('lidarStack')" in _MAP_JS and "isolation = 'isolate'" in _MAP_JS
|
||||
assert "e.key === 'p'" in _MAP_JS
|
||||
# Légende en 16 paliers exacts (échelle log de la densité)
|
||||
assert "const PREC_LEVELS = 16;" in _MAP_JS and "repeat(16, 1fr)" in _MAP_CSS
|
||||
# Pile et fusions supprimées
|
||||
for gone in ("function moveLayer(", "reorderRelative", "stackBlend", "dragstart", "blendOptions"):
|
||||
assert gone not in _MAP_JS, gone
|
||||
assert "Haut de pile" not in _MAP_HTML
|
||||
# Lien partagé : principale, mode:opacité, fond
|
||||
assert "'&M=' + STATE.main" in _MAP_JS
|
||||
assert "'&P=' + STATE.mode" in _MAP_JS
|
||||
|
||||
|
||||
def test_map_ui_tile_load_indicator_and_selection_pane():
|
||||
@ -301,85 +303,73 @@ def test_map_ui_tile_load_indicator_and_selection_pane():
|
||||
|
||||
|
||||
def test_defaults_roundtrip(tmp_path, monkeypatch):
|
||||
"""L'état réglé depuis la carte devient la configuration servie à tous."""
|
||||
"""L'affichage réglé depuis la carte devient la configuration servie à tous."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
_setup(tmp_path, monkeypatch, layers=("relief_oriente", "aspect", "densite_sol"))
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
|
||||
|
||||
# Sans enregistrement : réglages du registre (index.py)
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["defaults_saved"] is False
|
||||
assert meta["default_order"] is None
|
||||
assert meta["default_stack_blend"] == "normal"
|
||||
assert meta["default_main"] == "relief_oriente"
|
||||
assert meta["precision_layer"] == "densite_sol"
|
||||
assert meta["default_mode"] == "relief"
|
||||
assert meta["default_precision_opacity"] == 0.6
|
||||
|
||||
req = mapserve.DefaultsRequest(
|
||||
order=["slope", "aspect"], on=["aspect"],
|
||||
opacity={"aspect": 0.6, "slope": 1.0},
|
||||
blend={"aspect": "multiply", "slope": "normal"},
|
||||
base={"on": True, "opacity": 0.4, "dark": False},
|
||||
stack_blend="soft-light")
|
||||
req = mapserve.DefaultsRequest(main="aspect", mode="both", precision_opacity=0.4,
|
||||
base={"on": True, "opacity": 0.4, "dark": False})
|
||||
assert mapserve.set_defaults(req)["enregistré"] is True
|
||||
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["defaults_saved"] is True
|
||||
assert meta["default_order"] == ["slope", "aspect"]
|
||||
assert meta["default_layers"] == ["aspect"]
|
||||
assert meta["default_opacity"]["aspect"] == 0.6
|
||||
assert meta["default_blend"]["aspect"] == "multiply"
|
||||
assert meta["default_main"] == "aspect"
|
||||
assert meta["default_mode"] == "both"
|
||||
assert meta["default_precision_opacity"] == 0.4
|
||||
assert meta["default_base"] == {"on": True, "opacity": 0.4, "dark": False}
|
||||
assert meta["default_stack_blend"] == "soft-light"
|
||||
# Persisté sur disque : survit au redémarrage du conteneur
|
||||
assert (tmp_path / ".map-defaults.json").is_file()
|
||||
assert mapserve.get_defaults()["defaults"]["on"] == ["aspect"]
|
||||
assert mapserve.get_defaults()["defaults"]["mode"] == "both"
|
||||
|
||||
# Retrait : retour aux réglages du registre
|
||||
assert mapserve.clear_defaults()["supprimé"] is True
|
||||
assert mapserve.map_meta()["defaults_saved"] is False
|
||||
|
||||
|
||||
def test_defaults_obsolete_layers_fall_back(tmp_path, monkeypatch):
|
||||
"""Défauts figés sur des couches qui ne sont plus servies : registre.
|
||||
|
||||
Sinon un nouveau navigateur arrive sur une carte sans aucune couche
|
||||
LiDAR allumée (cas de la prod après le passage au seul relief orienté).
|
||||
"""
|
||||
def test_defaults_legacy_stack_file_falls_back(tmp_path, monkeypatch):
|
||||
"""Fichier de l'ancienne pile (order/on/blend) ou couche disparue :
|
||||
couche principale du registre, mode relief ; le fond est conservé."""
|
||||
import json
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch, layers=("relief_oriente",))
|
||||
defaults = tmp_path / ".map-defaults.json"
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", defaults)
|
||||
defaults.write_text(json.dumps({
|
||||
"order": ["aspect", "positive_openness"],
|
||||
"on": ["aspect", "positive_openness"], "opacity": {}, "blend": {},
|
||||
"base": {"on": True, "opacity": 0.85, "dark": True},
|
||||
"stack_blend": "normal"}), encoding="utf-8")
|
||||
"order": ["aspect"], "on": ["aspect"], "opacity": {}, "blend": {},
|
||||
"base": {"on": True, "opacity": 0.5, "dark": True}, "stack_blend": "normal"}),
|
||||
encoding="utf-8")
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["default_layers"] == ["relief_oriente"]
|
||||
# Tout éteint volontairement (couches connues) : respecté
|
||||
defaults.write_text(json.dumps({
|
||||
"order": ["relief_oriente"], "on": [], "opacity": {}, "blend": {},
|
||||
"base": {}, "stack_blend": "normal"}), encoding="utf-8")
|
||||
assert mapserve.map_meta()["default_layers"] == []
|
||||
assert meta["default_main"] == "relief_oriente"
|
||||
assert meta["default_mode"] == "relief"
|
||||
assert meta["default_base"]["opacity"] == 0.5
|
||||
defaults.write_text(json.dumps({"main": "aspect", "mode": "vaudou"}), encoding="utf-8")
|
||||
meta = mapserve.map_meta()
|
||||
assert meta["default_main"] == "relief_oriente" and meta["default_mode"] == "relief"
|
||||
|
||||
|
||||
def test_defaults_sanitised(tmp_path, monkeypatch):
|
||||
"""Couches inconnues écartées, opacités bornées, fusions validées."""
|
||||
"""Couche inconnue ou précision refusée comme principale, mode validé, opacités bornées."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
_setup(tmp_path, monkeypatch)
|
||||
_setup(tmp_path, monkeypatch, layers=("relief_oriente", "densite_sol"))
|
||||
monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json")
|
||||
data = mapserve.set_defaults(mapserve.DefaultsRequest(
|
||||
order=["aspect", "inexistante"], on=["aspect", "inexistante"],
|
||||
opacity={"aspect": 5, "slope": "x", "inexistante": 0.5},
|
||||
blend={"aspect": "vaudou", "slope": "screen"},
|
||||
base={"opacity": -3}, stack_blend="vaudou"))["defaults"]
|
||||
assert "inexistante" not in data["order"] and "inexistante" not in data["on"]
|
||||
# Les couches présentes mais non citées complètent l'ordre
|
||||
assert set(data["order"]) == {"aspect", "slope"}
|
||||
assert data["opacity"]["aspect"] == 1.0 # borné à 1
|
||||
assert data["opacity"]["slope"] == 1.0 # valeur illisible → opaque
|
||||
assert data["blend"] == {"slope": "screen"} # mode inconnu écarté
|
||||
main="inexistante", mode="vaudou", precision_opacity=5,
|
||||
base={"opacity": -3}))["defaults"]
|
||||
assert data["main"] is None
|
||||
assert data["mode"] == "relief"
|
||||
assert data["precision_opacity"] == 1.0 # borné à 1
|
||||
assert data["base"]["opacity"] == 0.0 # borné à 0
|
||||
assert data["stack_blend"] == "normal" # repli
|
||||
data = mapserve.set_defaults(mapserve.DefaultsRequest(main="densite_sol"))["defaults"]
|
||||
assert data["main"] is None # la précision n'est pas une principale
|
||||
|
||||
|
||||
def test_ui_applies_server_defaults():
|
||||
@ -387,38 +377,13 @@ def test_ui_applies_server_defaults():
|
||||
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
|
||||
assert 'id="btnDefault"' in _MAP_HTML and 'id="btnReset"' in _MAP_HTML
|
||||
assert "api/map/defaults" in _MAP_JS
|
||||
assert "meta.default_order" in _MAP_JS
|
||||
assert "meta.default_main" in _MAP_JS and "meta.default_mode" in _MAP_JS
|
||||
assert "meta.default_base" in _MAP_JS
|
||||
assert "validBlend(meta.default_stack_blend)" in _MAP_JS
|
||||
assert "meta.default_precision_opacity" in _MAP_JS
|
||||
# Réinitialiser oublie l'état local avant de reprendre celui du serveur
|
||||
assert "localStorage.removeItem(LS_KEY)" in _MAP_JS
|
||||
|
||||
|
||||
def test_ui_reorder_shows_destination():
|
||||
"""Réorganiser doit être lisible : repère de chute, fantôme, arrivée signalée.
|
||||
|
||||
Sans repère, on lâche une couche sans savoir où elle atterrit — la pile
|
||||
n'est pas un détail, elle décide de ce qu'on voit.
|
||||
"""
|
||||
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
|
||||
# Barre d'insertion selon la moitié survolée, effacée entre deux survols
|
||||
assert ".layer-row.drop-before" in _MAP_CSS and ".layer-row.drop-after" in _MAP_CSS
|
||||
assert "clearDropMarks" in _MAP_JS
|
||||
assert "box.height / 2" in _MAP_JS
|
||||
assert "row.classList.add(before ? 'drop-before' : 'drop-after')" in _MAP_JS
|
||||
# Ligne déplacée estompée pendant le glisser
|
||||
assert ".layer-row.dragging" in _MAP_CSS and "classList.add('dragging')" in _MAP_JS
|
||||
# Insertion relative (avant/après la ligne visée), pas un simple échange
|
||||
assert "function reorderRelative(" in _MAP_JS
|
||||
# Arrivée mise en évidence puis ramenée dans le champ de vision
|
||||
assert "just-moved" in _MAP_CSS and "@keyframes moved" in _MAP_CSS
|
||||
assert "scrollIntoView" in _MAP_JS
|
||||
assert "renderPanel(key)" in _MAP_JS and "renderPanel(src)" in _MAP_JS
|
||||
# Extrémités de la pile nommées
|
||||
assert 'id="edgeTop"' in _MAP_HTML and 'id="edgeBottom"' in _MAP_HTML
|
||||
assert "Haut de pile" in _MAP_HTML and "Bas de pile" in _MAP_HTML
|
||||
|
||||
|
||||
def test_ui_uses_standard_tilelayer():
|
||||
"""L'interface s'appuie sur le LOD natif de Leaflet, pas sur un palier maison."""
|
||||
from lidar_pipeline.mapui import _MAP_JS, render_html, ui_version
|
||||
|
||||
@ -20,16 +20,16 @@ class TestVizSteps:
|
||||
assert len(names) == len(set(names)), "VIZ_STEPS has duplicate names"
|
||||
|
||||
def test_expected_visualization_count(self):
|
||||
"""Should have 16 visualizations (14 terrain + ortho + topo)."""
|
||||
"""17 visualisations : 14 terrain + densité de points + ortho + topo."""
|
||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||
assert len(VIZ_STEPS) == 16
|
||||
assert len(VIZ_STEPS) == 17
|
||||
|
||||
def test_default_run_produces_only_relief(self, tmp_path):
|
||||
"""Sans --only : seule la couche affichée (relief orienté) est produite ;
|
||||
--only reste libre pour les autres visualisations."""
|
||||
def test_default_run_produces_only_panel_layers(self, tmp_path):
|
||||
"""Sans --only : seules les couches affichées (relief orienté, densité
|
||||
de points) sont produites ; --only reste libre pour les autres."""
|
||||
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||
p = LidarArchaeoPipeline(tmp_path, tmp_path / "out")
|
||||
assert [n for n, _ in p.viz_steps] == ["relief_oriente"]
|
||||
assert [n for n, _ in p.viz_steps] == ["relief_oriente", "densite_sol"]
|
||||
p = LidarArchaeoPipeline(tmp_path, tmp_path / "out2", only_viz=["slope"])
|
||||
assert [n for n, _ in p.viz_steps] == ["slope"]
|
||||
|
||||
|
||||
@ -217,3 +217,52 @@ class TestCoreTileWindow:
|
||||
(637900, 6626900, 639100, 6628100), 1200)
|
||||
with rasterio.open(tif) as src:
|
||||
assert _core_tile_window(tif, src) is None
|
||||
|
||||
|
||||
class TestDensiteSolCrop:
|
||||
"""Densité de points : WebP sans perte en niveaux de gris, sous-tuiles
|
||||
écrites depuis l'image d'origine (un seul encodage)."""
|
||||
|
||||
def test_lossless_gray_levels_and_subtiles(self, tmp_path):
|
||||
from PIL import Image as PILImage
|
||||
from lidar_pipeline.rendering import tif_to_crop
|
||||
base = "LHD_FXX_0660_6701_PTS_LAMB93_IGN69"
|
||||
vis = tmp_path / "visualisations" / f"{base}_r0p2"
|
||||
vis.mkdir(parents=True)
|
||||
levels = np.tile(np.arange(16, dtype=np.float32).repeat(4), (64, 1)) # 64 × 64
|
||||
tif = TestTifToCrop._write_named_tif(vis, f"{base}_densite_sol.tif", levels)
|
||||
out = tif_to_crop(tif, vis, 0.2, output_format='avif', subtiles_dir=tmp_path)
|
||||
assert out is not None and out.suffix == ".webp"
|
||||
# WebP n'a pas de mode gris : relu en RGB à canaux égaux
|
||||
rgb = np.asarray(PILImage.open(str(out)).convert("RGB")).astype(int)
|
||||
assert (rgb[..., 0] == rgb[..., 1]).all() and (rgb[..., 1] == rgb[..., 2]).all()
|
||||
img = PILImage.fromarray(rgb[..., 0].astype(np.uint8))
|
||||
row = np.asarray(img)[0, ::4].astype(int)
|
||||
assert len(set(row.tolist())) == 16 and np.all(np.diff(row) > 0)
|
||||
# Sous-tuiles 2 × 2 (0,2 m/px) en WebP sans perte, identiques à la dalle
|
||||
q = tmp_path / "index_subtiles" / f"{base}_r0p2_densite_sol_0_1.webp"
|
||||
assert q.exists()
|
||||
assert np.array_equal(np.asarray(PILImage.open(str(q)).convert("L")), np.asarray(img)[:32, :32])
|
||||
assert (tmp_path / "index_subtiles" / f"{base}_r0p2_densite_sol_0_1_mid.webp").exists()
|
||||
|
||||
def test_relief_subtiles_encoded_from_source(self, tmp_path):
|
||||
"""Relief : sous-tuiles AVIF écrites par tif_to_crop, plus récentes que la dalle."""
|
||||
import pytest
|
||||
from lidar_pipeline.rendering import tif_to_crop
|
||||
base = "LHD_FXX_0660_6701_PTS_LAMB93_IGN69"
|
||||
vis = tmp_path / "visualisations" / f"{base}_r0p2"
|
||||
vis.mkdir(parents=True)
|
||||
rgb = np.random.default_rng(1).integers(0, 255, (3, 64, 64)).astype('uint8')
|
||||
tif = vis / f"{base}_relief_oriente.tif"
|
||||
with rasterio.open(tif, 'w', driver='GTiff', height=64, width=64, count=3,
|
||||
dtype='uint8', crs='EPSG:2154',
|
||||
transform=from_bounds(660000, 6700000, 661000, 6701000, 64, 64)) as dst:
|
||||
dst.write(rgb)
|
||||
try:
|
||||
out = tif_to_crop(tif, vis, 0.2, output_format='avif', subtiles_dir=tmp_path)
|
||||
except Exception:
|
||||
pytest.skip("encodeur AVIF indisponible")
|
||||
sub = tmp_path / "index_subtiles"
|
||||
quads = sorted(sub.glob(f"{base}_r0p2_relief_oriente_?_?.avif"))
|
||||
assert len(quads) == 4
|
||||
assert all(q.stat().st_mtime_ns >= out.stat().st_mtime_ns for q in quads)
|
||||
|
||||
@ -687,3 +687,28 @@ def test_existing_cache_without_registry_is_refreshed_once(tmp_path):
|
||||
tiles._seen_cache.clear()
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "fresh"
|
||||
|
||||
|
||||
def test_webp_subtiles_indexed_and_rendered_nearest(tmp_path):
|
||||
"""Couche densité : quadrants .webp sans perte reconnus comme palier fin,
|
||||
et rendus au plus proche voisin (16 gris exacts même agrandis)."""
|
||||
from PIL import Image
|
||||
from lidar_pipeline import tiles
|
||||
base = _make_dalle(tmp_path, 1054, 6882, ["densite_sol"])
|
||||
sub = tmp_path / "index_subtiles"
|
||||
sub.mkdir(parents=True, exist_ok=True)
|
||||
for i in range(2):
|
||||
for j in range(2):
|
||||
im = Image.new("L", (8, 8), 0)
|
||||
im.paste(255, (0, 0, 4, 8)) # moitié blanche, moitié noire
|
||||
im.save(str(sub / f"{base}_densite_sol_{i}_{j}.webp"), format="WEBP", lossless=True)
|
||||
tiers = tiles.source_index(tmp_path, force=True)["densite_sol"][(1054, 6882)]
|
||||
quads = next(t for t in tiers if len(t) == 4)
|
||||
assert all(q.path.suffix == ".webp" for q in quads)
|
||||
assert "densite_sol" in tiles.NEAREST_LAYERS
|
||||
# z17 : résolution visée (~0,8 m) atteinte par les quadrants (0,5 m ici)
|
||||
x, y = _tile_of_cell(1054, 6882, 17)
|
||||
img = tiles.render_tile(tmp_path, "densite_sol", 17, x, y)
|
||||
assert img is not None
|
||||
grays = {p[0] for p in img.getdata() if p[3] == 255}
|
||||
assert grays <= {0, 255} # aucun gris intermédiaire inventé
|
||||
|
||||
@ -553,3 +553,30 @@ class TestPriorityFlood:
|
||||
|
||||
result = _priority_flood(dem, nodata)
|
||||
assert result[2, 2] == 999.0
|
||||
|
||||
|
||||
class TestDensiteSol:
|
||||
def test_density_levels_log_scale(self):
|
||||
from lidar_pipeline.visualizations import density_levels
|
||||
d = np.array([0.0, np.nan, 0.25, 0.36, 0.5, 1.0, 45.0, 45.3, 1000.0])
|
||||
assert density_levels(d).tolist() == [0, 0, 0, 1, 2, 4, 14, 15, 15]
|
||||
|
||||
def test_generate_reads_density_sidecar(self, tmp_path):
|
||||
import rasterio
|
||||
from rasterio.transform import from_bounds
|
||||
from lidar_pipeline.dtm import density_path
|
||||
from lidar_pipeline.visualizations import generate_densite_sol
|
||||
dem = tmp_path / "T_dtm_r0p2.tif"
|
||||
dem.touch()
|
||||
with rasterio.open(density_path(dem), 'w', driver='GTiff', width=4, height=1,
|
||||
count=1, dtype='float32', crs='EPSG:2154',
|
||||
transform=from_bounds(0, 0, 4, 1, 4, 1)) as dst:
|
||||
dst.write(np.array([[0.0, 1.0, 4.0, 64.0]], dtype='float32'), 1)
|
||||
out = generate_densite_sol(dem, "T", tmp_path, 0.2)
|
||||
with rasterio.open(out) as src:
|
||||
assert src.read(1).tolist() == [[0, 4, 8, 15]]
|
||||
assert src.width == 4 # grille de 1 m conservée
|
||||
|
||||
def test_missing_sidecar_returns_none(self, tmp_path):
|
||||
from lidar_pipeline.visualizations import generate_densite_sol
|
||||
assert generate_densite_sol(tmp_path / "X_dtm.tif", "X", tmp_path, 0.2) is None
|
||||
|
||||
Reference in New Issue
Block a user