Ajouter la fiche de dalle IGN et la rose des vents, imposer la génération

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 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 01:57:04 +02:00
parent d32502b74e
commit f836408d6f
7 changed files with 427 additions and 109 deletions

View File

@ -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