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:
@ -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();
|
||||||
|
|||||||
@ -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})]
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user