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