From f836408d6f5c9c38c60da3b0b7c184cd4b11323a Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 01:57:04 +0200 Subject: [PATCH] =?UTF-8?q?Ajouter=20la=20fiche=20de=20dalle=20IGN=20et=20?= =?UTF-8?q?la=20rose=20des=20vents,=20imposer=20la=20g=C3=A9n=C3=A9ration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un clic sélectionne toujours la dalle sous le curseur (cadre sur la carte), générée ou non : emprise, rendu et recalage des passes s'affichent tout de suite ; la fiche IGN (date et heure du scan LiDAR, capteurs, mission, édition, nombre de points, lien de téléchargement du .copc.laz) arrive à part via /api/map/ign, mise en cache, sans jamais bloquer la sélection. Une rose des vents donne la couleur de chaque orientation de pente du relief orienté, avec la même formule CIELAB que le rendu. La génération impose la classification IGN (sol) et le raccord de 100 m avec les dalles voisines : plus de réglage dans la carte, défauts CLI alignés. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- docs/MAPS.md | 25 ++- lidar_pipeline/cli.py | 11 +- lidar_pipeline/fetch_ign.py | 55 +++++- lidar_pipeline/mapserve.py | 141 +++++++++------ lidar_pipeline/mapui.py | 242 +++++++++++++++++++++----- lidar_pipeline/tests/test_mapserve.py | 57 +++++- 7 files changed, 427 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 17b3c4e..5c14092 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ docker build -t lidar-lidar . --debug Mode debug (détails internes fichier:ligne) -f / --force Régénérer tous les fichiers même si les WebP existent --force-classification Reclassifier le sol même si le fichier .las existe déjà - --ground-classification Méthode de classification: auto, smrf, pmf, csf (défaut: auto) + --ground-classification Méthode de classification: ign, auto, smrf, csf (défaut: ign — imposé par la carte) + --edge-buffer M Raccord des bords avec les dalles voisines (défaut: 100 m — imposé par la carte) --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques --test Exécuter les tests unitaires -h Afficher l'aide @@ -222,7 +223,7 @@ docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data | Résolution | `-r` | 0.5 | Résolution en mètres par pixel | | Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle | | GPU | `-g` | off | Activer l'accélération NVIDIA GPU | -| Classification sol | `--ground-classification` | auto | Méthode : auto, smrf, pmf, csf | +| Classification sol | `--ground-classification` | ign | Méthode : ign, auto, smrf, csf (la carte impose ign) | | Forcer classification | `--force-classification` | off | Reclassifier le sol même si .las existe | | Output | `-o` | /data/output | Dossier de sortie | | Force | `-f/--force` | off | Régénérer même si les WebP existent | diff --git a/docs/MAPS.md b/docs/MAPS.md index 3f9f6b8..28c8b74 100644 --- a/docs/MAPS.md +++ b/docs/MAPS.md @@ -27,10 +27,10 @@ docker compose -f docker-compose.maps.yml logs -f maps - **↻ Générer cette dalle** (fiche d'infos au clic) — une dalle précise, même sans données existantes. -Options du run : couches visées (défaut : les couches du panneau), -classification du sol (`ign`/`auto`/`smrf`/`csf`), classes LAS IGN -(`sol,unclassified…`), régénération forcée, reclassification, raccord des -bords (bande 100 m). Une demande lancée pendant un run part en **file +Options du run : couches visées (défaut : les couches du panneau) et +régénération forcée. La classification du sol (IGN, sol seul) et le raccord +des bords (bande de 100 m prise aux dalles voisines) sont imposés : aucun +réglage. Une demande lancée pendant un run part en **file d'attente** (persistée, jamais de coupure du travail en place) ; la progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu = en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis @@ -78,6 +78,23 @@ Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml` Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces URL pour la couche choisie. +## Fiche de dalle et rose des vents + +Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre +jaune en pointillés), qu'elle soit générée ou non. La fiche s'affiche tout de +suite avec le nom, l'emprise Lambert 93, puis, si la dalle est rendue, la +résolution, la date de génération et le recalage vertical des passes +(faisceaux, décalages, correction des lignes). Les informations IGN arrivent +à part (`GET /api/map/ign?col&row`, catalogue STAC mis en cache dans +`output/ign_meta/`) : date et heure du scan LiDAR, capteurs, mission, +opérateur, date d'édition, procédé de classement, nombre de points et lien de +téléchargement du nuage `.copc.laz` sur la géoplateforme. Un catalogue lent +ou injoignable n'empêche jamais la sélection. + +Quand le relief orienté est affiché, une rose des vents donne la couleur de +chaque orientation de pente (même formule CIELAB que le rendu) ; la clarté +porte le relief local (clair = bosse, sombre = creux). + ## Pile de couches La carte ne sert que les couches de `PANEL_VIZ` (`index.py`) : aujourd'hui le diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 5d34fc9..7723c1a 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -157,14 +157,14 @@ def main(): parser.add_argument( "--edge-buffer", type=float, - default=0.0, + default=100.0, metavar="METRES", help="Raccord des bords : étendre le MNT d'une bande de N mètres remplie avec " "les points sol des 8 tuiles LAZ voisines, pour que les rendus à grand " "noyau (openness, SVF, LRM) soient continus d'une tuile à l'autre. " "100 m couvre tous les rayons ; les images finales sont recadrées sur " - "la dalle 1 km exacte. 0 = désactivé (défaut). Un changement de valeur " - "régénère les DTM concernés." + "la dalle 1 km exacte. Défaut : 100 (toujours appliqué en production) ; " + "0 = désactivé. Un changement de valeur régénère les DTM concernés." ) parser.add_argument( "--no-strip-align", @@ -184,12 +184,13 @@ def main(): parser.add_argument( "--ground-classification", choices=["auto", "ign", "smrf", "csf"], - default="auto", + default="ign", help="Méthode de classification du sol : auto (préfère la pré-classification IGN si " "présente — base rapide — sinon détection SMRF/CSF), ign, smrf, csf. " "Avec ign, le MNT est la rasterisation pure des classes choisies " "(--ign-classes) sans aucune retouche ; avec smrf/csf, il est complété " - "par le retour le plus bas par cellule + interpolation des trous. (défaut: auto)" + "par le retour le plus bas par cellule + interpolation des trous. " + "(défaut: ign, imposé en production)" ) parser.add_argument( "--ign-classes", diff --git a/lidar_pipeline/fetch_ign.py b/lidar_pipeline/fetch_ign.py index c5bfca5..2c241be 100644 --- a/lidar_pipeline/fetch_ign.py +++ b/lidar_pipeline/fetch_ign.py @@ -70,6 +70,17 @@ def _bbox_wgs84(col, row): ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2 lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys) except Exception: + lons = None + if lons is None: + try: # image légère (carte) : pyproj sans rasterio + from pyproj import Transformer + tr = Transformer.from_crs("EPSG:2154", "EPSG:4326", always_xy=True) + xs = [col * 1000, (col + 1) * 1000, col * 1000, (col + 1) * 1000] + ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2 + lons, lats = tr.transform(xs, ys) + except Exception: + lons = None + if lons is None: from .index import _approx_l93_to_wgs84 pts = [_approx_l93_to_wgs84(x, y) for x in (col * 1000, (col + 1) * 1000) @@ -93,12 +104,48 @@ def match_feature(features, col, row): def find_tile_url(col, row, timeout=20, max_pages=5): """Cherche l'URL de téléchargement de la dalle (col,row) dans le catalogue STAC. - Suit la pagination du catalogue (lien rel=next) : sans elle, une dalle - au-delà de la première page de résultats semblait « introuvable ». - Returns: URL (str) ou None si la dalle n'est pas (encore) publiée par l'IGN. """ + feature = find_tile_feature(col, row, timeout=timeout, max_pages=max_pages) + return feature.get("assets", {}).get("data", {}).get("href") if feature else None + + +def tile_ign_metadata(col, row, timeout=10): + """Fiche IGN d'une dalle : acquisition, capteurs, édition, téléchargement. + + Returns: + dict normalisé (found=False si la dalle n'est pas publiée). + """ + feature = find_tile_feature(col, row, timeout=timeout) + if not feature: + return {"found": False} + p = feature.get("properties", {}) + self_link = next((link.get("href") for link in feature.get("links", []) + if link.get("rel") == "self"), None) + return { + "found": True, + "download_url": feature.get("assets", {}).get("data", {}).get("href"), + "acquisition_start": p.get("start_datetime") or p.get("lidarhd:date_debut_acquisition"), + "acquisition_end": p.get("end_datetime") or p.get("lidarhd:date_fin_acquisition"), + "sensors": p.get("lidarhd:capteur") or [], + "mission": p.get("lidarhd:code_mission"), + "acquisition_operator": p.get("lidarhd:moe_acquisition"), + "acquisition_owner": p.get("lidarhd:moa_acquisition"), + "edition_date": p.get("lidarhd:date_edition"), + "classification_process": p.get("lidarhd:procede_classement"), + "points": p.get("lidarhd:nombre_points") or p.get("pc:count"), + "altimetry": p.get("lidarhd:systeme_altimetrique"), + "stac_url": self_link, + } + + +def find_tile_feature(col, row, timeout=20, max_pages=5): + """Item STAC de la dalle (col,row), ou None si non publiée. + + Suit la pagination du catalogue (lien rel=next) : sans elle, une dalle + au-delà de la première page de résultats semblait « introuvable ». + """ w, s, e, n = _bbox_wgs84(col, row) query = urllib.parse.urlencode({"bbox": f"{w:.6f},{s:.6f},{e:.6f},{n:.6f}", "limit": 50}) url = f"{_STAC_ITEMS_URL}?{query}" @@ -108,7 +155,7 @@ def find_tile_url(col, row, timeout=20, max_pages=5): data = json.loads(response.read().decode("utf-8")) feature = match_feature(data.get("features", []), col, row) if feature: - return feature.get("assets", {}).get("data", {}).get("href") + return feature nxt = next((link.get("href") for link in data.get("links", []) if link.get("rel") == "next" and link.get("href")), None) if not nxt: diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index 9849338..b9398df 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -860,12 +860,86 @@ def map_meta(): } +# Fiche IGN (catalogue STAC) mise en cache par dalle : un seul appel réseau, +# une dalle non publiée est revérifiée au plus une fois par jour. +IGN_META_RETRY_S = 86400 + + +def _ign_tile_meta(col, row): + """Métadonnées IGN d'une dalle (acquisition, lien de téléchargement).""" + from .fetch_ign import tile_ign_metadata + meta_dir = OUTPUT_DIR / "ign_meta" + path = meta_dir / f"{col:04d}_{row:04d}.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("found") or time.time() - data.get("checked", 0) < IGN_META_RETRY_S: + return data + except (OSError, ValueError): + pass + try: + data = tile_ign_metadata(col, row) + except Exception as e: + logger.warning(f"Catalogue IGN injoignable pour {col}_{row} : {e}") + return {"found": False, "error": "catalogue IGN injoignable"} + data["checked"] = time.time() + try: + meta_dir.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, path) + except OSError: + pass + return data + + +def _calage_summary(basename, suffix): + """Résumé du recalage vertical des passes (sidecar du MNT), si présent.""" + path = OUTPUT_DIR / "DTM" / f"{basename}_dtm{suffix}_stripalign.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + offsets = data.get("offsets") or {} + lines = data.get("lines") or {} + return { + "beams": sorted(set(offsets) | set(lines), key=lambda k: int(k) if str(k).isdigit() else 0), + "offsets_cm": {k: round(v * 100, 1) for k, v in offsets.items()}, + "lines_rms_cm": {k: round(v.get("rms_m", 0) * 100, 1) for k, v in lines.items()}, + "model": data.get("line_model"), + } + + +@app.get("/api/map/ign") +def map_tile_ign(col: int, row: int): + """Fiche IGN d'une dalle (acquisition, capteurs, lien de téléchargement). + + Séparée de /api/map/tile : la sélection d'une dalle s'affiche tout de + suite, même si le catalogue IGN est lent ou injoignable. + """ + return _ign_tile_meta(col, row) + + @app.get("/api/map/tile") def map_tile_info(lat: float, lng: float): - """Dalle LHD sous un point WGS84 + métadonnées de génération.""" + """Dalle LHD sous un point WGS84 : emprise, génération, recalage. + + Répond toujours (dalle générée ou non) et sans appel réseau ; la fiche + IGN se charge à part (/api/map/ign). + """ from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles x, y = tiles_mod.wgs84_to_l93(lng, lat) col, row = int(x // 1000), int(y // 1000) + 1 + x0, y0, x1, y1 = col * 1000, (row - 1) * 1000, (col + 1) * 1000, row * 1000 + lons, lats = tiles_mod._transformer("EPSG:2154", "EPSG:4326").transform( + [x0, x1, x1, x0], [y1, y1, y0, y0]) + base = { + "col": col, + "row": row, + "name": f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69", + "l93_bbox": [x0, y0, x1, y1], + # coins NO, NE, SE, SO (lat, lng) : cadre de sélection sur la carte + "corners": [[round(la, 7), round(lo, 7)] for lo, la in zip(lons, lats)], + } best = None for t in scan_tiles(OUTPUT_DIR / "visualisations"): if (t["col"], t["row"]) != (col, row): @@ -873,18 +947,19 @@ def map_tile_info(lat: float, lng: float): if best is None or t["resolution"] < best["resolution"]: best = t if best is None: - return {"found": False, "col": col, "row": row} + return {"found": False, **base} meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM") + suffix = _res_suffix_str(best["resolution"]) return { "found": True, - "col": col, - "row": row, + **base, "name": best["basename"], "resolution": best["resolution"], "method": meta.get("method"), "generated": meta.get("generated"), "layers": sorted(best["viz"]), - "suffix": _res_suffix_str(best["resolution"]), + "suffix": suffix, + "calage": _calage_summary(best["basename"], suffix), } @@ -1116,28 +1191,9 @@ class GenerateRequest(BaseModel): tiles: list = Field(..., description="liste [col, row] (entiers km L93)") regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées " "(visualisations refaites, classification conservée)") - reclassify: bool = Field(False, - description="Relancer la classification du sol même si " - "la méthode demandée est déjà en cache " - "(--force-classification). Défaut : conserver " - "la classification existante ; choisir une " - "autre méthode reclassifie de toute façon " - "les tuiles concernées") - ground_class: str = Field("ign", - description="Méthode de classification du sol : " - "auto, ign, smrf, csf") - ign_classes: str = Field("sol", - description="Classes LAS pour le MNT IGN : liste noms ou " - "codes séparés par virgules — sol(2), " - "unclassified(1), eau(9), virtuel(66), " - "pont(17), sursol(64). Mode pur, " - "aucune retouche. (défaut: sol)") - edge_buffer: bool = Field(False, - description="Raccord des bords : étendre le MNT d'une " - "bande de 100 m remplie avec les points sol " - "des 8 tuiles voisines — rendus continus " - "d'une tuile à l'autre (recadrés sur la " - "dalle 1 km exacte)") + # Classification (IGN, sol seul) et raccord des bords (bande de 100 m prise + # aux dalles voisines) sont imposés : plus de réglage côté carte. Les + # anciens champs éventuellement envoyés par un client sont ignorés. viz: list = Field(None, description="Visualisations à générer, noms d'étapes du " "pipeline (ex: aspect, wavelet, slope) ; " @@ -1349,15 +1405,13 @@ def _queue_summary(): EDGE_BUFFER_METERS = 100.0 # raccord des bords : largeur de bande (m) -def _build_command(tiles, regenerate=False, ground_class="ign", ign_classes="sol", - viz=None, reclassify=False, edge_buffer=False): +def _build_command(tiles, regenerate=False, viz=None): """Commande de génération : téléchargement IGN + traitement des fichiers. - Avec regenerate=True, les visualisations des tuiles déjà présentes sont - refaites (--force) mais leur classification est conservée. Avec - reclassify=True, --force-classification relance la classification même à - méthode inchangée. Avec edge_buffer=True, le MNT est étendu d'une bande de - EDGE_BUFFER_METERS remplie avec les points sol des tuiles voisines. + Classification IGN (sol seul) et raccord des bords (bande de + EDGE_BUFFER_METERS remplie avec les points sol des 8 dalles voisines) + toujours appliqués. Avec regenerate=True, les visualisations des tuiles + déjà présentes sont refaites (--force), classification conservée. """ from .fetch_ign import tile_filename # -u : sortie non bufferisée — le journal .generation.log doit être @@ -1366,14 +1420,11 @@ def _build_command(tiles, regenerate=False, ground_class="ign", ign_classes="sol "-o", str(OUTPUT_DIR), "-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS), "--only", *(viz or _panel_viz_steps()), - "--ground-classification", ground_class, - "--ign-classes", ign_classes] - if edge_buffer: - cmd += ["--edge-buffer", f"{EDGE_BUFFER_METERS:g}"] + "--ground-classification", "ign", + "--ign-classes", "sol", + "--edge-buffer", f"{EDGE_BUFFER_METERS:g}"] if regenerate: cmd += ["--force"] - if reclassify: - cmd += ["--force-classification"] if os.environ.get("LIDAR_GPU", "") == "1": cmd += ["-g", "all"] workers = os.environ.get("LIDAR_WORKERS", "auto").strip() @@ -1424,10 +1475,6 @@ def _resolve_request(req): raise HTTPException(400, "aucune tuile fournie") if len(tiles) > MAX_CELLS: raise HTTPException(400, f"trop de tuiles ({len(tiles)}) — max {MAX_CELLS}") - if req.ground_class not in GROUND_CLASS_METHODS: - raise HTTPException( - 400, f"méthode de classification invalide : {req.ground_class!r} " - f"(attendu : {', '.join(GROUND_CLASS_METHODS)})") # Génération du nord au sud (row décroissante, puis col croissante) : les # workers prennent les tuiles dans l'ordre de soumission et la carte se # remplit de haut en bas pendant le run. @@ -1437,11 +1484,7 @@ def _resolve_request(req): def _launch_job(tiles, viz, req, qid=None): """Démarre un run du pipeline. À appeler sous _job_lock, serveur libre.""" - cmd = _build_command(tiles, regenerate=req.regenerate, - ground_class=req.ground_class, - ign_classes=req.ign_classes, - viz=viz, reclassify=req.reclassify, - edge_buffer=req.edge_buffer) + cmd = _build_command(tiles, regenerate=req.regenerate, viz=viz) LOG_FILE.parent.mkdir(parents=True, exist_ok=True) log_fh = open(LOG_FILE, "w", encoding="utf-8") # Nouveau run : journal d'événements remis à zéro et identifiant de run diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py index 4366da3..fe80ec9 100644 --- a/lidar_pipeline/mapui.py +++ b/lidar_pipeline/mapui.py @@ -93,6 +93,11 @@ _MAP_HTML = """
+ +