Arrêt de génération et reclassification optionnelle depuis l'interface

- POST /api/stop : SIGTERM au pipeline (groupe isolé via start_new_session),
  SIGKILL du groupe après 15 s ; forwardé au worker en mode deux machines
- « Régénérer » n'implique plus la reclassification : le DTM en cache est
  réutilisé tant que la méthode ne change pas ; nouvelle case « Reclasser le
  sol » (--force-classification) et rappel de la classification existante
- Tâche marquée « Interrompu » (returncode négatif ou nul) au lieu d'Échec
This commit is contained in:
Antoine Jacquin
2026-09-12 20:27:52 +02:00
parent bde2025714
commit 8e55fc5f98
3 changed files with 188 additions and 22 deletions

View File

@ -1216,6 +1216,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<label class="gen-opt">
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
</label>
<label class="gen-opt" title="Par défaut la classification du sol existante est conservée (DTM réutilisé). Cochez pour la relancer sur toutes les tuiles ; choisir une autre méthode ci-dessous reclassifie déjà les tuiles concernées.">
<input type="checkbox" id="genReclass"> Reclasser le sol
</label>
<div class="gen-hint" id="genClassInfo" hidden></div>
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
<span>Visualisations :</span>
<select id="genViz" class="gen-select" multiple size="8">
@ -1250,6 +1254,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<div class="queue-head">
<span class="panel-title">File de génération</span>
<span id="genQueueCount"></span>
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours" hidden>■</button>
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div>
<div id="genQueueBody"></div>
@ -1638,6 +1643,7 @@ body.zooming .ltile { will-change: transform; }
.gen-opt.col { flex-direction: column; align-items: stretch; gap: 4px; }
.gen-opt.col > span { color: var(--muted); font-size: 11px; }
.gen-opt select { flex: 1; min-width: 0; }
.gen-hint { color: var(--muted); font-size: 11px; padding: 0 2px; }
.gen-run { display: flex; gap: 8px; margin-top: 2px; }
.gen-run #genGo { flex: 1; }
@ -1658,7 +1664,7 @@ body.zooming .ltile { will-change: transform; }
background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
}
.gen-task.done .gen-task-dot { background: #39d98a; }
.gen-task.failed .gen-task-dot { background: var(--danger); }
.gen-task.failed .gen-task-dot, .gen-task.stopped .gen-task-dot { background: var(--danger); }
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.gen-task-label {
font-size: 12px; flex: 1; min-width: 120px;
@ -1667,7 +1673,7 @@ body.zooming .ltile { will-change: transform; }
.gen-task-state { font-size: 11px; white-space: nowrap; }
.gen-task.running .gen-task-state { color: var(--accent); }
.gen-task.done .gen-task-state { color: #39d98a; }
.gen-task.failed .gen-task-state { color: var(--danger); }
.gen-task.failed .gen-task-state, .gen-task.stopped .gen-task-state { color: var(--danger); }
.gen-task-time {
font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums;
}
@ -2404,6 +2410,7 @@ function regenerateCurrentTile() {
document.getElementById('genRegen').checked = true;
genGo.style.display = '';
showGenBar('1 tuile à régénérer — choisissez les options puis lancez');
updateGenClassInfo();
}
// ============================================================
@ -2419,12 +2426,15 @@ const genMsg = document.getElementById('genMsg');
const genGo = document.getElementById('genGo');
const genCancel = document.getElementById('genCancel');
const genRegen = document.getElementById('genRegen');
const genReclass = document.getElementById('genReclass');
const genClassInfo = document.getElementById('genClassInfo');
const genClass = document.getElementById('genClass');
const genIgnClasses = document.getElementById('genIgnClasses');
const genViz = document.getElementById('genViz');
const genBareEarth = document.getElementById('genBareEarth');
const genQueue = document.getElementById('genQueue');
const genBadge = document.getElementById('genBadge');
const genStop = document.getElementById('genStop');
const genQueueBody = document.getElementById('genQueueBody');
const genQueueClear = document.getElementById('genQueueClear');
const genQueueCount = document.getElementById('genQueueCount');
@ -2442,6 +2452,24 @@ function showGenBar(msg) {
genBar.classList.add('show');
}
// Classification déjà présente sur les tuiles visées : guide le choix
// « Reclasser le sol » (décoché = la classification existante est conservée).
function updateGenClassInfo() {
if (!genClassInfo) return;
const methods = new Set();
for (const pair of (pendingTiles || [])) {
for (const t of TILES) {
if (t.col === pair[0] && t.row === pair[1] && t.meta && t.meta.method)
methods.add(t.meta.method);
}
}
if (!methods.size) { genClassInfo.hidden = true; return; }
genClassInfo.textContent = 'Classification existante : ' +
[...methods].map(methodLabel).join(', ') +
' — conservée si « Reclasser le sol » est décoché et que la méthode ne change pas';
genClassInfo.hidden = false;
}
fetch('/api/status').then(r => {
apiAvailable = r.ok;
if (!apiAvailable) hideExportTab();
@ -2512,6 +2540,7 @@ map.on('mouseup', e => {
if (drawRect) { drawRect.remove(); drawRect = null; }
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = false; // zone dessinée : le scope global est levé
updateGenClassInfo();
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map)
));
@ -2563,6 +2592,7 @@ function toggleGenSel(t) {
pendingRegen = true;
pendingCompleteAll = false;
updateGenSelBar();
updateGenClassInfo();
}
function setGenSelMode(on) {
@ -2623,6 +2653,7 @@ btnComplete.addEventListener('click', () => {
}).then(r => r.json()).then(d => {
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = true;
updateGenClassInfo();
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map)
));
@ -2652,7 +2683,8 @@ genGo.addEventListener('click', () => {
all_missing: pendingCompleteAll,
ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
bare_earth: genBareEarth ? genBareEarth.checked : false })
bare_earth: genBareEarth ? genBareEarth.checked : false,
reclassify: genReclass ? genReclass.checked : false })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => {
const verb = isRegen ? 'régénération'
@ -2669,6 +2701,7 @@ genCancel.addEventListener('click', () => {
genBar.classList.remove('show');
clearGhosts();
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
updateGenClassInfo();
clearGenSel();
if (genMode) exitGenMode();
});
@ -2943,6 +2976,8 @@ function genTaskEl(t) {
: 'En cours…';
} else if (t.state === 'done') {
state.textContent = 'Terminé';
} else if (t.state === 'stopped') {
state.textContent = 'Interrompu';
} else {
state.textContent = 'Échec (code ' + t.returncode + ')';
}
@ -2988,6 +3023,7 @@ function renderGenQueue() {
const running = genTasks.filter(t => t.state === 'running').length;
genBadge.hidden = running === 0;
genBadge.textContent = running > 0 ? String(running) : '';
if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; }
genQueueCount.textContent = genTasks.length
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
: '';
@ -3008,7 +3044,10 @@ function finishRunningTasks(d) {
let changed = false;
for (const t of genTasks) {
if (t.state !== 'running') continue;
t.state = d.returncode === 0 ? 'done' : 'failed';
// returncode négatif = tué par /api/stop ; null = serveur relancé en plein
// run (job disparu) : dans les deux cas la tâche est interrompue, pas échouée.
t.state = d.returncode === 0 ? 'done'
: (d.returncode === null || d.returncode < 0) ? 'stopped' : 'failed';
t.returncode = d.returncode;
changed = true;
}
@ -3154,6 +3193,19 @@ genQueueClear.addEventListener('click', () => {
saveGenTasks(); renderGenQueue();
});
// Arrêt de la génération en cours : SIGTERM au pipeline côté serveur, qui
// nettoie ses workers et ses processus PDAL ; le sondage marque ensuite la
// tâche « Interrompu » (returncode négatif).
if (genStop) genStop.addEventListener('click', () => {
genStop.disabled = true;
fetch('/api/stop', { method: 'POST' })
.then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.catch(msg => {
genStop.disabled = false;
showGenBar('Erreur arrêt : ' + msg);
});
});
renderGenQueue();
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
// adopte une génération lancée hors interface (API directe), ou réaffiche le
@ -3175,7 +3227,8 @@ else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
genTasks.push({
id: Date.now(), label: 'Dernière génération',
time: now, started: s.started,
state: s.returncode === 0 ? 'done' : 'failed', returncode: s.returncode,
state: s.returncode === 0 ? 'done' : s.returncode < 0 ? 'stopped' : 'failed',
returncode: s.returncode,
log: [], logOpen: false, tiles: s.tiles,
});
saveGenTasks(); renderGenQueue();

View File

@ -222,10 +222,13 @@ def test_generate_all_missing_empty(tmp_path, monkeypatch):
def test_build_command_regenerate():
"""regenerate=True ajoute --force --force-classification à la commande."""
"""regenerate --force seul (classification conservée) ; reclassify ajoute --force-classification."""
from lidar_pipeline.webapp import _build_command
cmd = " ".join(_build_command([(1054, 6882)], regenerate=True))
assert "--force" in cmd
assert "--force-classification" not in cmd
cmd = " ".join(_build_command([(1054, 6882)], regenerate=True, reclassify=True))
assert "--force" in cmd
assert "--force-classification" in cmd
cmd = " ".join(_build_command([(1054, 6882)]))
assert "--force" not in cmd
@ -239,14 +242,56 @@ def test_build_command_ground_classification():
cmd = _build_command([(1054, 6882)])
i = cmd.index("--ground-classification")
assert cmd[i + 1] == "ign"
# Chaque méthode valide est transmise telle quelle, avec ou sans régénération
# Chaque méthode valide est transmise telle quelle ; --force suit
# regenerate, --force-classification suit reclassify (indépendants)
for method in GROUND_CLASS_METHODS:
for regenerate in (False, True):
cmd = _build_command([(1054, 6882)], regenerate=regenerate, ground_class=method)
i = cmd.index("--ground-classification")
assert cmd[i + 1] == method
assert ("--force" in cmd) == regenerate
assert ("--force-classification" in cmd) == regenerate
for reclassify in (False, True):
cmd = _build_command([(1054, 6882)], regenerate=regenerate,
ground_class=method, reclassify=reclassify)
i = cmd.index("--ground-classification")
assert cmd[i + 1] == method
assert ("--force" in cmd) == regenerate
assert ("--force-classification" in cmd) == reclassify
def test_stop_generation(monkeypatch):
"""/api/stop termine le processus en cours (SIGTERM) ou refuse (409)."""
import time
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
terminated = []
class FakeProc:
pid = 4242
def poll(self):
return None # en cours
def terminate(self):
terminated.append("SIGTERM")
def wait(self, timeout=None):
terminated.append("wait")
saved = {k: webapp._job.get(k) for k in ("proc", "started", "returncode", "cmd", "finished")}
try:
webapp._job.update({"proc": FakeProc(), "started": time.time(),
"returncode": None, "cmd": ["x"], "finished": None})
assert webapp.stop_generation() == {"arrêt": "demandé"}
assert "SIGTERM" in terminated
# Le fil d'escalade peut mettre un instant avant son wait : laisser
# la file de threads vider avant de restaurer l'état du job.
for _ in range(50):
if "wait" in terminated:
break
time.sleep(0.02)
# Sans job en cours : refus propre
webapp._job.update({"proc": None})
try:
webapp.stop_generation()
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 409
finally:
webapp._job.update(saved)
def test_build_command_default_viz_aspect():
@ -574,6 +619,7 @@ def test_generate_forwards_to_remote(monkeypatch):
assert result["demarré"] is True and result["distant"] is True
assert calls == [("POST", "/api/generate",
{"tiles": [[1054, 6882]], "regenerate": False,
"reclassify": False,
"ground_class": "ign", "ign_classes": "sol",
"bare_earth": False, "viz": ["aspect"],
"all_missing": False})]

View File

@ -18,12 +18,19 @@ le téléchargement IGN). Endpoints :
demandées, option regenerate=true pour inclure les
tuiles complètes
POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules
(option regenerate=true ajoute --force --force-classification ;
option ground_class choisit la méthode de classification du sol ;
(option regenerate=true ajoute --force : les visualisations
sont refaites mais la classification existante est
conservée ; option reclassify=true ajoute en plus
--force-classification pour relancer la classification
du sol — sinon changer simplement ground_class
reclassifie déjà les tuiles dont la méthode diffère ;
option all_missing=true traite toutes les dalles présentes
dans input/ qui manquent les visualisations demandées, sans
téléchargement ; sans --force, le pipeline ne génère que
les visualisations manquantes des tuiles existantes)
POST /api/stop → arrête la génération en cours (SIGTERM au pipeline, qui
nettoie ses workers et processus PDAL ; SIGKILL du groupe
en repli après 15 s)
POST /api/export → assemble des dalles adjacentes en une image (PNG/JPEG/
WebP) ou un PDF multi-couches, consultable sur téléphone
(module export.py — mosaïque sans couture, habillage
@ -52,6 +59,7 @@ import logging
import math
import os
import re
import signal
import subprocess
import sys
import threading
@ -444,7 +452,15 @@ class PreviewRequest(BaseModel):
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")
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")
@ -907,16 +923,20 @@ def preview(req: PreviewRequest):
return {"count": len(todo), "capped": capped, "cells": todo}
def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None):
def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None, reclassify=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",
pré-classification IGN ; le pipeline bascule sur SMRF si un fichier ne la
contient pas). Avec ign_classes, on choisit les classes LAS extraites
pour le MNT (mode pur, ex. "sol,unclassified" pour combler les trous
sans retouche). Avec regenerate=True, force la reclassification et la
régénération des visualisations des tuiles déjà présentes. Avec
bare_earth=True, le DTM est ramené au retour le plus bas de chaque
sans retouche). Avec regenerate=True, les visualisations des tuiles déjà
présentes sont refaites (--force) mais leur classification est conservée :
le pipeline réutilise le DTM en cache quand la méthode ne change pas.
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
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ;
défaut : aspect).
@ -933,7 +953,9 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
if bare_earth:
cmd += ["--bare-earth"]
if regenerate:
cmd += ["--force", "--force-classification"]
cmd += ["--force"]
if reclassify:
cmd += ["--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1":
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "2")]
# Carte régénérée après chaque tuile terminée : la webapp l'affiche en
@ -989,7 +1011,7 @@ def generate(req: GenerateRequest):
proc = _job["proc"]
if proc is not None and proc.poll() is None:
raise HTTPException(409, "une génération est déjà en cours")
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz)
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)
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
@ -998,8 +1020,13 @@ def generate(req: GenerateRequest):
reset_events(OUTPUT_DIR)
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None})
_save_job_state()
# start_new_session : le pipeline et ses workers/PDAL forment leur
# propre groupe de processus — /api/stop peut le tuer en bloc sans
# toucher la webapp, et le gestionnaire SIGTERM du pipeline (killpg)
# reste confiné à son groupe.
p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
cwd="/app" if Path("/app").exists() else None)
cwd="/app" if Path("/app").exists() else None,
start_new_session=True)
def _watch():
rc = p.wait()
@ -1014,6 +1041,46 @@ def generate(req: GenerateRequest):
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
@app.post("/api/stop", dependencies=[Depends(_require_token),
Depends(_require_lan_for_generation)])
def stop_generation():
"""Arrête la génération en cours.
SIGTERM au pipeline : son gestionnaire de signal nettoie ses workers et
ses processus PDAL (killpg sur son propre groupe, cf. cli.py). Si le
processus ne meurt pas dans les 15 s (worker bloqué), SIGKILL du groupe
entier. L'état final (returncode négatif) est enregistré par le fil de
surveillance du job.
"""
if GENERATION_URL:
# Webapp légère : l'arrêt concerne le pipeline de la machine distante.
return _proxy_api("POST", "/api/stop")
with _job_lock:
proc = _job["proc"]
if proc is None or proc.poll() is not None:
raise HTTPException(409, "aucune génération en cours")
try:
proc.terminate() # SIGTERM → nettoyage propre du pipeline
except OSError:
pass
def _escalate():
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (OSError, ProcessLookupError):
try:
proc.kill()
except OSError:
pass
threading.Thread(target=_escalate, daemon=True).start()
logger.info("Arrêt de la génération demandé (SIGTERM au pipeline)")
return {"arrêt": "demandé"}
# --- Export multi-dalles (mosaïque image/PDF pour téléphone) ---------------
_export_lock = threading.Lock()