Raccorder les bords de tuiles voisines et encadrer les rendus en cours

Le MNT de chaque dalle s'étend d'une bande de 100 m remplie avec les
points sol des 8 tuiles voisines (option « Raccord des bords ») : les
rendus à grand noyau (openness, SVF, LRM) deviennent continus d'une
dalle à l'autre, les images restent recadrées sur le kilomètre exact.

Pendant une génération, la carte encadre les dalles du run : orange
pulsant en cours de rendu, rouge en échec — les coins WGS84 sont portés
par /api/status pour toutes les dalles non terminées.

La file de génération trie les dalles du nord au sud et passe à 10
workers GPU.
This commit is contained in:
Antoine Jacquin
2026-09-19 20:44:44 +02:00
parent 204b2ad111
commit 5f018fe2b6
15 changed files with 1075 additions and 247 deletions

View File

@ -525,10 +525,12 @@ class GenerateRequest(BaseModel):
"unclassified(1), eau(9), virtuel(66), "
"pont(17), sursol(64). Mode pur, "
"aucune retouche. (défaut: sol)")
bare_earth: bool = Field(False,
description="Sol nu : DTM au retour le plus bas de "
"chaque cellule (requalifie le point le plus "
"bas en terrain)")
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)")
viz: list = Field(None,
description="Visualisations à générer, noms d'étapes du "
"pipeline (ex: aspect, wavelet, slope) ; "
@ -844,6 +846,29 @@ def missing_cells_with_corners(cells, output_dir, include_done=False, viz=None):
return todo
def _attach_frame_bounds(tiles):
"""Ajoute aux tuiles non terminées leurs coins WGS84 (cadre sur la carte).
L'interface dessine un cadre coloré autour des dalles du run en cours
(orange : rendu en cours, bleu : en attente, rouge : échec) ; les coins
viennent de la grille L93 (col, row) → bornes exactes via PROJ. Les
dalles terminées n'ont pas de cadre : coins omis pour alléger le sondage
/api/status (toutes les ~2 s pendant un run).
"""
from .index import attach_gps_bounds, parse_basename_coords
todo = []
for tile in tiles:
if tile.get("state") == "done":
continue
coords = parse_basename_coords(tile.get("name") or "")
if not coords:
continue
tile["col"], tile["row"] = coords
todo.append(tile)
if todo:
attach_gps_bounds(todo)
@app.get("/")
def root():
index = OUTPUT_DIR / "index.html"
@ -880,6 +905,9 @@ def status(request: Request = None):
data["regen_allowed"] = regen_allowed
return data
from .progress import progress_snapshot
tiles = progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels(),
run_id=_job.get("run_id"))
_attach_frame_bounds(tiles)
with _job_lock:
pending = bool(_queue)
if pending:
@ -900,9 +928,9 @@ def status(request: Request = None):
# L'interface masque les boutons de génération hors réseau local
"regen_allowed": regen_allowed,
# Tuiles une par une (nom court, type, état) — agrégées depuis les
# événements écrits par le pipeline (.generation.events.jsonl)
"tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels(),
run_id=_job.get("run_id")),
# événements écrits par le pipeline (.generation.events.jsonl) ;
# les non terminées portent leurs coins pour le cadre sur la carte
"tiles": tiles,
}
@ -1213,7 +1241,10 @@ def cell_at_point(lat: float, lng: float):
return {"col": col, "row": row, "corners": tile.get("corners") or []}
def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None, reclassify=False):
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):
"""Commande de génération : téléchargement IGN + traitement des fichiers.
La classification du sol est choisie via `ground_class` (défaut : "ign",
@ -1226,8 +1257,10 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
Avec reclassify=True, --force-classification relance la classification
même à méthode inchangée (sinon, choisir une méthode différente via
ground_class/ign_classes reclassifie déjà les tuiles concernées).
Avec bare_earth=True, le DTM est ramené au retour le plus bas de chaque
cellule (sol nu). Avec viz, on choisit les visualisations générées
Avec edge_buffer=True, le MNT est étendu d'une bande de
EDGE_BUFFER_METERS remplie avec les points sol des tuiles voisines
(raccord des bords : rendus continus entre tuiles, images recadrées sur
la dalle 1 km). Avec viz, on choisit les visualisations générées
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ;
défaut : aspect).
"""
@ -1240,14 +1273,14 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
"--only", *(viz or _panel_viz_steps()),
"--ground-classification", ground_class,
"--ign-classes", ign_classes]
if bare_earth:
cmd += ["--bare-earth"]
if edge_buffer:
cmd += ["--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", "-w", os.environ.get("LIDAR_WORKERS", "2")]
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "10")]
# Carte régénérée après chaque tuile terminée : la webapp l'affiche en
# direct via /api/tiles pendant le run.
cmd += ["--incremental-index"]
@ -1301,6 +1334,10 @@ def _resolve_request(req):
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.
tiles.sort(key=lambda cr: (-cr[1], cr[0]))
return tiles, viz
@ -1311,8 +1348,9 @@ def _launch_job(tiles, viz, req, qid=None):
la demande vient de la file (suivi côté interface).
"""
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class,
bare_earth=req.bare_earth, ign_classes=req.ign_classes,
viz=viz, reclassify=req.reclassify)
ign_classes=req.ign_classes,
viz=viz, reclassify=req.reclassify,
edge_buffer=req.edge_buffer)
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 (les tuiles affichées