diff --git a/AGENTS.md b/AGENTS.md index a34601e..58f90b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ - **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`. - **Couches produites et servies = `PANEL_VIZ` (`index.py`, aujourd'hui `('relief_oriente', 'densite_sol')`)** : 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)`. -- **Précision (`densite_sol`) et affichage de la carte** : `create_dtm_fast` écrit la densité des points sol retenus (bande de raccord comprise) dans `DTM/*_dtm*_density.tif` (mailles 1 m, moyenne 3 × 3, pts/m²) ; `generate_densite_sol` la quantifie en 16 niveaux (`density_levels` : échelle log fixe, niveau k dès 0,25 × 2^(k/2) pts/m²) et la garde à 1 m (dalle 1000², ~200 Ko en WebP sans perte, `rendering.LOSSLESS_GRAY_KEYWORDS`) ; tuiles au plus proche voisin (`tiles.NEAREST_LAYERS`) pour garder 16 gris nets aux zooms 18–19. Interface (`web/map.js`, onglet Affichage) : plus de pile de couches — une couche principale (`DEFAULT_VIZ`) et trois modes relief / précision / les deux (`VIEW_MODES`, précision en `multiply` à `DEFAULT_PRECISION_OPACITY` 0,6), touche P, lien `&M=…&P=mode:opacité`, défauts figés `{main, mode, precision_opacity, base}`. +- **Précision (`densite_sol`) et affichage de la carte** : `create_dtm_fast` écrit la densité des points sol retenus (bande de raccord comprise) dans `DTM/*_dtm*_density.tif` (mailles 1 m, moyenne 3 × 3, pts/m²) ; `generate_densite_sol` la quantifie en 16 niveaux (`density_levels` : échelle log fixe, niveau k dès 0,25 × 2^(k/2) pts/m²) et la garde à 1 m (dalle 1000², ~200 Ko en WebP sans perte, `rendering.LOSSLESS_GRAY_KEYWORDS`) ; tuiles au plus proche voisin (`tiles.NEAREST_LAYERS`) pour garder 16 gris nets aux zooms 18–19. Interface (`web/map.js`, onglet Affichage) : plus de pile de couches — une couche principale (`DEFAULT_VIZ`) et trois modes relief / précision / comparer (`VIEW_MODES`, barre glissante, lien `&P=compare&C=gauche:droite:%`) ; `both` (ancien produit) lu comme `relief`. Texte « Comment lire » : champ `reading` de `VIZ_LEGENDS` (carte + PDF). - **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 100 ; toujours appliqué par la génération depuis la carte, `EDGE_BUFFER_METERS` = 100 m dans `mapserve.py`, plus de case à cocher ; 0 = off en ligne de commande) 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). diff --git a/lidar_pipeline/export_pdf.py b/lidar_pipeline/export_pdf.py index 67d1c16..37bf68a 100644 --- a/lidar_pipeline/export_pdf.py +++ b/lidar_pipeline/export_pdf.py @@ -487,15 +487,27 @@ def _draw_legend(c, box, mm): c.setFont("Helvetica", 6) c.drawString((rcx + r_out + 5) * mm, (rcy + 2) * mm, _pdf_text("Teinte = orientation")) c.drawString((rcx + r_out + 5) * mm, (rcy - 1) * mm, _pdf_text("de la pente")) - # Texte de légende + # Texte de légende ("Comment lire" : phrases habillées à la largeur de la boîte) + from reportlab.pdfbase.pdfmetrics import stringWidth ty = rcy - r_out - 6 c.setFont("Helvetica", 5.8) - for line in VIZ_LEGENDS[LAYER]["legend"].split("\n"): - for part in line.split(" | "): + for sentence in VIZ_LEGENDS[LAYER].get("reading") or VIZ_LEGENDS[LAYER]["legend"].split("\n"): + line = "" + for word in _pdf_text(sentence).split(" "): + test = (line + " " + word).strip() + if stringWidth(test, "Helvetica", 5.8) * 0.3528 > w and line: # pt → mm + if ty < y + 1: + return + c.drawString(x * mm, ty * mm, line) + ty -= 2.9 + line = word + else: + line = test + if line: if ty < y + 1: return - c.drawString(x * mm, ty * mm, _pdf_text(part)) - ty -= 2.9 + c.drawString(x * mm, ty * mm, line) + ty -= 3.6 # interligne un peu plus grand entre deux phrases def _draw_quality(c, box, bbox, zq, mm): diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index d384c6c..ecb8335 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -185,15 +185,30 @@ VIZ_LEGENDS = { 'cmap': None, 'gradient': None, 'ticks': None, + # « Comment lire » : texte unique de la carte (/api/map/meta) et de la + # légende de la planche PDF. + 'reading': ( + 'Clarté = ouverture locale du terrain : clair = bosse, crête ; sombre = creux, fossé.', + 'Teinte = orientation de la pente (voir la rose).', + 'Trous noirs = aucun point sol : bâtiments, eau, couvert dense.', + "Entre les points, le relief est comblé seulement à l'intérieur de " + "l'enveloppe des points, sur un rayon de 1,5 × l'espacement local " + "(au moins 1 m) : assez pour éviter les trous, sans inventer de relief " + "ni amplifier le bruit.", + ), }, 'densite_sol': { 'title': 'Précision géométrique (densité de points sol)', 'legend': 'Points sol retenus pour le MNT, par m² (moyenne sur 3 × 3 m)\n16 gris, échelle log fixe : 2 niveaux = densité doublée\nNoir = ≤ 0,35 pt/m² ou aucun point (relief interpolé ou absent)\nBlanc = ≥ 45 pts/m²', - 'description': 'Où le relief est mesuré (clair) et où il est interpolé (sombre) — en « produit » sur le relief, assombrit les zones peu fiables', + 'description': 'Où le relief est mesuré (clair) et où il est interpolé (sombre)', 'cmap': 'gray', 'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080', '#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'), 'ticks': ('≤ 0,35 pt/m²', '≥ 45 pts/m²'), + 'reading': ( + 'Points sol par m², 16 gris : clair = relief mesuré, sombre = relief interpolé.', + 'Noir = moins de 0,35 point par m² ou aucun point.', + ), }, 'ortho': { 'title': 'Photographie Aérienne IGN', @@ -214,13 +229,11 @@ VIZ_LEGENDS = { } # Affichage de la carte : UNE couche principale (DEFAULT_VIZ) et la couche -# « précision » (densité de points sol), montrée seule ou superposée en -# « produit » sur la principale (assombrit les zones interpolées sans masquer -# le relief). Plus de pile de couches réordonnable. +# « précision » (densité de points sol), montrée seule ou comparée au relief +# de part et d'autre d'une barre glissante (mode « compare »). PRECISION_VIZ = 'densite_sol' -VIEW_MODES = ('relief', 'precision', 'both') # principale / précision / les deux +VIEW_MODES = ('relief', 'precision', 'compare') DEFAULT_VIEW_MODE = 'relief' -DEFAULT_PRECISION_OPACITY = 0.6 # opacité de la précision en mode « les deux » # Couche « principale » (mise en avant ; index_tiles.json + affichage en # direct de la webapp) : le relief orienté, qui fusionne openness et aspect. diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index 6701fc0..53fe1af 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -581,6 +581,7 @@ def _layer_infos(): out.append({"key": key, "label": VIZ_LABELS.get(key, key), "description": legend.get("description", ""), + "reading": list(legend.get("reading", ())), # Nom d'étape --only du pipeline (sélecteur de génération) "step": KEYWORD_TO_STEP.get(key, key)}) return out @@ -906,7 +907,7 @@ def _clamp01(value, fallback=1.0): def _sanitize_defaults(req): """Retient ce qui est connu et borné : couche réelle, mode valide, opacités 0–1.""" - from .index import DEFAULT_PRECISION_OPACITY, PRECISION_VIZ, VIEW_MODES + from .index import PRECISION_VIZ, VIEW_MODES known = set(tiles_mod.available_layers(OUTPUT_DIR)) - {PRECISION_VIZ} base_in = req.base if isinstance(req.base, dict) else {} base = {"on": bool(base_in.get("on", True)), @@ -918,14 +919,13 @@ def _sanitize_defaults(req): # l'ancien, qui écrivait toujours dark:true (voir map_meta). return {"main": req.main if req.main in known else None, "mode": req.mode if req.mode in VIEW_MODES else "relief", - "precision_opacity": _clamp01(req.precision_opacity, DEFAULT_PRECISION_OPACITY), "base": base, "v": 3, "saved_at": time.time()} class DefaultsRequest(BaseModel): main: Optional[str] = Field(None, description="couche d'affichage principal") - mode: str = Field("relief", description="relief | precision | both") - precision_opacity: float = Field(0.6, description="opacité de la précision en mode both, 0–1") + mode: str = Field("relief", description="relief | precision | compare") + precision_opacity: Optional[float] = Field(None, description="ignoré (ancien mode « les deux »)") base: dict = Field(default_factory=dict, description="fond de carte : on, opacity, dark") @@ -966,7 +966,7 @@ def clear_defaults(): @app.get("/api/map/meta") def map_meta(): """Couches, zooms, emprise et version — tout l'état initial de l'interface.""" - from .index import (DEFAULT_PRECISION_OPACITY, DEFAULT_VIEW_MODE, PRECISION_VIZ, + from .index import (DEFAULT_VIEW_MODE, PRECISION_VIZ, VIEW_MODES, default_main_layer) infos = _layer_infos() keys = [i["key"] for i in infos] @@ -988,8 +988,6 @@ def map_meta(): "default_main": main, "precision_layer": PRECISION_VIZ if PRECISION_VIZ in keys else None, "default_mode": mode, - "default_precision_opacity": _clamp01(saved.get("precision_opacity"), - DEFAULT_PRECISION_OPACITY), "default_base": base_default, "defaults_saved": bool(saved), # L'interface consomme des tuiles 512 px (2× moins de requêtes qu'en diff --git a/lidar_pipeline/tests/test_export_pdf.py b/lidar_pipeline/tests/test_export_pdf.py index cf9109d..299c57c 100644 --- a/lidar_pipeline/tests/test_export_pdf.py +++ b/lidar_pipeline/tests/test_export_pdf.py @@ -148,6 +148,24 @@ def test_pdf_text_replaces_unencodable(): assert _pdf_text("Relief orienté — 1:2 000 ≥ 🚀") == "Relief orienté — 1:2 000 ? ?" +def test_pdf_legend_uses_reading_text(monkeypatch): + """La légende de la planche PDF affiche le texte « Comment lire » de + VIZ_LEGENDS, habillé à la largeur de la boîte, plutôt que « legend ».""" + from lidar_pipeline import export_pdf + from lidar_pipeline.index import VIZ_LEGENDS + drawn = [] + + class FakeCanvas: + def __getattr__(self, name): + def rec(*a, **k): + if name in ("drawString", "drawCentredString", "drawRightString"): + drawn.append(a[-1]) + return rec + export_pdf._draw_legend(FakeCanvas(), (0, 0, 80, 200), 1.0) + first = export_pdf._pdf_text(VIZ_LEGENDS[export_pdf.LAYER]["reading"][0][:20]) + assert any(first in s for s in drawn) + + def _q(density, start="2023-03-15", end="2023-03-17", empty=0.1): return {"version": 1, "ground_density": density, "density_grid": [[density] * 20 for _ in range(20)], diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index be2209c..482d950 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -380,21 +380,20 @@ def test_defaults_roundtrip(tmp_path, monkeypatch): assert meta["default_main"] == "relief_oriente" assert meta["precision_layer"] == "densite_sol" assert meta["default_mode"] == "relief" - assert meta["default_precision_opacity"] == 0.6 + assert "default_precision_opacity" not in meta - req = mapserve.DefaultsRequest(main="aspect", mode="both", precision_opacity=0.4, + req = mapserve.DefaultsRequest(main="aspect", mode="compare", 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_main"] == "aspect" - assert meta["default_mode"] == "both" - assert meta["default_precision_opacity"] == 0.4 + assert meta["default_mode"] == "compare" assert meta["default_base"] == {"on": True, "opacity": 0.4, "dark": False} # Persisté sur disque : survit au redémarrage du conteneur assert (tmp_path / ".map-defaults.json").is_file() - assert mapserve.get_defaults()["defaults"]["mode"] == "both" + assert mapserve.get_defaults()["defaults"]["mode"] == "compare" # Retrait : retour aux réglages du registre assert mapserve.clear_defaults()["supprimé"] is True @@ -428,11 +427,11 @@ def test_defaults_sanitised(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( - main="inexistante", mode="vaudou", precision_opacity=5, + main="inexistante", mode="vaudou", base={"opacity": -3}))["defaults"] assert data["main"] is None assert data["mode"] == "relief" - assert data["precision_opacity"] == 1.0 # borné à 1 + assert "precision_opacity" not in data assert data["base"]["opacity"] == 0.0 # borné à 0 data = mapserve.set_defaults(mapserve.DefaultsRequest(main="densite_sol"))["defaults"] assert data["main"] is None # la précision n'est pas une principale @@ -475,6 +474,41 @@ def test_defaults_legacy_dark_true_becomes_unset(tmp_path, monkeypatch): assert meta["default_base"]["opacity"] == 0.85 +def test_view_modes_compare_and_legacy_both(tmp_path, monkeypatch): + """« both » (ancien mode « les deux ») disparaît de VIEW_MODES au profit de + « compare » ; un fichier ou une requête l'envoyant encore est lu comme + « relief » (precision_opacity accepté pour compatibilité, mais ignoré).""" + import lidar_pipeline.mapserve as mapserve + _setup(tmp_path, monkeypatch, layers=("relief_oriente", "densite_sol")) + from lidar_pipeline.index import VIEW_MODES + assert VIEW_MODES == ('relief', 'precision', 'compare') + monkeypatch.setattr(mapserve, "DEFAULTS_FILE", tmp_path / ".map-defaults.json") + # Ancien défaut figé « both » : lu comme relief + mapserve.DEFAULTS_FILE.parent.mkdir(parents=True, exist_ok=True) + mapserve.DEFAULTS_FILE.write_text('{"mode": "both", "precision_opacity": 0.6}', encoding="utf-8") + meta = mapserve.map_meta() + assert meta["default_mode"] == "relief" + assert "default_precision_opacity" not in meta + # Ancienne page encore ouverte : precision_opacity accepté, ignoré + req = mapserve.DefaultsRequest(main=None, mode="both", precision_opacity=0.4, base={}) + assert mapserve._sanitize_defaults(req)["mode"] == "relief" + assert "precision_opacity" not in mapserve._sanitize_defaults(req) + req = mapserve.DefaultsRequest(main=None, mode="compare", base={}) + assert mapserve._sanitize_defaults(req)["mode"] == "compare" + + +def test_meta_serves_reading_text(tmp_path, monkeypatch): + """/api/map/meta sert le texte « Comment lire » de chaque couche.""" + import lidar_pipeline.mapserve as mapserve + _setup(tmp_path, monkeypatch, layers=("relief_oriente", "densite_sol")) + layers = {l["key"]: l for l in mapserve.map_meta()["layers"]} + reading = layers["relief_oriente"]["reading"] + assert isinstance(reading, list) and len(reading) >= 3 + text = " ".join(reading) + assert "aucun point sol" in text and "1,5" in text and "1 m" in text + assert layers["densite_sol"]["reading"] + + def test_ui_applies_server_defaults(): """L'interface part des défauts du serveur et sait les (re)définir.""" from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS