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 = """
+