Sélection de tuiles au clic, file d'attente et rafraîchissement fiable
- Clic sur la carte pour choisir des dalles précises, même sans données existantes (via /api/cell) ; les zones dessinées s'ajoutent à la sélection au lieu de la remplacer - Une demande lancée pendant un run part en file d'attente côté serveur et démarre à la fin du travail en cours (plus de refus 409), file vidable depuis l'interface - Tuiles et interface toujours fraîches : images servies sans cache navigateur (revalidation 304), rechargement de la carte seulement une fois le rebuild de l'index terminé - serve-webapp.sh : sous-commande update retirée, documentation de déploiement corrigée en conséquence
This commit is contained in:
@ -5,7 +5,7 @@
|
||||
- build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU)
|
||||
- build générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes)
|
||||
- simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` peuplé à la demande depuis le worker. Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS).
|
||||
- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs|update]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git)
|
||||
- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git) ; mise à jour = `git pull` puis rebuild image + `restart` (cf. `docs/DEPLOY_WEBAPP.md`)
|
||||
- test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord)
|
||||
- test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>`
|
||||
- test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>`
|
||||
|
||||
@ -114,9 +114,9 @@ nano webapp.env # LIDAR_GENERATION_URL, jeton...
|
||||
```
|
||||
|
||||
Sous-commandes : `stop`, `restart` (relit `webapp.env`), `status` (conteneur
|
||||
+ état du rebuild), `sync` (rebuild + vignettes), `logs`, `update`
|
||||
(`git pull` + rebuild + redémarrage). Port hôte via `WEBAPP_PORT` dans
|
||||
`webapp.env` ou l'environnement (`WEBAPP_PORT=9000 ./serve-webapp.sh`).
|
||||
+ état du rebuild), `sync` (rebuild + vignettes), `logs`. Port hôte via
|
||||
`WEBAPP_PORT` dans `webapp.env` ou l'environnement (`WEBAPP_PORT=9000
|
||||
./serve-webapp.sh`).
|
||||
|
||||
**b. `run.sh`** — conteneur au premier plan (Ctrl-C arrête tout) : pratique
|
||||
pour tester, les variables se passent depuis l'environnement hôte, `~/.ssh`
|
||||
@ -238,19 +238,20 @@ des générations.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
Avec `serve-webapp.sh`, une seule commande (`git pull`, rebuild de l'image,
|
||||
redémarrage) :
|
||||
|
||||
```bash
|
||||
./serve-webapp.sh update
|
||||
```
|
||||
|
||||
Sinon, sur le Pi, après un `git pull` :
|
||||
Sur le Pi, après un `git pull` :
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage
|
||||
```
|
||||
|
||||
Avec `serve-webapp.sh`, l'image est à reconstruire explicitement avant de
|
||||
redémarrer (le script ne reconstruit qu'au premier lancement) :
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.webapp -t lidar-webapp .
|
||||
./serve-webapp.sh restart
|
||||
```
|
||||
|
||||
Avec `run.sh --serve-webapp`, l'image est reconstruite automatiquement à
|
||||
chaque lancement ; il suffit de relancer la commande. Le cache de tuiles
|
||||
(`output/`) n'est pas affecté par les mises à jour d'image.
|
||||
@ -318,5 +319,5 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles
|
||||
- `Dockerfile.webapp`, `docker-compose.webapp.yml` : webapp légère ARM64
|
||||
(FastAPI + Pillow AVIF natif + pyproj, ~200 Mo).
|
||||
- `serve-webapp.sh`, `webapp.env.example` : gestion de la stack webapp sur
|
||||
le Pi (démarrage/arrêt/sync/logs/update), configuration locale dans
|
||||
le Pi (démarrage/arrêt/sync/logs), configuration locale dans
|
||||
`webapp.env` (ignoré par git).
|
||||
|
||||
@ -12,8 +12,11 @@ Fonctionnalités :
|
||||
réordonnancement par glisser-déposer (persisté en localStorage).
|
||||
- Panneau d'infos par tuile : résolution, méthode de classification du sol
|
||||
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
|
||||
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
|
||||
dessin d'un rectangle → téléchargement IGN + traitement.
|
||||
- Génération via l'API web (./run.sh --serve) : dessin d'un rectangle
|
||||
(téléchargement IGN + traitement) ou sélection cumulative au clic —
|
||||
chaque tuile individuelle, avec ou sans données existantes ; les zones
|
||||
et les clics s'additionnent, et une demande lancée pendant un run part
|
||||
en file d'attente côté serveur (jamais de coupure du travail en place).
|
||||
- Onglet Export : sélection de dalles sur la carte → mosaïque jointive
|
||||
(image ou PDF multi-couches) téléchargeable pour consultation sur
|
||||
téléphone (/api/export de la webapp, module export.py).
|
||||
@ -1294,7 +1297,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<!-- Actions Zone / Sélection / Compléter : réservées à cet onglet -->
|
||||
<div class="gen-actions">
|
||||
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
|
||||
<button id="btnGenSel" title="Régénérer des tuiles précises : cliquer des dalles sur la carte pour les ajouter ou les retirer" hidden>↻ Sélectionner des tuiles</button>
|
||||
<button id="btnGenSel" title="Sélectionner des tuiles précises, à générer ou régénérer : un clic sur la carte ajoute ou retire la dalle (les zones dessinées s'ajoutent à la sélection)" hidden>Sélectionner des tuiles</button>
|
||||
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
|
||||
</div>
|
||||
|
||||
@ -1342,7 +1345,8 @@ _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="genStop" class="icon-btn" title="Arrêter la génération en cours (la file d'attente n'est pas touchée)" hidden>■</button>
|
||||
<button id="genQueueDrop" class="icon-btn" title="Retirer les demandes en attente (elles démarreraient après le run en cours sinon)" hidden>⏏</button>
|
||||
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
|
||||
</div>
|
||||
<div id="genQueueBody"></div>
|
||||
@ -1779,6 +1783,9 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; }
|
||||
.gen-task.running .gen-task-dot {
|
||||
background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
.gen-task.queued .gen-task-dot {
|
||||
background: var(--muted); animation: gen-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
.gen-task.done .gen-task-dot { background: #39d98a; }
|
||||
.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; } }
|
||||
@ -1788,6 +1795,7 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; }
|
||||
}
|
||||
.gen-task-state { font-size: 11px; white-space: nowrap; }
|
||||
.gen-task.running .gen-task-state { color: var(--accent); }
|
||||
.gen-task.queued .gen-task-state { color: var(--muted); }
|
||||
.gen-task.done .gen-task-state { color: #39d98a; }
|
||||
.gen-task.failed .gen-task-state, .gen-task.stopped .gen-task-state { color: var(--danger); }
|
||||
.gen-task-time {
|
||||
@ -2053,7 +2061,7 @@ function registerTile(t) {
|
||||
}).addTo(map);
|
||||
mk.on('click', () => {
|
||||
if (exportMode) { toggleExportSel(t); return; }
|
||||
if (genSelMode) { toggleGenSel(t); return; }
|
||||
if (genSelMode) { toggleGenSelCell(t.col, t.row, t.corners); return; }
|
||||
if (!genMode) showTileInfo(t);
|
||||
});
|
||||
const el = mk.getElement();
|
||||
@ -2799,7 +2807,10 @@ function saveViewHashSoon() {
|
||||
}
|
||||
window.addEventListener('pagehide', () => { clearTimeout(viewHashTimer); saveViewHash(); });
|
||||
map.on('moveend', () => { updateImages(); saveViewHashSoon(); });
|
||||
map.on('click', hideTileInfo);
|
||||
map.on('click', e => {
|
||||
if (genSelMode) { pickGenSelCell(e.latlng); return; }
|
||||
hideTileInfo();
|
||||
});
|
||||
map.whenReady(() => layoutTiles());
|
||||
|
||||
// ============================================================
|
||||
@ -2975,12 +2986,16 @@ const genBadge = document.getElementById('genBadge');
|
||||
const genStop = document.getElementById('genStop');
|
||||
const genQueueBody = document.getElementById('genQueueBody');
|
||||
const genQueueClear = document.getElementById('genQueueClear');
|
||||
const genQueueDrop = document.getElementById('genQueueDrop');
|
||||
const genQueueCount = document.getElementById('genQueueCount');
|
||||
let genMode = false, drawStart = null, drawRect = null;
|
||||
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
|
||||
const ghostCells = [];
|
||||
let genSelMode = false;
|
||||
const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération
|
||||
const genSel = new Set(); // clés "col,row" des dalles retenues (clics + zones cumulés)
|
||||
const genSelCorners = new Map(); // clé "col,row" → coins GPS (dalles sans données)
|
||||
const genSelPolys = new Map(); // clé "col,row" → polygone Leaflet de surlignage
|
||||
const GEN_MAX_CELLS = 400; // garde-fou serveur : ~400 km² max par demande
|
||||
|
||||
// Affiche la barre de confirmation de génération, dans l'onglet Génération
|
||||
// (bascule automatique sur cet onglet).
|
||||
@ -3041,7 +3056,7 @@ function exitGenMode() {
|
||||
genBtn.addEventListener('click', () => {
|
||||
if (genMode) { exitGenMode(); return; }
|
||||
clearGhosts();
|
||||
clearGenSel();
|
||||
pendingCompleteAll = false; // nouvelle zone : le scope global « Compléter » est levé
|
||||
genBar.classList.remove('show');
|
||||
genMode = true;
|
||||
map.dragging.disable();
|
||||
@ -3076,37 +3091,60 @@ map.on('mouseup', e => {
|
||||
viz: selectedGenViz() })
|
||||
}).then(r => r.json()).then(d => {
|
||||
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é
|
||||
// La zone S'AJOUTE à la sélection en cours (zones successives, clics
|
||||
// individuels) : les tuiles déjà retenues sont conservées, jamais perdues.
|
||||
let added = 0;
|
||||
for (const c of (d.cells || [])) {
|
||||
const k = genSelKey(c.col, c.row);
|
||||
if (!genSel.has(k)) { genSel.add(k); added++; }
|
||||
if (c.corners) genSelCorners.set(k, c.corners);
|
||||
}
|
||||
refreshGenSelHighlights();
|
||||
pendingTiles = genSelCells();
|
||||
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)
|
||||
));
|
||||
if (!pendingTiles.length) {
|
||||
if (!genSel.size) {
|
||||
genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' +
|
||||
(genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)');
|
||||
genGo.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
genMsg.textContent = pendingTiles.length + ' tuile(s) à ' +
|
||||
(genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') +
|
||||
(d.capped ? ' (zone tronquée à 400)' : '');
|
||||
genGo.style.display = '';
|
||||
genMsg.textContent = 'Zone : ' + added + ' tuile(s) ajoutée(s)' +
|
||||
(d.capped ? ' (zone tronquée à 400)' : '') +
|
||||
' — ' + genSel.size + ' tuile(s) sélectionnée(s) au total' +
|
||||
(genSel.size > GEN_MAX_CELLS
|
||||
? ' (maximum ' + GEN_MAX_CELLS + ' par lancement : retirez des tuiles)' : '');
|
||||
genGo.style.display = genSel.size > GEN_MAX_CELLS ? 'none' : '';
|
||||
}).catch(() => { showGenBar('API indisponible'); });
|
||||
});
|
||||
|
||||
// « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une
|
||||
// dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues
|
||||
// (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de
|
||||
// confirmation permet ensuite de choisir les couches et les options avant de
|
||||
// lancer la régénération (--force --force-classification) sur la sélection.
|
||||
// « Sélectionner des tuiles » : sélection cumulative, tuile par tuile — le
|
||||
// clic sur la carte ajoute ou retire la dalle de 1 km sous le curseur, avec
|
||||
// ou sans données existantes (téléchargement + génération à la validation ;
|
||||
// les sous-tuiles 0,2 m comptent pour une seule dalle). Les zones dessinées
|
||||
// s'ajoutent à la même sélection. La barre de confirmation permet de choisir
|
||||
// les couches et les options avant de lancer (--force implicite sur une
|
||||
// sélection clic par clic : retraitement ciblé des tuiles choisies).
|
||||
function genSelKey(col, row) { return col + ',' + row; }
|
||||
|
||||
function refreshGenSelHighlights() {
|
||||
const markerCells = new Set();
|
||||
for (const entry of markers.values()) {
|
||||
const k = genSelKey(entry.t.col, entry.t.row);
|
||||
markerCells.add(k);
|
||||
const div = entryDiv(entry);
|
||||
if (!div) continue;
|
||||
div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row)));
|
||||
if (div) div.classList.toggle('genSel', genSel.has(k));
|
||||
}
|
||||
// Dalles sans données (aucun marqueur) : surlignage en polygone
|
||||
for (const [k, poly] of genSelPolys) {
|
||||
if (!genSel.has(k) || markerCells.has(k)) { poly.remove(); genSelPolys.delete(k); }
|
||||
}
|
||||
for (const k of genSel) {
|
||||
if (markerCells.has(k) || genSelPolys.has(k)) continue;
|
||||
const corners = genSelCorners.get(k);
|
||||
if (!corners) continue;
|
||||
genSelPolys.set(k, L.polygon(corners,
|
||||
{ color: '#5aa9e6', weight: 1.5, fillOpacity: 0.15, pane: 'genDraw' }).addTo(map));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3115,16 +3153,21 @@ function genSelCells() {
|
||||
}
|
||||
|
||||
function updateGenSelBar() {
|
||||
showGenBar(genSel.size
|
||||
? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'
|
||||
: 'Mode sélection : clic sur dalle = ajouter / retirer');
|
||||
genGo.style.display = genSel.size ? '' : 'none';
|
||||
showGenBar(!genSel.size
|
||||
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile'
|
||||
: genSel.size > GEN_MAX_CELLS
|
||||
? genSel.size + ' tuile(s) sélectionnée(s) — maximum ' + GEN_MAX_CELLS + ' par lancement'
|
||||
: genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer');
|
||||
genGo.style.display = genSel.size && genSel.size <= GEN_MAX_CELLS ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleGenSel(t) {
|
||||
const key = genSelKey(t.col, t.row);
|
||||
function toggleGenSelCell(col, row, corners) {
|
||||
const key = genSelKey(col, row);
|
||||
if (genSel.has(key)) genSel.delete(key);
|
||||
else genSel.add(key);
|
||||
else {
|
||||
genSel.add(key);
|
||||
if (corners) genSelCorners.set(key, corners);
|
||||
}
|
||||
refreshGenSelHighlights();
|
||||
pendingTiles = genSelCells();
|
||||
pendingRegen = true;
|
||||
@ -3133,14 +3176,26 @@ function toggleGenSel(t) {
|
||||
updateGenClassInfo();
|
||||
}
|
||||
|
||||
// Clic sur la carte hors dalle affichée : le serveur convertit le point
|
||||
// WGS84 en dalle L93 de 1 km — permet de sélectionner des tuiles qui
|
||||
// n'existent pas encore (aucun marqueur, aucune donnée).
|
||||
function pickGenSelCell(latlng) {
|
||||
fetch('/api/cell?lat=' + latlng.lat + '&lng=' + latlng.lng)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (d && d.col !== null && d.col !== undefined && d.row !== undefined)
|
||||
toggleGenSelCell(d.col, d.row, d.corners);
|
||||
}).catch(() => { /* hors ligne : le clic suivant réessaiera */ });
|
||||
}
|
||||
|
||||
function setGenSelMode(on) {
|
||||
genSelMode = on;
|
||||
document.body.classList.toggle('genSel', on);
|
||||
if (btnGenSel) {
|
||||
btnGenSel.classList.toggle('primary', on);
|
||||
btnGenSel.innerHTML = on
|
||||
? '✓ Sélection en cours — cliquer des dalles'
|
||||
: '↻ Sélectionner des tuiles';
|
||||
? '✓ Sélection en cours — cliquer sur la carte'
|
||||
: 'Sélectionner des tuiles';
|
||||
}
|
||||
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
|
||||
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider).
|
||||
@ -3149,7 +3204,7 @@ function setGenSelMode(on) {
|
||||
}
|
||||
if (hintChip) {
|
||||
hintChip.innerHTML = on
|
||||
? 'Mode sélection : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
|
||||
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile<br>La sélection se cumule avec les zones dessinées — Échap pour terminer'
|
||||
: hintDefaultHtml;
|
||||
}
|
||||
if (on) {
|
||||
@ -3167,6 +3222,7 @@ function clearGenSel() {
|
||||
if (genSelMode) setGenSelMode(false);
|
||||
if (genSel.size) {
|
||||
genSel.clear();
|
||||
genSelCorners.clear();
|
||||
refreshGenSelHighlights();
|
||||
}
|
||||
}
|
||||
@ -3224,13 +3280,18 @@ genGo.addEventListener('click', () => {
|
||||
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(() => {
|
||||
.then(d => {
|
||||
const verb = isRegen ? 'régénération'
|
||||
: pendingCompleteAll ? 'complétion' : 'génération';
|
||||
pendingCompleteAll = false;
|
||||
pendingRegen = false;
|
||||
if (fromSel) clearGenSel();
|
||||
return startGenTask(nTiles + ' tuile(s) — ' + verb);
|
||||
const label = nTiles + ' tuile(s) — ' + verb;
|
||||
// Un run est déjà en cours : la demande est partie en file d'attente
|
||||
// côté serveur et démarrera à sa fin, sans couper le travail en place.
|
||||
if (d && d['demarré'] === false && d.en_file)
|
||||
return startGenTask(label, { qid: d.qid, position: d.en_file });
|
||||
return startGenTask(label);
|
||||
})
|
||||
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
|
||||
});
|
||||
@ -3507,7 +3568,11 @@ function genTaskEl(t) {
|
||||
label.title = t.label; label.textContent = t.label;
|
||||
const state = document.createElement('span'); state.className = 'gen-task-state';
|
||||
const tiles = t.tiles || [];
|
||||
if (t.state === 'running') {
|
||||
if (t.state === 'queued') {
|
||||
state.textContent = t.position
|
||||
? 'En file — position ' + t.position
|
||||
: 'En file d\'attente';
|
||||
} else if (t.state === 'running') {
|
||||
const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length;
|
||||
state.textContent = tiles.length
|
||||
? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)'
|
||||
@ -3559,21 +3624,27 @@ function renderGenQueue() {
|
||||
// Plus récentes en haut
|
||||
for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t));
|
||||
const running = genTasks.filter(t => t.state === 'running').length;
|
||||
const queued = genTasks.filter(t => t.state === 'queued').length;
|
||||
genBadge.hidden = running === 0;
|
||||
genBadge.textContent = running > 0 ? String(running) : '';
|
||||
if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; }
|
||||
const active = running + queued;
|
||||
genQueueCount.textContent = genTasks.length
|
||||
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
|
||||
? (active ? active + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
|
||||
: '';
|
||||
genQueue.hidden = genTasks.length === 0;
|
||||
}
|
||||
|
||||
function startGenTask(labelText) {
|
||||
function startGenTask(labelText, opts) {
|
||||
const now = new Date();
|
||||
const qid = opts ? opts.qid : undefined;
|
||||
genTasks.push({
|
||||
id: Date.now(), label: labelText,
|
||||
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
state: 'running', returncode: null, log: [], logOpen: false, tiles: [],
|
||||
// Demande partie en file d'attente serveur : suivie jusqu'à son tour
|
||||
state: qid !== undefined ? 'queued' : 'running',
|
||||
qid: qid, position: opts ? opts.position : undefined,
|
||||
returncode: null, log: [], logOpen: false, tiles: [],
|
||||
});
|
||||
saveGenTasks(); renderGenQueue(); pollGenStatus();
|
||||
}
|
||||
@ -3664,7 +3735,12 @@ function mergeTilesData(data) {
|
||||
}
|
||||
}
|
||||
if (changed || metaChanged) renderLayerPanel();
|
||||
if (changed) { syncTileLayers(); layoutTiles(); }
|
||||
if (changed) {
|
||||
syncTileLayers(); layoutTiles();
|
||||
// Une dalle qui vient d'apparaître prend le relais du polygone de
|
||||
// sélection (surlignage du marqueur désormais possible)
|
||||
refreshGenSelHighlights();
|
||||
}
|
||||
}
|
||||
function pollLiveTiles() {
|
||||
const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : '';
|
||||
@ -3683,8 +3759,31 @@ function pollGenStatus() {
|
||||
// Réponse d'erreur (ex: machine de traitement injoignable) : ignorer,
|
||||
// le sondage reprendra — ne pas marquer les tâches en échec.
|
||||
if (typeof d.running !== 'boolean') return;
|
||||
// Demandes en file côté serveur : bouton de retrait
|
||||
if (genQueueDrop) genQueueDrop.hidden = !(d.queue && d.queue.length);
|
||||
// Tâches en file : démarrent quand le serveur attaque leur run (qid),
|
||||
// « Interrompu » si la demande est retirée sans avoir tourné.
|
||||
const serverQids = new Set((d.queue || []).map(q => q.id));
|
||||
for (const t of genTasks) {
|
||||
if (t.state !== 'queued') continue;
|
||||
const gone = !serverQids.has(t.qid);
|
||||
if (d.running && d.qid === t.qid) {
|
||||
// Le run de cette demande a démarré : les tâches « en cours » plus
|
||||
// anciennes appartenaient au run précédent, forcément terminé (le
|
||||
// serveur n'exécute qu'un run à la fois).
|
||||
for (const o of genTasks) {
|
||||
if (o !== t && o.state === 'running') { o.state = 'done'; o.returncode = 0; }
|
||||
}
|
||||
t.state = 'running'; t.started = d.started; t.position = undefined;
|
||||
} else if (d.running && d.qid == null && gone) {
|
||||
t.state = 'running'; t.started = d.started; t.position = undefined;
|
||||
} else if (!d.running && gone) {
|
||||
t.state = 'stopped';
|
||||
}
|
||||
}
|
||||
const running = genTasks.filter(t => t.state === 'running');
|
||||
if (!running.length) {
|
||||
const queued = genTasks.filter(t => t.state === 'queued');
|
||||
if (!running.length && !queued.length) {
|
||||
clearInterval(genPollTimer); genPollTimer = null;
|
||||
return;
|
||||
}
|
||||
@ -3695,8 +3794,14 @@ function pollGenStatus() {
|
||||
if (d.tiles && d.tiles.length) t.tiles = d.tiles;
|
||||
}
|
||||
if (!d.running) {
|
||||
clearInterval(genPollTimer); genPollTimer = null;
|
||||
finishRunningTasks(d);
|
||||
if (genTasks.some(t => t.state === 'queued')) {
|
||||
// Des demandes attendent encore leur tour : poursuivre la veille
|
||||
// (le rechargement de fin de run attendra la fin de la file).
|
||||
saveGenTasks(); renderGenQueue();
|
||||
return;
|
||||
}
|
||||
clearInterval(genPollTimer); genPollTimer = null;
|
||||
// Sauvegarde la vue courante pour qu'elle survive au rechargement
|
||||
// (sinon fitView() recadre sur l'ensemble des tuiles et le zoom part à zéro).
|
||||
try {
|
||||
@ -3715,23 +3820,31 @@ function pollGenStatus() {
|
||||
// Après un run : reconstruire vignettes et carte depuis le cache à la demande
|
||||
// (/api/sync — sur la webapp légère, les tuiles proviennent de la machine de
|
||||
// traitement), puis recharger. /api/sync se réduit à un rebuild local : même
|
||||
// flux partout. Rechargement de secours après 30 min.
|
||||
// flux partout. Le rechargement attend la fin de CE rebuild (done >= started,
|
||||
// horodatage serveur retourné au POST) : sans cela, un sondage trop tôt
|
||||
// lirait le done du rebuild précédent et rechargerait une carte périmée.
|
||||
// Rechargement de secours après 30 min.
|
||||
function syncThenReload() {
|
||||
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
|
||||
const t0 = Date.now();
|
||||
const check = setInterval(() => {
|
||||
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
|
||||
if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) {
|
||||
clearInterval(check);
|
||||
location.reload();
|
||||
}
|
||||
}).catch(() => { clearInterval(check); location.reload(); });
|
||||
}, 2000);
|
||||
}).catch(() => location.reload());
|
||||
fetch('/api/sync', { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
const started = (d && d.started) || 0;
|
||||
const t0 = Date.now();
|
||||
const check = setInterval(() => {
|
||||
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
|
||||
const finished = s && !s.running
|
||||
&& (!started || (s.done || 0) >= started);
|
||||
if (finished || (s && s.error) || Date.now() - t0 > 30 * 60 * 1000) {
|
||||
clearInterval(check);
|
||||
location.reload();
|
||||
}
|
||||
}).catch(() => { clearInterval(check); location.reload(); });
|
||||
}, 2000);
|
||||
}).catch(() => location.reload());
|
||||
}
|
||||
|
||||
genQueueClear.addEventListener('click', () => {
|
||||
genTasks = genTasks.filter(t => t.state === 'running');
|
||||
genTasks = genTasks.filter(t => t.state === 'running' || t.state === 'queued');
|
||||
saveGenTasks(); renderGenQueue();
|
||||
});
|
||||
|
||||
@ -3748,15 +3861,37 @@ if (genStop) genStop.addEventListener('click', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Retrait des demandes en file d'attente côté serveur : le run en cours
|
||||
// continue, les demandes non démarrées sont abandonnées.
|
||||
if (genQueueDrop) genQueueDrop.addEventListener('click', () => {
|
||||
genQueueDrop.disabled = true;
|
||||
fetch('/api/queue/clear', { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
|
||||
.then(() => {
|
||||
genQueueDrop.disabled = false;
|
||||
genQueueDrop.hidden = true;
|
||||
if (!genPollTimer) pollGenStatus(); // marquer les tâches « Interrompu »
|
||||
})
|
||||
.catch(msg => {
|
||||
genQueueDrop.disabled = false;
|
||||
showGenBar('Erreur file d\'attente : ' + 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
|
||||
// dernier run terminé après un redémarrage du serveur (état rechargé du
|
||||
// fichier .generation.job.json). La clé `started` évite les doublons au
|
||||
// rechargement : un même run n'est adopté qu'une fois.
|
||||
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
|
||||
if (genTasks.some(t => t.state === 'running' || t.state === 'queued')) pollGenStatus();
|
||||
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
|
||||
if (!s || genTasks.some(t => t.started === s.started)) return;
|
||||
// Déjà suivie par une tâche (en cours ou en file côté serveur) : pas
|
||||
// d'adoption en doublon d'un run lancé depuis cet onglet.
|
||||
const trackedQids = new Set(genTasks.filter(t => t.qid !== undefined).map(t => t.qid));
|
||||
if (s.qid != null && trackedQids.has(s.qid)) return;
|
||||
if ((s.queue || []).some(q => trackedQids.has(q.id))) return;
|
||||
const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
if (s.running) {
|
||||
genTasks.push({
|
||||
|
||||
@ -294,6 +294,75 @@ def test_build_command_ground_classification():
|
||||
assert ("--force-classification" in cmd) == reclassify
|
||||
|
||||
|
||||
def test_rebuild_flag_synchronous(monkeypatch):
|
||||
"""Le rebuild est marqué running dès le POST (pas après coup).
|
||||
|
||||
Un GET /api/sync juste après le POST doit voir le rebuild en cours :
|
||||
sinon il lirait le done du rebuild précédent et l'interface
|
||||
rechargerait une carte périmée (bug de rafraîchissement).
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
import lidar_pipeline.webapp as webapp
|
||||
import lidar_pipeline.index as index_mod
|
||||
|
||||
go = threading.Event()
|
||||
|
||||
def slow_build(out):
|
||||
go.wait(2) # rebuild artificiellement long
|
||||
|
||||
monkeypatch.setattr(index_mod, "build_index", slow_build)
|
||||
saved = dict(webapp._rebuild)
|
||||
try:
|
||||
d = webapp._start_rebuild()
|
||||
assert d["demarré"] is True
|
||||
assert d["started"] <= time.time()
|
||||
# Déjà « running » au retour du POST, avant la fin du fil
|
||||
assert webapp.rebuild_status()["running"] is True
|
||||
assert webapp.rebuild_status()["done"] is None
|
||||
go.set()
|
||||
for _ in range(100):
|
||||
if not webapp.rebuild_status()["running"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
s = webapp.rebuild_status()
|
||||
assert s["running"] is False
|
||||
assert s["done"] is not None and s["done"] >= d["started"]
|
||||
finally:
|
||||
webapp._rebuild.update(saved)
|
||||
|
||||
|
||||
def test_static_mounts_served_no_cache(tmp_path):
|
||||
"""Les montages d'images servent Cache-Control: no-cache.
|
||||
|
||||
Le ?v= des URLs suit la mtime de la source, pas celle du fichier servi
|
||||
(vignette recalculée, cache webapp rapatrié) : sans revalidation imposée,
|
||||
le cache heuristique du navigateur peut afficher l'ancien rendu.
|
||||
"""
|
||||
import asyncio
|
||||
from starlette.routing import Mount
|
||||
import lidar_pipeline.webapp as webapp
|
||||
|
||||
assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles)
|
||||
mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)}
|
||||
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
|
||||
route = mounted.get(f"/{name}")
|
||||
assert route is not None, f"montage /{name} absent"
|
||||
assert isinstance(route.app, webapp._NoCacheStaticFiles)
|
||||
|
||||
# L'en-tête est bien posé sur la réponse servie
|
||||
(tmp_path / "x.jpg").write_bytes(b"1")
|
||||
srv = webapp._NoCacheStaticFiles(directory=str(tmp_path))
|
||||
|
||||
async def fetch():
|
||||
scope = {"type": "http", "method": "GET", "path": "/x.jpg",
|
||||
"headers": [], "query_string": b""}
|
||||
return await srv.get_response("x.jpg", scope)
|
||||
|
||||
resp = asyncio.run(fetch())
|
||||
assert resp.headers["cache-control"] == "no-cache, must-revalidate"
|
||||
|
||||
|
||||
def test_stop_generation(monkeypatch):
|
||||
"""/api/stop termine le processus en cours (SIGTERM) ou refuse (409)."""
|
||||
import time
|
||||
@ -333,6 +402,117 @@ def test_stop_generation(monkeypatch):
|
||||
webapp._job.update(saved)
|
||||
|
||||
|
||||
def test_point_to_cell_matches_bbox_cells():
|
||||
"""point_to_cell désigne la cellule contenant le point (cohérent bbox_to_cells)."""
|
||||
from lidar_pipeline.webapp import point_to_cell, bbox_to_cells
|
||||
from rasterio.warp import transform as warp_transform
|
||||
# Centre de la dalle 1054,6882 en L93 → WGS84
|
||||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500])
|
||||
lon, lat = lons[0], lats[0]
|
||||
assert point_to_cell(lon, lat) == (1054, 6882)
|
||||
# Une petite bbox autour du même point donne la même cellule, elle seule
|
||||
cells = bbox_to_cells(lon - 1e-4, lat - 1e-4, lon + 1e-4, lat + 1e-4)
|
||||
assert cells == [(1054, 6882)]
|
||||
# Point vers le bord est de la dalle suivante
|
||||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1055800], [6881500])
|
||||
assert point_to_cell(lons[0], lats[0]) == (1055, 6882)
|
||||
|
||||
|
||||
def test_api_cell_returns_corners():
|
||||
"""/api/cell (clic carte) renvoie la dalle L93 et ses coins GPS."""
|
||||
from lidar_pipeline.webapp import cell_at_point
|
||||
from rasterio.warp import transform as warp_transform
|
||||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500])
|
||||
d = cell_at_point(lat=lats[0], lng=lons[0])
|
||||
assert d["col"] == 1054 and d["row"] == 6882
|
||||
assert len(d["corners"]) == 4 # SW, SE, NE, NW
|
||||
|
||||
|
||||
def test_generate_enqueues_while_running(tmp_path, monkeypatch):
|
||||
"""Une demande pendant un run part en file (plus de 409, rien n'est coupé)."""
|
||||
import time
|
||||
import lidar_pipeline.webapp as webapp
|
||||
|
||||
class FakeProc:
|
||||
pid = 4242
|
||||
def poll(self):
|
||||
return None # en cours
|
||||
def wait(self, timeout=None):
|
||||
return None
|
||||
|
||||
saved_job = dict(webapp._job)
|
||||
saved_queue = list(webapp._queue)
|
||||
monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json")
|
||||
try:
|
||||
webapp._job.update({"proc": FakeProc(), "started": time.time(),
|
||||
"returncode": None, "cmd": ["x"], "finished": None,
|
||||
"qid": None})
|
||||
webapp._queue.clear()
|
||||
req = webapp.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"])
|
||||
d = webapp.generate(req)
|
||||
assert d["demarré"] is False
|
||||
assert d["en_file"] == 1 and d["tuiles"] == 1 and d["qid"] is not None
|
||||
assert len(webapp._queue) == 1
|
||||
assert webapp._queue[0]["req"]["tiles"] == [[1054, 6882]]
|
||||
# La file survit à un « redémarrage » du serveur
|
||||
webapp._queue.clear()
|
||||
webapp._load_queue()
|
||||
assert len(webapp._queue) == 1
|
||||
# Retrait des demandes en attente (le run en cours n'est pas touché)
|
||||
assert webapp.queue_clear() == {"retirées": 1}
|
||||
assert webapp._queue == []
|
||||
finally:
|
||||
webapp._job.update(saved_job)
|
||||
webapp._queue[:] = saved_queue
|
||||
|
||||
|
||||
def test_start_next_queued_launches_after_run(tmp_path, monkeypatch):
|
||||
"""Serveur libre + file non vide : la demande suivante démarre (fidélité qid)."""
|
||||
import time
|
||||
import lidar_pipeline.webapp as webapp
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
|
||||
class _P:
|
||||
def wait(self):
|
||||
return 0
|
||||
|
||||
def poll(self):
|
||||
return 0
|
||||
return _P()
|
||||
|
||||
import subprocess as _real_subprocess
|
||||
|
||||
class _SubprocessStub:
|
||||
Popen = staticmethod(fake_popen)
|
||||
run = staticmethod(_real_subprocess.run)
|
||||
STDOUT = _real_subprocess.STDOUT
|
||||
|
||||
monkeypatch.setattr(webapp, "subprocess", _SubprocessStub)
|
||||
monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path)
|
||||
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||
monkeypatch.setattr(webapp, "LOG_FILE", tmp_path / "gen.log")
|
||||
monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json")
|
||||
saved_job = dict(webapp._job)
|
||||
saved_queue = list(webapp._queue)
|
||||
try:
|
||||
webapp._job.update({"proc": None, "started": None, "returncode": 0,
|
||||
"cmd": None, "finished": time.time(), "qid": None})
|
||||
webapp._queue[:] = [{"id": 7, "queued_at": 1.0,
|
||||
"req": {"tiles": [[1054, 6882]], "viz": ["aspect"]}}]
|
||||
assert webapp._start_next_queued() is True
|
||||
assert webapp._queue == [] # la demande est consommée
|
||||
assert webapp._job["qid"] == 7 # le run porte l'identifiant de file
|
||||
assert "--fetch-tiles" in captured["cmd"]
|
||||
assert "1054,6882" in captured["cmd"]
|
||||
finally:
|
||||
webapp._job.update(saved_job)
|
||||
webapp._queue[:] = saved_queue
|
||||
|
||||
|
||||
def test_build_command_default_viz_aspect():
|
||||
"""Sans choix de visualisation, la commande génère uniquement aspect."""
|
||||
from lidar_pipeline.webapp import _build_command
|
||||
|
||||
@ -85,6 +85,7 @@ OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output"))
|
||||
PORT = int(os.environ.get("LIDAR_PORT", "8973"))
|
||||
LOG_FILE = OUTPUT_DIR / ".generation.log"
|
||||
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
|
||||
QUEUE_FILE = OUTPUT_DIR / ".generation.queue.json"
|
||||
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées)
|
||||
MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes)
|
||||
MAX_EXPORT_TILES = 64 # garde-fou : dalles assemblables par export (mémoire)
|
||||
@ -245,7 +246,25 @@ def _fetch_remote_to_cache(rel_path):
|
||||
return False
|
||||
|
||||
|
||||
class _OnDemandStaticFiles(_StaticFiles):
|
||||
class _NoCacheStaticFiles(_StaticFiles):
|
||||
"""Fichiers statiques servis sans cache navigateur (revalidation 304).
|
||||
|
||||
Les images (visualisations, vignettes, sous-tuiles, DTM) n'ont pas
|
||||
toujours une URL invalidée après régénération : le ?v= suit la mtime de
|
||||
la SOURCE, pas celle du fichier servi (vignette recalculée après coup,
|
||||
cache webapp rapatrié…). Sans Cache-Control, le cache heuristique du
|
||||
navigateur peut continuer d'afficher l'ancien rendu sur la même URL.
|
||||
no-cache force la revalidation à chaque affichage — ETag/Last-Modified
|
||||
la rend quasi gratuite quand le fichier n'a pas changé (réponse 304).
|
||||
"""
|
||||
|
||||
async def get_response(self, path, scope):
|
||||
resp = await super().get_response(path, scope)
|
||||
resp.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return resp
|
||||
|
||||
|
||||
class _OnDemandStaticFiles(_NoCacheStaticFiles):
|
||||
"""Montage statique qui peuple le cache depuis la machine de traitement.
|
||||
|
||||
Fichier local absent, ou périmé (?v= plus récent que sa mtime) → téléchar-
|
||||
@ -294,7 +313,7 @@ for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
|
||||
_OnDemandStaticFiles(directory=str(_dir), url_prefix=name),
|
||||
name=name)
|
||||
else:
|
||||
app.mount(f"/{name}", _StaticFiles(directory=str(_dir)), name=name)
|
||||
app.mount(f"/{name}", _NoCacheStaticFiles(directory=str(_dir)), name=name)
|
||||
|
||||
|
||||
@app.get("/assets/{file_path:path}")
|
||||
@ -507,7 +526,7 @@ class ExportRequest(BaseModel):
|
||||
|
||||
|
||||
# --- État du job de génération -------------------------------------------
|
||||
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None}
|
||||
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None, "qid": None}
|
||||
_job_lock = threading.Lock()
|
||||
|
||||
|
||||
@ -518,7 +537,7 @@ def _save_job_state():
|
||||
plus le dernier run et la file de génération reste vide au rechargement.
|
||||
Best-effort : n'acquiert pas le verrou (appelé aussi sous verrou).
|
||||
"""
|
||||
state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished")}
|
||||
state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished", "qid")}
|
||||
try:
|
||||
JOB_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||||
except OSError:
|
||||
@ -536,10 +555,54 @@ def _load_job_state():
|
||||
_job["returncode"] = state.get("returncode")
|
||||
_job["cmd"] = state.get("cmd")
|
||||
_job["finished"] = state.get("finished")
|
||||
_job["qid"] = state.get("qid")
|
||||
|
||||
|
||||
_load_job_state()
|
||||
|
||||
# --- File d'attente des demandes de génération -----------------------------
|
||||
# Une demande arrivée pendant un run n'est plus rejetée (ancien 409) : elle
|
||||
# part en file et démarre automatiquement à la fin du run en cours — le
|
||||
# travail déjà lancé n'est jamais coupé. La file survit aux redémarrages du
|
||||
# serveur (fichier .generation.queue.json) et est vidable via /api/queue/clear.
|
||||
_queue = []
|
||||
_queue_seq = 0
|
||||
|
||||
|
||||
def _save_queue():
|
||||
"""Écrit la file d'attente sur disque (atomique, best-effort)."""
|
||||
try:
|
||||
tmp = QUEUE_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_queue), encoding="utf-8")
|
||||
os.replace(tmp, QUEUE_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_queue():
|
||||
"""Recharge la file d'attente au démarrage (et son compteur d'ids)."""
|
||||
global _queue_seq
|
||||
try:
|
||||
data = json.loads(QUEUE_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
_queue[:] = [it for it in data
|
||||
if isinstance(it, dict) and isinstance(it.get("req"), dict)]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
_queue_seq = max((it.get("id", 0) for it in _queue), default=0)
|
||||
|
||||
|
||||
def _queue_summary():
|
||||
"""Aperçu de la file pour /api/status (sans la requête brute)."""
|
||||
return [{"id": it.get("id"),
|
||||
"tuiles": len(it["req"].get("tiles") or []),
|
||||
"toutes": bool(it["req"].get("all_missing")),
|
||||
"en_attente_depuis": it.get("queued_at")}
|
||||
for it in _queue]
|
||||
|
||||
|
||||
_load_queue()
|
||||
|
||||
|
||||
# --- Presets de couches (partagés entre navigateurs) --------------------------
|
||||
# Stockés dans OUTPUT_DIR/.presets.json : un preset est un jeu nommé de
|
||||
@ -665,6 +728,28 @@ def bbox_to_cells(w, s, e, n):
|
||||
return [(c, r) for r in rows for c in cols]
|
||||
|
||||
|
||||
def point_to_cell(lon, lat):
|
||||
"""Cellule L93 de 1 km (col, row) contenant un point WGS84.
|
||||
|
||||
Cohérent avec bbox_to_cells : une cellule (col, row) couvre
|
||||
X ∈ [col, col+1] km, Y ∈ [row-1, row] km — sert au clic carte pour
|
||||
sélectionner une dalle précise, même sans données existantes.
|
||||
"""
|
||||
from .index import _approx_wgs84_to_l93
|
||||
try:
|
||||
from rasterio.warp import transform as warp_transform
|
||||
x, y = warp_transform("EPSG:4326", "EPSG:2154", [lon], [lat])
|
||||
x, y = x[0], y[0]
|
||||
except ImportError:
|
||||
try:
|
||||
from pyproj import Transformer
|
||||
transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True)
|
||||
x, y = transformer.transform(lon, lat)
|
||||
except ImportError:
|
||||
x, y = _approx_wgs84_to_l93(lon, lat)
|
||||
return int(math.floor(x / 1000)), int(math.floor(y / 1000)) + 1
|
||||
|
||||
|
||||
def processed_cells(output_dir):
|
||||
"""Ensemble des cellules (col, row) ayant déjà des visualisations."""
|
||||
from .index import scan_tiles
|
||||
@ -764,6 +849,12 @@ def status(request: Request = None):
|
||||
data["regen_allowed"] = regen_allowed
|
||||
return data
|
||||
from .progress import progress_snapshot
|
||||
with _job_lock:
|
||||
pending = bool(_queue)
|
||||
if pending:
|
||||
# File d'attente non vide et serveur libre (ex. redémarrage du
|
||||
# conteneur en cours de file) : la demande suivante repart.
|
||||
_start_next_queued()
|
||||
with _job_lock:
|
||||
proc = _job["proc"]
|
||||
running = proc is not None and proc.poll() is None
|
||||
@ -772,6 +863,8 @@ def status(request: Request = None):
|
||||
"started": _job["started"],
|
||||
"returncode": _job["returncode"],
|
||||
"cmd": _job["cmd"],
|
||||
"qid": _job.get("qid"),
|
||||
"queue": _queue_summary(),
|
||||
"log": _tail_log(40),
|
||||
# L'interface masque les boutons de génération hors réseau local
|
||||
"regen_allowed": regen_allowed,
|
||||
@ -896,15 +989,24 @@ _rebuild = {"running": False, "error": None, "done": None, "phase": None}
|
||||
|
||||
|
||||
def _start_rebuild():
|
||||
"""Lance en arrière-plan le rebuild de l'index (vignettes + carte)."""
|
||||
"""Lance en arrière-plan le rebuild de l'index (vignettes + carte).
|
||||
|
||||
L'état running est posé de façon SYNCHRONE, avant le fil : un GET
|
||||
/api/sync juste après le POST doit voir le rebuild en cours — sinon il
|
||||
lirait le done du rebuild précédent et l'interface rechargerait une
|
||||
carte périmée. Le timestamp started retourné permet au client
|
||||
d'attendre la fin de CE rebuild précis (done >= started).
|
||||
"""
|
||||
if _rebuild["running"]:
|
||||
raise HTTPException(409, "un rebuild est déjà en cours")
|
||||
started = time.time()
|
||||
_rebuild.update({"running": True, "error": None, "done": None,
|
||||
"phase": "index"})
|
||||
# Index distant (mode deux machines) : cache 60 s vidé pour que le
|
||||
# premier /api/tiles suivant reflète l'état du worker sans attendre.
|
||||
_REMOTE_INDEX["fetched"] = 0.0
|
||||
|
||||
def _run():
|
||||
_rebuild["running"] = True
|
||||
_rebuild["error"] = None
|
||||
_rebuild["done"] = None
|
||||
_rebuild["phase"] = "index"
|
||||
try:
|
||||
from .index import build_index
|
||||
build_index(OUTPUT_DIR)
|
||||
@ -916,7 +1018,7 @@ def _start_rebuild():
|
||||
_rebuild["done"] = time.time()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"demarré": True}
|
||||
return {"demarré": True, "started": started}
|
||||
|
||||
|
||||
@app.post("/api/sync", dependencies=[Depends(_require_token)])
|
||||
@ -984,6 +1086,24 @@ def preview(req: PreviewRequest):
|
||||
return {"count": len(todo), "capped": capped, "cells": todo}
|
||||
|
||||
|
||||
@app.get("/api/cell")
|
||||
def cell_at_point(lat: float, lng: float):
|
||||
"""Dalle L93 de 1 km contenant un point WGS84 (clic sur la carte).
|
||||
|
||||
Retourne col, row et les coins GPS de la dalle — permet de sélectionner
|
||||
une tuile individuelle même sans données existantes (téléchargement +
|
||||
génération à la validation).
|
||||
"""
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : la conversion précise vit sur la machine de traitement
|
||||
return _proxy_api("GET", f"/api/cell?lat={lat}&lng={lng}")
|
||||
from .index import attach_gps_bounds
|
||||
col, row = point_to_cell(lng, lat)
|
||||
tile = {"col": col, "row": row}
|
||||
attach_gps_bounds([tile])
|
||||
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):
|
||||
"""Commande de génération : téléchargement IGN + traitement des fichiers.
|
||||
|
||||
@ -1029,15 +1149,13 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
return cmd
|
||||
|
||||
|
||||
@app.post("/api/generate", dependencies=[Depends(_require_token),
|
||||
Depends(_require_lan_for_generation)])
|
||||
def generate(req: GenerateRequest):
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : téléchargement + traitement sur la machine distante,
|
||||
# qui valide les paramètres et renvoie son propre état de job.
|
||||
data = _proxy_api("POST", "/api/generate", json.loads(req.json()))
|
||||
data["distant"] = True
|
||||
return data
|
||||
def _resolve_request(req):
|
||||
"""Valide une demande de génération et résout sa liste de tuiles.
|
||||
|
||||
Retourne (tuiles [(col, row)], viz [noms d'étapes]). Réexécutée au
|
||||
démarrage de chaque demande sortie de file : les dalles présentes dans
|
||||
input/ peuvent avoir changé entre la mise en file et le départ du run.
|
||||
"""
|
||||
names = _viz_step_names()
|
||||
viz = [v for v in (req.viz or []) if v] or ["aspect"]
|
||||
invalid = [v for v in viz if v not in names]
|
||||
@ -1067,41 +1185,124 @@ def generate(req: GenerateRequest):
|
||||
raise HTTPException(
|
||||
400, f"méthode de classification invalide : {req.ground_class!r} "
|
||||
f"(attendu : {', '.join(GROUND_CLASS_METHODS)})")
|
||||
return tiles, viz
|
||||
|
||||
|
||||
def _launch_job(tiles, viz, req, qid=None):
|
||||
"""Démarre un run du pipeline. À appeler sous _job_lock, serveur libre.
|
||||
|
||||
Retourne la commande lancée. qid : identifiant de file d'attente quand
|
||||
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)
|
||||
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
|
||||
# dans la file correspondent au run qui démarre, pas au précédent)
|
||||
from .progress import reset_events
|
||||
reset_events(OUTPUT_DIR)
|
||||
_job.update({"proc": None, "started": time.time(), "returncode": None,
|
||||
"cmd": cmd, "finished": None, "qid": qid})
|
||||
_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,
|
||||
start_new_session=True)
|
||||
|
||||
def _watch():
|
||||
rc = p.wait()
|
||||
log_fh.close()
|
||||
with _job_lock:
|
||||
_job["returncode"] = rc
|
||||
_job["finished"] = time.time()
|
||||
_save_job_state()
|
||||
# Le run est fini : la demande suivante de la file peut partir.
|
||||
_start_next_queued()
|
||||
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
_job["proc"] = p
|
||||
return cmd
|
||||
|
||||
|
||||
def _start_next_queued():
|
||||
"""Démarre la première demande de la file dès que le serveur est libre.
|
||||
|
||||
Les demandes devenues invalides (ex. plus aucune tuile à compléter)
|
||||
sont ignorées ; une demande repoussée en tête de file si un run part
|
||||
entre-temps. Ne coupe jamais le run en cours.
|
||||
"""
|
||||
while True:
|
||||
with _job_lock:
|
||||
proc = _job["proc"]
|
||||
if proc is not None and proc.poll() is None:
|
||||
return False
|
||||
if not _queue:
|
||||
return False
|
||||
item = _queue.pop(0)
|
||||
_save_queue()
|
||||
try:
|
||||
req = GenerateRequest(**item["req"])
|
||||
tiles, viz = _resolve_request(req)
|
||||
except HTTPException as e:
|
||||
logger.warning("Demande en file ignorée : %s", e.detail)
|
||||
continue
|
||||
with _job_lock:
|
||||
proc = _job["proc"]
|
||||
if proc is not None and proc.poll() is None:
|
||||
_queue.insert(0, item) # un autre run est parti : remise en tête
|
||||
_save_queue()
|
||||
return False
|
||||
_launch_job(tiles, viz, req, qid=item.get("id"))
|
||||
return True
|
||||
|
||||
|
||||
@app.post("/api/generate", dependencies=[Depends(_require_token),
|
||||
Depends(_require_lan_for_generation)])
|
||||
def generate(req: GenerateRequest):
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : téléchargement + traitement sur la machine distante,
|
||||
# qui valide les paramètres, met en file et renvoie son propre état.
|
||||
data = _proxy_api("POST", "/api/generate", json.loads(req.json()))
|
||||
data["distant"] = True
|
||||
return data
|
||||
tiles, viz = _resolve_request(req)
|
||||
with _job_lock:
|
||||
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, 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
|
||||
# dans la file correspondent au run qui démarre, pas au précédent)
|
||||
from .progress import reset_events
|
||||
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,
|
||||
start_new_session=True)
|
||||
|
||||
def _watch():
|
||||
rc = p.wait()
|
||||
log_fh.close()
|
||||
with _job_lock:
|
||||
_job["returncode"] = rc
|
||||
_job["finished"] = time.time()
|
||||
_save_job_state()
|
||||
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
_job["proc"] = p
|
||||
running = proc is not None and proc.poll() is None
|
||||
if running:
|
||||
# Un run est en cours : la demande part en file d'attente et
|
||||
# démarrera à sa fin — jamais de coupure du travail en place.
|
||||
global _queue_seq
|
||||
_queue_seq += 1
|
||||
item = {"id": _queue_seq, "req": json.loads(req.json()),
|
||||
"queued_at": time.time()}
|
||||
_queue.append(item)
|
||||
_save_queue()
|
||||
return {"demarré": False, "en_file": len(_queue), "qid": item["id"],
|
||||
"tuiles": len(tiles)}
|
||||
cmd = _launch_job(tiles, viz, req)
|
||||
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
|
||||
|
||||
|
||||
@app.post("/api/queue/clear", dependencies=[Depends(_require_token),
|
||||
Depends(_require_lan_for_generation)])
|
||||
def queue_clear():
|
||||
"""Retire les demandes en attente (le run en cours n'est pas touché)."""
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : la file vit sur la machine de traitement
|
||||
return _proxy_api("POST", "/api/queue/clear")
|
||||
with _job_lock:
|
||||
n = len(_queue)
|
||||
_queue.clear()
|
||||
_save_queue()
|
||||
return {"retirées": n}
|
||||
|
||||
|
||||
@app.post("/api/stop", dependencies=[Depends(_require_token),
|
||||
Depends(_require_lan_for_generation)])
|
||||
def stop_generation():
|
||||
|
||||
@ -23,7 +23,6 @@
|
||||
# ./serve-webapp.sh status état du conteneur et de la dernière sync
|
||||
# ./serve-webapp.sh sync forcer un rebuild + régénération des vignettes
|
||||
# ./serve-webapp.sh logs suivre les logs du conteneur
|
||||
# ./serve-webapp.sh update git pull, rebuild de l'image, redémarrer
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@ -174,13 +173,6 @@ cmd_sync() {
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
echo "Mise à jour du code (git pull)..."
|
||||
git -C "$SCRIPT_DIR" pull --ff-only
|
||||
build_image
|
||||
cmd_start
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
start) cmd_start ;;
|
||||
stop) cmd_stop ;;
|
||||
@ -188,9 +180,8 @@ case "$CMD" in
|
||||
status) cmd_status ;;
|
||||
sync) cmd_sync ;;
|
||||
logs) exec docker logs -f "$CONTAINER" ;;
|
||||
update) cmd_update ;;
|
||||
*)
|
||||
echo "Usage : $0 [start|stop|restart|status|sync|logs|update]" >&2
|
||||
echo "Usage : $0 [start|stop|restart|status|sync|logs]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user