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"> <label class="gen-opt">
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes <input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
</label> </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"> <label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
<span>Visualisations :</span> <span>Visualisations :</span>
<select id="genViz" class="gen-select" multiple size="8"> <select id="genViz" class="gen-select" multiple size="8">
@ -1250,6 +1254,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<div class="queue-head"> <div class="queue-head">
<span class="panel-title">File de génération</span> <span class="panel-title">File de génération</span>
<span id="genQueueCount"></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> <button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div> </div>
<div id="genQueueBody"></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 { flex-direction: column; align-items: stretch; gap: 4px; }
.gen-opt.col > span { color: var(--muted); font-size: 11px; } .gen-opt.col > span { color: var(--muted); font-size: 11px; }
.gen-opt select { flex: 1; min-width: 0; } .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 { display: flex; gap: 8px; margin-top: 2px; }
.gen-run #genGo { flex: 1; } .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; background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
} }
.gen-task.done .gen-task-dot { background: #39d98a; } .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; } } @keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.gen-task-label { .gen-task-label {
font-size: 12px; flex: 1; min-width: 120px; 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-state { font-size: 11px; white-space: nowrap; }
.gen-task.running .gen-task-state { color: var(--accent); } .gen-task.running .gen-task-state { color: var(--accent); }
.gen-task.done .gen-task-state { color: #39d98a; } .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 { .gen-task-time {
font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums;
} }
@ -2404,6 +2410,7 @@ function regenerateCurrentTile() {
document.getElementById('genRegen').checked = true; document.getElementById('genRegen').checked = true;
genGo.style.display = ''; genGo.style.display = '';
showGenBar('1 tuile à régénérer — choisissez les options puis lancez'); 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 genGo = document.getElementById('genGo');
const genCancel = document.getElementById('genCancel'); const genCancel = document.getElementById('genCancel');
const genRegen = document.getElementById('genRegen'); const genRegen = document.getElementById('genRegen');
const genReclass = document.getElementById('genReclass');
const genClassInfo = document.getElementById('genClassInfo');
const genClass = document.getElementById('genClass'); const genClass = document.getElementById('genClass');
const genIgnClasses = document.getElementById('genIgnClasses'); const genIgnClasses = document.getElementById('genIgnClasses');
const genViz = document.getElementById('genViz'); const genViz = document.getElementById('genViz');
const genBareEarth = document.getElementById('genBareEarth'); const genBareEarth = document.getElementById('genBareEarth');
const genQueue = document.getElementById('genQueue'); const genQueue = document.getElementById('genQueue');
const genBadge = document.getElementById('genBadge'); const genBadge = document.getElementById('genBadge');
const genStop = document.getElementById('genStop');
const genQueueBody = document.getElementById('genQueueBody'); const genQueueBody = document.getElementById('genQueueBody');
const genQueueClear = document.getElementById('genQueueClear'); const genQueueClear = document.getElementById('genQueueClear');
const genQueueCount = document.getElementById('genQueueCount'); const genQueueCount = document.getElementById('genQueueCount');
@ -2442,6 +2452,24 @@ function showGenBar(msg) {
genBar.classList.add('show'); 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 => { fetch('/api/status').then(r => {
apiAvailable = r.ok; apiAvailable = r.ok;
if (!apiAvailable) hideExportTab(); if (!apiAvailable) hideExportTab();
@ -2512,6 +2540,7 @@ map.on('mouseup', e => {
if (drawRect) { drawRect.remove(); drawRect = null; } if (drawRect) { drawRect.remove(); drawRect = null; }
pendingTiles = (d.cells || []).map(c => [c.col, c.row]); pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = false; // zone dessinée : le scope global est levé pendingCompleteAll = false; // zone dessinée : le scope global est levé
updateGenClassInfo();
(d.cells || []).forEach(c => ghostCells.push( (d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map) 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; pendingRegen = true;
pendingCompleteAll = false; pendingCompleteAll = false;
updateGenSelBar(); updateGenSelBar();
updateGenClassInfo();
} }
function setGenSelMode(on) { function setGenSelMode(on) {
@ -2623,6 +2653,7 @@ btnComplete.addEventListener('click', () => {
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
pendingTiles = (d.cells || []).map(c => [c.col, c.row]); pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = true; pendingCompleteAll = true;
updateGenClassInfo();
(d.cells || []).forEach(c => ghostCells.push( (d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map) 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, all_missing: pendingCompleteAll,
ground_class: genClass ? genClass.value : 'ign', ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol', 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(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => { .then(() => {
const verb = isRegen ? 'régénération' const verb = isRegen ? 'régénération'
@ -2669,6 +2701,7 @@ genCancel.addEventListener('click', () => {
genBar.classList.remove('show'); genBar.classList.remove('show');
clearGhosts(); clearGhosts();
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false; pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
updateGenClassInfo();
clearGenSel(); clearGenSel();
if (genMode) exitGenMode(); if (genMode) exitGenMode();
}); });
@ -2943,6 +2976,8 @@ function genTaskEl(t) {
: 'En cours…'; : 'En cours…';
} else if (t.state === 'done') { } else if (t.state === 'done') {
state.textContent = 'Terminé'; state.textContent = 'Terminé';
} else if (t.state === 'stopped') {
state.textContent = 'Interrompu';
} else { } else {
state.textContent = 'Échec (code ' + t.returncode + ')'; state.textContent = 'Échec (code ' + t.returncode + ')';
} }
@ -2988,6 +3023,7 @@ function renderGenQueue() {
const running = genTasks.filter(t => t.state === 'running').length; const running = genTasks.filter(t => t.state === 'running').length;
genBadge.hidden = running === 0; genBadge.hidden = running === 0;
genBadge.textContent = running > 0 ? String(running) : ''; genBadge.textContent = running > 0 ? String(running) : '';
if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; }
genQueueCount.textContent = genTasks.length genQueueCount.textContent = genTasks.length
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)') ? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
: ''; : '';
@ -3008,7 +3044,10 @@ function finishRunningTasks(d) {
let changed = false; let changed = false;
for (const t of genTasks) { for (const t of genTasks) {
if (t.state !== 'running') continue; 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; t.returncode = d.returncode;
changed = true; changed = true;
} }
@ -3154,6 +3193,19 @@ genQueueClear.addEventListener('click', () => {
saveGenTasks(); renderGenQueue(); 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(); renderGenQueue();
// Reprend le suivi si une tâche tournait avant un rechargement de la page, // 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 // 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({ genTasks.push({
id: Date.now(), label: 'Dernière génération', id: Date.now(), label: 'Dernière génération',
time: now, started: s.started, 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, log: [], logOpen: false, tiles: s.tiles,
}); });
saveGenTasks(); renderGenQueue(); saveGenTasks(); renderGenQueue();

View File

@ -222,10 +222,13 @@ def test_generate_all_missing_empty(tmp_path, monkeypatch):
def test_build_command_regenerate(): 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 from lidar_pipeline.webapp import _build_command
cmd = " ".join(_build_command([(1054, 6882)], regenerate=True)) cmd = " ".join(_build_command([(1054, 6882)], regenerate=True))
assert "--force" in cmd 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 assert "--force-classification" in cmd
cmd = " ".join(_build_command([(1054, 6882)])) cmd = " ".join(_build_command([(1054, 6882)]))
assert "--force" not in cmd assert "--force" not in cmd
@ -239,14 +242,56 @@ def test_build_command_ground_classification():
cmd = _build_command([(1054, 6882)]) cmd = _build_command([(1054, 6882)])
i = cmd.index("--ground-classification") i = cmd.index("--ground-classification")
assert cmd[i + 1] == "ign" 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 method in GROUND_CLASS_METHODS:
for regenerate in (False, True): for regenerate in (False, True):
cmd = _build_command([(1054, 6882)], regenerate=regenerate, ground_class=method) for reclassify in (False, True):
i = cmd.index("--ground-classification") cmd = _build_command([(1054, 6882)], regenerate=regenerate,
assert cmd[i + 1] == method ground_class=method, reclassify=reclassify)
assert ("--force" in cmd) == regenerate i = cmd.index("--ground-classification")
assert ("--force-classification" in cmd) == regenerate 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(): 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 result["demarré"] is True and result["distant"] is True
assert calls == [("POST", "/api/generate", assert calls == [("POST", "/api/generate",
{"tiles": [[1054, 6882]], "regenerate": False, {"tiles": [[1054, 6882]], "regenerate": False,
"reclassify": False,
"ground_class": "ign", "ign_classes": "sol", "ground_class": "ign", "ign_classes": "sol",
"bare_earth": False, "viz": ["aspect"], "bare_earth": False, "viz": ["aspect"],
"all_missing": False})] "all_missing": False})]

View File

@ -18,12 +18,19 @@ le téléchargement IGN). Endpoints :
demandées, option regenerate=true pour inclure les demandées, option regenerate=true pour inclure les
tuiles complètes tuiles complètes
POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules
(option regenerate=true ajoute --force --force-classification ; (option regenerate=true ajoute --force : les visualisations
option ground_class choisit la méthode de classification du sol ; 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 option all_missing=true traite toutes les dalles présentes
dans input/ qui manquent les visualisations demandées, sans dans input/ qui manquent les visualisations demandées, sans
téléchargement ; sans --force, le pipeline ne génère que téléchargement ; sans --force, le pipeline ne génère que
les visualisations manquantes des tuiles existantes) 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/ POST /api/export → assemble des dalles adjacentes en une image (PNG/JPEG/
WebP) ou un PDF multi-couches, consultable sur téléphone WebP) ou un PDF multi-couches, consultable sur téléphone
(module export.py — mosaïque sans couture, habillage (module export.py — mosaïque sans couture, habillage
@ -52,6 +59,7 @@ import logging
import math import math
import os import os
import re import re
import signal
import subprocess import subprocess
import sys import sys
import threading import threading
@ -444,7 +452,15 @@ class PreviewRequest(BaseModel):
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
tiles: list = Field(..., description="liste [col, row] (entiers km L93)") 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", ground_class: str = Field("ign",
description="Méthode de classification du sol : " description="Méthode de classification du sol : "
"auto, ign, smrf, csf") "auto, ign, smrf, csf")
@ -907,16 +923,20 @@ def preview(req: PreviewRequest):
return {"count": len(todo), "capped": capped, "cells": todo} 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. """Commande de génération : téléchargement IGN + traitement des fichiers.
La classification du sol est choisie via `ground_class` (défaut : "ign", 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 pré-classification IGN ; le pipeline bascule sur SMRF si un fichier ne la
contient pas). Avec ign_classes, on choisit les classes LAS extraites contient pas). Avec ign_classes, on choisit les classes LAS extraites
pour le MNT (mode pur, ex. "sol,unclassified" pour combler les trous pour le MNT (mode pur, ex. "sol,unclassified" pour combler les trous
sans retouche). Avec regenerate=True, force la reclassification et la sans retouche). Avec regenerate=True, les visualisations des tuiles déjà
régénération des visualisations des tuiles déjà présentes. Avec présentes sont refaites (--force) mais leur classification est conservée :
bare_earth=True, le DTM est ramené au retour le plus bas de chaque 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 cellule (sol nu). Avec viz, on choisit les visualisations générées
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ; (noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ;
défaut : aspect). défaut : aspect).
@ -933,7 +953,9 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
if bare_earth: if bare_earth:
cmd += ["--bare-earth"] cmd += ["--bare-earth"]
if regenerate: if regenerate:
cmd += ["--force", "--force-classification"] cmd += ["--force"]
if reclassify:
cmd += ["--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1": 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", "2")]
# Carte régénérée après chaque tuile terminée : la webapp l'affiche en # 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"] proc = _job["proc"]
if proc is not None and proc.poll() is None: if proc is not None and proc.poll() is None:
raise HTTPException(409, "une génération est déjà en cours") 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_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(LOG_FILE, "w", encoding="utf-8") log_fh = open(LOG_FILE, "w", encoding="utf-8")
# Nouveau run : journal d'événements remis à zéro (les tuiles affichées # 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) reset_events(OUTPUT_DIR)
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None}) _job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None})
_save_job_state() _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, 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(): def _watch():
rc = p.wait() rc = p.wait()
@ -1014,6 +1041,46 @@ def generate(req: GenerateRequest):
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)} 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 multi-dalles (mosaïque image/PDF pour téléphone) ---------------
_export_lock = threading.Lock() _export_lock = threading.Lock()