From 31645a42e8b745245c25784434ebf6da0ccdc6ea Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 00:10:08 +0200 Subject: [PATCH] =?UTF-8?q?Faire=20du=20relief=20orient=C3=A9=20la=20seule?= =?UTF-8?q?=20couche=20produite=20et=20affich=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PANEL_VIZ ne contient plus que relief_oriente et pilote désormais tout : le pipeline sans --only ne produit que cette couche, la génération lancée depuis la carte aussi, et la carte ne liste ni ne sert en tuiles (panneau, XYZ, TileJSON, WMTS, JOSM) les autres visualisations présentes sur disque. Les autres visualisations restent calculables explicitement avec --only. Corrige au passage _panel_viz_steps, qui ne retenait que les couches dont le nom de fichier diffère du nom d'étape : la génération depuis la carte ne produisait que l'openness positive. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 ++- README.md | 2 ++ docs/MAPS.md | 5 +++++ lidar_pipeline/index.py | 23 +++++++++++++++-------- lidar_pipeline/mapserve.py | 5 ++--- lidar_pipeline/pipeline.py | 6 +++++- lidar_pipeline/tests/test_mapserve.py | 25 +++++++++++++++++++------ lidar_pipeline/tests/test_pipeline.py | 9 +++++++++ lidar_pipeline/tests/test_tiles.py | 14 ++++++++++++-- lidar_pipeline/tiles.py | 6 +++++- 10 files changed, 76 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 269671b..1935cd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ - **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98). - **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`. - **Gigue intra-faisceau (2ᵉ passe du calage)** : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas. `_strip_jitter_offsets` découpe chaque faisceau en fenêtres de temps GPS (`STRIP_JITTER_BIN` = 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥ `STRIP_JITTER_MIN_CELLS` = 40 cellules), lisse la série (médiane glissante `STRIP_JITTER_SMOOTH` = 5 fenêtres), la borne à ± `STRIP_JITTER_MAX` (10 cm) puis l'interpole au temps GPS de chaque point (`_apply_strip_jitter`) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimension `gps_time` (silencieusement ignorée sinon). Sidecar version 2 (séries dans `jitter`), couverte par `--no-strip-align`. -- **Relief orienté (`relief_oriente`, couche par défaut de la carte)** : image RGB unique (GeoTIFF uint8 3 bandes, rendue telle quelle comme ortho/topo via `RGB_KEYWORDS` dans `rendering.py`) — clarté CIELAB = openness positive **locale** (MNT − gaussienne `RELIEF_DETREND_M` = 10 m, rayons `RELIEF_RADII_M` = 5/10/20 m, 16 directions) 65 % + ombrage 35 % ; teinte = aspect, chroma fixe (`RELIEF_CHROMA`). Échelle log fixe `RELIEF_OPEN_RANGE` (pas de statistique par dalle) et support total 40 m < bande de raccord 100 m : dalles jointives. Rapide : détendance + rayons sur grille décimée ~0,8 m (`RELIEF_GRID_M`), noyau dédié qui n'accumule que la moyenne des angles (`_mean_horizon_*` : CuPy `RawKernel` sur GPU, numba parallèle sur CPU, numpy en repli), colorisation fusionnée (numba) ou vectorisée sans trigonométrie (CuPy) via une table L* × teinte (`_relief_lut`). ~5 s de calcul hors préparation sur CPU 12 cœurs. Tout changement de constante change le rendu : régénérer les dalles (`--only relief_oriente --force`). +- **Couches produites et servies = `PANEL_VIZ` (`index.py`, aujourd'hui `('relief_oriente',)`)** : pipeline sans `--only`/`--skip` (`panel_steps()`), génération lancée depuis la carte (`_panel_viz_steps` dans `mapserve.py`) et couches servies (`tiles.available_layers` filtre : panneau, `/tiles/…`, TileJSON, WMTS, JOSM). Les autres visualisations restent calculables avec `--only`. Les tests de la carte lèvent la restriction via `_setup(..., panel=None)`. +- **Relief orienté (`relief_oriente`, seule couche de la carte)** : image RGB unique (GeoTIFF uint8 3 bandes, rendue telle quelle comme ortho/topo via `RGB_KEYWORDS` dans `rendering.py`) — clarté CIELAB = openness positive **locale** (MNT − gaussienne `RELIEF_DETREND_M` = 10 m, rayons `RELIEF_RADII_M` = 5/10/20 m, 16 directions) 65 % + ombrage 35 % ; teinte = aspect, chroma fixe (`RELIEF_CHROMA`). Échelle log fixe `RELIEF_OPEN_RANGE` (pas de statistique par dalle) et support total 40 m < bande de raccord 100 m : dalles jointives. Rapide : détendance + rayons sur grille décimée ~0,8 m (`RELIEF_GRID_M`), noyau dédié qui n'accumule que la moyenne des angles (`_mean_horizon_*` : CuPy `RawKernel` sur GPU, numba parallèle sur CPU, numpy en repli), colorisation fusionnée (numba) ou vectorisée sans trigonométrie (CuPy) via une table L* × teinte (`_relief_lut`). ~5 s de calcul hors préparation sur CPU 12 cœurs. Tout changement de constante change le rendu : régénérer les dalles (`--only relief_oriente --force`). - **Openness sous-échantillonnée** : `generate_openness` calcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (`OPENNESS_DOWNSAMPLE = 2` : max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît. `--openness-downsample 1` = pleine résolution. SVF et openness anisotrope ne sont PAS concernées. - **Raccord des bords entre tuiles** : les rendus à grand noyau (openness/SVF : rayons 100 m ; LRM : 15 m) tronquent leur fenêtre au bord de dalle — bandes d'artefacts à chaque changement de tuile. `--edge-buffer N` (défaut 0 = off ; case « Raccord des bords » de la carte, `EDGE_BUFFER_METERS` = 100 m dans `mapserve.py`) fait rastériser le DTM sur la **dalle nominale 1 km alignée sur la grille** plus une bande de N m remplie avec les points sol des 8 LAZ voisines (`_neighbor_ground_points` dans `dtm.py` : PDAL en flux, découpe + filtre de classes IGN ; voisine absente = téléchargement automatique depuis le catalogue IGN avant le run, **isolée dans `input/edge_neighbors/`** pour ne pas gonfler le corpus des passes globales (dédupliqué sur tout le lot, `_fetch_edge_neighbors` dans `pipeline.py`) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puis `rendering.py` (`_core_tile_window`, via `tif_to_crop`/`tif_to_png`) recadre les sorties sur la dalle 1 km exacte lue dans le nom LHD — les AVIF restent des carrés 1 km alignés dans la mosaïque. Tampon consigné dans le tag GeoTIFF `LIDAR_EDGE_BUFFER` du DTM : changer `--edge-buffer` invalide le cache DTM automatiquement (tag absent = 0). Bandes voisines non calées par faisceaux (contexte seul, recadrée hors image finale). Coût : ~7 s de lecture par voisine + ~44 % de pixels en plus à 100 m/0,2 m. Nom hors pattern LHD : option ignorée (bornes d'en-tête, pas de recadrage). - **Tests use lazy imports inside each test function**, never at module top, to avoid importing CuPy/GDAL at import time. diff --git a/README.md b/README.md index bbe7804..17b3c4e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Workflow automatisé pour générer des visualisations exploitables à partir de ### Relief orienté (couche par défaut) Une seule image fusionne le micro-relief et l'orientation des pentes : la **clarté** porte le relief local (openness positive sur MNT détendancé, rayons 5–20 m, plus un léger ombrage), la **teinte** porte l'orientation (aspect, cercle CIELAB à clarté constante : aucune couleur ne crée de faux relief). Échelle fixe : les dalles voisines se raccordent sans couture. +C'est la **seule couche produite et affichée par défaut** (`PANEL_VIZ` dans `index.py`) : un traitement sans `--only`, la génération lancée depuis la carte et la carte elle-même (panneau, tuiles XYZ, TileJSON, WMTS) se limitent au relief orienté. Les visualisations ci-dessous restent calculables explicitement (`--only slope aspect ...`). + ### Visualisations principales | # | Visualisation | Utilité archéologique | |---|--------------|----------------------| diff --git a/docs/MAPS.md b/docs/MAPS.md index 16051f8..3f9f6b8 100644 --- a/docs/MAPS.md +++ b/docs/MAPS.md @@ -80,6 +80,11 @@ URL pour la couche choisie. ## Pile de couches +La carte ne sert que les couches de `PANEL_VIZ` (`index.py`) : aujourd'hui le +seul **relief orienté**, qui fusionne openness locale et orientation des pentes +en une image. Les autres visualisations présentes sur disque ne sont ni listées +ni servies en tuiles. + Chaque couche du panneau porte trois réglages, tous persistés et transportés par le lien de partage : diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 3e49404..cd5c253 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -220,15 +220,15 @@ DEFAULT_OPACITY = {} DEFAULT_BLEND = {'hillshade_multi': 'multiply', 'solar': 'multiply', 'svf': 'multiply'} # Couche « principale » (mise en avant ; index_tiles.json + affichage en -# direct de la webapp) : le relief archéologique, pas l'aspect. -DEFAULT_VIZ = 'positive_openness' +# direct de la webapp) : le relief orienté, qui fusionne openness et aspect. +DEFAULT_VIZ = 'relief_oriente' -# Restriction du panneau de couches : seule la sélection ci-dessous est -# proposée (couches conservées ; les autres ne sont plus ni affichées ni -# référencées). None = toutes les visualisations présentes sur disque. -# Le sélecteur de génération/régénération est piloté par le même registre -# La pente y reste proposée mais éteinte par défaut. -PANEL_VIZ = ('relief_oriente', 'slope', 'aspect', 'positive_openness') +# Couches produites et affichées : seule cette sélection est générée par +# défaut (pipeline sans --only, génération depuis la carte) et servie par la +# carte (panneau, tuiles XYZ, TileJSON, WMTS). Les autres visualisations +# restent calculables avec --only mais ne sont plus proposées. +# None = toutes les visualisations présentes sur disque. +PANEL_VIZ = ('relief_oriente',) # Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline # (les trois visualisations dont le nom de sortie diffère du nom d'étape, @@ -243,6 +243,13 @@ KEYWORD_TO_STEP = { STEP_TO_KEYWORD = {step: kw for kw, step in KEYWORD_TO_STEP.items()} +def panel_steps(): + """Noms d'étapes --only des couches produites par défaut (PANEL_VIZ).""" + if PANEL_VIZ is None: + return None + return [KEYWORD_TO_STEP.get(k, k) for k in PANEL_VIZ] + + def step_to_keyword(step): """Nom d'étape du pipeline (ex: 'pos_open') → mot-clé de fichier ('positive_openness').""" return STEP_TO_KEYWORD.get(step, step) diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index b8b28c9..9849338 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -1084,9 +1084,8 @@ def _viz_step_names(): def _panel_viz_steps(): """Couches réellement affichées par la carte, en noms d'étapes --only.""" - from .index import PANEL_VIZ, KEYWORD_TO_STEP - keys = [k for k in PANEL_VIZ if k in KEYWORD_TO_STEP] - return [KEYWORD_TO_STEP[k] for k in keys] or _viz_step_names() + from .index import panel_steps + return panel_steps() or _viz_step_names() def _viz_step_labels(): diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index 263fb17..ea5bda0 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -187,7 +187,11 @@ class LidarArchaeoPipeline: raise ValueError(f"Visualisations inconnues: {', '.join(invalid)}. Disponibles: {', '.join(all_viz_names)}") self.viz_steps = [(n, f) for n, f in VIZ_STEPS if n not in skip_viz] else: - self.viz_steps = VIZ_STEPS + # Sans --only/--skip : seules les couches affichées par la carte + # (PANEL_VIZ, index.py) sont produites. + from .index import panel_steps + default = panel_steps() + self.viz_steps = [(n, f) for n, f in VIZ_STEPS if default is None or n in default] logger.info("Pipeline initialisé") logger.info(f" Entrée : {self.input_dir}") diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 59fe146..9e04314 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -18,9 +18,12 @@ class _FakeRequest: "port": 8975})() -def _setup(tmp_path, monkeypatch, layers=("aspect", "slope")): +def _setup(tmp_path, monkeypatch, layers=("aspect", "slope"), panel=None): + """Dalle factice ; panel=None lève la restriction PANEL_VIZ pour que les + couches de test (aspect, slope) soient servies.""" import lidar_pipeline.mapserve as mapserve - from lidar_pipeline import tiles + from lidar_pipeline import index, tiles + monkeypatch.setattr(index, "PANEL_VIZ", panel) _make_dalle(tmp_path, 1054, 6882, list(layers)) monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) tiles.source_index(tmp_path, force=True) @@ -209,6 +212,19 @@ def test_map_meta(tmp_path, monkeypatch): assert meta["default_layers"] == ["relief_oriente"] +def test_only_panel_layers_are_served(tmp_path, monkeypatch): + """Seules les couches de PANEL_VIZ sont affichées et servies ; la + génération depuis la carte ne produit qu'elles.""" + from fastapi import HTTPException + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente", "aspect", "slope"), + panel=("relief_oriente",)) + meta = mapserve.map_meta() + assert [l["key"] for l in meta["layers"]] == ["relief_oriente"] + with pytest.raises(HTTPException): + asyncio.run(mapserve.tile("aspect", 15, 1, "1.png")) + assert mapserve._panel_viz_steps() == ["relief_oriente"] + + def test_map_tile_info(tmp_path, monkeypatch): """/api/map/tile identifie la dalle sous un point et lit ses métadonnées.""" mapserve = _setup(tmp_path, monkeypatch) @@ -542,11 +558,8 @@ def _reset_bg(mapserve): def test_tile_cache_only_never_renders(tmp_path, monkeypatch): """Mode cache seule : tuile manquante = transparente non mémorisable.""" - import lidar_pipeline.mapserve as mapserve + mapserve = _setup(tmp_path, monkeypatch, layers=("slope",)) from lidar_pipeline import tiles - _make_dalle(tmp_path, 1054, 6882, ["slope"]) - monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) - tiles.source_index(tmp_path, force=True) monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") z = 15 x, y = _tile_of_cell(1054, 6882, z) diff --git a/lidar_pipeline/tests/test_pipeline.py b/lidar_pipeline/tests/test_pipeline.py index 7af6cf7..109735d 100644 --- a/lidar_pipeline/tests/test_pipeline.py +++ b/lidar_pipeline/tests/test_pipeline.py @@ -24,6 +24,15 @@ class TestVizSteps: from lidar_pipeline.pipeline import VIZ_STEPS assert len(VIZ_STEPS) == 16 + 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.""" + from lidar_pipeline.pipeline import LidarArchaeoPipeline + p = LidarArchaeoPipeline(tmp_path, tmp_path / "out") + assert [n for n, _ in p.viz_steps] == ["relief_oriente"] + p = LidarArchaeoPipeline(tmp_path, tmp_path / "out2", only_viz=["slope"]) + assert [n for n, _ in p.viz_steps] == ["slope"] + def test_ortho_and_topo_present(self): from lidar_pipeline.pipeline import VIZ_STEPS names = [name for name, _ in VIZ_STEPS] diff --git a/lidar_pipeline/tests/test_tiles.py b/lidar_pipeline/tests/test_tiles.py index a57d8fe..0a1be09 100644 --- a/lidar_pipeline/tests/test_tiles.py +++ b/lidar_pipeline/tests/test_tiles.py @@ -119,9 +119,10 @@ def test_zoom_supported_bounds(): # Index des sources # --------------------------------------------------------------------------- -def test_source_index_and_layers(tmp_path): +def test_source_index_and_layers(tmp_path, monkeypatch): """L'index liste les couches présentes et leurs paliers (grossier → fin).""" - from lidar_pipeline import tiles + from lidar_pipeline import index, tiles + monkeypatch.setattr(index, "PANEL_VIZ", None) _make_dalle(tmp_path, 1054, 6882, ["aspect", "slope"]) layers = tiles.source_index(tmp_path, force=True) assert set(layers) == {"aspect", "slope"} @@ -132,6 +133,15 @@ def test_source_index_and_layers(tmp_path): set(tiles.available_layers(tmp_path)) == {"slope", "aspect"} +def test_available_layers_follow_panel(tmp_path, monkeypatch): + """Couches sur disque hors PANEL_VIZ : ni affichées ni servies.""" + from lidar_pipeline import index, tiles + _make_dalle(tmp_path, 1054, 6882, ["aspect", "relief_oriente"]) + tiles.source_index(tmp_path, force=True) + monkeypatch.setattr(index, "PANEL_VIZ", ("relief_oriente",)) + assert tiles.available_layers(tmp_path) == ["relief_oriente"] + + def test_grid_bounds(tmp_path): """Emprise L93 et WGS84 de la grille disponible.""" from lidar_pipeline import tiles diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py index 091b5b6..727d021 100644 --- a/lidar_pipeline/tiles.py +++ b/lidar_pipeline/tiles.py @@ -501,9 +501,13 @@ def source_index(output_dir, force=False): def available_layers(output_dir): - """Couches présentes sur disque, ordonnées comme le panneau de la carte.""" + """Couches présentes sur disque et affichées (PANEL_VIZ), ordonnées + comme le panneau de la carte.""" + from . import index as index_mod from .index import _VIZ_FALLBACK_ORDER found = set(source_index(output_dir)) + if index_mod.PANEL_VIZ is not None: + found &= set(index_mod.PANEL_VIZ) ordered = [v for v in _VIZ_FALLBACK_ORDER if v in found] ordered += sorted(found - set(ordered)) return ordered